chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:12 +08:00
commit d8dcd5f6d1
8604 changed files with 2479390 additions and 0 deletions
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env node
/**
* cursor-tap — capture cursor agent.v1.AgentService/Run wire bytes for tests.
*
* Usage:
* CURSOR_TOKEN=... node scripts/ad-hoc/cursor-tap.cjs <fixture-name> <prompt>
*
* Examples:
* node scripts/ad-hoc/cursor-tap.cjs single-turn-chat "say only PING"
* node scripts/ad-hoc/cursor-tap.cjs system-prompt "be brief|hi" # split on first '|'
* node scripts/ad-hoc/cursor-tap.cjs tool-call "weather in Paris" --tools=get_weather
* node scripts/ad-hoc/cursor-tap.cjs composer-2-fast "hi" --model=composer-2-fast
*
* Writes the upstream response bytes to tests/fixtures/cursor/<fixture-name>.bin
* and prints decoded summary to stdout. Use these fixtures in unit tests to
* catch schema drift in cursor-agent's protobuf format.
*
* Note: this is a one-time / on-demand tool. The .bin output is gitignored
* by default; commit fixtures explicitly when you want them in the test
* baseline (tests/fixtures/cursor/.gitignore controls this).
*/
const fs = require("fs");
const path = require("path");
const http2 = require("http2");
const crypto = require("crypto");
const args = process.argv.slice(2);
if (args.length < 2) {
console.error(
"Usage: cursor-tap.cjs <fixture-name> <prompt> [--model=...] [--tools=name1,name2]"
);
process.exit(1);
}
const [fixtureName, prompt, ...flags] = args;
const flagMap = Object.fromEntries(
flags.map((f) => {
const m = f.match(/^--([^=]+)=(.*)$/);
return m ? [m[1], m[2]] : [f.replace(/^--/, ""), true];
})
);
const token = process.env.CURSOR_TOKEN;
if (!token) {
console.error("Set CURSOR_TOKEN environment variable.");
process.exit(1);
}
const model = flagMap.model || "auto";
const conversationId = crypto.randomUUID();
const requestId = crypto.randomUUID();
const traceParent = `00-${crypto.randomBytes(16).toString("hex")}-${crypto.randomBytes(8).toString("hex")}-01`;
// ─── Minimal protobuf encoder (mirrors open-sse/utils/cursorAgentProtobuf.ts) ─
function encodeVarint(n) {
const out = [];
let v = BigInt(n);
while (v > 0x7fn) {
out.push(Number(v & 0x7fn) | 0x80);
v >>= 7n;
}
out.push(Number(v));
return Buffer.from(out);
}
function tag(field, wt) {
return encodeVarint((field << 3) | wt);
}
function lenField(f, payload) {
return Buffer.concat([tag(f, 2), encodeVarint(payload.length), payload]);
}
function strField(f, s) {
return lenField(f, Buffer.from(s, "utf8"));
}
function varintField(f, n) {
return Buffer.concat([tag(f, 0), encodeVarint(n)]);
}
function wrapConnectFrame(payload) {
const header = Buffer.alloc(5);
header[0] = 0;
header.writeUInt32BE(payload.length, 1);
return Buffer.concat([header, payload]);
}
// AgentRunRequest body
const userMessage = lenField(
1,
Buffer.concat([
strField(1, prompt),
strField(2, crypto.randomUUID()),
lenField(3, Buffer.alloc(0)),
varintField(4, 1),
])
);
const userMessageAction = lenField(1, userMessage);
const action = lenField(2, userMessageAction);
const conversationState = lenField(1, Buffer.alloc(0));
const requestedModel = lenField(9, strField(1, model === "auto" ? "default" : model));
const arr = Buffer.concat([
conversationState,
action,
lenField(4, Buffer.alloc(0)), // mcp_tools
strField(5, conversationId),
requestedModel,
varintField(12, 0),
strField(16, conversationId),
]);
const acm = lenField(1, arr);
const body = wrapConnectFrame(acm);
// ─── h2 request ────────────────────────────────────────────────────────────
const cleanToken = token.includes("::") ? token.split("::")[1] : token;
const client = http2.connect("https://agentn.global.api5.cursor.sh");
const collected = [];
let responseStatus = 0;
const req = client.request({
":method": "POST",
":path": "/agent.v1.AgentService/Run",
":authority": "agentn.global.api5.cursor.sh",
":scheme": "https",
authorization: `Bearer ${cleanToken}`,
"backend-traceparent": traceParent,
"connect-accept-encoding": "gzip,br",
"connect-protocol-version": "1",
"content-type": "application/connect+proto",
traceparent: traceParent,
"user-agent": "connect-es/1.6.1",
"x-cursor-client-type": "cli",
"x-cursor-client-version": "cli-2025.10.21-b2dfaef",
"x-ghost-mode": "true",
"x-original-request-id": requestId,
"x-request-id": requestId,
});
req.on("response", (h) => {
responseStatus = Number(h[":status"]);
});
req.on("data", (chunk) => {
collected.push(Buffer.from(chunk));
});
req.on("end", () => {
const raw = Buffer.concat(collected);
const outDir = path.join(__dirname, "..", "..", "tests", "fixtures", "cursor");
fs.mkdirSync(outDir, { recursive: true });
const outFile = path.join(outDir, `${fixtureName}.bin`);
fs.writeFileSync(outFile, raw);
console.log(`[cursor-tap] status=${responseStatus} bytes=${raw.length}${outFile}`);
client.close();
});
req.on("error", (err) => {
console.error("[cursor-tap] req error:", err);
process.exit(1);
});
req.write(body);
// NOTE: we never end the request; cursor closes the stream itself when the
// turn is done. For tool-using captures, the script may need to write
// follow-up frames before the stream closes — extend as needed.
// Safety timeout: if cursor doesn't close in 60s, dump what we have.
setTimeout(() => {
console.warn("[cursor-tap] safety timeout; closing");
try {
req.close();
client.close();
} catch {}
const raw = Buffer.concat(collected);
if (raw.length > 0) {
const outDir = path.join(__dirname, "..", "..", "tests", "fixtures", "cursor");
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, `${fixtureName}.bin`), raw);
}
process.exit(0);
}, 60_000);
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
const env = { ...process.env };
await exec("npx", ["next", "build", "--experimental-build-mode", "generate"]);
// launch application
const [command, ...args] = process.argv.slice(2);
if (!command) {
throw new Error("Missing command to launch after database setup");
}
await exec(command, args);
function exec(command, args = []) {
const child = spawn(command, args, { stdio: "inherit", env });
return new Promise((resolve, reject) => {
child.on("exit", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`${[command, ...args].join(" ")} failed rc=${code}`));
}
});
});
}
+26
View File
@@ -0,0 +1,26 @@
import { execSync } from "child_process";
try {
console.log("Fetching workflow runs...");
const output = execSync("gh run list --limit 100 --json status,conclusion,databaseId", {
encoding: "utf8",
});
const runs = JSON.parse(output);
console.log(`Found ${runs.length} runs.`);
let count = 0;
for (const run of runs) {
if (run.conclusion !== "success") {
console.log(`Deleting run ID ${run.databaseId} with conclusion '${run.conclusion}'...`);
try {
execSync(`gh run delete ${run.databaseId}`);
count++;
} catch (err) {
console.error(`Failed to delete run ID ${run.databaseId}:`, err.message);
}
}
}
console.log(`Deleted ${count} runs successfully.`);
} catch (error) {
console.error("Error executing script:", error);
}
+83
View File
@@ -0,0 +1,83 @@
// Trae SOLO auth diagnostic — probes the live API with your token across several
// Authorization variants, so we can see which one (if any) the server accepts.
//
// Usage:
// node scripts/ad-hoc/diag-trae-auth.mjs <YOUR_CLOUD_IDE_JWT>
// TRAE_TOKEN=eyJ... node scripts/ad-hoc/diag-trae-auth.mjs
//
// Paste ONLY the token value (no "Cloud-IDE-JWT " prefix). The token is never
// printed in full; only its length + last 6 chars are shown for sanity.
const token = (process.argv[2] || process.env.TRAE_TOKEN || "").trim();
if (!token) {
console.error("No token. Pass it as arg or set TRAE_TOKEN.");
process.exit(1);
}
console.log(`token: len=${token.length}${token.slice(-6)}`);
const BASE = "https://core-normal.trae.ai/api/remote/v1";
const MODELS = `${BASE}/models?functions=solo_agent_remote,solo_work_remote`;
const commonHeaders = {
"Content-Type": "application/json",
"X-Trae-Client-Type": "web",
"X-Preferenced-Language": "en",
"x-user-region": "US",
Referer: "https://solo.trae.ai/",
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
};
// Each variant = a set of auth-bearing headers to try.
const variants = [
{
name: "Authorization: Cloud-IDE-JWT <t>",
headers: { Authorization: `Cloud-IDE-JWT ${token}` },
},
{ name: "Authorization: Bearer <t>", headers: { Authorization: `Bearer ${token}` } },
{ name: "Authorization: <t> (raw)", headers: { Authorization: token } },
{ name: "Cloud-IDE-JWT: <t> (header)", headers: { "Cloud-IDE-JWT": token } },
{ name: "x-cloud-ide-jwt: <t>", headers: { "x-cloud-ide-jwt": token } },
{ name: "x-cloud-ide-token: <t>", headers: { "x-cloud-ide-token": token } },
];
async function probe(label, url, method, headers) {
try {
const res = await fetch(url, {
method,
headers: { ...commonHeaders, ...headers },
body:
method === "POST"
? JSON.stringify({ mode: "code", env: "remote", origin: "web" })
: undefined,
});
const text = await res.text();
const snippet = text.replace(/\s+/g, " ").slice(0, 160);
const ok = res.ok && !/"code"\s*:\s*1001/.test(text) && !/not able to authenticate/i.test(text);
console.log(`${ok ? "✅" : "❌"} [${res.status}] ${label}\n ${snippet}`);
return ok;
} catch (e) {
console.log(`💥 ${label}${e?.message || e}`);
return false;
}
}
console.log(`\n=== GET ${MODELS} ===`);
let anyOk = false;
for (const v of variants) {
const ok = await probe(v.name, MODELS, "GET", v.headers);
anyOk = anyOk || ok;
}
console.log(`\n=== POST ${BASE}/chat_sessions (only the first auth variant, sanity) ===`);
await probe(variants[0].name, `${BASE}/chat_sessions`, "POST", variants[0].headers);
console.log(
anyOk
? "\n→ At least one variant authenticated. Tell me which ✅ line, I'll set the executor to it."
: "\n→ No header-only variant worked. The web client likely authenticates via a COOKIE, not a\n" +
" bearer header. Next step: in DevTools → Network, right-click a request to\n" +
" core-normal.trae.ai → Copy → Copy as cURL, and paste it here (redact the token). That\n" +
" shows the exact auth header/cookie the server actually accepts."
);
+58
View File
@@ -0,0 +1,58 @@
import { execSync } from "child_process";
import fs from "fs";
import path from "path";
const REPO = "diegosouzapw/OmniRoute";
const artifactsDir =
process.env.ARTIFACTS_DIR ||
path.join(process.cwd(), "artifacts");
async function main() {
try {
// 1. Get PR numbers
console.log("Fetching open PR numbers...");
const prNumbersOutput = execSync(
`gh pr list --repo ${REPO} --state open --limit 500 --json number --jq '.[].number'`,
{ encoding: "utf-8" }
);
const prNumbers = prNumbersOutput.trim().split("\n").map(Number).filter(Boolean);
console.log(`Found ${prNumbers.length} open PRs:`, prNumbers);
if (!fs.existsSync(artifactsDir)) {
fs.mkdirSync(artifactsDir, { recursive: true });
}
// 2. Fetch metadata and diff for each PR
for (const prNum of prNumbers) {
console.log(`\n--- Fetching PR #${prNum} ---`);
// Metadata
try {
const metadataCmd = `gh pr view ${prNum} --repo ${REPO} --json number,title,author,headRefName,baseRefName,body,createdAt,additions,deletions,files`;
const metadataJson = execSync(metadataCmd, { encoding: "utf-8" });
const metadataPath = path.join(artifactsDir, `pr_${prNum}_meta.json`);
fs.writeFileSync(metadataPath, metadataJson);
console.log(`Saved metadata to ${metadataPath}`);
} catch (err) {
console.error(`Failed to fetch metadata for PR #${prNum}:`, err.message);
}
// Diff
try {
const diffCmd = `gh pr diff ${prNum} --repo ${REPO}`;
const diffText = execSync(diffCmd, { encoding: "utf-8", maxBuffer: 100 * 1024 * 1024 });
const diffPath = path.join("/tmp", `pr${prNum}.diff`);
fs.writeFileSync(diffPath, diffText);
console.log(`Saved diff to ${diffPath} (Size: ${diffText.length} bytes)`);
} catch (err) {
console.error(`Failed to fetch diff for PR #${prNum}:`, err.message);
}
}
console.log("\nAll PR data fetched successfully!");
} catch (error) {
console.error("Error during PR fetching:", error);
}
}
main();
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env node
/**
* OmniRoute v3.3 -> v3.4 Environment Migration Script
* Resolves breaking changes in environment variables format and validation.
*/
import fs from "fs";
import path from "path";
import crypto from "crypto";
const envPath = path.resolve(process.cwd(), ".env");
if (!fs.existsSync(envPath)) {
console.log("No .env file found. Migration skipped.");
process.exit(0);
}
let content = fs.readFileSync(envPath, "utf8");
let modified = false;
// 1. Migrate NEXTAUTH_SECRET to JWT_SECRET if missing
const nextAuthMatch = content.match(/^NEXTAUTH_SECRET=(.+)$/m);
const jwtMatch = content.match(/^JWT_SECRET=(.+)$/m);
if (nextAuthMatch && !jwtMatch) {
console.log("Migrating NEXTAUTH_SECRET to JWT_SECRET...");
let newJwt = nextAuthMatch[1].trim();
// Enforce 32 char minimum for secretsValidator.ts
if (newJwt.length < 32) {
console.warn(
`Original NEXTAUTH_SECRET was too short (${newJwt.length} chars). Generating a secure one...`
);
newJwt = crypto.randomBytes(48).toString("base64");
}
content += `\n# Migrated from NEXTAUTH_SECRET\nJWT_SECRET=${newJwt}\n`;
modified = true;
} else if (jwtMatch && jwtMatch[1].trim().length < 32) {
console.warn(
`JWT_SECRET is too short (${jwtMatch[1].trim().length} chars). Generating a secure one for v3.4.0+...`
);
const newJwt = crypto.randomBytes(48).toString("base64");
content = content.replace(/^JWT_SECRET=(.*)$/m, `JWT_SECRET=${newJwt}`);
modified = true;
}
// 2. Ensure API_KEY_SECRET exists (required in 3.4.0)
if (!content.match(/^API_KEY_SECRET=/m)) {
console.log("Adding required API_KEY_SECRET for v3.4.0...");
const newApiSecret = crypto.randomBytes(32).toString("hex");
content += `\n# Required for v3.4.0 API Key HMAC\nAPI_KEY_SECRET=${newApiSecret}\n`;
modified = true;
}
if (modified) {
// Backup old .env
fs.writeFileSync(envPath + ".bak", fs.readFileSync(envPath));
console.log("Created backup at .env.bak");
// Write new .env
fs.writeFileSync(envPath, content, "utf8");
console.log("Successfully migrated .env file for OmniRoute 3.4.x.");
} else {
console.log(".env file is already compatible with OmniRoute 3.4.x.");
}
+186
View File
@@ -0,0 +1,186 @@
/**
* Diagnóstico NVIDIA NIM — `s.startsWith is not a function` (issue #2463 / report 3.8.5+)
*
* Como rodar (a chave NUNCA é commitada — vem por env):
* NVIDIA_API_KEY="nvapi-..." node --import tsx/esm scripts/ad-hoc/nvidia-startswith-diag.ts
*
* Opcional — apontar para outra base/model:
* NVIDIA_BASE_URL="https://integrate.api.nvidia.com/v1/chat/completions"
* NVIDIA_MODEL="openai/gpt-oss-120b"
*
* O que ele faz:
* Parte A — Validação real via validateProviderApiKey() (caminho do botão "testar conexão").
* Parte B — Sanidade do upstream: POST direto na NVIDIA (isola a chave/model do nosso pipeline).
* Parte C — Probes de type-crash SEM chave: alimenta model malformado em resolveModelAlias +
* replica o strip de prefixo de chatCore.ts:3316 e parseModel(), capturando o stack
* NÃO-minificado (rodamos contra a fonte TS) para cravar a linha exata do startsWith.
*
* Parte C não precisa de chave — prova quais linhas são vulneráveis hoje.
*/
const KEY = process.env.NVIDIA_API_KEY ?? "";
const BASE_URL = process.env.NVIDIA_BASE_URL || "https://integrate.api.nvidia.com/v1/chat/completions";
const MODEL = process.env.NVIDIA_MODEL || "openai/gpt-oss-120b";
// Neutralize CR/LF before logging so env-derived values (NVIDIA_MODEL, etc.)
// cannot forge extra log lines (S5145 log injection).
const line = (s = "") => console.log(String(s).replace(/[\r\n]+/g, " "));
const hr = () => line("─".repeat(72));
function show(label: string, value: unknown) {
line(` ${label}: ${typeof value === "string" ? value : JSON.stringify(value)}`);
}
// ──────────────────────────────────────────────────────────────────────────
// Parte A — validateProviderApiKey (caminho de validação/teste de conexão)
// ──────────────────────────────────────────────────────────────────────────
async function partA() {
hr();
line("PARTE A — validateProviderApiKey({ provider: 'nvidia' })");
hr();
if (!KEY) {
line(" ⏭ pulada — defina NVIDIA_API_KEY para rodar.");
return;
}
try {
const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts");
const providerSpecificData = { baseUrl: BASE_URL };
const result = await validateProviderApiKey({
provider: "nvidia",
apiKey: KEY,
providerSpecificData,
});
line(" ✅ validateProviderApiKey retornou (sem crash):");
show("resultado", result);
if (typeof (result as any)?.error === "string" && (result as any).error.includes("startsWith")) {
line(" ⚠️ A mensagem de erro contém 'startsWith' → crash CAPTURADO dentro do try/catch da validação.");
}
} catch (err: any) {
line(" ❌ validateProviderApiKey LANÇOU (crash não tratado):");
line(` ${err?.message}`);
line(err?.stack ?? String(err));
}
}
// ──────────────────────────────────────────────────────────────────────────
// Parte B — sanidade do upstream NVIDIA (isola chave/model do nosso pipeline)
// ──────────────────────────────────────────────────────────────────────────
async function partB() {
hr();
line("PARTE B — POST direto no upstream NVIDIA (sanidade da chave/model)");
hr();
if (!KEY) {
line(" ⏭ pulada — defina NVIDIA_API_KEY para rodar.");
return;
}
const url = BASE_URL.endsWith("/chat/completions") ? BASE_URL : `${BASE_URL}/chat/completions`;
show("url", url);
show("model", MODEL);
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${KEY}` },
body: JSON.stringify({
model: MODEL,
messages: [{ role: "user", content: "ping" }],
max_tokens: 1,
}),
});
const text = await res.text();
show("status", res.status);
line(` body (256): ${text.slice(0, 256)}`);
if (res.ok) line(" ✅ upstream OK — chave e model válidos.");
else if (res.status === 401 || res.status === 403) line(" ❌ chave inválida (401/403).");
else line(" ⚠️ não-OK não-auth — chave provavelmente válida, ver corpo.");
} catch (err: any) {
line(` ❌ fetch falhou: ${err?.message}`);
}
}
// ──────────────────────────────────────────────────────────────────────────
// Parte C — probes de type-crash (sem chave) — crava a linha do startsWith
// ──────────────────────────────────────────────────────────────────────────
async function partC() {
hr();
line("PARTE C — probes de type-crash (resolveModelAlias + strip chatCore:3316 + parseModel)");
hr();
const { resolveModelAlias } = await import("../../open-sse/services/modelDeprecation.ts");
const { parseModel } = await import("../../open-sse/services/model.ts");
// Replica EXATA do trecho de chatCore.ts:3315-3320 (feature #1261), sem guard.
function stripPrefixLikeChatCore(effectiveModel: any, provider: string, alias?: string) {
let finalModelToUpstream = effectiveModel;
if (finalModelToUpstream.startsWith(`${provider}/`)) {
finalModelToUpstream = finalModelToUpstream.slice(provider.length + 1);
} else if (alias && finalModelToUpstream.startsWith(`${alias}/`)) {
finalModelToUpstream = finalModelToUpstream.slice(alias.length + 1);
}
return finalModelToUpstream;
}
const inputs: Array<{ label: string; model: any }> = [
{ label: "string normal (multi-barra NVIDIA)", model: "nvidia/openai/gpt-oss-120b" },
{ label: "objeto {} (UI bug / providerSpecificData mal salvo)", model: {} },
{ label: "objeto {id: '...'}", model: { id: "openai/gpt-oss-120b" } },
{ label: "number", model: 123 },
{ label: "array", model: ["openai/gpt-oss-120b"] },
{ label: "null", model: null },
{ label: "undefined", model: undefined },
];
for (const { label, model } of inputs) {
line("");
line(` ▶ input: ${label} (typeof=${typeof model})`);
// 1) resolveModelAlias — deixa não-string passar? (if (!modelId) return modelId)
let effective: any;
try {
effective = resolveModelAlias(model as any);
line(` resolveModelAlias → ${typeof effective} ${JSON.stringify(effective)}`);
} catch (err: any) {
line(` resolveModelAlias THROW: ${err?.message}`);
effective = model;
}
// 2) strip de prefixo (chatCore:3316) — captura o stack EXATO
try {
const out = stripPrefixLikeChatCore(effective, "nvidia", "nvidia");
line(` chatCore strip → ${JSON.stringify(out)} ✅ sem crash`);
} catch (err: any) {
line(` ❌ chatCore:3316 strip THROW: ${err?.message}`);
const at = (err?.stack ?? "").split("\n").find((l: string) => l.includes(".ts"));
if (at) line(` ${at.trim()}`);
}
// 3) parseModel (model.ts:315) — captura o stack EXATO
try {
const parsed = parseModel(model as any);
line(` parseModel → ${JSON.stringify(parsed)} ✅ sem crash`);
} catch (err: any) {
line(` ❌ model.ts parseModel THROW: ${err?.message}`);
const at = (err?.stack ?? "").split("\n").find((l: string) => l.includes("model.ts"));
if (at) line(` ${at.trim()}`);
}
}
}
async function main() {
line("");
line("NVIDIA NIM — diagnóstico `startsWith is not a function`");
// Never echo any portion of the key (js/clear-text-logging) — presence only.
show("NVIDIA_API_KEY presente", KEY ? "sim" : "não");
show("BASE_URL", BASE_URL);
show("MODEL", MODEL);
line("");
await partA();
await partB();
await partC();
hr();
line("FIM.");
}
main().catch((e) => {
console.error("erro fatal no diagnóstico:", e);
process.exit(1);
});
+23
View File
@@ -0,0 +1,23 @@
/**
* One-shot regen of ~/.config/opencode/opencode.json from the live
* OmniRoute /v1/models catalog. Run after a catalog change to refresh
* the opencode client.
*
* Usage: bun run scripts/regen-opencode-config.ts
* or npx tsx scripts/regen-opencode-config.ts
*/
import { generateOpencodeConfig } from "../src/lib/cli-helper/config-generator/opencode.ts";
const baseURL = process.env.OMNIROUTE_URL ?? "http://localhost:20128";
const apiKey = process.env.OMNIROUTE_KEY ?? process.env.OPENCODE_API_KEY ?? "";
if (!apiKey) {
console.error(
"OMNIROUTE_KEY (or OPENCODE_API_KEY) env var is required. " +
"Find it in OmniRoute dashboard → Settings → API Keys."
);
process.exit(1);
}
const out = await generateOpencodeConfig({ baseUrl: baseURL, apiKey });
console.log(out);
+280
View File
@@ -0,0 +1,280 @@
import fs from "fs";
import { execSync } from "child_process";
import path from "path";
const projectRoot = process.env.PROJECT_ROOT || process.cwd();
const filesToCheckoutOurs = [
".source/browser.ts",
".source/server.ts",
"package-lock.json",
"electron/package-lock.json",
"src/app/(dashboard)/dashboard/providers/[id]/page.tsx",
"src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx",
"src/lib/db/contextHandoffs.ts",
"src/app/api/keys/groups/[id]/keys/route.ts",
"src/app/api/keys/groups/[id]/permissions/route.ts",
"src/app/api/keys/groups/[id]/route.ts",
"src/app/api/keys/groups/route.ts",
"src/app/api/middleware/hooks/[name]/route.ts",
"src/app/api/middleware/hooks/route.ts",
"src/app/api/relay/tokens/[id]/route.ts",
"src/app/api/relay/tokens/route.ts",
"src/app/api/playground/simulate-route/route.ts",
];
function runCmd(cmd) {
console.log(`Running: ${cmd}`);
return execSync(cmd, { cwd: projectRoot, encoding: "utf-8" });
}
async function main() {
// 1. Checkout ours for the files where HEAD is the preferred up-to-date state
for (const file of filesToCheckoutOurs) {
try {
runCmd(`git checkout --ours "${file}"`);
runCmd(`git add "${file}"`);
} catch (err) {
console.error(`Failed to checkout --ours for ${file}:`, err.message);
}
}
// 2. Resolve .dockerignore (keep release/v3.8.4 doc rules)
try {
runCmd("git checkout --theirs .dockerignore");
runCmd("git add .dockerignore");
} catch (err) {
console.error("Failed to resolve .dockerignore:", err.message);
}
// 3. Resolve docs/reference/ENVIRONMENT.md (keep release/v3.8.4 table formatting)
try {
runCmd("git checkout --theirs docs/reference/ENVIRONMENT.md");
runCmd("git add docs/reference/ENVIRONMENT.md");
} catch (err) {
console.error("Failed to resolve docs/reference/ENVIRONMENT.md:", err.message);
}
// 4. Resolve open-sse/executors/index.ts (keep both ClaudeWebExecutor and InnerAiExecutor)
const execIndexFile = path.join(projectRoot, "open-sse/executors/index.ts");
if (fs.existsSync(execIndexFile)) {
let content = fs.readFileSync(execIndexFile, "utf-8");
// Resolve imports conflict
content = content.replace(
/<<<<<<< HEAD\r?\nimport \{ ClaudeWebExecutor \} from "\.\/claude-web\.ts";\r?\n=======\r?\nimport \{ InnerAiExecutor \} from "\.\/inner-ai\.ts";\r?\n>>>>>>> release\/v3\.8\.4/g,
'import { ClaudeWebExecutor } from "./claude-web.ts";\nimport { InnerAiExecutor } from "./inner-ai.ts";'
);
// Resolve executor registration conflict
content = content.replace(
/<<<<<<< HEAD\r?\n\s+"claude-web": new ClaudeWebExecutor\(\),\r?\n\s+"cw-web": new ClaudeWebExecutor\(\), \/\/ Alias\r?\n=======\r?\n\s+"inner-ai": new InnerAiExecutor\(\),\r?\n\s+"in-ai": new InnerAiExecutor\(\), \/\/ Alias\r?\n>>>>>>> release\/v3\.8\.4/g,
' "claude-web": new ClaudeWebExecutor(),\n "cw-web": new ClaudeWebExecutor(), // Alias\n "inner-ai": new InnerAiExecutor(),\n "in-ai": new InnerAiExecutor(), // Alias'
);
fs.writeFileSync(execIndexFile, content);
runCmd("git add open-sse/executors/index.ts");
}
// 7. Resolve src/app/api/providers/[id]/models/route.ts (combine imports)
const modelsRoute = path.join(projectRoot, "src/app/api/providers/[id]/models/route.ts");
if (fs.existsSync(modelsRoute)) {
let content = fs.readFileSync(modelsRoute, "utf-8");
content = content.replace(
/<<<<<<< HEAD\r?\n=======\r?\nimport \{ sanitizeErrorMessage \} from "@omniroute\/open-sse\/utils\/error";\r?\nimport \{ getStaticQoderModels \} from "@omniroute\/open-sse\/services\/qoderCli\.ts";\r?\n>>>>>>> release\/v3\.8\.4/g,
'import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";\nimport { getStaticQoderModels } from "@omniroute/open-sse/services/qoderCli.ts";'
);
fs.writeFileSync(modelsRoute, content);
runCmd("git add src/app/api/providers/[id]/models/route.ts");
}
// 8. Resolve src/sse/handlers/chat.ts
const sseChat = path.join(projectRoot, "src/sse/handlers/chat.ts");
if (fs.existsSync(sseChat)) {
let content = fs.readFileSync(sseChat, "utf-8");
// Resolve comment / modelStr conflict
content = content.replace(
/<<<<<<< HEAD\r?\n=======\r?\n\s+\/\/ `let` because the middleware-hook pipeline \(line ~319\) may reassign this\r?\n\s+\/\/ when a hook rewrites the target model\. Previously declared `const`, which\r?\n\s+\/\/ broke turbopack\/strict-mode builds \(PR #2670 regression\)\.\r?\n>>>>>>> release\/v3\.8\.4\r?\n\s+let modelStr = body\.model;/g,
" // `let` because the middleware-hook pipeline (line ~319) may reassign this\n // when a hook rewrites the target model. Previously declared `const`, which\n // broke turbopack/strict-mode builds (PR [PR #2670](file:///home/diegosouzapw/dev/proxys/OmniRoute/package.json#L2670) regression).\n let modelStr = body.model;"
);
// Resolve trafficType / modelAbortSignal conflict (1st occurrence)
content = content.replace(
/<<<<<<< HEAD\r?\n\s+trafficType\?: "production" \| "shadow";\r?\n=======\r?\n\s+modelAbortSignal\?: AbortSignal \| null;\r?\n>>>>>>> release\/v3\.8\.4/g,
' trafficType?: "production" | "shadow";\n modelAbortSignal?: AbortSignal | null;'
);
fs.writeFileSync(sseChat, content);
runCmd("git add src/sse/handlers/chat.ts");
}
// 9. Resolve bin/cli/tray/autostart.mjs (keep execFileSync, combine ignoreFailure and systemd CI fallback)
const autostart = path.join(projectRoot, "bin/cli/tray/autostart.mjs");
if (fs.existsSync(autostart)) {
let content = fs.readFileSync(autostart, "utf-8");
// runUserSystemctl conflict
content = content.replace(
/<<<<<<< HEAD\r?\n\s+\} catch \{\r?\n=======\r?\n\s+\} catch \(err\) \{\r?\n\s+if \(!ignoreFailure\) throw err;\r?\n>>>>>>> release\/v3\.8\.4/g,
` } catch (err) { \n if (!ignoreFailure) throw err;`
);
// isSystemdServiceEnabled conflict
content = content.replace(
/<<<<<<< HEAD\r?\n\s+return false;\r?\n=======\r?\n\s+\/\/ systemctl --user can't query the bus \(headless environments \/ CI runners\)\.\r?\n\s+\/\/ Treat the presence of the unit file as the source of truth, matching the\r?\n\s+\/\/ fallback used in enableLinux\(\) where unit-file existence counts as success\.\r?\n\s+return true;\r?\n>>>>>>> release\/v3\.8\.4/g,
` // systemctl --user can't query the bus (headless environments / CI runners).\n // Treat the presence of the unit file as the source of truth, matching the\n // fallback used in enableLinux() where unit-file existence counts as success.\n return true;`
);
fs.writeFileSync(autostart, content);
runCmd("git add bin/cli/tray/autostart.mjs");
}
// 10. Resolve electron/package.json
const electronPkg = path.join(projectRoot, "electron/package.json");
if (fs.existsSync(electronPkg)) {
let content = fs.readFileSync(electronPkg, "utf-8");
content = content.replace(
/<<<<<<< HEAD\r?\n\s+"electron": "\^42\.2\.0",\r?\n\s+"electron-builder": "\^26\.11\.0"\r?\n=======\r?\n\s+"electron": "\^41\.2\.0",\r?\n\s+"electron-builder": "\^26\.11\.1"\r?\n>>>>>>> release\/v3\.8\.4/g,
' "electron": "^42.2.0",\n "electron-builder": "^26.11.1"'
);
fs.writeFileSync(electronPkg, content);
runCmd("git add electron/package.json");
}
// 11. Resolve .github/workflows/ci.yml
const ciYaml = path.join(projectRoot, ".github/workflows/ci.yml");
if (fs.existsSync(ciYaml)) {
let content = fs.readFileSync(ciYaml, "utf-8");
// Run c8 over shard title
content = content.replace(
/<<<<<<< HEAD\r?\n\s+rm -rf coverage-shard coverage-shard-report\r?\n=======\r?\n\s+# `--temp-directory` \(writable via NODE_V8_COVERAGE\) is what the merge\r?\n\s+# job reads with `c8 report --temp-directory \.\.\.`\. Using `--output-dir`\r?\n\s+# only produces the final json \*report\* and leaves the raw v8 files in\r?\n\s+# `coverage\/tmp`, so uploading `coverage-shard\/` was empty\. Pin the temp\r?\n\s+# dir so the raw coverage files live there and the artifact upload picks\r?\n\s+# them up regardless of `--test-force-exit` timing\.\r?\n>>>>>>> release\/v3\.8\.4/g,
" rm -rf coverage-shard coverage-shard-report\n # `--temp-directory` (writable via NODE_V8_COVERAGE) is what the merge\n # job reads with `c8 report --temp-directory ...`. Using `--output-dir`\n # only produces the final json *report* and leaves the raw v8 files in\n # `coverage/tmp`, so uploading `coverage-shard/` was empty. Pin the temp\n # dir so the raw coverage files live there and the artifact upload picks\n # them up regardless of `--test-force-exit` timing."
);
// c8 temp-directory arg
content = content.replace(
/<<<<<<< HEAD\r?\n=======\r?\n\s+--temp-directory=coverage-shard\r?\n>>>>>>> release\/v3\.8\.4/g,
" --temp-directory=coverage-shard"
);
fs.writeFileSync(ciYaml, content);
runCmd("git add .github/workflows/ci.yml");
}
// 12. Resolve Dockerfile
const dockerfile = path.join(projectRoot, "Dockerfile");
if (fs.existsSync(dockerfile)) {
let content = fs.readFileSync(dockerfile, "utf-8");
// FROM node
content = content.replace(
/FROM node:26\.2\.0-trixie-slim AS builder\r?\nFROM node:24-trixie-slim AS builder/g,
"FROM node:24-trixie-slim AS builder"
);
// apt-get cache mounts
content = content.replace(
/<<<<<<< HEAD\r?\nRUN --mount=type=cache,target=\/var\/cache\/apt,sharing=locked \\\r?\n\s+--mount=type=cache,target=\/var\/lib\/apt\/lists,sharing=locked \\\r?\n\s+apt-get update \\\r?\n=======\r?\nRUN apt-get update \\\r?\n>>>>>>> release\/v3\.8\.4/g,
"RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\\n --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \\\n apt-get update \\"
);
// npm ci script ignore and reproducible build check
content = content.replace(
/<<<<<<< HEAD\r?\nRUN --mount=type=cache,target=\/root\/\.npm \\\r?\n\s+if \[ -f package-lock\.json \]; then \\\r?\n\s+npm ci --no-audit --no-fund --legacy-peer-deps; \\\r?\n\s+else \\\r?\n\s+npm install --no-audit --no-fund --legacy-peer-deps; \\\r?\n\s+fi\r?\n=======\r?\n# `--ignore-scripts` blocks the install\/postinstall hooks of dependencies,[\s\S]*?RUN npm ci --no-audit --no-fund --legacy-peer-deps --ignore-scripts\r?\n>>>>>>> release\/v3\.8\.4/g,
`# --ignore-scripts blocks the install/postinstall hooks of dependencies,
# closing the supply-chain attack surface where a transitive dep can run
# arbitrary code at install time. OmniRoute's own postinstall (
# better-sqlite3 binary touchups, @swc/helpers copy) is only needed when
# a packaged app/node_modules is unpacked — inside the Docker builder we
# are doing a fresh native-platform install, so dropping the scripts is safe.
#
# We REQUIRE a committed package-lock.json so resolved dependency versions
# are reproducible.
RUN test -f package-lock.json \\
|| (echo "package-lock.json is required for reproducible Docker builds" >&2 && exit 1)
RUN --mount=type=cache,target=/root/.npm \\
npm ci --no-audit --no-fund --legacy-peer-deps --ignore-scripts`
);
// npm global install
content = content.replace(
/<<<<<<< HEAD\r?\nRUN --mount=type=cache,target=\/root\/\.npm \\\r?\n\s+npm install -g --no-audit --no-fund @openai\/codex @anthropic-ai\/claude-code droid openclaw@latest\r?\n=======\r?\nRUN npm install -g --no-audit --no-fund @openai\/codex @anthropic-ai\/claude-code droid openclaw@latest\r?\n\r?\nUSER node\r?\n\r?\n>>>>>>> release\/v3\.8\.4/g,
"RUN --mount=type=cache,target=/root/.npm \\\n npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest\n\nUSER node"
);
fs.writeFileSync(dockerfile, content);
runCmd("git add Dockerfile");
}
// 13. Resolve open-sse/services/combo.ts
const openSseCombo = path.join(projectRoot, "open-sse/services/combo.ts");
if (fs.existsSync(openSseCombo)) {
let content = fs.readFileSync(openSseCombo, "utf-8");
// IntentClassifierConfig imports
content = content.replace(
/<<<<<<< HEAD\r?\nimport \{\r?\n\s+classifyWithConfig,\r?\n\s+DEFAULT_INTENT_CONFIG,\r?\n\s+type IntentClassifierConfig,\r?\n\} from "\.\/intentClassifier\.ts";\r?\n=======\r?\nimport \{ notifyWebhookEvent \} from "\.\.\/\.\.\/src\/lib\/webhookDispatcher";\r?\nimport \{ classifyWithConfig, DEFAULT_INTENT_CONFIG \} from "\.\/intentClassifier\.ts";\r?\n>>>>>>> release\/v3\.8\.4/g,
'import { notifyWebhookEvent } from "../../src/lib/webhookDispatcher";\nimport {\n classifyWithConfig,\n DEFAULT_INTENT_CONFIG,\n type IntentClassifierConfig,\n} from "./intentClassifier.ts";'
);
// handlePipelineCombo call
content = content.replace(
/<<<<<<< HEAD\r?\n\s+handleChatCore: handleSingleModel,\r?\n\s+log: \{\r?\n\s+info: log\.info,\r?\n\s+warn: log\.warn,\r?\n\s+error: log\.error \?\? log\.warn,\r?\n\s+\},\r?\n\s+settings: settings \?\? \{\},\r?\n\s+signal: signal \?\? undefined,\r?\n=======\r?\n\s+handleChatCore: handleSingleModelWithTimeout,\r?\n\s+log,\r?\n\s+settings,\r?\n\s+signal,\r?\n>>>>>>> release\/v3\.8\.4/g,
" handleChatCore: handleSingleModelWithTimeout,\n log: {\n info: log.info,\n warn: log.warn,\n error: log.error ?? log.warn,\n },\n settings: settings ?? {},\n signal: signal ?? undefined,"
);
// handleSingleModel call in loop
content = content.replace(
/<<<<<<< HEAD\r?\n\s+const result = await handleSingleModelWrapped\(attemptBody, modelStr, \{\r?\n=======\r?\n\s+const result = await handleSingleModelWithTimeout\(body, modelStr, \{\r?\n>>>>>>> release\/v3\.8\.4/g,
" const result = await handleSingleModelWithTimeout(attemptBody, modelStr, {"
);
// recordSessionModelUsage conflict
content = content.replace(
/<<<<<<< HEAD\r?\n\s+recordSessionModelUsage\([\s\S]*?\);\r?\n\s+\r?\n=======\r?\n>>>>>>> release\/v3\.8\.4/g,
" recordSessionModelUsage(\n relayOptions.sessionId,\n combo.name,\n modelStr,\n provider,\n target.connectionId ?? undefined\n );"
);
fs.writeFileSync(openSseCombo, content);
runCmd("git add open-sse/services/combo.ts");
}
// 14. Resolve src/app/api/copilot/chat/route.ts
const copilotChatRoute = path.join(projectRoot, "src/app/api/copilot/chat/route.ts");
if (fs.existsSync(copilotChatRoute)) {
let content = fs.readFileSync(copilotChatRoute, "utf-8");
// Imports conflict
content = content.replace(
/<<<<<<< HEAD\r?\nimport \{ requireManagementAuth \} from "@\/lib\/api\/requireManagementAuth";\r?\nimport \{ processCopilotChat \} from "@\/lib\/copilot\/engine";\r?\nimport \{ isValidationFailure, validateBody \} from "@\/shared\/validation\/helpers";\r?\nimport \{ sanitizeErrorMessage \} from "@omniroute\/open-sse\/utils\/error\.ts";\r?\n=======\r?\nimport \{ processCopilotChat \} from "@\/lib\/copilot\/engine";\r?\nimport type \{ CopilotRequest \} from "@\/lib\/copilot\/engine";\r?\nimport \{ buildErrorBody \} from "@omniroute\/open-sse\/utils\/error";\r?\n>>>>>>> release\/v3\.8\.4/g,
'import { requireManagementAuth } from "@/lib/api/requireManagementAuth";\nimport { processCopilotChat } from "@/lib/copilot/engine";\nimport { isValidationFailure, validateBody } from "@/shared/validation/helpers";\nimport { sanitizeErrorMessage, buildErrorBody } from "@omniroute/open-sse/utils/error.ts";'
);
// Schema content min length
content = content.replace(
/<<<<<<< HEAD\r?\n\s+content: z\.string\(\)\.min\(1, "message content is required"\),\r?\n=======\r?\n\s+content: z\.string\(\),\r?\n>>>>>>> release\/v3\.8\.4/g,
' content: z.string().min(1, "message content is required"),'
);
// POST implementation conflict
content = content.replace(
/<<<<<<< HEAD\r?\n\s+const authError = await requireManagementAuth\(request\);\r?\n\s+if \(authError\) return authError;\r?\n\r?\n\s+try \{\r?\n\s+const rawBody = await request.json\(\);\r?\n\s+const validation = validateBody\(copilotRequestSchema, rawBody\);\r?\n\s+if \(isValidationFailure\(validation\)\) \{\r?\n\s+return NextResponse\.json\(\{ error: validation\.error \}, \{ status: 400 \}\);\r?\n=======\r?\n\s+try \{\r?\n\s+const raw = await request.json\(\);\r?\n\s+const parsed = copilotRequestSchema\.safeParse\(raw\);\r?\n\s+if \(!parsed\.success\) \{\r?\n\s+return NextResponse\.json\r?\n\s+buildErrorBody\(400, parsed\.error\.issues\[0\]\?\.message \?\? "Invalid request"\),\r?\n\s+\{ status: 400 \}\r?\n\s+\);\r?\n>>>>>>> release\/v3\.8\.4\r?\n\s+\}\r?\n\s+const body = parsed\.data as CopilotRequest;\r?\n\r?\n\s+const response = await processCopilotChat\(body\);/g,
" const authError = await requireManagementAuth(request);\n if (authError) return authError;\n\n try {\n const rawBody = await request.json();\n const validation = validateBody(copilotRequestSchema, rawBody);\n if (isValidationFailure(validation)) {\n return NextResponse.json(\n buildErrorBody(400, validation.error),\n { status: 400 }\n );\n }\n const response = await processCopilotChat(validation.data);"
);
// Error handling conflict
content = content.replace(
/<<<<<<< HEAD\r?\n\s+const message = sanitizeErrorMessage\(error\);\r?\n\s+return NextResponse\.json\(\{ error: `Copilot error: \$\{message\}` \}, \{ status: 500 \}\);\r?\n=======\r?\n\s+\/\/ buildErrorBody\(\) routes through sanitizeErrorMessage\(\), which strips\r?\n\s+\/\/ stack traces and absolute file paths\. Hard rule #12\.\r?\n\s+const message = error instanceof Error \? error\.message : "Unknown error";\r?\n\s+return NextResponse\.json\(buildErrorBody\(500, message\), \{ status: 500 \}\);\r?\n>>>>>>> release\/v3\.8\.4/g,
" const message = sanitizeErrorMessage(error);\n return NextResponse.json(buildErrorBody(500, `Copilot error: ${message}`), { status: 500 });"
);
fs.writeFileSync(copilotChatRoute, content);
runCmd("git add src/app/api/copilot/chat/route.ts");
}
console.log("Resolutions written and staged!");
}
main();
+61
View File
@@ -0,0 +1,61 @@
// End-to-end smoke test: drives TraeExecutor against the real Trae API with your
// JWT from trae_solo.env (kept outside the repo), printing content + usage.
// Run: node --import tsx/esm scripts/ad-hoc/smoke-trae.mjs
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const envPath = path.resolve(__dirname, "../../../trae_solo.env");
if (!fs.existsSync(envPath)) {
console.error(`Не найден ${envPath}. Положи туда TRAE_TOKEN= и TRAE_WEB_ID= и т.д.`);
process.exit(1);
}
const cfg = Object.fromEntries(
fs
.readFileSync(envPath, "utf8")
.split("\n")
.filter((l) => l && !l.startsWith("#") && l.includes("="))
.map((l) => {
const i = l.indexOf("=");
return [l.slice(0, i), l.slice(i + 1)];
})
);
const { TraeExecutor } = await import("../../open-sse/executors/trae.ts");
const ex = new TraeExecutor();
const credentials = {
accessToken: cfg.TRAE_TOKEN,
providerSpecificData: {
webId: cfg.TRAE_WEB_ID || "",
bizUserId: cfg.TRAE_BIZ_USER_ID || "",
userUniqueId: cfg.TRAE_USER_UNIQUE_ID || "",
scope: cfg.TRAE_SCOPE || "marscode-us",
tenant: cfg.TRAE_TENANT || "marscode",
region: cfg.TRAE_REGION || "US-East",
aiRegion: cfg.TRAE_AIREGION || cfg.TRAE_REGION || "US-East",
appLanguage: cfg.TRAE_APP_LANGUAGE || "en",
appVersion: cfg.TRAE_APP_VERSION || "1.0.0.1229",
},
};
const model = process.argv[2] || "auto";
const prompt = process.argv.slice(3).join(" ") || "Ответь одним словом: столица Франции?";
console.log(`[smoke] model=${model} prompt=${JSON.stringify(prompt)}`);
const { response } = await ex.execute({
model,
body: { messages: [{ role: "user", content: prompt }] },
stream: false,
credentials,
});
const text = await response.text();
if (response.status !== 200) {
console.error(`[smoke] HTTP ${response.status}\n${text}`);
process.exit(1);
}
const json = JSON.parse(text);
console.log("content:", JSON.stringify(json.choices?.[0]?.message?.content));
console.log("usage: ", json.usage);
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env node
// Sync the cursor models list in open-sse/config/providerRegistry.ts from
// cursor-agent's runtime model list. Triggers an intentional invalid --model
// invocation so cursor-agent prints "Available models: ..." on stderr.
//
// Usage:
// node scripts/ad-hoc/sync-cursor-models.mjs # spawn cursor-agent and apply
// node scripts/ad-hoc/sync-cursor-models.mjs --dry-run # print proposed block, don't write
// node scripts/ad-hoc/sync-cursor-models.mjs --from-stdin # read the error message from stdin
import { spawnSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const REGISTRY_PATH = resolve(__dirname, "..", "open-sse", "config", "providerRegistry.ts");
const args = new Set(process.argv.slice(2));
const DRY_RUN = args.has("--dry-run");
const FROM_STDIN = args.has("--from-stdin");
function readSource() {
if (FROM_STDIN) return readFileSync(0, "utf8");
// cursor-agent prints "Available models: ..." to stderr and exits non-zero.
const r = spawnSync("cursor-agent", ["--model", "--help"], { encoding: "utf8" });
return `${r.stdout || ""}\n${r.stderr || ""}`;
}
// `auto` is a CLI-side abstraction (cursor-agent resolves it locally before
// sending) and `composer-*` targets cursor's edit/composer endpoint, neither
// of which work via the chat RPC. Filter them so the dashboard never offers
// them to the chat path. Pass --include-unsupported to keep them.
function isUnsupportedChatModel(id) {
if (id === "auto") return true;
if (id.startsWith("composer-")) return true;
return false;
}
function parseModelIds(text) {
const m = text.match(/Available models:\s*([^\n]+)/);
if (!m) throw new Error("Could not find 'Available models:' line in cursor-agent output");
const includeUnsupported = args.has("--include-unsupported");
return m[1]
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.filter((id) => includeUnsupported || !isUnsupportedChatModel(id));
}
const SEGMENT_OVERRIDES = {
gpt: "GPT",
claude: "Claude",
gemini: "Gemini",
grok: "Grok",
kimi: "Kimi",
composer: "Composer",
opus: "Opus",
sonnet: "Sonnet",
haiku: "Haiku",
codex: "Codex",
mini: "Mini",
nano: "Nano",
max: "Max",
high: "High",
low: "Low",
medium: "Medium",
xhigh: "XHigh",
none: "None",
fast: "Fast",
thinking: "Thinking",
extra: "Extra",
spark: "Spark",
preview: "Preview",
flash: "Flash",
pro: "Pro",
};
// Pretty-print an id by:
// 1) collapsing claude-NAME-X-Y dotted version (e.g. claude-opus-4-7 → claude-opus-4.7)
// 2) splitting on '-'
// 3) applying SEGMENT_OVERRIDES; falling back to capitalize-first
function humanize(id) {
if (id === "auto") return "Auto (Server Picks)";
// Collapse "X-Y" numeric suffix in claude-foo-X-Y- patterns into "X.Y"
const collapsed = id.replace(/(\d+)-(\d+)(?=-|$)/g, "$1.$2");
const parts = collapsed.split("-");
const labelled = parts.map((p) => {
if (SEGMENT_OVERRIDES[p]) return SEGMENT_OVERRIDES[p];
if (/^\d/.test(p)) return p; // leave version numbers / "k2.5" alone
return p.charAt(0).toUpperCase() + p.slice(1);
});
return labelled.join(" ");
}
function buildModelsArrayLines(ids) {
const seen = new Set();
const out = [];
for (const id of ids) {
if (seen.has(id)) continue;
seen.add(id);
out.push(` { id: ${JSON.stringify(id)}, name: ${JSON.stringify(humanize(id))} },`);
}
return out.join("\n");
}
function replaceCursorModels(source, modelsBlock) {
// Match the `cursor:` provider entry and replace just its `models: [ ... ],` array.
const re = /(\n cursor:\s*\{[\s\S]*?\n models:\s*\[)([\s\S]*?)(\n \],)/;
if (!re.test(source)) throw new Error("Could not locate cursor.models array in registry");
return source.replace(re, `$1\n${modelsBlock}$3`);
}
const ids = parseModelIds(readSource());
const block = buildModelsArrayLines(ids);
if (DRY_RUN) {
console.log(block);
process.exit(0);
}
const before = readFileSync(REGISTRY_PATH, "utf8");
const after = replaceCursorModels(before, block);
if (before === after) {
console.log("No changes — cursor models already in sync.");
process.exit(0);
}
writeFileSync(REGISTRY_PATH, after);
console.log(`Updated ${ids.length} cursor models in ${REGISTRY_PATH}`);
+542
View File
@@ -0,0 +1,542 @@
#!/usr/bin/env node
/**
* assembleStandalone.mjs - Shared standalone bundle assembler for OmniRoute.
*
* Task 0.1 Inventory: Copy/sync operations across the three build scripts
* -----------------------------------------------------------------------
* Operation build-next-isolated prepublish electron Status
* --------------------------------------------------- ------------------- ----------- -------- ------
* .next/standalone -> outDir (cp) Y Y Y SHARED
* .next/static -> outDir/.next/static (cp) Y Y Y SHARED
* public/ -> outDir/public/ (cp) Y Y Y SHARED
* wreq-js/rust -> outDir/node_modules/wreq-js/rust Y - - SHARED (native asset)
* better-sqlite3/build -> outDir/node_modules/better-sqlite3/ Y - - SHARED (native asset)
* @swc/helpers -> outDir/node_modules/@swc/helpers Y Y Y SHARED (extra module)
* pino-abstract-transport -> outDir/node_modules/... Y - - SHARED (extra module)
* pino-pretty -> outDir/node_modules/pino-pretty Y - - SHARED (extra module)
* split2 -> outDir/node_modules/split2 Y - - SHARED (extra module)
* src/lib/db/migrations -> outDir/migrations Y Y - SHARED (extra module)
* src/mitm/server.cjs -> outDir/src/mitm/server.cjs Y - - SHARED (extra module)
* scripts/dev/run-standalone.mjs -> outDir/dev/run-standalone Y - - SHARED (extra module)
* scripts/dev/standalone-server-ws.mjs -> outDir/server-ws Y Y - SHARED (extra module)
* scripts/dev/peer-stamp.mjs -> outDir/peer-stamp.mjs Y Y - SHARED (extra module)
* scripts/dev/responses-ws-proxy.mjs -> outDir/responses-ws- Y Y - SHARED (extra module)
* scripts/build/runtime-env.mjs -> outDir/build/runtime-env Y - - SHARED (extra module)
* scripts/build/bootstrap-env.mjs -> outDir/build/bootstrap- Y - - SHARED (extra module)
* scripts/dev/healthcheck.mjs -> outDir/healthcheck.mjs Y - - SHARED (extra module)
* playwright-core -> outDir/node_modules/playwright-core Y - - SHARED (extra module)
* sqlite-vec -> outDir/node_modules/sqlite-vec Y - - SHARED (extra module)
* sqlite-vec-linux-x64/arm64/darwin-x64/arm64/win-x64 (same) Y - - SHARED (extra module)
* abs-path sanitization in server.js + required-server-files - Y Y SHARED (opt-in: sanitizePaths)
* Turbopack hashed-chunk patch (.next/server/ *.js) - Y - SHARED (opt-in: patchTurbopackChunks)
* --- npm-UNIQUE ---
* MITM tsc compile -> app/src/mitm/ - Y - UNIQUE (prepublish)
* MCP server esbuild -> dist/open-sse/mcp-server/server.js - Y - UNIQUE (prepublish)
* CLI esbuild -> bin/omniroute.mjs - Y - UNIQUE (prepublish)
* sidecar/doc copies (.env.example, docs/, sync-env, etc.) - Y - UNIQUE (prepublish)
* prune + validate (pack-artifact-policy) - Y - UNIQUE (prepublish)
* data/ dir creation - Y - UNIQUE (prepublish)
* --- electron-UNIQUE ---
* better-sqlite3 + keytar native strip (ABI rebuild) - - Y UNIQUE (electron)
* symlink guard (assertBundleIsPackagable) - - Y UNIQUE (electron)
* removeGeneratedElectronArtifacts - - Y UNIQUE (electron)
*/
import fs from "node:fs/promises";
import fsSync from "node:fs";
import path from "node:path";
/**
* Check whether a path exists (async).
* @param {string} targetPath
* @returns {Promise<boolean>}
*/
async function exists(targetPath) {
try {
await fs.access(targetPath);
return true;
} catch {
return false;
}
}
/**
* SINGLE SOURCE OF TRUTH for the standalone bundle's native assets and extra
* modules/sidecars. Both the async path (syncStandaloneNativeAssets /
* syncStandaloneExtraModules, used by build-next-isolated + tests) and the sync
* path (copyNativeAssetsAndExtraModules, used by assembleStandalone) derive their
* copy lists from these arrays. Add a sidecar in ONE place — never two.
*
* Each entry uses path SEGMENT arrays (not pre-joined strings) so the source
* (relative to projectRoot) and destination (relative to outDir) can be joined
* for either path/platform. @type {{label:string, src:string[], dest:string[]}[]}
*/
const NATIVE_ASSET_ENTRIES = [
{
label: "wreq-js native runtime",
src: ["node_modules", "wreq-js", "rust"],
dest: ["node_modules", "wreq-js", "rust"],
},
{
label: "better-sqlite3 native binary",
src: ["node_modules", "better-sqlite3", "build"],
dest: ["node_modules", "better-sqlite3", "build"],
},
{
// TPROXY IP_TRANSPARENT addon (Fase 3 / Epic A). Built by build-tproxy-native
// before assembly; Linux-only + opt-in, so the source is absent on non-Linux
// builds → syncNativeAssetsToDir skips it gracefully. The runtime loader
// (transparentSocket.ts) resolves it cwd-relative to this same dest.
label: "TPROXY transparent-socket addon (Linux-only, opt-in)",
src: ["src", "mitm", "tproxy", "native", "build", "Release", "transparent.node"],
dest: ["src", "mitm", "tproxy", "native", "build", "Release", "transparent.node"],
},
];
/** @type {{label:string, src:string[], dest:string[]}[]} */
const EXTRA_MODULE_ENTRIES = [
{
label: "@swc/helpers",
src: ["node_modules", "@swc", "helpers"],
dest: ["node_modules", "@swc", "helpers"],
},
{
label: "pino-abstract-transport",
src: ["node_modules", "pino-abstract-transport"],
dest: ["node_modules", "pino-abstract-transport"],
},
{
label: "pino-pretty",
src: ["node_modules", "pino-pretty"],
dest: ["node_modules", "pino-pretty"],
},
{ label: "split2", src: ["node_modules", "split2"], dest: ["node_modules", "split2"] },
{ label: "migrations", src: ["src", "lib", "db", "migrations"], dest: ["migrations"] },
{ label: "MITM server", src: ["src", "mitm", "server.cjs"], dest: ["src", "mitm", "server.cjs"] },
{
label: "run-standalone script",
src: ["scripts", "dev", "run-standalone.mjs"],
dest: ["dev", "run-standalone.mjs"],
},
{
// WS-aware wrapper that run-standalone.mjs prefers over bare server.js.
// It installs the trusted peer-IP stamp the authz middleware needs to allow
// loopback/LAN access to LOCAL_ONLY routes; without it the Docker container
// fails closed (every LOCAL_ONLY request 403s). Imports peer-stamp.mjs +
// responses-ws-proxy.mjs, so all three are co-located.
label: "WS/peer-stamp standalone server wrapper",
src: ["scripts", "dev", "standalone-server-ws.mjs"],
dest: ["server-ws.mjs"],
},
{
label: "peer-stamp helper (server-ws.mjs dependency)",
src: ["scripts", "dev", "peer-stamp.mjs"],
dest: ["peer-stamp.mjs"],
},
{
label: "HTTP method guard (server-ws.mjs dependency)",
src: ["scripts", "dev", "http-method-guard.cjs"],
dest: ["http-method-guard.cjs"],
},
{
label: "responses-ws-proxy (server-ws.mjs dependency)",
src: ["scripts", "dev", "responses-ws-proxy.mjs"],
dest: ["responses-ws-proxy.mjs"],
},
{
label: "webdav-handler (server-ws.mjs dependency)",
src: ["scripts", "dev", "webdav-handler.mjs"],
dest: ["webdav-handler.mjs"],
},
{
// #5242: opt-in HTTPS/TLS resolver (server-ws.mjs dependency).
label: "tls-options (server-ws.mjs dependency)",
src: ["scripts", "dev", "tls-options.mjs"],
dest: ["tls-options.mjs"],
},
{
label: "runtime-env script",
src: ["scripts", "build", "runtime-env.mjs"],
dest: ["build", "runtime-env.mjs"],
},
{
label: "bootstrap-env script",
src: ["scripts", "build", "bootstrap-env.mjs"],
dest: ["build", "bootstrap-env.mjs"],
},
{
label: "healthcheck script",
src: ["scripts", "dev", "healthcheck.mjs"],
dest: ["healthcheck.mjs"],
},
{ label: "public directory", src: ["public"], dest: ["public"] },
{
label: "playwright-core (dynamic import by gemini-web executor)",
src: ["node_modules", "playwright-core"],
dest: ["node_modules", "playwright-core"],
},
{
label: "sqlite-vec wrapper (vector memory - loaded at runtime via createRequire)",
src: ["node_modules", "sqlite-vec"],
dest: ["node_modules", "sqlite-vec"],
},
// sqlite-vec's native vec0.so lives in a platform-specific package resolved at
// runtime via require.resolve(). Next.js does NOT trace it into the standalone
// (the externalized wrapper is copied, but its optional platform dep is missed -
// Next.js #88844), so without this the bundled/Docker build silently degrades
// vector search to FTS5: the wrapper loads but getLoadablePath() throws
// MODULE_NOT_FOUND. Copy whichever platform package npm actually installed. See #3066.
...[
"sqlite-vec-linux-x64",
"sqlite-vec-linux-arm64",
"sqlite-vec-darwin-x64",
"sqlite-vec-darwin-arm64",
"sqlite-vec-windows-x64",
].map((pkg) => ({ label: pkg, src: ["node_modules", pkg], dest: ["node_modules", pkg] })),
];
/**
* Copy native standalone assets (wreq-js rust/, better-sqlite3 build/).
*
* The destination is derived as <rootDir>/<distDir>/standalone/node_modules/...
* for backward compatibility with existing callers and tests.
*
* @param {string} rootDir - project root (node_modules are read from here)
* @param {typeof fs} [fsImpl] - fs/promises implementation (injectable for tests)
* @param {Console|{log:Function}} [log] - logger
* @returns {Promise<boolean>} true if any asset was copied
*/
export async function syncStandaloneNativeAssets(rootDir, fsImpl = fs, log = console, outDir) {
const standaloneRoot =
outDir || path.join(rootDir, process.env.NEXT_DIST_DIR || ".build/next", "standalone");
return syncNativeAssetsToDir(rootDir, standaloneRoot, fsImpl, log);
}
/**
* Copy extra modules and sidecars into the Next.js standalone output.
*
* The destination is derived as <rootDir>/<distDir>/standalone/...
* where distDir defaults to ".build/next" (overridable via NEXT_DIST_DIR).
*
* @param {string} rootDir - project root
* @param {typeof fs} [fsImpl] - fs/promises implementation (injectable for tests)
* @param {Console|{log:Function}} [log] - logger
* @returns {Promise<boolean>} true if any module was copied
*/
export async function syncStandaloneExtraModules(rootDir, fsImpl = fs, log = console, outDir) {
const standaloneRoot =
outDir || path.join(rootDir, process.env.NEXT_DIST_DIR || ".build/next", "standalone");
return syncExtraModulesToDir(rootDir, standaloneRoot, fsImpl, log);
}
/**
* Internal: copy native assets to an arbitrary outDir.
*
* @param {string} projectRoot
* @param {string} outDir
* @param {typeof fs} fsImpl
* @param {Console|{log:Function}} log
* @returns {Promise<boolean>}
*/
async function syncNativeAssetsToDir(projectRoot, outDir, fsImpl, log) {
let changed = false;
for (const entry of NATIVE_ASSET_ENTRIES) {
const sourcePath = path.join(projectRoot, ...entry.src);
if (!(await exists(sourcePath))) continue;
const destinationPath = path.join(outDir, ...entry.dest);
const mkdir =
typeof fsImpl.mkdir === "function" ? fsImpl.mkdir.bind(fsImpl) : fs.mkdir.bind(fs);
await mkdir(path.dirname(destinationPath), { recursive: true });
await fsImpl.cp(sourcePath, destinationPath, {
recursive: true,
force: true,
});
log.log(
`[assembleStandalone] Copied native standalone asset: ${path.relative(
projectRoot,
destinationPath
)}`
);
changed = true;
}
return changed;
}
/**
* Internal: copy extra modules/sidecars to an arbitrary outDir.
*
* @param {string} projectRoot
* @param {string} outDir
* @param {typeof fs} fsImpl
* @param {Console|{log:Function}} log
* @returns {Promise<boolean>}
*/
async function syncExtraModulesToDir(projectRoot, outDir, fsImpl, log) {
let changed = false;
for (const entry of EXTRA_MODULE_ENTRIES) {
const sourcePath = path.join(projectRoot, ...entry.src);
if (!(await exists(sourcePath))) continue;
const destPath = path.join(outDir, ...entry.dest);
const mkdir =
typeof fsImpl.mkdir === "function" ? fsImpl.mkdir.bind(fsImpl) : fs.mkdir.bind(fs);
await mkdir(path.dirname(destPath), { recursive: true });
await fsImpl.cp(sourcePath, destPath, { recursive: true, force: true });
log.log(`[assembleStandalone] Synced standalone module: ${entry.label}`);
changed = true;
}
return changed;
}
/**
* Sanitize absolute build-machine paths in server.js and required-server-files.json.
* Replaces the build root with "." so paths resolve relative to wherever the standalone
* bundle is installed.
*
* @param {string} projectRoot - repo root (the path to replace)
* @param {string} outDir - assembled standalone output directory
* @returns {number} number of path replacements made
*/
export function assemblePathSanitize(projectRoot, outDir, distDir = ".next") {
const buildRoot = projectRoot.replaceAll("\\", "/"); // normalise for regex safety
const sanitizeTargets = [
path.join(outDir, "server.js"),
// required-server-files.json lives under the distDir (e.g. .build/next), not
// a literal .next — the standalone preserves the configured distDir path.
path.join(outDir, distDir, "required-server-files.json"),
];
let sanitisedCount = 0;
for (const filePath of sanitizeTargets) {
if (!fsSync.existsSync(filePath)) continue;
let content = fsSync.readFileSync(filePath, "utf8");
// Escape special regex characters in the path
const escaped = buildRoot.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
const re = new RegExp(escaped, "g");
const matches = content.match(re);
if (matches) {
content = content.replace(re, ".");
fsSync.writeFileSync(filePath, content);
sanitisedCount += matches.length;
}
}
return sanitisedCount;
}
/**
* Strip Turbopack hashed externals from compiled chunks.
* Even when Turbopack is disabled at build time, some instrumentation chunks
* may still emit require('package-<16hexchars>') instead of require('package').
* We strip the hex suffix from all .js files in outDir/.next/server/.
*
* @param {string} outDir - assembled standalone output directory
* @returns {{ patchedFiles: number, patchedMatches: number }}
*/
export function patchTurbopackChunks(outDir, distDir = ".next") {
const serverOutput = path.join(outDir, distDir, "server");
const HASH_RE = /(['"\\])([a-z@][a-z0-9@./_-]+?-[0-9a-f]{16}(?:\/[^'"\\]+)?)\1/g;
let patchedFiles = 0;
let patchedMatches = 0;
const walkDir = (dir) => {
let entries = [];
try {
entries = fsSync.readdirSync(dir);
} catch {
return;
}
for (const entry of entries) {
const full = path.join(dir, entry);
try {
const st = fsSync.statSync(full);
if (st.isDirectory()) {
walkDir(full);
continue;
}
if (!entry.endsWith(".js")) continue;
const src = fsSync.readFileSync(full, "utf8");
let count = 0;
const patched = src.replace(HASH_RE, (_, q, name) => {
const base = name.replace(/-[0-9a-f]{16}(?=\/|$)/, "");
count++;
return `${q}${base}${q}`;
});
if (count > 0) {
fsSync.writeFileSync(full, patched);
patchedFiles++;
patchedMatches += count;
}
} catch {
/* skip unreadable files */
}
}
};
if (fsSync.existsSync(serverOutput)) {
walkDir(serverOutput);
}
return { patchedFiles, patchedMatches };
}
/**
* Next.js standalone's server.js is CommonJS (uses require()), but the root package.json
* (which Next copies into the standalone) has "type":"module". Strip "type" so Node treats
* .js files as CJS in the bundle dir — otherwise `node server.js` fails with
* "require is not defined in ES module scope".
*
* @param {string} resolvedOutDir - assembled standalone output directory
*/
function patchStandalonePackageJson(resolvedOutDir) {
const outDirPkgJson = path.join(resolvedOutDir, "package.json");
if (!fsSync.existsSync(outDirPkgJson)) return;
try {
const pkg = JSON.parse(fsSync.readFileSync(outDirPkgJson, "utf8"));
if (pkg.type !== "module") return;
delete pkg.type;
fsSync.writeFileSync(outDirPkgJson, JSON.stringify(pkg, null, 2) + "\n");
console.log(
"[assembleStandalone] Removed 'type':'module' from standalone package.json (server.js is CJS)"
);
} catch (err) {
console.warn(`[assembleStandalone] Could not patch standalone package.json: ${err.message}`);
}
}
/**
* Copy <distDir>/static -> outDir/<relDistDir>/static and projectRoot/public -> outDir/public.
* The static dest mirrors the configured distDir (e.g. .build/next), which is where the
* standalone server serves /_next/static from. See step 2 in assembleStandalone for why.
*
* @param {{ distDir: string, relDistDir: string, projectRoot: string, resolvedOutDir: string }} opts
*/
function copyStaticAndPublic({ distDir, relDistDir, projectRoot, resolvedOutDir }) {
const staticSrc = path.join(distDir, "static");
const staticDest = path.join(resolvedOutDir, relDistDir, "static");
if (fsSync.existsSync(staticSrc)) {
fsSync.mkdirSync(path.dirname(staticDest), { recursive: true });
fsSync.cpSync(staticSrc, staticDest, { recursive: true, force: true });
}
const publicSrc = path.join(projectRoot, "public");
if (fsSync.existsSync(publicSrc)) {
fsSync.cpSync(publicSrc, path.join(resolvedOutDir, "public"), { recursive: true, force: true });
}
}
/**
* Copy native assets (wreq-js, better-sqlite3) and extra runtime modules/sidecars
* (pino, migrations, MITM server, helper scripts, sqlite-vec platform packages, …)
* into the assembled bundle. Missing sources are skipped silently.
*
* @param {string} projectRoot
* @param {string} resolvedOutDir
*/
function copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir) {
for (const asset of NATIVE_ASSET_ENTRIES) {
const src = path.join(projectRoot, ...asset.src);
if (!fsSync.existsSync(src)) continue;
const dest = path.join(resolvedOutDir, ...asset.dest);
fsSync.mkdirSync(path.dirname(dest), { recursive: true });
fsSync.cpSync(src, dest, { recursive: true, force: true });
console.log(`[assembleStandalone] Copied native asset: ${asset.label}`);
}
for (const mod of EXTRA_MODULE_ENTRIES) {
const src = path.join(projectRoot, ...mod.src);
if (!fsSync.existsSync(src)) continue;
const dest = path.join(resolvedOutDir, ...mod.dest);
fsSync.mkdirSync(path.dirname(dest), { recursive: true });
fsSync.cpSync(src, dest, { recursive: true, force: true });
console.log(`[assembleStandalone] Synced module: ${mod.label}`);
}
}
/**
* Assemble the Next.js standalone bundle into outDir.
*
* Copies <distDir>/standalone -> outDir, then <distDir>/static -> outDir/.next/static,
* projectRoot/public -> outDir/public, native assets, and extra modules/sidecars.
* Optionally sanitizes abs paths and patches Turbopack chunks.
*
* This is a synchronous function for use in build scripts.
*
* @param {object} opts
* @param {string} opts.distDir - Next.js distDir (e.g. ".next" or ".build/next")
* @param {string} opts.outDir - destination directory for the assembled bundle
* @param {string} [opts.projectRoot] - repo root; defaults to process.cwd()
* @param {boolean} [opts.sanitizePaths] - replace build-machine abs paths with "." (default false)
* @param {boolean} [opts.patchTurbopackChunks] - strip hashed externals from .next/server js files (default false)
* @param {boolean} [opts.copyNatives] - copy native assets + extra modules (default true)
* @returns {void}
*/
export function assembleStandalone({
distDir,
outDir,
projectRoot = process.cwd(),
sanitizePaths = false,
patchTurbopackChunks: doPatchChunks = false,
copyNatives = true,
}) {
if (!distDir) throw new Error("[assembleStandalone] distDir is required");
if (!outDir) throw new Error("[assembleStandalone] outDir is required");
// The standalone bundle preserves the distDir path RELATIVE to projectRoot
// (the server's baked config uses e.g. "./.build/next"), so output dest paths
// for static / required-server-files / server chunks must use the relative
// distDir appended to outDir — never the absolute build-machine distDir.
const relDistDir = path.isAbsolute(distDir) ? path.relative(projectRoot, distDir) : distDir;
const standaloneDir = path.resolve(path.join(distDir, "standalone"));
const resolvedOutDir = path.resolve(outDir);
if (!fsSync.existsSync(standaloneDir)) {
throw new Error(
`[assembleStandalone] standalone dir not found: ${standaloneDir}. Run \`next build\` first.`
);
}
// 1. Copy <distDir>/standalone -> outDir (skip when outDir IS the standalone dir — in-place mode)
fsSync.mkdirSync(resolvedOutDir, { recursive: true });
if (resolvedOutDir !== standaloneDir) {
fsSync.cpSync(standaloneDir, resolvedOutDir, { recursive: true });
}
// 1.5. Standalone server.js is CJS — strip "type":"module" from the copied package.json.
patchStandalonePackageJson(resolvedOutDir);
// 2/3. Copy <distDir>/static -> outDir/<relDistDir>/static and projectRoot/public -> outDir/public.
// CRITICAL: the standalone server.js is built with distDir baked into its config
// (e.g. "./.build/next"), so it serves /_next/static from <outDir>/<relDistDir>/static,
// NOT a literal <outDir>/.next/static. Copying to .next/static leaves the server's
// static dir empty → every JS/CSS chunk 404s → blank page. Mirror the distDir path.
copyStaticAndPublic({ distDir, relDistDir, projectRoot, resolvedOutDir });
// 4. Optionally sanitize abs paths
if (sanitizePaths) {
const count = assemblePathSanitize(projectRoot, resolvedOutDir, relDistDir);
if (count > 0) {
console.log(`[assembleStandalone] Sanitised ${count} hardcoded path references`);
}
}
// 5. Optionally patch Turbopack hashed chunks
if (doPatchChunks) {
const { patchedFiles, patchedMatches } = patchTurbopackChunks(resolvedOutDir, relDistDir);
if (patchedMatches > 0) {
console.log(
`[assembleStandalone] Hash-strip: patched ${patchedMatches} hashed require() in ${patchedFiles} server chunk file(s)`
);
}
}
// 6. Optionally copy native assets + extra modules (synchronous)
if (copyNatives) {
copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir);
}
}
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env node
/**
* Backend-only build helper.
*
* When `OMNIROUTE_BUILD_BACKEND_ONLY=1` (or `OMNIROUTE_BUILD_PROFILE=backend`) is set,
* `build-next-isolated.mjs` calls `stubDashboardPages()` BEFORE `next build` and
* `restoreDashboardPages()` in a `finally` afterward.
*
* WHY: OmniRoute embedders that only consume the HTTP API (`/api/*`, `/v1/*`, `/v1beta/*`)
* — e.g. the VibeProxy desktop app, headless self-hosters, CI that only needs the router —
* do NOT need the Next.js dashboard UI. Building it dominates `next build`: the ~126 leaf
* pages pull in heavy client vendor chunks (recharts, monaco-editor, @xyflow, mermaid,
* @lobehub/icons), the static-generation pass renders every route, and React Server Actions
* generate a client-entry manifest. Replacing every App-Router UI file (page/layout/template/
* loading/error/not-found/default) with a trivial server stub removes the client graph, the
* prerender pass, AND all `"use server"` actions, while leaving EVERY `route.ts` (API) handler,
* `middleware`, and metadata route (sitemap/robots/manifest/icon/...) fully intact. The
* resulting standalone `server.js` serves the complete backend API; the dashboard renders
* nothing.
*
* WHY STUB LAYOUTS TOO (not just pages): Next's FlightClientEntryPlugin builds a per-route
* Server-Actions manifest. Stubbing only the pages while leaving layouts (which import client
* providers and inline `"use server"` actions) leaves actions registered whose page-level
* client entry no longer exists — `createActionAssets` then dereferences an undefined module
* map ("Cannot read properties of undefined (reading 'server')"). Stubbing the whole UI tree
* removes every action and client reference, so the plugin has nothing to reconcile.
*
* SAFETY: these files are git-tracked, so a hard kill is recoverable via `git checkout -- src/app`.
* The caller also restores in a `finally` block and on SIGINT/SIGTERM. Stubs carry a marker so
* the operation is idempotent and detectable.
*/
import fs from "node:fs";
import path from "node:path";
export const BACKEND_ONLY_STUB_MARKER =
"/* omniroute:backend-only-stub (auto-restored after build) */";
const HEADER = `${BACKEND_ONLY_STUB_MARKER}\n`;
// Leaf page → force-dynamic (never prerendered) server component returning null.
const PAGE_STUB = `${HEADER}export const dynamic = "force-dynamic";\nexport default function BackendOnlyPageStub() {\n return null;\n}\n`;
// Root layout MUST render <html>/<body>. Minimal server component, no client imports.
const ROOT_LAYOUT_STUB = `${HEADER}export const dynamic = "force-dynamic";\nexport default function RootLayout({ children }) {\n return (\n <html lang="en">\n <body>{children}</body>\n </html>\n );\n}\n`;
// Non-root layout / template → pass children through unchanged.
const PASSTHROUGH_STUB = `${HEADER}export default function BackendOnlyPassthroughStub({ children }) {\n return children;\n}\n`;
// loading / default / not-found → render nothing.
const NULL_STUB = `${HEADER}export default function BackendOnlyNullStub() {\n return null;\n}\n`;
// Error boundaries must be Client Components in Next; a no-op client stub carries no action.
const ERROR_STUB = `${HEADER}"use client";\nexport default function BackendOnlyErrorStub() {\n return null;\n}\n`;
// global-error replaces the root layout on a root error, so it must render <html>/<body>.
const GLOBAL_ERROR_STUB = `${HEADER}"use client";\nexport default function BackendOnlyGlobalErrorStub() {\n return (\n <html>\n <body></body>\n </html>\n );\n}\n`;
const UI_BASENAME_RE = /^(page|layout|template|loading|error|global-error|not-found|default)\.(tsx|jsx|ts|js)$/;
const ROUTE_FILE_RE = /[\\/]route\.(ts|js|tsx|jsx)$/;
/**
* Strip a leading `"use server"` module directive. Some OmniRoute API Route Handlers
* (`src/app/api/**\/route.ts`) carry a top-level `"use server"` — which registers the module
* as a React Server-Actions provider. Once the dashboard pages that import those exports as
* actions are stubbed away, Next's FlightClientEntryPlugin still has the action registered but
* no client entry to bind it to, and `createActionAssets` dereferences an undefined module map
* ("Cannot read properties of undefined (reading 'server')"). Removing the directive turns the
* file back into a plain Route Handler — the GET/POST HTTP endpoint is UNCHANGED (route handlers
* are server-side regardless of the directive) — so the API keeps working while the phantom
* action registration disappears. The directive is only removed when it is the first real
* statement (comments may precede it); a `"use server"` string appearing later is untouched.
*/
function stripLeadingUseServer(src) {
const lines = src.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const t = lines[i].trim().replace(/^\uFEFF/, "");
if (t === "") continue;
if (/^["']use server["'];?$/.test(t)) {
lines.splice(i, 1);
return { changed: true, src: lines.join("\n") };
}
if (t.startsWith("//") || t.startsWith("/*") || t.startsWith("*")) continue;
break; // first real statement is not the directive
}
return { changed: false, src };
}
/** True when the current build should skip the dashboard frontend. */
export function isBackendOnlyBuild(env = process.env) {
return env.OMNIROUTE_BUILD_BACKEND_ONLY === "1" || env.OMNIROUTE_BUILD_PROFILE === "backend";
}
function walkFiles(dir, out = []) {
let entries = [];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walkFiles(full, out);
else out.push(full);
}
return out;
}
/** Pick the stub source for an App-Router UI file (null = don't stub this file). */
function stubFor(file, appDir) {
const base = path.basename(file);
const m = UI_BASENAME_RE.exec(base);
if (!m) return null;
const kind = m[1];
const relDir = path.relative(appDir, path.dirname(file));
const isRoot = relDir === "" || relDir === ".";
switch (kind) {
case "page":
return PAGE_STUB;
case "layout":
return isRoot ? ROOT_LAYOUT_STUB : PASSTHROUGH_STUB;
case "template":
return PASSTHROUGH_STUB;
case "loading":
case "default":
case "not-found":
return NULL_STUB;
case "error":
return ERROR_STUB;
case "global-error":
return GLOBAL_ERROR_STUB;
default:
return null;
}
}
/**
* Replace every App-Router UI file under src/app with a trivial stub.
* @returns {{file:string, original:string}[]} stubbed files + their original contents.
*/
export function stubDashboardPages(rootDir = process.cwd(), log = console) {
const appDir = path.join(rootDir, "src", "app");
if (!fs.existsSync(appDir)) {
log.warn?.("[backend-only] src/app not found — nothing to stub");
return [];
}
const stubbed = [];
for (const file of walkFiles(appDir)) {
const stub = stubFor(file, appDir);
if (stub) {
let original;
try {
original = fs.readFileSync(file, "utf8");
} catch {
continue;
}
if (original.includes(BACKEND_ONLY_STUB_MARKER)) continue; // idempotent
try {
fs.writeFileSync(file, stub, "utf8");
stubbed.push({ file, original });
} catch (err) {
log.warn?.(`[backend-only] Could not stub ${file}: ${err?.message || err}`);
}
continue;
}
// Route Handlers with a leading "use server" directive: strip the directive so the module
// is no longer registered as a Server-Actions provider (the HTTP endpoint is unchanged).
if (ROUTE_FILE_RE.test(file)) {
let original;
try {
original = fs.readFileSync(file, "utf8");
} catch {
continue;
}
const { changed, src } = stripLeadingUseServer(original);
if (!changed) continue;
try {
fs.writeFileSync(file, src, "utf8");
stubbed.push({ file, original });
} catch (err) {
log.warn?.(`[backend-only] Could not de-action ${file}: ${err?.message || err}`);
}
}
}
const uiCount = stubbed.filter((e) => ROUTE_FILE_RE.test(e.file) === false).length;
log.log?.(
`[backend-only] Stubbed ${uiCount} App-Router UI file(s) + de-actioned ${
stubbed.length - uiCount
} route handler(s); route.ts HTTP endpoints left intact`
);
return stubbed;
}
/** Restore the original contents of every stubbed file. Best-effort; logs failures. */
export function restoreDashboardPages(stubbed, log = console) {
if (!Array.isArray(stubbed) || stubbed.length === 0) return;
let restored = 0;
for (const entry of stubbed) {
if (!entry) continue;
try {
fs.writeFileSync(entry.file, entry.original, "utf8");
restored += 1;
} catch (err) {
log.error?.(
`[backend-only] FAILED to restore ${entry.file}: ${err?.message || err}` +
`run \`git checkout -- ${entry.file}\` to recover`
);
}
}
log.log?.(`[backend-only] Restored ${restored}/${stubbed.length} App-Router UI file(s)`);
}
+346
View File
@@ -0,0 +1,346 @@
#!/usr/bin/env node
/**
* OmniRoute — Zero-Config Bootstrap
*
* Auto-generates required secrets (JWT_SECRET, STORAGE_ENCRYPTION_KEY) if
* missing or empty, persists them to {DATA_DIR}/server.env so they survive
* restarts, Docker volume remounts, and upgrades.
*
* Works across all deployment modes:
* - npm / app runners: called from run-standalone.mjs and run-next.mjs
* - Docker: same, secrets persisted in mounted volume
* - Electron: called from main.js startup, persisted in DATA_DIR
*
* Priority (lowest → highest):
* 1. Auto-generated defaults
* 2. {DATA_DIR}/server.env (persisted on first boot)
* 3. Preferred config .env (DATA_DIR/.env -> ~/.omniroute/.env -> ./.env)
* 4. process.env (shell / Docker -e flags, highest priority)
*/
import { randomBytes, createDecipheriv, scryptSync, createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
const require = createRequire(import.meta.url);
// ── OAuth secrets that are optional but warn if missing ─────────────────────
const OPTIONAL_OAUTH_SECRETS = [
{ keys: ["ANTIGRAVITY_OAUTH_CLIENT_SECRET"], label: "Antigravity OAuth" },
{ keys: ["QODER_OAUTH_CLIENT_SECRET"], label: "Qoder OAuth" },
];
// ── Resolve DATA_DIR (mirrors dataPaths.ts logic) ───────────────────────────
function resolveDataDir(overridePath, env = process.env) {
if (overridePath?.trim()) return resolve(overridePath);
const configured = env.DATA_DIR?.trim();
if (configured) return resolve(configured);
if (process.platform === "win32") {
const appData = env.APPDATA || join(homedir(), "AppData", "Roaming");
return join(appData, "omniroute");
}
const xdg = env.XDG_CONFIG_HOME?.trim();
if (xdg) return join(resolve(xdg), "omniroute");
return join(homedir(), ".omniroute");
}
function getPreferredEnvFilePath(env = process.env) {
const candidates = [];
if (env.DATA_DIR?.trim()) {
candidates.push(join(resolve(env.DATA_DIR.trim()), ".env"));
}
candidates.push(join(resolveDataDir(null, env), ".env"));
candidates.push(join(process.cwd(), ".env"));
return candidates.find((filePath) => existsSync(filePath)) ?? null;
}
function isNativeSqliteLoadError(error) {
const message = error instanceof Error ? error.message : String(error);
const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
return (
message.includes("Module did not self-register") ||
message.includes("NODE_MODULE_VERSION") ||
message.includes("ERR_DLOPEN_FAILED") ||
message.includes("Could not locate the bindings file") ||
message.includes("Cannot find module 'better-sqlite3'") ||
code === "ERR_DLOPEN_FAILED" ||
code === "MODULE_NOT_FOUND"
);
}
function hasEncryptedCredentials(dataDir) {
const dbPath = join(dataDir, "storage.sqlite");
if (!existsSync(dbPath)) return false;
try {
const Database = require("better-sqlite3");
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
try {
const row = db
.prepare(
`SELECT 1
FROM provider_connections
WHERE access_token LIKE 'enc:v1:%'
OR refresh_token LIKE 'enc:v1:%'
OR api_key LIKE 'enc:v1:%'
OR id_token LIKE 'enc:v1:%'
LIMIT 1`
)
.get();
return !!row;
} finally {
db.close();
}
} catch (error) {
if (isNativeSqliteLoadError(error)) {
return false;
}
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Unable to inspect existing database at ${dbPath}: ${message}`);
}
}
// ── Parse a simple KEY=VALUE env file ───────────────────────────────────────
function parseEnvFile(filePath) {
if (!existsSync(filePath)) return {};
const env = {};
const lines = readFileSync(filePath, "utf8").split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx < 1) continue;
const key = trimmed.slice(0, eqIdx).trim();
const val = unquoteEnvValue(trimmed.slice(eqIdx + 1).trim());
env[key] = val;
}
return env;
}
function unquoteEnvValue(value) {
if (value.length < 2) return value;
const quote = value[0];
if ((quote !== '"' && quote !== "'") || value[value.length - 1] !== quote) return value;
return value.slice(1, -1);
}
// ── Write a simple KEY=VALUE env file ───────────────────────────────────────
function writeEnvFile(filePath, env) {
const lines = [
"# Auto-generated by OmniRoute bootstrap — do not delete",
`# Created: ${new Date().toISOString()}`,
"",
...Object.entries(env).map(([k, v]) => `${k}=${v}`),
"",
];
writeFileSync(filePath, lines.join("\n"), "utf8");
}
// ── Main bootstrap function ──────────────────────────────────────────────────
/**
* @param {{ dataDirOverride?: string; quiet?: boolean }} options
* @returns {Record<string, string>} merged env to pass to child process
*/
export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) {
const log = quiet ? () => {} : (msg) => process.stderr.write(`[bootstrap] ${msg}\n`);
const preferredEnvPath = getPreferredEnvFilePath(process.env);
const preferredEnv = preferredEnvPath ? parseEnvFile(preferredEnvPath) : {};
const dataDir = resolveDataDir(dataDirOverride, { ...preferredEnv, ...process.env });
const serverEnvPath = join(dataDir, "server.env");
// ── Layer 1: Load persisted server.env ────────────────────────────────────
let persisted = parseEnvFile(serverEnvPath);
// ── Layer 2: Load the same preferred .env that the CLI wrapper uses ───────
// This keeps run-next / run-standalone consistent with `bin/omniroute.mjs`.
//
// We strip empty values from preferredEnv so an empty placeholder
// (e.g. `STORAGE_ENCRYPTION_KEY=` in the project .env template) does not
// override the real value persisted in server.env. Only the .env entries
// that the operator actually set should win.
const preferredEnvFiltered = Object.fromEntries(
Object.entries(preferredEnv).filter(([, v]) => typeof v === "string" && v.length > 0)
);
const merged = { ...persisted, ...preferredEnvFiltered, ...process.env };
// ── Auto-generate required secrets ────────────────────────────────────────
let needsPersist = false;
if (!merged.JWT_SECRET?.trim()) {
persisted.JWT_SECRET = randomBytes(64).toString("hex");
merged.JWT_SECRET = persisted.JWT_SECRET;
needsPersist = true;
log("✨ JWT_SECRET auto-generated (first run)");
}
if (!merged.STORAGE_ENCRYPTION_KEY?.trim()) {
if (hasEncryptedCredentials(dataDir)) {
throw new Error(
`Refusing to auto-generate STORAGE_ENCRYPTION_KEY: encrypted credentials already exist in ${join(
dataDir,
"storage.sqlite"
)}. Restore the key via ${preferredEnvPath ?? "an appropriate .env file"}, ${serverEnvPath}, or process.env.`
);
}
persisted.STORAGE_ENCRYPTION_KEY = randomBytes(32).toString("hex");
merged.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY;
needsPersist = true;
log("✨ STORAGE_ENCRYPTION_KEY auto-generated (first run)");
}
if (!merged.STORAGE_ENCRYPTION_KEY_VERSION?.trim()) {
persisted.STORAGE_ENCRYPTION_KEY_VERSION = "v1";
merged.STORAGE_ENCRYPTION_KEY_VERSION = persisted.STORAGE_ENCRYPTION_KEY_VERSION;
needsPersist = true;
}
if (!merged.API_KEY_SECRET?.trim()) {
persisted.API_KEY_SECRET = randomBytes(32).toString("hex");
merged.API_KEY_SECRET = persisted.API_KEY_SECRET;
needsPersist = true;
log("✨ API_KEY_SECRET auto-generated (first run)");
}
// ── Persist new secrets ────────────────────────────────────────────────────
if (needsPersist) {
try {
mkdirSync(dataDir, { recursive: true });
// Only persist keys that we auto-generated (not .env or process.env vals)
writeEnvFile(serverEnvPath, persisted);
log(`📁 Secrets persisted to: ${serverEnvPath}`);
} catch (e) {
log(`⚠️ Could not persist secrets to ${serverEnvPath}: ${e.message}`);
}
}
// ── Mark as bootstrapped ───────────────────────────────────────────────────
if (needsPersist) {
merged.OMNIROUTE_BOOTSTRAPPED = "true";
}
// ── Warn about missing optional OAuth secrets ──────────────────────────────
const missingOauth = OPTIONAL_OAUTH_SECRETS.filter(
({ keys }) => !keys.some((key) => merged[key]?.trim())
);
if (missingOauth.length > 0) {
log("️ The following OAuth integrations are not configured:");
for (const { keys, label } of missingOauth) {
log(`${label} (${keys.join(" or ")}) — set in .env or ${serverEnvPath}`);
}
log(" These providers will not work until configured.");
}
// ── Warn about default password ────────────────────────────────────────────
if (merged.INITIAL_PASSWORD === "CHANGEME" || !merged.INITIAL_PASSWORD?.trim()) {
log("⚠️ INITIAL_PASSWORD is not set — using default 'CHANGEME'. Change it in Settings!");
}
// ── Decrypt-probe: verify STORAGE_ENCRYPTION_KEY matches encrypted data (#1622) ─
if (merged.STORAGE_ENCRYPTION_KEY?.trim() && hasEncryptedCredentials(dataDir)) {
try {
const Database = require("better-sqlite3");
const db = new Database(join(dataDir, "storage.sqlite"), {
readonly: true,
fileMustExist: true,
});
try {
const row = db
.prepare(
`SELECT api_key, access_token, refresh_token, id_token
FROM provider_connections
WHERE api_key LIKE 'enc:v1:%'
OR access_token LIKE 'enc:v1:%'
OR refresh_token LIKE 'enc:v1:%'
OR id_token LIKE 'enc:v1:%'
LIMIT 1`
)
.get();
if (row) {
const ciphertext = row.api_key || row.access_token || row.refresh_token || row.id_token;
if (ciphertext?.startsWith("enc:v1:")) {
const parts = ciphertext.split(":");
// enc:v1:<iv>:<ct>:<tag>
if (parts.length >= 5) {
const iv = Buffer.from(parts[2], "hex");
const ct = Buffer.from(parts[3], "hex");
const tag = Buffer.from(parts[4], "hex");
// Try decrypting with both key derivation methods matching encryption.ts
const tryDecrypt = (derivedKey) => {
const decipher = createDecipheriv("aes-256-gcm", derivedKey, iv);
decipher.setAuthTag(tag);
decipher.update(ct);
decipher.final();
};
// Dynamic salt (current): scryptSync(secret, sha256(secret).slice(0,16), 32)
const dynamicSalt = createHash("sha256")
.update(merged.STORAGE_ENCRYPTION_KEY)
.digest()
.slice(0, 16);
const dynamicKey = scryptSync(merged.STORAGE_ENCRYPTION_KEY, dynamicSalt, 32);
// Legacy salt (fallback): scryptSync(secret, "omniroute-field-encryption-v1", 32)
const legacySalt = "omniroute-field-encryption-v1";
const legacyKey = scryptSync(merged.STORAGE_ENCRYPTION_KEY, legacySalt, 32);
let keyMatched = false;
try {
tryDecrypt(dynamicKey);
keyMatched = true;
} catch {
// Try legacy key as fallback
try {
tryDecrypt(legacyKey);
keyMatched = true;
} catch {
// Both failed — key truly doesn't match
}
}
if (!keyMatched) {
log(
"⛔ STORAGE_ENCRYPTION_KEY does not match the key used to encrypt your stored credentials."
);
log(
" Either restore your previous key via ~/.omniroute/server.env or ~/.omniroute/.env,"
);
log(
" or run: omniroute reset-encrypted-columns --force (wipes credentials, keeps provider config)"
);
}
}
}
}
} finally {
db.close();
}
} catch {
// Non-fatal — probe is best-effort
}
}
return merged;
}
// ── CLI usage: node scripts/build/bootstrap-env.mjs ──────────────────────────────
if (process.argv[1] && process.argv[1].endsWith("bootstrap-env.mjs")) {
const env = bootstrapEnv();
process.stderr.write(`[bootstrap] Done. DATA_DIR resolved to: ${resolveDataDir()}\n`);
process.stderr.write(`[bootstrap] JWT_SECRET length: ${env.JWT_SECRET?.length ?? 0}\n`);
process.stderr.write(
`[bootstrap] STORAGE_ENCRYPTION_KEY length: ${env.STORAGE_ENCRYPTION_KEY?.length ?? 0}\n`
);
}
+322
View File
@@ -0,0 +1,322 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
import { pathToFileURL } from "node:url";
import {
assembleStandalone,
syncStandaloneNativeAssets as _syncNativeAssets,
syncStandaloneExtraModules as _syncExtraModules,
} from "./assembleStandalone.mjs";
import {
isBackendOnlyBuild,
stubDashboardPages,
restoreDashboardPages,
} from "./backendOnlyPages.mjs";
/**
* Layer 1: `app/` has been renamed to `dist/` and the App-Router collision is gone.
* The only transient paths remaining are `.tmp/wine32` (Wine prefix used by some
* older build tools) and `_tasks` (planning workspace).
*/
const projectRoot = process.cwd();
const distDir = path.resolve(process.env.NEXT_DIST_DIR || ".build/next");
const backupRoot = path.join(os.tmpdir(), `omniroute-build-isolated-${process.pid}-${Date.now()}`);
export function getTransientBuildPaths(rootDir = projectRoot, env = process.env) {
const paths = [
{
label: "local Wine prefix",
sourcePath: path.join(rootDir, ".tmp", "wine32"),
backupPath: path.join(backupRoot, "wine32"),
},
];
if (env.OMNIROUTE_BUILD_MOVE_TASKS === "1") {
paths.push({
label: "task planning workspace",
sourcePath: path.join(rootDir, "_tasks"),
backupPath: path.join(backupRoot, "_tasks"),
});
}
return paths;
}
async function exists(targetPath) {
try {
await fs.access(targetPath);
return true;
} catch {
return false;
}
}
export async function movePath(sourcePath, destinationPath, fsImpl = fs) {
const mkdir = typeof fsImpl.mkdir === "function" ? fsImpl.mkdir.bind(fsImpl) : fs.mkdir.bind(fs);
await mkdir(path.dirname(destinationPath), { recursive: true });
try {
await fsImpl.rename(sourcePath, destinationPath);
} catch (error) {
if (error?.code !== "EXDEV") {
throw error;
}
console.warn(
`[build-next-isolated] EXDEV while moving ${sourcePath} -> ${destinationPath}; falling back to copy/remove`
);
await fsImpl.cp(sourcePath, destinationPath, {
recursive: true,
preserveTimestamps: true,
force: false,
errorOnExist: true,
});
await fsImpl.rm(sourcePath, { recursive: true, force: true });
}
}
function runNextBuild() {
return new Promise((resolve) => {
const nextBin = path.join(projectRoot, "node_modules", "next", "dist", "bin", "next");
const child = spawn(process.execPath, [nextBin, "build", resolveNextBuildBundlerFlag()], {
cwd: projectRoot,
stdio: "inherit",
env: resolveNextBuildEnv(process.env),
});
const forward = (signal) => {
if (!child.killed) child.kill(signal);
};
process.on("SIGINT", forward);
process.on("SIGTERM", forward);
child.on("exit", (code, signal) => {
process.off("SIGINT", forward);
process.off("SIGTERM", forward);
if (signal) {
resolve({ code: 1, signal });
return;
}
resolve({ code: code ?? 1, signal: null });
});
});
}
export function resolveNextBuildBundlerFlag(baseEnv = process.env) {
// Turbopack is the default production bundler (Next 16 stable). Benchmarked on
// this codebase: 2-3x faster than the single-threaded webpack pass (17min -> 9min
// on a 32-core box; ~20min -> 7min on ubuntu-latest), artifact validated
// end-to-end (standalone smoke + e2e/package/electron CI jobs). Webpack stays as
// the explicit escape hatch (=0) for bundler-compat regressions.
return baseEnv.OMNIROUTE_USE_TURBOPACK === "0" ? "--webpack" : "--turbopack";
}
export function resolveNextBuildEnv(baseEnv = process.env) {
const env = {
...baseEnv,
NEXT_PRIVATE_BUILD_WORKER: baseEnv.NEXT_PRIVATE_BUILD_WORKER || "0",
};
// Raise the Node heap for the spawned `next build`. The webpack production pass
// ("Compiling instrumentation" bundles the whole server graph) is the heaviest
// phase and overflows V8's default ~2 GB ceiling on memory-constrained machines,
// stalling/OOMing local `npm run build` (npm-global installs). #4076/#4104 fixed
// this only in the Docker builder stage (ENV NODE_OPTIONS); the local/native path
// was left unprotected. Respect an existing --max-old-space-size (Docker already
// sets one — don't clobber/duplicate) and let OMNIROUTE_BUILD_MEMORY_MB override.
if (!/--max-old-space-size/.test(env.NODE_OPTIONS || "")) {
// Default 8 GB (was 4 GB): the clean module graph peaks ~3.9 GB during the webpack
// production pass, which brushed the old 4 GB ceiling on a borderline OOM. 8 GB gives
// headroom without risk. NOTE: heap size does NOT fix a poisoned scope — if the build
// OOMs/livelocks far above this, check for worktrees/cruft leaking into the tsconfig
// scope (run `npm run check:build-scope`), not for "more heap". See incident 2026-06-25.
const heapMb = Number(baseEnv.OMNIROUTE_BUILD_MEMORY_MB) || 8192;
env.NODE_OPTIONS = `${env.NODE_OPTIONS || ""} --max-old-space-size=${heapMb}`.trim();
}
return env;
}
async function resetStandaloneOutput(rootDir = projectRoot, fsImpl = fs) {
// Use the module-level distDir so NEXT_DIST_DIR is respected
const resolvedDistDir =
rootDir === projectRoot
? distDir
: path.join(rootDir, process.env.NEXT_DIST_DIR || ".build/next");
const standaloneRoot = path.join(resolvedDistDir, "standalone");
if (!(await exists(standaloneRoot))) return;
const staleStandaloneBackup = path.join(backupRoot, "standalone-stale");
await movePath(standaloneRoot, staleStandaloneBackup, fsImpl);
console.log("[build-next-isolated] Moved stale standalone output out of the build path");
}
export async function pruneStandaloneArtifacts(rootDir = projectRoot, fsImpl = fs) {
const resolvedDistDirForPrune =
rootDir === projectRoot
? distDir
: path.join(rootDir, process.env.NEXT_DIST_DIR || ".build/next");
const standaloneRoot = path.join(resolvedDistDirForPrune, "standalone");
const pruneTargets = [path.join(standaloneRoot, "_tasks")];
for (const targetPath of pruneTargets) {
if (!(await exists(targetPath))) continue;
await fsImpl.rm(targetPath, { recursive: true, force: true });
console.log(
`[build-next-isolated] Pruned standalone artifact: ${path.relative(rootDir, targetPath)}`
);
}
}
export async function syncStandaloneNativeAssets(
rootDir = projectRoot,
fsImpl = fs,
log = console
) {
return _syncNativeAssets(rootDir, fsImpl, log);
}
export async function syncStandaloneExtraModules(
rootDir = projectRoot,
fsImpl = fs,
log = console
) {
return _syncExtraModules(rootDir, fsImpl, log);
}
export async function main() {
const movedPaths = [];
const transientBuildPaths = getTransientBuildPaths();
// Backend-only fast build: replace the dashboard leaf pages with zero-cost stubs so
// `next build` skips the frontend (client vendor chunks + prerender) while keeping every
// API route handler. Restored in `finally` and on SIGINT/SIGTERM (git-recoverable regardless).
let stubbedPages = [];
const restoreStubbedPagesOnce = () => {
if (stubbedPages.length > 0) {
restoreDashboardPages(stubbedPages);
stubbedPages = [];
}
};
const onFatalSignal = (signal) => {
console.warn(`[build-next-isolated] Received ${signal} — restoring stubbed pages before exit`);
restoreStubbedPagesOnce();
process.exit(1);
};
try {
for (const entry of transientBuildPaths) {
if (!(await exists(entry.sourcePath))) continue;
await movePath(entry.sourcePath, entry.backupPath);
movedPaths.push(entry);
}
if (isBackendOnlyBuild()) {
console.log(
"[build-next-isolated] OMNIROUTE_BUILD_BACKEND_ONLY set — building API only (dashboard UI stubbed)"
);
stubbedPages = stubDashboardPages(projectRoot);
process.once("SIGINT", onFatalSignal);
process.once("SIGTERM", onFatalSignal);
}
await resetStandaloneOutput(projectRoot);
const result = await runNextBuild();
const standaloneDir = path.join(distDir, "standalone");
if (result.code === 0 && (await exists(standaloneDir))) {
try {
await fs.cp(path.join(projectRoot, "docs"), path.join(standaloneDir, "docs"), {
recursive: true,
});
console.log("[build-next-isolated] Copied docs/ to standalone output");
} catch (docsCopyErr) {
console.warn("[build-next-isolated] Non-fatal error copying docs/:", docsCopyErr?.message);
}
try {
await pruneStandaloneArtifacts(projectRoot);
} catch (pruneErr) {
console.warn(
"[build-next-isolated] Non-fatal error pruning standalone artifacts:",
pruneErr
);
}
// Best-effort: build the TPROXY native addon (Linux-only, opt-in) BEFORE
// assembling, so its transparent.node is present for assembleStandalone's
// NATIVE_ASSET_ENTRIES copy. Non-Linux / no-toolchain is non-fatal — the
// capture mode degrades gracefully when the addon is absent.
try {
const { buildTproxyNative } = await import("./build-tproxy-native.mjs");
const res = buildTproxyNative(projectRoot);
console.log(
res.built
? "[build-next-isolated] Built TPROXY native addon (transparent.node)"
: `[build-next-isolated] TPROXY native addon skipped: ${res.reason}`
);
} catch (nativeErr) {
console.warn(
"[build-next-isolated] Non-fatal error building TPROXY native addon:",
nativeErr?.message
);
}
try {
console.log(
"[build-next-isolated] Assembling standalone bundle (static + public + natives + extras)..."
);
assembleStandalone({
distDir,
outDir: standaloneDir,
projectRoot,
copyNatives: true,
});
} catch (assembleErr) {
console.warn("[build-next-isolated] Non-fatal error assembling standalone:", assembleErr);
}
}
process.exitCode = result.code;
} catch (error) {
console.error("[build-next-isolated] Build failed:", error);
process.exitCode = 1;
} finally {
// Restore the stubbed dashboard pages FIRST so the working tree is clean even if the
// transient-path restore below throws.
restoreStubbedPagesOnce();
process.off("SIGINT", onFatalSignal);
process.off("SIGTERM", onFatalSignal);
while (movedPaths.length > 0) {
const entry = movedPaths.pop();
if (!entry) continue;
try {
await movePath(entry.backupPath, entry.sourcePath);
} catch (restoreError) {
console.error(
`[build-next-isolated] Failed to restore ${entry.label} from ${entry.backupPath}:`,
restoreError
);
process.exitCode = 1;
}
}
try {
await fs.rm(backupRoot, { recursive: true, force: true });
} catch (cleanupError) {
console.warn("[build-next-isolated] Failed to clean temporary backup root:", cleanupError);
}
}
}
const entryScript = process.argv[1] ? pathToFileURL(process.argv[1]).href : null;
if (entryScript === import.meta.url) {
await main();
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Best-effort build of the TPROXY IP_TRANSPARENT native addon so the production
* build can copy `build/Release/transparent.node` into the standalone bundle
* (assembleStandalone's NATIVE_ASSET_ENTRIES). Called from build-next-isolated.mjs
* before the standalone is assembled.
*
* IP_TRANSPARENT is Linux-only, so this is a no-op everywhere else. A missing C
* toolchain is NOT fatal — the TPROXY capture mode degrades gracefully when the
* addon is absent (transparentSocket.ts returns "unavailable"). Every effectful
* seam (platform/run/exists) is injectable so the decision logic is unit-testable.
*
* Hard Rule #13: the command + args are a fixed allowlist (no interpolation of
* external/runtime values); `cwd` is derived from `projectRoot`, never user input.
*/
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
/**
* @param {string} projectRoot
* @param {{ platform?: string, run?: (cmd:string, args:string[], cwd:string) => void,
* exists?: (p:string) => boolean }} [opts]
* @returns {{ built: boolean, reason?: string }}
*/
export function buildTproxyNative(projectRoot, opts = {}) {
const platform = opts.platform ?? process.platform;
const run = opts.run ?? defaultRun;
const exists = opts.exists ?? existsSync;
if (platform !== "linux") {
return { built: false, reason: "non-linux host (IP_TRANSPARENT is Linux-only)" };
}
const nativeDir = path.join(projectRoot, "src", "mitm", "tproxy", "native");
if (!exists(path.join(nativeDir, "binding.gyp"))) {
return { built: false, reason: "native sources absent (binding.gyp not found)" };
}
const out = path.join(nativeDir, "build", "Release", "transparent.node");
try {
run("npx", ["--yes", "node-gyp", "rebuild"], nativeDir);
} catch (err) {
return { built: false, reason: `toolchain/build failed: ${err?.message ?? String(err)}` };
}
if (!exists(out)) {
return { built: false, reason: "node-gyp produced no transparent.node" };
}
return { built: true };
}
/** @type {(cmd: string, args: string[], cwd: string) => void} */
function defaultRun(cmd, args, cwd) {
execFileSync(cmd, args, { cwd, stdio: "inherit" });
}
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env node
/**
* OmniRoute — Co-locate the LLMLingua-2 optional dependency closure into the standalone bundle.
*
* The compression "ultra" SLM tier (PR #4257) runs `@atjsh/llmlingua-2` +
* `@huggingface/transformers` + `@tensorflow/tfjs` + `js-tiktoken` inside a worker thread
* (`open-sse/services/compression/engines/llmlingua/onnxWorker.js`, shipped under `dist/`). These
* are `optionalDependencies`: npm installs them into the ROOT `node_modules` on
* `--include=optional`, but the Next.js standalone trace bundles ONLY `@huggingface/transformers`
* (3.5.2, pinned) into `dist/node_modules` — it does NOT trace the optional, dynamically-imported
* SLM packages.
*
* ## Why this matters (the instance-split bug)
*
* The worker lives under `dist/`, so its `import("@huggingface/transformers")` resolves
* `dist/node_modules/@huggingface/transformers` (3.5.2) and the worker sets the model `cacheDir`
* on THAT instance's `env`. But its `import("@atjsh/llmlingua-2")` walks past `dist/node_modules`
* (no `@atjsh` there) up to the ROOT `node_modules`, and llmlingua-2's own
* `import("@huggingface/transformers")` then resolves the ROOT transformers — a DIFFERENT instance.
* The `cacheDir`/`localModelPath` config the worker set never reaches the instance llmlingua-2
* actually uses, so the local model under `DATA_DIR/models/llmlingua` is never found and the SLM
* tier silently fails-open (no compression). Worse, if the root transformers is a 4.x line,
* llmlingua-2 throws on a tokenizer-API change (`decoder.decode` is undefined).
*
* ## The fix
*
* Co-locate the SLM optional dependency CLOSURE from the root `node_modules` into
* `dist/node_modules` (NO-CLOBBER, so the pinned `dist` transformers 3.5.2 / onnxruntime / sharp
* stay). Then the worker resolves `@atjsh/llmlingua-2` AND `@huggingface/transformers` from the
* SAME `dist/node_modules` — a single 3.5.2 instance — so the env config applies and the local
* model loads.
*
* `@huggingface/transformers` is intentionally NOT a closure seed: it is a PEER of
* `@atjsh/llmlingua-2` (not a regular dependency) and is already bundled in `dist/node_modules`,
* so the closure walk never reaches it and the no-clobber guard would skip it anyway.
*
* ## Validation (Hard Rule #18)
*
* Manual co-location of this exact closure on the production VPS produced real 54.8% compression
* (11520 → 5203 chars) via real ONNX inference — both the default and the `modelPath` (PR #4257)
* code paths. See the unit test for the closure-walk + no-clobber contract.
*
* Idempotent + fail-soft: skips when the optionals are absent (the common case — they are OPTIONAL)
* or already co-located; a per-package copy failure only disables the SLM tier, which is itself
* fail-open, so this never throws into the install.
*/
import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
/**
* Entry packages of the SLM optional stack (the closure roots). `@huggingface/transformers` is
* deliberately absent — it is the pinned instance already present in `dist/node_modules`.
*/
export const SEED_PACKAGES = ["@atjsh/llmlingua-2", "@tensorflow/tfjs", "js-tiktoken"];
/**
* Compute the transitive dependency closure of `seeds` by walking each package's `dependencies` +
* `optionalDependencies` from a `node_modules` directory. Packages that are not present in that
* tree (e.g. peers provided elsewhere, like `@huggingface/transformers` in `dist`) are skipped —
* the closure only contains packages that actually exist in `nodeModulesDir`.
*
* @param {string} nodeModulesDir absolute path to the source `node_modules`
* @param {string[]} [seeds] closure roots (defaults to {@link SEED_PACKAGES})
* @returns {string[]} package names in discovery order, seeds first
*/
export function computeDependencyClosure(nodeModulesDir, seeds = SEED_PACKAGES) {
const closure = [];
const seen = new Set();
const stack = [...seeds];
while (stack.length) {
const name = stack.shift();
if (seen.has(name)) continue;
seen.add(name);
const pkgDir = join(nodeModulesDir, name);
if (!existsSync(pkgDir)) continue; // absent in this tree (peer provided elsewhere) — skip
closure.push(name);
let manifest;
try {
manifest = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"));
} catch {
continue; // unreadable/absent manifest — copy the dir but do not recurse
}
const deps = { ...manifest.dependencies, ...manifest.optionalDependencies };
for (const dep of Object.keys(deps)) {
if (!seen.has(dep)) stack.push(dep);
}
}
return closure;
}
/**
* Co-locate the SLM optional closure from `<rootDir>/node_modules` into
* `<rootDir>/dist/node_modules`. No-op when the standalone `dist` bundle or the optional seeds are
* absent, and idempotent once co-located. Never throws.
*
* @param {{ rootDir: string, log?: (message: string) => void }} opts
* @returns {{ skipped: true, reason: string }
* | { skipped: false, copied: number, closure: number }}
*/
export function colocateLlmlinguaOptionals({ rootDir, log = () => {} }) {
const rootNm = join(rootDir, "node_modules");
const distNm = join(rootDir, "dist", "node_modules");
if (!existsSync(distNm)) {
return { skipped: true, reason: "no standalone dist/node_modules" };
}
// Gate: only run when the optional stack was actually installed (`npm install --include=optional`).
if (!SEED_PACKAGES.every((seed) => existsSync(join(rootNm, seed)))) {
return { skipped: true, reason: "SLM optionals not installed at root" };
}
// Idempotent: the entry package is already co-located → nothing to do.
if (existsSync(join(distNm, "@atjsh", "llmlingua-2"))) {
return { skipped: true, reason: "already co-located" };
}
const closure = computeDependencyClosure(rootNm);
let copied = 0;
for (const name of closure) {
const dest = join(distNm, name);
if (existsSync(dest)) continue; // no-clobber: keep dist's pinned copy (transformers 3.5.2, …)
try {
mkdirSync(dirname(dest), { recursive: true });
cpSync(join(rootNm, name), dest, { recursive: true });
copied++;
} catch (err) {
log(` ⚠️ LLMLingua optional co-location failed for ${name}: ${err.message}`);
}
}
if (copied > 0) {
log(` ✅ Co-located ${copied} LLMLingua SLM optional package(s) into dist/node_modules.\n`);
}
return { skipped: false, copied, closure: closure.length };
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Decide whether the Turbopack build should alias @/mitm/manager to the
* feature-degraded stub (src/mitm/manager.stub.ts).
*
* History (#6344): the alias used to be UNCONDITIONAL in next.config.mjs
* because Docker images were the only Turbopack consumers (webpack was the
* production default and never aliased the manager). When v3.8.45 flipped the
* production bundler default to Turbopack, the stub silently shipped to every
* npm / Electron / VPS artifact — Agent Bridge start then threw
* "MITM manager stub reached at runtime" for all non-Docker users.
*
* The stub is only correct where the runtime genuinely cannot run the MITM
* stack (containers without host access — #3390 graceful degradation), so it
* is now opt-in via OMNIROUTE_MITM_STUB=1, set by the Dockerfile.
*/
export function shouldStubMitmManager(env = process.env) {
return env.OMNIROUTE_MITM_STUB === "1";
}
/** Turbopack resolveAlias fragment for @/mitm/manager, derived from the env. */
export function mitmManagerAliasFor(env = process.env) {
return shouldStubMitmManager(env) ? { "@/mitm/manager": "./src/mitm/manager.stub.ts" } : {};
}
+167
View File
@@ -0,0 +1,167 @@
import { existsSync, openSync, readSync, closeSync } from "node:fs";
export const PUBLISHED_BUILD_PLATFORM = "linux";
export const PUBLISHED_BUILD_ARCH = "x64";
const HEADER_SIZE = 4096;
const MAX_FAT_ARCH_COUNT = 30;
function mapElfMachine(machine) {
switch (machine) {
case 62:
return "x64";
case 183:
return "arm64";
default:
return null;
}
}
function mapMachCpuType(cpuType) {
switch (cpuType) {
case 0x01000007:
return "x64";
case 0x0100000c:
return "arm64";
default:
return null;
}
}
function mapPeMachine(machine) {
switch (machine) {
case 0x8664:
return "x64";
case 0xaa64:
return "arm64";
default:
return null;
}
}
function readUInt16(buffer, offset, littleEndian) {
return littleEndian ? buffer.readUInt16LE(offset) : buffer.readUInt16BE(offset);
}
function readUInt32(buffer, offset, littleEndian) {
return littleEndian ? buffer.readUInt32LE(offset) : buffer.readUInt32BE(offset);
}
const ELF_MAGIC = 0x7f454c46;
function detectElfTarget(buffer) {
if (buffer.length < 20) return null;
if (buffer.readUInt32BE(0) !== ELF_MAGIC) return null;
const littleEndian = buffer[5] !== 2;
const arch = mapElfMachine(readUInt16(buffer, 18, littleEndian));
if (!arch) return null;
return { platform: "linux", architectures: [arch] };
}
const THIN_MACH_MAGIC = new Map([
[0xfeedface, false],
[0xfeedfacf, false],
[0xcefaedfe, true],
[0xcffaedfe, true],
]);
const FAT_MACH_MAGIC = new Map([
[0xcafebabe, false],
[0xcafebabf, false],
[0xbebafeca, true],
[0xbfbafeca, true],
]);
function detectMachTarget(buffer) {
if (buffer.length < 8) return null;
const magic = buffer.readUInt32BE(0);
if (THIN_MACH_MAGIC.has(magic)) {
const littleEndian = THIN_MACH_MAGIC.get(magic);
const arch = mapMachCpuType(readUInt32(buffer, 4, littleEndian));
if (!arch) return null;
return { platform: "darwin", architectures: [arch] };
}
if (!FAT_MACH_MAGIC.has(magic)) return null;
const littleEndian = FAT_MACH_MAGIC.get(magic);
const isFat64 = magic === 0xcafebabf || magic === 0xbfbafeca;
const archCount = readUInt32(buffer, 4, littleEndian);
if (archCount > MAX_FAT_ARCH_COUNT) return null;
const entrySize = isFat64 ? 32 : 20;
const architectures = new Set();
for (let index = 0; index < archCount; index += 1) {
const offset = 8 + index * entrySize;
if (offset + 4 > buffer.length) break;
const arch = mapMachCpuType(readUInt32(buffer, offset, littleEndian));
if (arch) architectures.add(arch);
}
if (architectures.size === 0) return null;
return { platform: "darwin", architectures: [...architectures] };
}
function detectPeTarget(buffer) {
if (buffer.length < 0x40) return null;
if (buffer.readUInt16LE(0) !== 0x5a4d) return null;
const peHeaderOffset = buffer.readUInt32LE(0x3c);
if (peHeaderOffset + 6 > buffer.length) return null;
if (buffer.readUInt32LE(peHeaderOffset) !== 0x00004550) return null;
const arch = mapPeMachine(buffer.readUInt16LE(peHeaderOffset + 4));
if (!arch) return null;
return { platform: "win32", architectures: [arch] };
}
export function detectNativeBinaryTarget(buffer) {
return detectElfTarget(buffer) ?? detectMachTarget(buffer) ?? detectPeTarget(buffer);
}
export function readNativeBinaryTarget(binaryPath) {
if (!existsSync(binaryPath)) return null;
let fd;
try {
fd = openSync(binaryPath, "r");
const buffer = Buffer.alloc(HEADER_SIZE);
const bytesRead = readSync(fd, buffer, 0, HEADER_SIZE, 0);
return detectNativeBinaryTarget(buffer.subarray(0, bytesRead));
} catch (err) {
console.warn(` ⚠️ Could not read native binary at ${binaryPath}: ${err.message}`);
return null;
} finally {
if (fd !== undefined) closeSync(fd);
}
}
export function isNativeBinaryCompatible(
binaryPath,
{ runtimePlatform = process.platform, runtimeArch = process.arch, dlopen = process.dlopen } = {}
) {
const target = readNativeBinaryTarget(binaryPath);
if (target) {
if (
(target.platform !== runtimePlatform &&
!(target.platform === "linux" && runtimePlatform === "android")) ||
!target.architectures.includes(runtimeArch)
) {
return false;
}
} else if (runtimePlatform !== PUBLISHED_BUILD_PLATFORM || runtimeArch !== PUBLISHED_BUILD_ARCH) {
return false;
}
try {
dlopen({ exports: {} }, binaryPath);
return true;
} catch (err) {
console.warn(` ⚠️ Native binary dlopen failed: ${err.message}`);
return false;
}
}
+208
View File
@@ -0,0 +1,208 @@
/**
* Shared policy for OmniRoute npm publish artifact hygiene.
*
* The package publishes the standalone runtime under dist/ (Layer 1: renamed from app/).
* This policy keeps local backups, QA scratch files, and development-only
* directories out of the staged dist/ tree and out of the final tarball.
*/
const STAGING_FORBIDDEN_DIRECTORIES = [
"app.__qa_backup",
"coverage",
"electron",
"logs",
"scripts/scratch",
"tests",
"vscode-extension",
"_ideia",
"_mono_repo",
"_references",
"_tasks",
];
const STAGING_FORBIDDEN_FILES = ["audit-report.json", "package-lock.json"];
export const APP_STAGING_REMOVAL_PATHS: string[] = [
...STAGING_FORBIDDEN_DIRECTORIES,
...STAGING_FORBIDDEN_FILES,
// onnxruntime CUDA provider binary (~316 MB) inflates the npm tarball
// past the registry 413 limit for npm.org. It's only needed on systems
// with a CUDA GPU — users install CUDA providers separately.
"node_modules/onnxruntime-node/bin/napi-v6/linux/x64/libonnxruntime_providers_cuda.so",
];
export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [
".env.example",
"BUILD_SHA",
"docs/openapi.yaml",
"http-method-guard.cjs",
"open-sse/mcp-server/server.js",
// LLMLingua ONNX worker — esbuild'd standalone .js spawned via worker_threads
// (the Next.js bundler can't trace the computed Worker path). Kept like the MCP server.
"open-sse/services/compression/engines/llmlingua/onnxWorker.js",
"package.json",
"peer-stamp.mjs",
"responses-ws-proxy.mjs",
"scripts/dev/sync-env.mjs",
"scripts/dev/tls-options.mjs",
"server.js",
"server-ws.mjs",
// #5452: dist/tls-options.mjs is copied by assembleStandalone (EXTRA_MODULE_ENTRIES)
// and imported by dist/server-ws.mjs for opt-in native HTTPS/TLS (#5361). Without
// this bare entry the prepublish prune (Step 10.7) deletes it → `omniroute serve`
// crashes with ERR_MODULE_NOT_FOUND (regressed in the published 3.8.41 tarball).
"tls-options.mjs",
"webdav-handler.mjs",
];
export const APP_STAGING_ALLOWED_PATH_PREFIXES: string[] = [
// Layer 1: Next.js distDir changed from ".next" to ".build/next"; the server
// bundle now lives under .build/next/ inside the standalone output.
".build/next/",
".next/",
"data/",
"node_modules/",
"open-sse/services/compression/engines/rtk/filters/",
"open-sse/services/compression/rules/",
"public/",
"src/lib/db/migrations/",
"src/mitm/",
];
export const PACK_ARTIFACT_ALLOWED_EXACT_PATHS: string[] = APP_STAGING_ALLOWED_EXACT_PATHS.map(
(filePath: string) => `dist/${filePath}`
);
export const PACK_ARTIFACT_ALLOWED_PATH_PREFIXES: string[] = APP_STAGING_ALLOWED_PATH_PREFIXES.map(
(directoryPath: string) => `dist/${directoryPath}`
);
export const PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS: string[] = [
".env.example",
"LICENSE",
"README.md",
"bin/mcp-server.mjs",
"bin/nodeRuntimeSupport.mjs",
"bin/omniroute.mjs",
"bin/reset-password.mjs",
// Operator incident-recovery / cold-start shell tooling (rollback, snapshot,
// restore, cold-start bench) shipped in bin/ for self-hosters — not imported by
// the runtime. Included via the package.json "files": ["bin/"] entry, so they
// must be allowed here. Each script is self-documenting via --help.
"bin/_ops-common.sh",
"bin/cold-start-bench.sh",
"bin/restore-data.sh",
"bin/restore-policies.sh",
"bin/rollback.sh",
"bin/snapshot-data.sh",
"open-sse/mcp-server/README.md",
"open-sse/mcp-server/audit.ts",
"open-sse/mcp-server/httpTransport.ts",
"open-sse/mcp-server/index.ts",
"open-sse/mcp-server/runtimeHeartbeat.ts",
"open-sse/mcp-server/scopeEnforcement.ts",
"open-sse/mcp-server/server.ts",
// Runtime polyfill eagerly imported by bin/omniroute.mjs (Node <22 compat);
// shipped via package.json "files", so it must be allowed in the tarball.
"open-sse/utils/setupPolyfill.ts",
"package.json",
"scripts/build/build-next-isolated.mjs",
"scripts/check/check-supported-node-runtime.ts",
"scripts/build/native-binary-compat.mjs",
"scripts/build/postinstall.mjs",
"scripts/build/postinstallSupport.mjs",
"scripts/build/colocateOptionals.mjs",
// #5227: imported at runtime by bin/cli/commands/serve.mjs (heap auto-calibration).
"scripts/build/runtime-env.mjs",
"scripts/build/sync-env.mjs",
"scripts/dev/responses-ws-proxy.mjs",
"scripts/dev/sync-env.mjs",
// #5361: imported at runtime by bin/cli/commands/serve.mjs + the standalone
// server wrapper for opt-in native HTTPS/TLS serving (kept dependency-light).
"scripts/dev/tls-options.mjs",
"scripts/postinstall.mjs",
"src/shared/utils/nodeRuntimeSupport.ts",
];
export const PACK_ARTIFACT_ROOT_ALLOWED_PATH_PREFIXES: string[] = [
"@omniroute/opencode-plugin/",
"@omniroute/opencode-provider/",
"bin/cli/",
// Broad open-sse + src source dirs added to package.json "files" in v3.8.21
// to allow TypeScript-first imports from the published package.
"open-sse/",
"src/domain/",
"src/lib/",
"src/models/",
"src/mitm/",
"src/server/",
"src/shared/",
"src/sse/",
"src/types/",
];
export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [
"dist/open-sse/services/compression/engines/rtk/filters/generic-output.json",
"dist/open-sse/services/compression/rules/en/filler.json",
"dist/server.js",
"dist/server-ws.mjs",
"dist/responses-ws-proxy.mjs",
"dist/peer-stamp.mjs",
"dist/http-method-guard.cjs",
// #5452: regression guard — make check:pack-artifact fail loudly if the TLS
// opt-in sidecar (imported by dist/server-ws.mjs) ever vanishes from the tarball.
"dist/tls-options.mjs",
"dist/webdav-handler.mjs",
"bin/cli/program.mjs",
"bin/mcp-server.mjs",
"bin/nodeRuntimeSupport.mjs",
"bin/omniroute.mjs",
"package.json",
"scripts/build/native-binary-compat.mjs",
"scripts/build/postinstall.mjs",
"scripts/build/postinstallSupport.mjs",
"scripts/build/colocateOptionals.mjs",
"scripts/build/runtime-env.mjs",
"src/shared/utils/nodeRuntimeSupport.ts",
];
PACK_ARTIFACT_ALLOWED_EXACT_PATHS.push(...PACK_ARTIFACT_ROOT_ALLOWED_EXACT_PATHS);
PACK_ARTIFACT_ALLOWED_PATH_PREFIXES.push(...PACK_ARTIFACT_ROOT_ALLOWED_PATH_PREFIXES);
export function normalizeArtifactPath(filePath: string): string {
return String(filePath || "")
.replace(/\\/g, "/")
.replace(/^\.\//, "")
.replace(/^\/+/, "")
.replace(/\/{2,}/g, "/");
}
export function findUnexpectedArtifactPaths(
filePaths: string[],
{ exactPaths = [], prefixPaths = [] }: { exactPaths?: string[]; prefixPaths?: string[] } = {}
): string[] {
const normalizedExact = new Set(exactPaths.map(normalizeArtifactPath));
const normalizedPrefixes = prefixPaths.map(normalizeArtifactPath);
return filePaths
.map(normalizeArtifactPath)
.filter(Boolean)
.filter(
(filePath) =>
!normalizedExact.has(filePath) &&
!normalizedPrefixes.some((prefix) => filePath.startsWith(prefix))
)
.sort();
}
export function findMissingArtifactPaths(
filePaths: string[],
requiredPaths: string[] = []
): string[] {
const normalizedPaths = new Set(filePaths.map(normalizeArtifactPath).filter(Boolean));
return requiredPaths
.map(normalizeArtifactPath)
.filter(Boolean)
.filter((requiredPath) => !normalizedPaths.has(requiredPath))
.sort();
}
+357
View File
@@ -0,0 +1,357 @@
#!/usr/bin/env node
/**
* OmniRoute — Postinstall Native Module Fix
*
* The npm package ships with a Next.js standalone build that includes
* native modules compiled for the build platform (Linux x64) inside
* dist/node_modules/. However, npm also installs these as top-level
* dependencies (in the root node_modules/), correctly compiled for
* the user's platform.
*
* This script copies the correctly-built native binaries from the root
* into the standalone dist directory — no rebuild or build tools needed.
*
* Modules repaired:
* - better-sqlite3 (SQLite bindings)
* - wreq-js (TLS client for OAuth providers)
*
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/129
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/321
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/426
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/1634
*/
import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { PUBLISHED_BUILD_ARCH, PUBLISHED_BUILD_PLATFORM } from "./native-binary-compat.mjs";
import { hasStandaloneAppBundle, isTermux } from "./postinstallSupport.mjs";
import { colocateLlmlinguaOptionals } from "./colocateOptionals.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ROOT = join(__dirname, "..", "..");
const appBinary = join(
ROOT,
"dist",
"node_modules",
"better-sqlite3",
"build",
"Release",
"better_sqlite3.node"
);
const rootBinary = join(
ROOT,
"node_modules",
"better-sqlite3",
"build",
"Release",
"better_sqlite3.node"
);
async function fixBetterSqliteBinary() {
if (!existsSync(join(ROOT, "dist", "node_modules", "better-sqlite3"))) {
return;
}
const platformMatch =
process.platform === PUBLISHED_BUILD_PLATFORM && process.arch === PUBLISHED_BUILD_ARCH;
if (platformMatch) {
try {
process.dlopen({ exports: {} }, appBinary);
return;
} catch (err) {
console.warn(` ⚠️ Bundled binary incompatible despite platform match: ${err.message}`);
}
}
console.log(`\n 🔧 Fixing better-sqlite3 binary for ${process.platform}-${process.arch}...`);
if (existsSync(rootBinary)) {
try {
mkdirSync(dirname(appBinary), { recursive: true });
copyFileSync(rootBinary, appBinary);
} catch (err) {
console.warn(` ⚠️ Failed to copy binary: ${err.message}`);
}
try {
process.dlopen({ exports: {} }, appBinary);
console.log(" ✅ Native module fixed successfully!\n");
return;
} catch (err) {
console.warn(` ⚠️ Copied binary failed to load: ${err.message}`);
}
}
console.log(" 📥 Attempting to download prebuilt binary via node-pre-gyp...");
try {
const { execSync } = await import("node:child_process");
const preGypBin = join(
ROOT,
"dist",
"node_modules",
".bin",
process.platform === "win32" ? "node-pre-gyp.cmd" : "node-pre-gyp"
);
const preGypFallback = join(
ROOT,
"dist",
"node_modules",
"@mapbox",
"node-pre-gyp",
"bin",
"node-pre-gyp"
);
const preGypCmd = existsSync(preGypBin) ? preGypBin : preGypFallback;
if (existsSync(preGypCmd)) {
execSync(`"${process.execPath}" "${preGypCmd}" install --fallback-to-build=false`, {
cwd: join(ROOT, "dist", "node_modules", "better-sqlite3"),
stdio: "inherit",
timeout: 60_000,
});
mkdirSync(dirname(appBinary), { recursive: true });
try {
process.dlopen({ exports: {} }, appBinary);
console.log(" ✅ Prebuilt binary downloaded and loaded successfully!\n");
return;
} catch (loadErr) {
console.warn(` ⚠️ Downloaded binary failed to load: ${loadErr.message}`);
}
} else {
console.warn(" ⚠️ node-pre-gyp not found, skipping prebuilt download.");
}
} catch (err) {
console.warn(` ⚠️ node-pre-gyp download failed: ${err.message.split("\n")[0]}`);
}
console.log(" ⚠️ Attempting npm rebuild (requires build tools)...");
try {
const { execSync } = await import("node:child_process");
// On Android/Termux, rebuild from source with --build-from-source flag
const isAndroid = process.platform === "android" || isTermux();
const rebuildCmd = isAndroid
? "npm install better-sqlite3 --build-from-source --force"
: "npm rebuild better-sqlite3";
const env = { ...process.env };
if (isAndroid) {
env.GYP_DEFINES = "android_ndk_path=''";
}
execSync(rebuildCmd, {
cwd: join(ROOT, "dist"),
stdio: "inherit",
timeout: isAndroid ? 600_000 : 300_000, // ARM compilation is slower
env,
});
process.dlopen({ exports: {} }, appBinary);
console.log(" ✅ Native module rebuilt successfully!\n");
return;
} catch (err) {
const isTimeout = err.killed || err.signal === "SIGTERM";
if (isTimeout) {
const secs = isAndroid ? 600 : 300;
console.warn(` ⚠️ npm rebuild timed out after ${secs}s.`);
} else {
console.warn(` ⚠️ npm rebuild failed: ${err.message}`);
}
}
console.warn("\n ⚠️ Could not fix better-sqlite3 native module automatically.");
console.warn(" The server may not start correctly.");
console.warn(" Manual fix options:");
if (process.platform === "win32") {
console.warn(" Option A (easiest — no build tools needed):");
console.warn(` cd "${join(ROOT, "dist", "node_modules", "better-sqlite3")}"`);
console.warn(" npx @mapbox/node-pre-gyp install --fallback-to-build=false");
console.warn(" Option B (requires Build Tools for Visual Studio):");
console.warn(` cd "${join(ROOT, "dist")}" && npm rebuild better-sqlite3`);
console.warn(" Install from: https://visualstudio.microsoft.com/visual-cpp-build-tools/");
console.warn(" Also ensure Python is installed: https://python.org");
} else if (process.platform === "darwin") {
console.warn(` cd ${join(ROOT, "dist")} && npm rebuild better-sqlite3`);
console.warn(" If build tools are missing: xcode-select --install");
} else {
console.warn(` cd ${join(ROOT, "dist")} && npm rebuild better-sqlite3`);
}
console.warn("");
}
/**
* Fix wreq-js native binary for the standalone dist directory.
*
* wreq-js ships platform-specific .node binaries under rust/.
* The standalone build may only contain Linux binaries from the CI.
* This copies the correct platform binary from the root install.
*
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/1634
*/
async function fixWreqJsBinary() {
// wreq-js native module is not loadable in Termux (libgcc path mismatch).
// The runtime already falls back gracefully when wreq-js is unavailable.
if (process.platform === "android" || isTermux()) {
console.log(
" [postinstall] wreq-js: skipped on Termux/Android " +
"(libgcc not available — OAuth TLS fingerprinting will use the fallback path)"
);
return;
}
const appWreqDir = join(ROOT, "dist", "node_modules", "wreq-js", "rust");
const rootWreqDir = join(ROOT, "node_modules", "wreq-js", "rust");
if (!existsSync(join(ROOT, "dist", "node_modules", "wreq-js"))) {
return;
}
const binaryName = `wreq-js.${process.platform}-${process.arch}.node`;
const appBinaryPath = join(appWreqDir, binaryName);
const rootBinaryPath = join(rootWreqDir, binaryName);
// Check if the platform binary already exists and loads
if (existsSync(appBinaryPath)) {
try {
process.dlopen({ exports: {} }, appBinaryPath);
return; // Already working
} catch (err) {
console.warn(` ⚠️ wreq-js binary exists but failed to load: ${err.message}`);
}
}
console.log(`\n 🔧 Fixing wreq-js binary for ${process.platform}-${process.arch}...`);
// Strategy 1: Copy from root node_modules
if (existsSync(rootBinaryPath)) {
try {
mkdirSync(appWreqDir, { recursive: true });
copyFileSync(rootBinaryPath, appBinaryPath);
process.dlopen({ exports: {} }, appBinaryPath);
console.log(" ✅ wreq-js native module fixed successfully!\n");
return;
} catch (err) {
console.warn(` ⚠️ Copied wreq-js binary failed to load: ${err.message}`);
}
}
// Strategy 2: Copy entire rust/ directory from root (gets all platform binaries)
if (existsSync(rootWreqDir)) {
try {
mkdirSync(appWreqDir, { recursive: true });
const files = readdirSync(rootWreqDir);
for (const file of files) {
if (file.endsWith(".node")) {
copyFileSync(join(rootWreqDir, file), join(appWreqDir, file));
}
}
if (existsSync(appBinaryPath)) {
process.dlopen({ exports: {} }, appBinaryPath);
console.log(" ✅ wreq-js native module fixed (full copy) successfully!\n");
return;
}
} catch (err) {
console.warn(` ⚠️ wreq-js full copy failed: ${err.message}`);
}
}
// Strategy 3: Rebuild wreq-js inside dist/
console.log(" 📥 Attempting npm rebuild wreq-js...");
try {
const { execSync } = await import("node:child_process");
execSync("npm rebuild wreq-js", {
cwd: join(ROOT, "dist"),
stdio: "inherit",
timeout: 120_000,
});
if (existsSync(appBinaryPath)) {
process.dlopen({ exports: {} }, appBinaryPath);
console.log(" ✅ wreq-js native module rebuilt successfully!\n");
return;
}
} catch (err) {
console.warn(` ⚠️ wreq-js rebuild failed: ${err.message}`);
}
console.warn(
`\n ⚠️ Could not fix wreq-js native module for ${process.platform}-${process.arch}.`
);
console.warn(" OAuth-based providers (Codex, Cursor, etc.) may not work.");
console.warn(` Manual fix: cd ${join(ROOT, "dist")} && npm install wreq-js --no-save\n`);
}
async function ensureSwcHelpers() {
if (!hasStandaloneAppBundle(ROOT)) {
return;
}
const swcHelpersApp = join(ROOT, "dist", "node_modules", "@swc", "helpers");
const swcHelpersRoot = join(ROOT, "node_modules", "@swc", "helpers");
if (existsSync(swcHelpersApp)) {
return;
}
if (existsSync(swcHelpersRoot)) {
try {
const { cpSync } = await import("node:fs");
mkdirSync(join(ROOT, "dist", "node_modules", "@swc"), { recursive: true });
cpSync(swcHelpersRoot, swcHelpersApp, { recursive: true });
console.log(" ✅ @swc/helpers copied to standalone dist/node_modules.\n");
} catch (err) {
console.warn(` ⚠️ Could not copy @swc/helpers: ${err.message}`);
console.warn(
" Try manually: cp -r node_modules/@swc/helpers dist/node_modules/@swc/helpers\n"
);
}
return;
}
console.warn(" ⚠️ @swc/helpers not found in root node_modules either.");
console.warn(" Try: npm install --save-exact @swc/helpers@0.5.19\n");
}
async function syncProjectEnv() {
try {
const { syncEnv } = await import("./sync-env.mjs");
syncEnv({ rootDir: ROOT });
} catch (err) {
console.warn(` ⚠️ .env sync skipped: ${err.message}`);
}
}
/**
* Co-locate the LLMLingua-2 SLM optional dependency closure into dist/node_modules so the
* compression "ultra" SLM tier (PR #4257) resolves a single @huggingface/transformers instance at
* runtime. No-op unless the optionals were installed (`--include=optional`). See colocateOptionals.mjs.
*/
async function ensureLlmlinguaOptionals() {
try {
colocateLlmlinguaOptionals({ rootDir: ROOT, log: (m) => console.log(m) });
} catch (err) {
// Best-effort: the SLM tier is itself fail-open, so a co-location hiccup never fails the install.
console.warn(` ⚠️ LLMLingua optional co-location skipped: ${err.message}`);
}
}
await fixBetterSqliteBinary();
await fixWreqJsBinary();
await ensureSwcHelpers();
await ensureLlmlinguaOptionals();
await syncProjectEnv();
// Warm up native runtimes (better-sqlite3 in ~/.omniroute/runtime/).
// Non-fatal: errors are caught inside postinstall.mjs.
try {
await import("../postinstall.mjs");
} catch {
// Silently skip — runtime warm-up is best-effort.
}
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env node
import { existsSync } from "node:fs";
import { join } from "node:path";
/**
* Detect whether the current install tree contains the published standalone bundle.
* Checks for dist/server.js (Layer 1: renamed from app/server.js).
* Source checkouts will not have dist/ so postinstall skips platform-specific
* native repairs (which only apply to the shipped pre-built bundle).
*
* @param {string} rootDir
* @returns {boolean}
*/
export function hasStandaloneAppBundle(rootDir) {
// The published bundle ships in dist/ (build-output-isolation). Also accept the
// legacy app/ location so an upgrade over a partially-replaced install is still
// detected as a published bundle — mirrors the serve CLI's dist/ -> app/ fallback.
return (
existsSync(join(rootDir, "dist", "server.js")) ||
existsSync(join(rootDir, "app", "server.js"))
);
}
/**
* Returns true when running inside a Termux environment on Android.
*
* Node.js on Termux reports process.platform === "linux" (not "android"),
* so OS-level platform checks are insufficient. Use Termux-specific signals:
* 1. TERMUX_VERSION env var (set by Termux bootstrap, most reliable)
* 2. PREFIX env var containing "com.termux"
* 3. Filesystem probe at /data/data/com.termux (last resort, no env needed)
*
* @param {object} [env] Override process.env for testing.
* @returns {boolean}
*/
export function isTermux(env = process.env) {
if (env.TERMUX_VERSION) return true;
if (typeof env.PREFIX === "string" && env.PREFIX.includes("com.termux")) return true;
try {
return existsSync("/data/data/com.termux");
} catch {
return false;
}
}
@@ -0,0 +1,195 @@
#!/usr/bin/env node
import { cpSync, existsSync, lstatSync, readFileSync, readdirSync, rmSync } from "node:fs";
import { basename, dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { assembleStandalone } from "./assembleStandalone.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ROOT = join(__dirname, "..", "..");
const NEXT_DIST_DIR = process.env.NEXT_DIST_DIR || ".build/next";
const DIST_DIR = join(ROOT, NEXT_DIST_DIR);
const STANDALONE_DIR = join(DIST_DIR, "standalone");
const ELECTRON_STANDALONE_DIR = join(ROOT, ".build", "electron-standalone");
// --- Electron-UNIQUE: resolve the nested server.js location ----------------
function resolveStandaloneBundleDir() {
const directServer = join(STANDALONE_DIR, "server.js");
if (existsSync(directServer)) {
return STANDALONE_DIR;
}
const nestedCandidates = [
join(STANDALONE_DIR, "projects", "OmniRoute"),
join(STANDALONE_DIR, basename(ROOT)),
];
for (const candidate of nestedCandidates) {
if (existsSync(join(candidate, "server.js"))) {
return candidate;
}
}
throw new Error(
`Standalone server bundle not found in ${STANDALONE_DIR}. Run \`npm run build\` first.`
);
}
// --- Electron-UNIQUE: symlink guard (electron-builder fails on symlinked node_modules) ---
function assertBundleIsPackagable(bundleDir) {
const nodeModulesPath = join(bundleDir, "node_modules");
if (!existsSync(nodeModulesPath)) return;
if (lstatSync(nodeModulesPath).isSymbolicLink()) {
throw new Error(
[
"Next standalone emitted app/node_modules as a symlink.",
"electron-builder preserves extraResources symlinks, which would make the packaged app",
"depend on the original build machine path at runtime.",
"",
`Offending path: ${nodeModulesPath}`,
"Use a real node_modules directory in the build worktree before packaging Electron.",
].join("\n")
);
}
}
// --- Electron-UNIQUE: strip generated electron artifacts from staged dir ---
function removeGeneratedElectronArtifacts() {
const generatedDirs = [join(ELECTRON_STANDALONE_DIR, "electron", "dist-electron")];
for (const dir of generatedDirs) {
rmSync(dir, { recursive: true, force: true });
}
}
// --- Electron-UNIQUE: remove native modules for electron-builder ABI rebuild ---
function removeNativeModules(baseDir, prefixes = ["keytar"]) {
if (!existsSync(baseDir)) return;
const dirs = readdirSync(baseDir);
for (const dir of dirs) {
if (prefixes.some((p) => dir.startsWith(p))) {
const fullPath = join(baseDir, dir);
rmSync(fullPath, { recursive: true, force: true });
}
}
}
// --- Electron-UNIQUE: rebuild better-sqlite3 against the Electron ABI --------
//
// The `npm ci` at the repo root compiles better-sqlite3 for the CI *Node* ABI
// (e.g. 137 for Node 24). The packaged app runs its Next.js server via
// ELECTRON_RUN_AS_NODE, so it needs the *Electron* ABI (146 for electron 42,
// 148 for electron 43). We cannot rely on electron-builder's @electron/rebuild
// here: it searches `electron/node_modules` (where better-sqlite3 does not live)
// and, with the default prebuild path, tries to fetch a prebuilt binary — but
// better-sqlite3@12.11.1 only ships prebuilds up to electron-v146, so electron
// 43 (v148) silently gets no rebuild and the app dies with "Nenhum driver
// SQLite disponível — better-sqlite3 (falhou)".
//
// Instead we copy the *full* module (source + binding.gyp) from the root into
// the standalone and compile it from source against the Electron headers, so
// `bindings` finds a correct build/Release/better_sqlite3.node regardless of
// prebuild availability. Robust to any current/future electron version.
function readElectronVersion() {
const pkg = JSON.parse(readFileSync(join(ROOT, "electron", "package.json"), "utf8"));
const raw = pkg.devDependencies?.electron || pkg.dependencies?.electron || "";
return String(raw).replace(/^[\^~]/, "");
}
function rebuildBetterSqlite3ForElectron(standaloneNodeModules) {
const srcMod = join(ROOT, "node_modules", "better-sqlite3");
if (!existsSync(srcMod)) {
console.warn("[electron] better-sqlite3 not found at repo root — skipping ABI rebuild.");
return;
}
const electronVersion = readElectronVersion();
if (!electronVersion) {
throw new Error("[electron] could not resolve electron version for better-sqlite3 rebuild.");
}
const destMod = join(standaloneNodeModules, "better-sqlite3");
// copyNatives only copies build/; we need the full module (src + binding.gyp)
// to compile from source. Overwrite the copied Node-ABI build in the process.
cpSync(srcMod, destMod, { recursive: true, force: true });
rmSync(join(destMod, "build"), { recursive: true, force: true });
console.log(`[electron] rebuilding better-sqlite3 against electron ${electronVersion} ABI…`);
const result = spawnSync(
process.platform === "win32" ? "npx.cmd" : "npx",
["--yes", "node-gyp", "rebuild"],
{
cwd: destMod,
stdio: "inherit",
// Compile against the Electron headers (not Node's) so the .node lands in
// build/Release with the Electron NODE_MODULE_VERSION. No shell interpolation.
env: {
...process.env,
npm_config_runtime: "electron",
npm_config_target: electronVersion,
npm_config_disturl: "https://electronjs.org/headers",
npm_config_arch: process.arch,
npm_config_build_from_source: "true",
},
}
);
if (result.status !== 0) {
throw new Error(
`[electron] better-sqlite3 rebuild against electron ${electronVersion} failed (exit ${result.status}).`
);
}
// Drop the now-unneeded compile inputs to keep the packaged app lean.
for (const dir of ["deps", "src", "build/Debug", "build/obj.target"]) {
rmSync(join(destMod, dir), { recursive: true, force: true });
}
}
function logContextualError(error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[electron] failed to prepare standalone bundle: ${message}`);
process.exitCode = 1;
}
process.on("uncaughtException", logContextualError);
// Resolve the bundle dir (handles nested project layout) and check for symlinks
const bundleDir = resolveStandaloneBundleDir();
assertBundleIsPackagable(bundleDir);
// Clean the stage dir before assembly
rmSync(ELECTRON_STANDALONE_DIR, { recursive: true, force: true });
// Shared assembly: standalone copy + .next/static + public + abs-path sanitization + natives/@swc/helpers
assembleStandalone({
distDir: DIST_DIR,
outDir: ELECTRON_STANDALONE_DIR,
projectRoot: ROOT,
sanitizePaths: true,
copyNatives: true,
});
// Electron-UNIQUE post-assembly steps
removeGeneratedElectronArtifacts();
// Rebuild better-sqlite3 from source against the Electron ABI in the primary
// node_modules (where the standalone server resolves it). keytar is still
// stripped so electron-builder's @electron/rebuild handles it (it has electron
// prebuilds); also drop any stray Node-ABI better-sqlite3 under .next/node_modules
// so it cannot shadow the rebuilt one.
rebuildBetterSqlite3ForElectron(join(ELECTRON_STANDALONE_DIR, "node_modules"));
removeNativeModules(join(ELECTRON_STANDALONE_DIR, "node_modules"), ["keytar"]);
removeNativeModules(join(ELECTRON_STANDALONE_DIR, ".next", "node_modules"), [
"better-sqlite3",
"keytar",
]);
console.log(
`[electron] prepared standalone bundle: ${relative(ROOT, ELECTRON_STANDALONE_DIR) || "."}`
);
+521
View File
@@ -0,0 +1,521 @@
#!/usr/bin/env node
/**
* OmniRoute — Prepublish Build Script
*
* Consumes the .build/next/standalone artifact produced by `npm run build`
* (build-next-isolated.mjs) and assembles the npm staging `dist/` directory.
* Does NOT run a second `next build` — the caller must run `npm run build` first,
* or this script will invoke it exactly once if the artifact is absent.
*
* Run with: node scripts/build/prepublish.ts
*/
import { execFileSync } from "node:child_process";
import {
existsSync,
mkdirSync,
cpSync,
rmSync,
writeFileSync,
readFileSync,
readdirSync,
statSync,
chmodSync,
} from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { assembleStandalone } from "./assembleStandalone.mjs";
import {
APP_STAGING_ALLOWED_EXACT_PATHS,
APP_STAGING_ALLOWED_PATH_PREFIXES,
APP_STAGING_REMOVAL_PATHS,
findUnexpectedArtifactPaths,
} from "./pack-artifact-policy.ts";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ROOT = join(__dirname, "..", "..");
const NPX_BIN = process.platform === "win32" ? "npx.cmd" : "npx";
const DIST_DIR = join(ROOT, "dist");
const METHOD_GUARD_REQUIRE = 'require("./http-method-guard.cjs").installHttpMethodGuard();\n';
function walkFiles(dir: string, rootDir: string = dir, files: string[] = []): string[] {
let entries: string[] = [];
try {
entries = readdirSync(dir);
} catch {
return files;
}
for (const entry of entries) {
const fullPath = join(dir, entry);
let stat;
try {
stat = statSync(fullPath);
} catch {
continue;
}
if (stat.isDirectory()) {
walkFiles(fullPath, rootDir, files);
continue;
}
files.push(
fullPath
.replace(rootDir, "")
.replace(/^[/\\]/, "")
.replace(/\\/g, "/")
);
}
return files;
}
function removeEmptyDirectories(dir: string): boolean {
let entries: string[] = [];
try {
entries = readdirSync(dir);
} catch {
return false;
}
let hasFiles = false;
for (const entry of entries) {
const fullPath = join(dir, entry);
let stat;
try {
stat = statSync(fullPath);
} catch {
continue;
}
if (stat.isDirectory()) {
const childHasFiles = removeEmptyDirectories(fullPath);
if (!childHasFiles) {
rmSync(fullPath, { recursive: true, force: true });
} else {
hasFiles = true;
}
continue;
}
hasFiles = true;
}
return hasFiles;
}
console.log("🔨 OmniRoute — Building for npm publish...\n");
// ── Step 1: Clean previous dist/ directory ─────────────────
if (existsSync(DIST_DIR)) {
console.log(" 🧹 Cleaning previous dist/ directory...");
rmSync(DIST_DIR, { recursive: true, force: true });
}
// ── Step 2: Assert / trigger the Next.js standalone build ──
// prepublish no longer runs its own `next build`. It consumes the
// .build/next/standalone artifact produced by `npm run build` (build-next-isolated.mjs).
// If the artifact is absent we invoke it exactly once.
const NEXT_DIST = process.env.NEXT_DIST_DIR || ".build/next";
const standaloneServerJs = join(ROOT, NEXT_DIST, "standalone", "server.js");
if (!existsSync(standaloneServerJs)) {
console.log(" 🏗️ .build/next/standalone not found — running `npm run build` once...");
execFileSync(process.execPath, ["scripts/build/build-next-isolated.mjs"], {
cwd: ROOT,
stdio: "inherit",
});
if (!existsSync(standaloneServerJs)) {
console.error(
"\n ❌ Standalone build not found after `npm run build` at:",
standaloneServerJs
);
console.error(" Make sure next.config.mjs has: output: 'standalone'");
process.exit(1);
}
}
console.log(" ✅ Standalone artifact present:", standaloneServerJs);
// ── Step 37: Assemble standalone into dist/ ───────────────
// All shared copy/sync/sanitize/chunk-patch operations are delegated to
// assembleStandalone. npm-UNIQUE steps (MITM, MCP, CLI, sidecars) follow.
console.log(" 📋 Assembling standalone bundle into dist/...");
assembleStandalone({
distDir: join(ROOT, NEXT_DIST),
outDir: DIST_DIR,
projectRoot: ROOT,
sanitizePaths: true,
patchTurbopackChunks: true,
copyNatives: true,
});
console.log(" ✅ Standalone bundle assembled to dist/");
const distServer = join(DIST_DIR, "server.js");
const methodGuardSrc = join(ROOT, "scripts", "dev", "http-method-guard.cjs");
const methodGuardDest = join(DIST_DIR, "http-method-guard.cjs");
if (existsSync(methodGuardSrc)) {
cpSync(methodGuardSrc, methodGuardDest);
}
if (existsSync(distServer)) {
const serverSource = readFileSync(distServer, "utf8");
if (!serverSource.includes("installHttpMethodGuard")) {
writeFileSync(distServer, METHOD_GUARD_REQUIRE + serverSource);
console.log(" ✅ Patched dist/server.js with HTTP method guard.");
}
}
// ── Step 8: Compile + copy MITM cert utilities ─────────────
const mitmSrc = join(ROOT, "src", "mitm");
const mitmDest = join(DIST_DIR, "src", "mitm");
if (existsSync(mitmSrc)) {
console.log(" 🔨 Compiling MITM utilities (TypeScript → JavaScript)...");
mkdirSync(mitmDest, { recursive: true });
// Write a temporary tsconfig.json targeting the mitm directory
const mitmTsconfig = {
compilerOptions: {
target: "ES2022",
module: "NodeNext",
moduleResolution: "NodeNext",
outDir: mitmDest,
rootDir: mitmSrc,
strict: false,
noImplicitAny: false,
strictNullChecks: false,
noEmitOnError: true,
allowImportingTsExtensions: true,
rewriteRelativeImportExtensions: true,
ignoreDeprecations: "6.0",
resolveJsonModule: true,
esModuleInterop: true,
skipLibCheck: true,
types: ["node"],
baseUrl: ".",
paths: {
"@/*": ["src/*"],
},
},
include: [mitmSrc + "/**/*"],
};
const tmpTsconfigPath = join(ROOT, "tsconfig.mitm.tmp.json");
writeFileSync(tmpTsconfigPath, JSON.stringify(mitmTsconfig, null, 2));
try {
execFileSync(NPX_BIN, ["tsc", "-p", "tsconfig.mitm.tmp.json"], {
cwd: ROOT,
stdio: "inherit",
});
const mitmServerSrc = join(mitmSrc, "server.cjs");
if (existsSync(mitmServerSrc)) {
cpSync(mitmServerSrc, join(mitmDest, "server.cjs"));
}
console.log(" ✅ MITM utilities compiled to dist/src/mitm/");
} catch (err: any) {
console.warn(" ⚠️ MITM compile warning (non-fatal):", err.message);
// Fallback: copy source files so at least they are present
cpSync(mitmSrc, mitmDest, { recursive: true });
} finally {
// Cleanup temp tsconfig
try {
rmSync(tmpTsconfigPath);
} catch {}
}
}
// ── Step 8.5: Bundle MCP server ────────────────────────────
const mcpSrcFile = join(ROOT, "open-sse", "mcp-server", "server.ts");
const mcpDestDir = join(DIST_DIR, "open-sse", "mcp-server");
const mcpDestFile = join(mcpDestDir, "server.js");
if (existsSync(mcpSrcFile)) {
console.log(" 🔨 Bundling MCP Server (TypeScript → JavaScript)...");
mkdirSync(mcpDestDir, { recursive: true });
try {
execFileSync(
NPX_BIN,
[
"esbuild",
"open-sse/mcp-server/server.ts",
"--bundle",
"--platform=node",
"--packages=external",
"--format=esm",
"--outfile=dist/open-sse/mcp-server/server.js",
],
{ cwd: ROOT, stdio: "inherit" }
);
console.log(" ✅ MCP Server bundled to dist/open-sse/mcp-server/server.js");
} catch (err: any) {
console.warn(" ⚠️ MCP Server bundle error:", err.message);
}
}
// ── Step 8.6: Bundle LLMLingua ONNX worker ────────────────────────────
// The worker is spawned via worker_threads at a path the Next.js bundler cannot
// statically trace, so it must ship as a standalone .js (mirrors the MCP-server
// bundling above). Heavy deps (@atjsh/llmlingua-2 / @huggingface/transformers /
// @tensorflow/tfjs / js-tiktoken) stay EXTERNAL — they are optionalDependencies,
// dynamically imported at runtime, and the worker fail-opens if any is absent.
const llmWorkerSrc = join(
ROOT,
"open-sse",
"services",
"compression",
"engines",
"llmlingua",
"onnxWorker.ts"
);
const llmWorkerDestDir = join(
DIST_DIR,
"open-sse",
"services",
"compression",
"engines",
"llmlingua"
);
if (existsSync(llmWorkerSrc)) {
console.log(" 🔨 Bundling LLMLingua ONNX worker (TypeScript → JavaScript)...");
mkdirSync(llmWorkerDestDir, { recursive: true });
try {
execFileSync(
NPX_BIN,
[
"esbuild",
"open-sse/services/compression/engines/llmlingua/onnxWorker.ts",
"--bundle",
"--platform=node",
"--packages=external",
"--format=esm",
"--outfile=dist/open-sse/services/compression/engines/llmlingua/onnxWorker.js",
],
{ cwd: ROOT, stdio: "inherit" }
);
console.log(
" ✅ LLMLingua worker bundled to dist/open-sse/services/compression/engines/llmlingua/onnxWorker.js"
);
} catch (err: any) {
console.warn(" ⚠️ LLMLingua worker bundle error:", err.message);
}
}
// ── Step 8.7: Bundle CLI Entrypoint ──────────────────────────
const cliSrcFile = join(ROOT, "bin", "omniroute.ts");
const cliDestFile = join(ROOT, "bin", "omniroute.mjs");
if (existsSync(cliSrcFile)) {
console.log(" 🔨 Bundling CLI Entrypoint (TypeScript → JavaScript)...");
try {
execFileSync(
NPX_BIN,
[
"esbuild",
"bin/omniroute.ts",
"--bundle",
"--platform=node",
"--packages=external",
"--format=esm",
"--outfile=bin/omniroute.mjs",
],
{ cwd: ROOT, stdio: "inherit" }
);
chmodSync(cliDestFile, 0o755);
console.log(" ✅ CLI Entrypoint bundled to bin/omniroute.mjs");
} catch (err: any) {
console.warn(" ⚠️ CLI bundle error:", err.message);
}
}
// ── Step 8.8: Build @omniroute/opencode-plugin ──────────────
// The plugin ships bundled inside the omniroute npm package (see root
// package.json "files": ["@omniroute/", ...]). Its built `dist/` MUST be
// present in the publish tarball so `omniroute setup opencode` can copy it
// into the user's OpenCode plugin dir. If the build fails we surface the
// error — shipping without the plugin's dist breaks the documented install
// flow for every downstream user.
const opencodePluginSrc = join(ROOT, "@omniroute", "opencode-plugin");
const opencodePluginDist = join(opencodePluginSrc, "dist", "index.js");
const opencodePluginCjs = join(opencodePluginSrc, "dist", "index.cjs");
if (existsSync(opencodePluginSrc) && existsSync(join(opencodePluginSrc, "package.json"))) {
const pluginAlreadyBuilt = existsSync(opencodePluginDist) && existsSync(opencodePluginCjs);
if (!pluginAlreadyBuilt) {
console.log("\n 🔨 Building @omniroute/opencode-plugin (tsup)...");
try {
// The plugin is a standalone package (not an npm workspace), so the root
// install never populates its node_modules — and tsup with `dts: true`
// needs the plugin's own devDependencies (typescript, @opencode-ai/plugin
// types). Without this install a fresh CI publish fails at this step.
if (!existsSync(join(opencodePluginSrc, "node_modules"))) {
const NPM_BIN = process.platform === "win32" ? "npm.cmd" : "npm";
execFileSync(NPM_BIN, ["install", "--no-audit", "--no-fund"], {
cwd: opencodePluginSrc,
stdio: "inherit",
});
}
execFileSync(NPX_BIN, ["tsup"], {
cwd: opencodePluginSrc,
stdio: "inherit",
env: { ...process.env, NODE_ENV: "production" },
});
console.log(" ✅ @omniroute/opencode-plugin bundled to @omniroute/opencode-plugin/dist/");
} catch (err: any) {
console.error(" ❌ Failed to build @omniroute/opencode-plugin:", err.message);
console.error(" The published package would be missing the plugin dist.");
console.error(
" Run `cd @omniroute/opencode-plugin && npm install && npm run build` to debug."
);
process.exit(1);
}
} else {
console.log(" ✅ @omniroute/opencode-plugin dist/ already present (skipping rebuild)");
}
// Remove plugin node_modules after build — hard links created by npm install on Linux
// (CI runner) end up in the tarball as LINK entries, which npm registry rejects with
// E415 "Hard link is not allowed". The node_modules are only needed for the tsup build;
// they must not ship in the published package.
const pluginNodeModules = join(opencodePluginSrc, "node_modules");
if (existsSync(pluginNodeModules)) {
rmSync(pluginNodeModules, { recursive: true, force: true });
console.log(" 🧹 Removed @omniroute/opencode-plugin/node_modules (hard link guard)");
}
} else {
console.log(" ⏭️ @omniroute/opencode-plugin not found in workspace (skipping build)");
}
// ── Step 9: Copy shared utilities needed at runtime ────────
const sharedApiKey = join(ROOT, "src", "shared", "utils", "apiKey.js");
const sharedApiKeyDest = join(DIST_DIR, "src", "shared", "utils");
if (existsSync(sharedApiKey)) {
console.log(" 📋 Copying shared utilities...");
mkdirSync(sharedApiKeyDest, { recursive: true });
cpSync(sharedApiKey, join(sharedApiKeyDest, "apiKey.js"));
}
// ── Step 9.5: Copy minimal runtime sidecars required outside .next ─────────
const envExampleSrc = join(ROOT, ".env.example");
if (existsSync(envExampleSrc)) {
cpSync(envExampleSrc, join(DIST_DIR, ".env.example"));
}
const openapiSpecSrc = join(ROOT, "docs", "openapi.yaml");
if (existsSync(openapiSpecSrc)) {
const docsDest = join(DIST_DIR, "docs");
mkdirSync(docsDest, { recursive: true });
cpSync(openapiSpecSrc, join(docsDest, "openapi.yaml"));
}
const docsMarkdownSrc = join(ROOT, "docs");
if (existsSync(docsMarkdownSrc)) {
const docsDest = join(DIST_DIR, "docs");
mkdirSync(docsDest, { recursive: true });
const mdFiles = readdirSync(docsMarkdownSrc).filter(
(f) => f.endsWith(".md") || f.endsWith(".mdx")
);
for (const mdFile of mdFiles) {
cpSync(join(docsMarkdownSrc, mdFile), join(docsDest, mdFile));
}
if (mdFiles.length > 0) {
console.log(`[prepublish] Copied ${mdFiles.length} docs markdown files to dist/docs/`);
}
}
const syncEnvSrc = join(ROOT, "scripts", "sync-env.mjs");
if (existsSync(syncEnvSrc)) {
const scriptsDest = join(DIST_DIR, "scripts");
mkdirSync(scriptsDest, { recursive: true });
cpSync(syncEnvSrc, join(scriptsDest, "sync-env.mjs"));
}
const migrationsSrc = join(ROOT, "src", "lib", "db", "migrations");
if (existsSync(migrationsSrc)) {
const migrationsDest = join(DIST_DIR, "src", "lib", "db", "migrations");
mkdirSync(join(DIST_DIR, "src", "lib", "db"), { recursive: true });
cpSync(migrationsSrc, migrationsDest, { recursive: true, force: true });
}
const runtimeAssetDirs = [
{
source: join(ROOT, "open-sse", "services", "compression", "engines", "rtk", "filters"),
destination: join(DIST_DIR, "open-sse", "services", "compression", "engines", "rtk", "filters"),
},
{
source: join(ROOT, "open-sse", "services", "compression", "rules"),
destination: join(DIST_DIR, "open-sse", "services", "compression", "rules"),
},
];
for (const assetDir of runtimeAssetDirs) {
if (existsSync(assetDir.source)) {
mkdirSync(dirname(assetDir.destination), { recursive: true });
cpSync(assetDir.source, assetDir.destination, { recursive: true, force: true });
}
}
// ── Step 10: Ensure data/ directory exists ──────────────────
mkdirSync(join(DIST_DIR, "data"), { recursive: true });
// ── Step 10.5: Copy @swc/helpers into standalone ───────────
// Next.js standalone tracer sometimes omits @swc/helpers from dist/node_modules/,
// causing MODULE_NOT_FOUND at runtime. Always copy it explicitly.
const swcHelpersSrc = join(ROOT, "node_modules", "@swc", "helpers");
const swcHelpersDst = join(DIST_DIR, "node_modules", "@swc", "helpers");
if (existsSync(swcHelpersSrc) && !existsSync(swcHelpersDst)) {
console.log(" 📋 Copying @swc/helpers to standalone dist/node_modules...");
mkdirSync(join(DIST_DIR, "node_modules", "@swc"), { recursive: true });
cpSync(swcHelpersSrc, swcHelpersDst, { recursive: true });
console.log(" ✅ @swc/helpers included in standalone build.");
}
// ── Step 10.6: Remove development-only residue from staged dist/ ────────────
for (const relativePath of APP_STAGING_REMOVAL_PATHS) {
const targetPath = join(DIST_DIR, relativePath);
if (existsSync(targetPath)) {
console.log(` 🧹 Removing dist/${relativePath} (not needed in npm package)...`);
rmSync(targetPath, { recursive: true, force: true });
console.log(` ✅ dist/${relativePath} removed.`);
}
}
// ── Step 10.7: Prune any staged dist/ file outside the allowed runtime set ──
const stagedFiles = walkFiles(DIST_DIR);
const unexpectedStagedFiles = findUnexpectedArtifactPaths(stagedFiles, {
exactPaths: APP_STAGING_ALLOWED_EXACT_PATHS,
prefixPaths: APP_STAGING_ALLOWED_PATH_PREFIXES,
});
if (unexpectedStagedFiles.length > 0) {
console.log(" 🧹 Pruning unexpected files from staged dist/...");
unexpectedStagedFiles.forEach((unexpectedPath: string) => {
rmSync(join(DIST_DIR, unexpectedPath), { force: true });
console.log(` ✅ Removed dist/${unexpectedPath}`);
});
removeEmptyDirectories(DIST_DIR);
}
const remainingUnexpectedFiles = findUnexpectedArtifactPaths(walkFiles(DIST_DIR), {
exactPaths: APP_STAGING_ALLOWED_EXACT_PATHS,
prefixPaths: APP_STAGING_ALLOWED_PATH_PREFIXES,
});
if (remainingUnexpectedFiles.length > 0) {
console.error("\n ❌ Staged dist/ still contains unexpected publish artifacts:");
remainingUnexpectedFiles.forEach((violation: string) =>
console.error(` - dist/${violation}`)
);
process.exit(1);
}
// ── Done ───────────────────────────────────────────────────
const distPkg = join(DIST_DIR, "package.json");
if (existsSync(distPkg)) {
JSON.parse(readFileSync(distPkg, "utf8"));
console.log(`\n ✅ Build complete!`);
console.log(` Dist directory: dist/`);
console.log(` Server entry: dist/server.js`);
} else {
console.log(`\n ✅ Build complete! (dist/ ready for publish)`);
}
console.log("");
+140
View File
@@ -0,0 +1,140 @@
import { spawn } from "node:child_process";
export function parsePort(value, fallback) {
const parsed = Number.parseInt(String(value), 10);
return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback;
}
/**
* Resolve the V8 heap ceiling (MB) for the server process from
* `OMNIROUTE_MEMORY_MB`, mirroring `omniroute serve`. Clamped to [64, 16384];
* invalid/unset → fallback (512). The standalone launcher uses this so
* OMNIROUTE_MEMORY_MB can override the Docker image's NODE_OPTIONS fallback
* without clobbering any other runtime flags (#2939).
* @param {string | number | undefined | null} value
* @param {number} [fallback]
*/
export function resolveMaxOldSpaceMb(value, fallback = 512) {
const parsed = Number.parseInt(String(value), 10);
return Number.isFinite(parsed) && parsed >= 64 && parsed <= 16384 ? parsed : fallback;
}
/**
* Derive a sane DEFAULT V8 heap ceiling (MB) from the host's physical RAM, used
* when `OMNIROUTE_MEMORY_MB` is unset. A fixed 512MB default crashed boxes with
* plenty of RAM under load (65 providers / 2600 models → "Ineffective
* mark-compacts near heap limit ~500MB"); see #5172 / #5160 / #5152. Targets
* ~35% of total RAM, clamped to [512, 4096]. Invalid/zero totalmem → 512.
* Pass the result as the `fallback` of {@link resolveMaxOldSpaceMb} so an
* explicit OMNIROUTE_MEMORY_MB override always wins.
* @param {number | undefined | null} totalmemBytes — typically `os.totalmem()`
*/
export function calibrateHeapFallbackMb(totalmemBytes) {
const totalMb = Number(totalmemBytes) / (1024 * 1024);
if (!Number.isFinite(totalMb) || totalMb <= 0) return 512;
const target = Math.floor(totalMb * 0.35);
return Math.min(4096, Math.max(512, target));
}
const MAX_OLD_SPACE_FLAG = "--max-old-space-size";
/**
* True when the caller already pinned the V8 heap via NODE_OPTIONS
* (`--max-old-space-size=…`). Used to decide whether `omniroute serve` may
* append/inject the calibrated default — a user-set value must always win.
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
*/
export function envHasExplicitHeapFlag(env) {
const sourceEnv = arguments.length === 0 ? process.env : env;
return String(sourceEnv?.NODE_OPTIONS || "").includes(MAX_OLD_SPACE_FLAG);
}
/**
* Assemble the NODE_OPTIONS string for the spawned server, preserving any flags
* the user already exported. #5238: `omniroute serve` used to UNCONDITIONALLY
* overwrite NODE_OPTIONS with the calibrated `--max-old-space-size`, silently
* discarding a user-set `NODE_OPTIONS=--max-old-space-size=8192` (reporter set
* 8192 and still OOM'd at ~505MB). Mirrors the Electron (electron/main.js) and
* standalone (scripts/dev/run-standalone.mjs) launchers:
* - if NODE_OPTIONS already contains `--max-old-space-size`, keep it as-is
* (the user's value wins);
* - otherwise append the calibrated `--max-old-space-size=<memoryLimit>` to
* the existing NODE_OPTIONS, preserving unrelated flags (e.g.
* `--enable-source-maps`).
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
* @param {number} memoryLimit — calibrated V8 heap ceiling (MB)
* @returns {string} the NODE_OPTIONS value to pass to the child process
*/
export function buildServerNodeOptions(env = process.env, memoryLimit) {
const existing = String(env?.NODE_OPTIONS || "").trim();
if (existing.includes(MAX_OLD_SPACE_FLAG)) return existing;
return `${existing} ${MAX_OLD_SPACE_FLAG}=${memoryLimit}`.trim();
}
/**
* Build the leading `node` CLI args that pin the V8 heap. When the user already
* pinned the heap via NODE_OPTIONS, return `[]` so we do NOT inject a
* conflicting/shadowing CLI `--max-old-space-size` (CLI args override
* NODE_OPTIONS, which would re-introduce #5238). Otherwise return the calibrated
* flag — NODE_OPTIONS already carries the same value, so this stays redundant
* (identical value), never conflicting.
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
* @param {number} memoryLimit — calibrated V8 heap ceiling (MB)
* @returns {string[]}
*/
export function buildNodeHeapArgs(env = process.env, memoryLimit) {
return envHasExplicitHeapFlag(env) ? [] : [`${MAX_OLD_SPACE_FLAG}=${memoryLimit}`];
}
/**
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [fromEnv]
* Defaults to process.env. Pass bootstrap `merged` so project `.env` PORT applies before spawn.
*/
export function resolveRuntimePorts(fromEnv = process.env) {
const basePort = parsePort(fromEnv.PORT || "20128", 20128);
const apiPort = parsePort(fromEnv.API_PORT || String(basePort), basePort);
const dashboardPort = parsePort(fromEnv.DASHBOARD_PORT || String(basePort), basePort);
return { basePort, apiPort, dashboardPort };
}
export function withRuntimePortEnv(env, runtimePorts) {
const { basePort, apiPort, dashboardPort } = runtimePorts;
return {
...env,
OMNIROUTE_PORT: String(basePort),
PORT: String(dashboardPort),
DASHBOARD_PORT: String(dashboardPort),
API_PORT: String(apiPort),
};
}
export function sanitizeColorEnv(env = {}) {
const sanitized = { ...env };
// Node warns when both FORCE_COLOR and NO_COLOR are set.
// Prefer NO_COLOR in test tooling to avoid noisy process warnings.
if (typeof sanitized.FORCE_COLOR !== "undefined" && typeof sanitized.NO_COLOR !== "undefined") {
delete sanitized.FORCE_COLOR;
}
return sanitized;
}
export function spawnWithForwardedSignals(command, args, options = {}) {
const child = spawn(command, args, options);
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
});
process.on("SIGINT", () => child.kill("SIGINT"));
process.on("SIGTERM", () => child.kill("SIGTERM"));
return child;
}
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env node
export { getEnvSyncPlan, parseEnvFile, syncEnv } from "../dev/sync-env.mjs";
+64
View File
@@ -0,0 +1,64 @@
import fs from "fs";
import os from "os";
import path from "path";
import { execSync } from "child_process";
const args = process.argv.slice(2);
const fullUninstall = args.includes("--full");
const uninstallAlreadyInProgress =
process.env.OMNIROUTE_SKIP_UNINSTALL_HOOK === "1" ||
process.env.npm_lifecycle_event === "uninstall";
console.log("🛑 OmniRoute Uninstaller");
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
// 1. Stop PM2 process if it exists
try {
console.log("Stopping and removing background PM2 processes...");
execSync("pm2 delete omniroute 2>/dev/null", { stdio: "ignore" });
} catch {
// It's perfectly fine if pm2 is not installed or the process doesn't exist.
}
// 2. Local AppData / Config Folder cleanup (Only on Full Uninstall)
const dataDir = process.env.DATA_DIR || path.join(os.homedir(), ".omniroute");
if (fullUninstall) {
console.log(`🧹 Full Uninstall selected. Erasing database and files at: ${dataDir}`);
try {
if (fs.existsSync(dataDir)) {
fs.rmSync(dataDir, { recursive: true, force: true });
console.log("✅ Data directory removed.");
} else {
console.log("️ Data directory did not exist. Skipping.");
}
} catch (error) {
console.warn("⚠️ Failed to remove data directory:", error.message);
}
} else {
console.log(`💾 Keeping data files at: ${dataDir} intact.`);
}
// 3. NPM uninstall
if (uninstallAlreadyInProgress) {
console.log("️ npm uninstall is already in progress. Skipping nested uninstall command.");
} else {
console.log("🗑️ Removing npm package...");
try {
execSync("npm uninstall -g omniroute", {
stdio: "inherit",
env: {
...process.env,
OMNIROUTE_SKIP_UNINSTALL_HOOK: "1",
},
});
console.log("\n✅ OmniRoute has been successfully uninstalled from your system.");
if (!fullUninstall) {
console.log(`️ Your configurations and databases were preserved in ${dataDir}.`);
}
} catch (error) {
console.warn(
"⚠️ Failed to remove npm package. You might need to run this command with 'sudo'."
);
}
}
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
PACK_ARTIFACT_ALLOWED_EXACT_PATHS,
PACK_ARTIFACT_ALLOWED_PATH_PREFIXES,
PACK_ARTIFACT_REQUIRED_PATHS,
findMissingArtifactPaths,
findUnexpectedArtifactPaths,
} from "./pack-artifact-policy.ts";
const __filename: string = fileURLToPath(import.meta.url);
const __dirname: string = dirname(__filename);
const ROOT: string = join(__dirname, "..", "..");
const npmCommand: string = process.platform === "win32" ? "npm.cmd" : "npm";
function runNpm(args: string[], stdio: "inherit" | "pipe" = "pipe"): string {
const npmExecPath = process.env.npm_execpath;
const isBunRuntime = "Bun" in globalThis;
const command = npmExecPath && !isBunRuntime ? process.execPath : npmCommand;
const commandArgs = npmExecPath && !isBunRuntime ? [npmExecPath, ...args] : args;
return execFileSync(command, commandArgs, {
cwd: ROOT,
encoding: "utf8",
stdio: stdio === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"],
maxBuffer: 64 * 1024 * 1024,
});
}
function ensureAppStagingReady(): void {
const missingAppRequiredPaths = PACK_ARTIFACT_REQUIRED_PATHS.filter((requiredPath) =>
requiredPath.startsWith("dist/")
).filter((requiredPath) => !existsSync(join(ROOT, requiredPath)));
if (missingAppRequiredPaths.length === 0) return;
console.log("📦 dist/ staging is missing required runtime files; running npm run build:cli...");
runNpm(["run", "build:cli"], "inherit");
}
function runPackDryRun(): any {
const output = runNpm(["pack", "--dry-run", "--json", "--ignore-scripts"]);
const jsonStart = output.indexOf("[");
const jsonEnd = output.lastIndexOf("]");
const jsonPayload =
jsonStart >= 0 && jsonEnd > jsonStart ? output.slice(jsonStart, jsonEnd + 1) : output;
const parsed = JSON.parse(jsonPayload);
const packReport = Array.isArray(parsed) ? parsed[0] : null;
if (!packReport || !Array.isArray(packReport.files)) {
throw new Error("npm pack --dry-run --json did not return the expected files[] payload.");
}
return packReport;
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 1024) {
return `${bytes || 0} B`;
}
const units = ["KB", "MB", "GB"];
let value = bytes / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
}
// --policy-only: skip the build (ensureAppStagingReady → build:cli) and the
// required-runtime-files check (which needs the built dist/), running ONLY the
// unexpected-files allowlist check. The unexpected files (e.g. stray bin/*.sh) are
// SOURCE files that `npm pack --dry-run` lists regardless of build, so this catches
// the "new file leaked into the tarball" regression cheaply on the fast-path (PR→release),
// instead of only on the release PR's full Package Artifact job. See incident v3.8.36 (#5029).
const POLICY_ONLY = process.argv.includes("--policy-only");
try {
if (!POLICY_ONLY) ensureAppStagingReady();
const packReport = runPackDryRun();
const artifactPaths: string[] = packReport.files.map((file: any) => file.path);
const unexpectedPaths: string[] = findUnexpectedArtifactPaths(artifactPaths, {
exactPaths: PACK_ARTIFACT_ALLOWED_EXACT_PATHS,
prefixPaths: PACK_ARTIFACT_ALLOWED_PATH_PREFIXES,
});
const missingRequiredPaths: string[] = POLICY_ONLY
? []
: findMissingArtifactPaths(artifactPaths, PACK_ARTIFACT_REQUIRED_PATHS);
console.log("📦 npm pack artifact summary");
console.log(` File: ${packReport.filename}`);
console.log(` Entry count: ${packReport.entryCount}`);
console.log(` Packed size: ${formatBytes(packReport.size)}`);
console.log(` Unpacked size: ${formatBytes(packReport.unpackedSize)}`);
if (unexpectedPaths.length > 0) {
console.error("\n❌ Unexpected files were found in the npm publish artifact:");
for (const unexpectedPath of unexpectedPaths) {
console.error(` - ${unexpectedPath}`);
}
}
if (missingRequiredPaths.length > 0) {
console.error("\n❌ Required runtime files are missing from the npm publish artifact:");
for (const missingPath of missingRequiredPaths) {
console.error(` - ${missingPath}`);
}
}
if (unexpectedPaths.length > 0 || missingRequiredPaths.length > 0) {
process.exit(1);
}
console.log("\n✅ Pack artifact policy check passed.");
} catch (error) {
console.error(`\n❌ Pack artifact validation failed: ${error.message}`);
process.exit(1);
}
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env node
/**
* write-build-sha.mjs — HEAD sentinel guard for OmniRoute release builds.
*
* Writes OMNIROUTE_BUILD_SHA (or git rev-parse --short HEAD) into:
* - dist/BUILD_SHA
* - .build/next/standalone/BUILD_SHA (present after `npm run build`)
*
* Exits 1 if the standalone directory does not exist, which guards against
* stale-cache shipping where a previous build artifact is accidentally published
* without a fresh `next build`.
*
* Usage: node scripts/build/write-build-sha.mjs
* Called by: npm run build:release (after npm run build)
*/
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..", "..");
// Resolve the build SHA: prefer the env var (set by build:release script), fall
// back to running git rev-parse.
function resolveBuildSha() {
if (process.env.OMNIROUTE_BUILD_SHA) {
return process.env.OMNIROUTE_BUILD_SHA.trim();
}
try {
return execFileSync("git", ["rev-parse", "--short", "HEAD"], {
cwd: ROOT,
encoding: "utf8",
}).trim();
} catch (err) {
console.error("[write-build-sha] Could not determine build SHA:", err.message);
process.exit(1);
}
}
const sha = resolveBuildSha();
const NEXT_DIST = process.env.NEXT_DIST_DIR || ".build/next";
const standaloneDir = path.join(ROOT, NEXT_DIST, "standalone");
const distDir = path.join(ROOT, "dist");
// Guard: standalone must exist (ensures build:release runs after npm run build)
if (!fs.existsSync(standaloneDir)) {
console.error(
`[write-build-sha] FATAL: standalone dir not found: ${standaloneDir}\n` +
` Run \`npm run build\` before \`npm run build:release\`.`
);
process.exit(1);
}
// Write sentinel to the standalone dir (Docker/dev path)
const standaloneSentinel = path.join(standaloneDir, "BUILD_SHA");
fs.writeFileSync(standaloneSentinel, sha + "\n");
console.log(`[write-build-sha] Written ${sha} -> ${path.relative(ROOT, standaloneSentinel)}`);
// Write sentinel to dist/ (npm publish path) if it exists
if (fs.existsSync(distDir)) {
const distSentinel = path.join(distDir, "BUILD_SHA");
fs.writeFileSync(distSentinel, sha + "\n");
console.log(`[write-build-sha] Written ${sha} -> ${path.relative(ROOT, distSentinel)}`);
}
console.log(`[write-build-sha] Build SHA: ${sha}`);
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh
set -e
# ── Memory limit override ──────────────────────────────────────────────
# If OMNIROUTE_MEMORY_MB is set, build NODE_OPTIONS dynamically so the
# user can tune heap size via environment without editing the Dockerfile.
if [ -n "$OMNIROUTE_MEMORY_MB" ]; then
export NODE_OPTIONS="${NODE_OPTIONS:-} --max-old-space-size=${OMNIROUTE_MEMORY_MB}"
fi
DATA_PATH="${DATA_DIR:-/app/data}"
if [ -d "$DATA_PATH" ] && [ ! -w "$DATA_PATH" ]; then
echo "WARNING: $DATA_PATH is not writable by the current user (UID $(id -u))."
if [ "${CONTAINER_HOST:-}" = "podman" ]; then
echo "Rootless Podman maps container UIDs into a subordinate range."
echo "Run this on the host to fix (using the host-side bind-mount path):"
echo " podman unshare chown -R $(id -u):$(id -g) <host-data-dir>"
else
echo "Run this on the Docker host to fix (using the host-side bind-mount path):"
echo " sudo chown -R $(id -u):$(id -g) <host-data-dir>"
echo " chmod -R u+rwX <host-data-dir>"
fi
fi
exec "$@"
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env node
// check-build-scope.mjs — guards against worktrees / cruft leaking into the
// TypeScript build scope and poisoning `next build`.
//
// Root cause of the 2026-06-25 build OOM/GC-livelock incident: `tsconfig.json`
// uses `include: ["**/*.ts","**/*.tsx","**/*.js","**/*.jsx"]` (recursive glob),
// and 69 git worktrees under `.claude/worktrees/` were NOT in `exclude` — so the
// TS scope ballooned to 355,215 files (vs 4,547 real source files) and `next build`
// processed ~70x the codebase, OOMing even at a 64 GB heap. The CI built fine
// because its checkout is clean.
//
// This gate counts the .ts/.tsx/.js/.jsx files that tsconfig's include would match
// (respecting its top-level exclude dirs) and FAILS if the count exceeds a
// threshold — catching a leak BEFORE it detonates the build. Heap size is NOT the
// fix for an over-large scope; a clean scope is.
//
// Usage: node scripts/check/check-build-scope.mjs [--max N] [--json]
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const args = process.argv.slice(2);
const MAX = Number(args[args.indexOf("--max") + 1]) || 12000;
const JSON_OUT = args.includes("--json");
const EXT = new Set([".ts", ".tsx", ".js", ".jsx"]);
// Read tsconfig.json exclude (the source of truth for what's out of scope).
const tsconfigPath = path.join(ROOT, "tsconfig.json");
let exclude = [];
try {
exclude = JSON.parse(fs.readFileSync(tsconfigPath, "utf8")).exclude || [];
} catch {
console.error("[build-scope] could not read tsconfig.json exclude — aborting");
process.exit(2);
}
// Always skip VCS + the exclude dirs (normalise to bare top-level names).
const SKIP_DIRS = new Set([".git", ...exclude.map((e) => e.replace(/^\.\//, "").replace(/\/.*$/, ""))]);
let count = 0;
const byTop = {};
function walk(dir, top) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
if (e.isSymbolicLink()) continue; // don't follow symlinks (e.g. node_modules)
const full = path.join(dir, e.name);
if (e.isDirectory()) {
walk(full, top);
} else if (EXT.has(path.extname(e.name))) {
count++;
byTop[top] = (byTop[top] || 0) + 1;
}
}
}
for (const e of fs.readdirSync(ROOT, { withFileTypes: true })) {
if (!e.isDirectory()) {
if (EXT.has(path.extname(e.name))) {
count++;
byTop["(root)"] = (byTop["(root)"] || 0) + 1;
}
continue;
}
if (SKIP_DIRS.has(e.name)) continue;
walk(path.join(ROOT, e.name), e.name);
}
if (JSON_OUT) {
console.log(JSON.stringify({ count, max: MAX, byTop }, null, 2));
} else {
console.log(`[build-scope] ${count} .ts/.tsx/.js/.jsx files in tsconfig scope (max ${MAX})`);
const top = Object.entries(byTop)
.sort((a, b) => b[1] - a[1])
.slice(0, 8);
for (const [d, n] of top) console.log(` ${String(n).padStart(7)} ${d}`);
}
if (count > MAX) {
console.error(
`\n❌ [build-scope] scope of ${count} files exceeds ${MAX} — something is leaking into the\n` +
` tsconfig include scope (worktree, vendored copy, or build output). This poisons\n` +
` \`next build\` (OOM/GC-livelock). Add the offending dir to tsconfig.json "exclude"\n` +
` (and .dockerignore). Worktrees MUST live under .claude/worktrees/ (already excluded).\n` +
` Heap size does NOT fix this — a clean scope does. See incident 2026-06-25.`
);
process.exit(1);
}
console.log("✅ [build-scope] OK — no leak into the build scope.");
+285
View File
@@ -0,0 +1,285 @@
#!/usr/bin/env node
// scripts/check/check-bundle-size.mjs
// Catraca de bundle size (Task 12 — Fase 7).
//
// MODO PREFERENCIAL — size-limit + @size-limit/file (ou outro plugin):
// Rodar `size-limit --json` via .size-limit.json; extrair o campo `size` de cada
// entry e somar. Emite `bundleSize=<bytes>`.
//
// MODO FALLBACK — raw fs.statSync() (sem plugins instalados):
// Quando size-limit retorna "no plugins" (isEmpty — só o core está instalado), o
// script lê os `path` declarados em .size-limit.json diretamente via fs.statSync()
// e soma os bytes. Mesmas entradas, mesma métrica. Emite `bundleSize=<bytes>`.
//
// MODO SKIP — entradas inexistentes:
// Se nenhuma das entradas do .size-limit.json existir (ex: build não rodou e os
// arquivos apontados são artefatos gerados), emite `bundleSize=SKIP reason=no-build`
// e sai 0.
//
// Por default é ADVISORY: sempre sai 0 independente do resultado. Passe --ratchet
// para tornar BLOQUEANTE: lê metrics.bundleSize.value de
// config/quality/quality-baseline.json, compara o total MEDIDO e SAI 1 SE — E SOMENTE
// SE — o medido for MAIOR que o baseline (regressão real, direction:down).
//
// IMPORTANTE: o baseline (5601) é o valor GZIP do size-limit + @size-limit/file
// (instalado por 'npm ci' no CI). O modo FALLBACK-stat lê bytes CRUS (uma métrica
// DIFERENTE e maior) — comparar fallback-stat contra o baseline gzip seria um falso-
// positivo. Por isso o --ratchet SÓ bloqueia quando a medição veio do size-limit
// REAL (plugin presente); o fallback-stat e o no-build são SKIP gracioso (exit 0)
// mesmo com --ratchet — falta de plugin/build nunca bloqueia, só uma regressão
// medida na MESMA métrica do baseline bloqueia.
//
// Uso:
// node scripts/check/check-bundle-size.mjs
// node scripts/check/check-bundle-size.mjs --json (força saída JSON de size-limit se possível)
// node scripts/check/check-bundle-size.mjs --ratchet (falha exit 1 numa regressão)
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { pathToFileURL, fileURLToPath } from "node:url";
const ROOT = process.cwd();
const SIZE_LIMIT_CONFIG = path.join(ROOT, ".size-limit.json");
const SIZE_LIMIT_BIN = path.join(ROOT, "node_modules", ".bin", "size-limit");
const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json");
const RATCHET = process.argv.includes("--ratchet");
/**
* Tenta rodar size-limit --json e retorna o array de resultados.
* Lança se size-limit não tiver plugins instalados (plugins.isEmpty).
*
* @returns {Array<{name: string, size: number, sizeLimit?: number, passed?: boolean}>}
* @throws {SizeLimitNoPluginsError}
*/
export function runSizeLimit(cwd = ROOT, binPath = SIZE_LIMIT_BIN) {
if (!fs.existsSync(binPath)) {
throw Object.assign(new Error("size-limit binary not found"), { code: "SL_NO_BIN" });
}
let stdout;
try {
stdout = execFileSync("node", [binPath, "--json"], {
encoding: "utf8",
cwd,
maxBuffer: 8 * 1024 * 1024,
});
} catch (err) {
const combined = (err.stdout || "") + (err.stderr || "");
if (
combined.includes("Install Size Limit preset") ||
combined.includes("plugins.isEmpty") ||
combined.includes("@size-limit/preset")
) {
throw Object.assign(new Error("size-limit: no plugins installed"), {
code: "SL_NO_PLUGINS",
});
}
throw err;
}
return JSON.parse(stdout.trim());
}
/**
* Parseia o JSON de saída do size-limit e retorna o total em bytes.
* Lança se o JSON não tiver o campo `size` em pelo menos uma entrada.
*
* @param {Array<{name: string, size?: number}>} results
* @returns {number} total em bytes
*/
export function parseSizeLimitResults(results) {
if (!Array.isArray(results)) {
throw new TypeError("parseSizeLimitResults: esperado array de resultados");
}
let total = 0;
let hasMeasured = false;
for (const entry of results) {
if (typeof entry.size === "number") {
total += entry.size;
hasMeasured = true;
}
}
if (!hasMeasured) {
throw new Error("parseSizeLimitResults: nenhuma entrada com campo `size` numérico");
}
return total;
}
/**
* Fallback: lê os `path` do .size-limit.json via fs.statSync().
* Retorna {total, entries, allMissing} onde:
* - total: soma dos bytes dos arquivos encontrados
* - entries: [{name, path, size}]
* - allMissing: true se NENHUM arquivo existia (skip)
*
* @param {string} configPath
* @param {string} cwd
*/
export function measureViaFileStat(configPath = SIZE_LIMIT_CONFIG, cwd = ROOT) {
if (!fs.existsSync(configPath)) {
return { total: 0, entries: [], allMissing: true };
}
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
let total = 0;
let found = 0;
const entries = [];
for (const entry of config) {
const entryPath = path.isAbsolute(entry.path) ? entry.path : path.join(cwd, entry.path);
if (!fs.existsSync(entryPath)) {
entries.push({ name: entry.name, path: entry.path, size: null });
continue;
}
const size = fs.statSync(entryPath).size;
total += size;
found++;
entries.push({ name: entry.name, path: entry.path, size });
}
return { total, entries, allMissing: found === 0 };
}
// ---------------------------------------------------------------------------
// Ratchet (direction:down) — exported for tests
// ---------------------------------------------------------------------------
/**
* Avalia o total MEDIDO de bytes contra o baseline.
* Direction: down (o tamanho só pode CAIR — maior = regressão).
*
* @param {number} current - Total de bytes medido agora (gzip, via size-limit).
* @param {number} baseline - Total congelado em quality-baseline.json.
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateBundleSizeRatchet(current, baseline) {
return {
regressed: current > baseline,
improved: current < baseline,
};
}
/**
* Lê metrics.bundleSize.value do quality-baseline.json.
* Retorna null se o arquivo ou a métrica estiverem ausentes (sem baseline não há
* ratchet possível — o caller trata como SKIP gracioso, exit 0).
*
* @param {string} baselinePath
* @returns {number|null}
*/
export function readBaselineBundleSizeValue(baselinePath = BASELINE_PATH) {
if (!fs.existsSync(baselinePath)) return null;
let baselineJson;
try {
baselineJson = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
} catch {
return null;
}
const metric = baselineJson?.metrics?.bundleSize;
if (!metric || typeof metric.value !== "number") return null;
return metric.value;
}
/**
* Aplica o ratchet (direction:down) sobre o total medido vs o baseline.
* Sem --ratchet: advisory (exit 0). Com --ratchet + medição comparável (gzip via
* size-limit): exit 1 numa regressão real (medido > baseline). Baseline ausente →
* SKIP gracioso (exit 0). Define process.exitCode; não lança.
*
* @param {number} totalBytes - Total MEDIDO pelo size-limit (gzip).
*/
function applyRatchet(totalBytes) {
if (!RATCHET) {
process.exitCode = 0;
return;
}
const baselineValue = readBaselineBundleSizeValue(BASELINE_PATH);
if (baselineValue === null) {
console.log("[bundle-size] --ratchet: baseline ausente (metrics.bundleSize) — SKIP, sai 0.");
process.exitCode = 0;
return;
}
const { regressed } = evaluateBundleSizeRatchet(totalBytes, baselineValue);
if (regressed) {
console.error(
`[bundle-size] REGRESSÃO — ${totalBytes} bytes > baseline ${baselineValue}.\n` +
" → Reduza o tamanho dos entrypoints, ou re-baseline metrics.bundleSize em\n" +
" config/quality/quality-baseline.json se o crescimento for legítimo e justificado."
);
process.exitCode = 1;
return;
}
console.log(
`[bundle-size] --ratchet OK — ${totalBytes} bytes, baseline ${baselineValue} (sem regressão).`
);
process.exitCode = 0;
}
function main() {
// Step 1: tenta com size-limit + plugin instalado
let totalBytes = null;
let mode = "size-limit";
try {
const results = runSizeLimit(ROOT, SIZE_LIMIT_BIN);
totalBytes = parseSizeLimitResults(results);
} catch (err) {
if (err.code === "SL_NO_PLUGINS" || err.code === "SL_NO_BIN") {
// Step 2: fallback para leitura direta de arquivo
mode = "fallback-stat";
const { total, entries, allMissing } = measureViaFileStat(SIZE_LIMIT_CONFIG, ROOT);
if (allMissing) {
// Step 3: skip gracioso — entradas não existem (build necessário).
// SKIP sai 0 mesmo com --ratchet (build ausente nunca bloqueia).
console.log("bundleSize=SKIP reason=no-build");
if (process.env.CI) {
console.log(
"::notice::check-bundle-size skipped — entradas do .size-limit.json não encontradas (build necessário)"
);
}
return;
}
totalBytes = total;
for (const e of entries) {
if (e.size !== null) {
const kb = (e.size / 1024).toFixed(2);
console.log(` ${e.name}: ${kb} KB (${e.size} bytes)`);
} else {
console.log(` ${e.name}: ausente (não contabilizado)`);
}
}
} else {
// Erro inesperado — reporta mas não falha (advisory).
// SKIP sai 0 mesmo com --ratchet (erro de medição nunca bloqueia).
console.error(`[bundle-size] Aviso: size-limit retornou erro inesperado: ${err.message}`);
console.log("bundleSize=SKIP reason=size-limit-error");
return;
}
}
const kb = (totalBytes / 1024).toFixed(2);
console.log(`bundleSize=${totalBytes}`);
// O ratchet só pode comparar a MESMA métrica que congelou o baseline (gzip via
// size-limit + @size-limit/file). O fallback-stat lê bytes CRUS — uma métrica
// diferente e maior — então com --ratchet ele faz SKIP gracioso (exit 0) em vez
// de um falso-positivo. Sem --ratchet, ambos os modos só reportam (advisory).
if (mode !== "size-limit") {
if (RATCHET) {
console.log(
`[bundle-size] ${mode}: total ${kb} KB (${totalBytes} bytes) — ` +
"--ratchet SKIP (medição não-comparável ao baseline gzip; instale @size-limit/file)."
);
process.exitCode = 0;
return;
}
console.log(`[bundle-size] ${mode}: total ${kb} KB (${totalBytes} bytes) — advisory, saindo 0`);
return;
}
if (!RATCHET) {
console.log(`[bundle-size] ${mode}: total ${kb} KB (${totalBytes} bytes) — advisory, saindo 0`);
}
applyRatchet(totalBytes);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env node
// scripts/check/check-changelog-integrity.mjs
//
// Anti "CHANGELOG-eat" gate: no bullet line that exists in the BASE branch's
// CHANGELOG.md may disappear in the merge result. The chronic failure mode is
// git's merge auto-resolve silently dropping sibling bullets (or whole version
// sections) when two branches touch adjacent CHANGELOG lines — incident
// 2026-07-05: PR #6193's merge ate 212 lines (the entire [3.8.45] + [3.8.44]
// sections, 130 bullets), only recovered by hand from the pre-merge ref.
//
// On pull_request CI the checkout is refs/pull/N/merge — the auto-resolved
// merge result — so comparing it against origin/<base> catches the eat BEFORE
// the merge lands, in the PR that would cause it.
//
// Policy (Princípio Zero): this only ever ADDS work for the maintainer side —
// quality.yml runs it blocking for own-origin PRs and report-only for forks.
// The release captain's reconciliation rewrites the CHANGELOG legitimately,
// but that happens on the release PR (PR → main, ci.yml), which does not run
// this gate. Escape hatch for intentional removals (e.g. reverting a reverted
// feature's bullet): ALLOW_CHANGELOG_REMOVALS=1 turns failures into a report.
//
// Usage:
// node scripts/check/check-changelog-integrity.mjs
// env GITHUB_BASE_REF PR base branch (CI); local fallback: current release/*
// env CHANGELOG_BASE_REF explicit ref override (e.g. origin/release/v3.8.45)
// env ALLOW_CHANGELOG_REMOVALS=1 report-only (never fails)
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const CHANGELOG = "CHANGELOG.md";
/** Extract the set of bullet lines (trimmed) from a CHANGELOG text. */
export function extractBullets(text) {
const bullets = new Set();
for (const raw of String(text || "").split("\n")) {
const line = raw.trim();
if (line.startsWith("- ") && line.length > 4) bullets.add(line);
}
return bullets;
}
/**
* Bullet lines present in the base CHANGELOG but absent from the head
* CHANGELOG — the "eaten" set. Pure so it has a unit test.
*/
export function findLostBullets(baseText, headText) {
const headBullets = extractBullets(headText);
const lost = [];
for (const b of extractBullets(baseText)) {
if (!headBullets.has(b)) lost.push(b);
}
return lost;
}
function git(args) {
return execFileSync("git", args, { cwd: ROOT, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
}
function resolveBaseRef() {
if (process.env.CHANGELOG_BASE_REF) return process.env.CHANGELOG_BASE_REF;
if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`;
// Local fallback: the highest release/v* on origin (the active development base).
try {
const branches = git(["branch", "-r", "--list", "origin/release/v*", "--format=%(refname:short)"])
.split("\n")
.map((s) => s.trim())
.filter(Boolean)
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
return branches[branches.length - 1] || null;
} catch {
return null;
}
}
function main() {
const baseRef = resolveBaseRef();
if (!baseRef) {
console.log("[changelog-integrity] SKIP — could not resolve a base ref (offline/fresh clone).");
return 0;
}
let baseText;
try {
baseText = git(["show", `${baseRef}:${CHANGELOG}`]);
} catch {
console.log(`[changelog-integrity] SKIP — ${CHANGELOG} not readable at ${baseRef}.`);
return 0;
}
const headText = readFileSync(join(ROOT, CHANGELOG), "utf8");
const lost = findLostBullets(baseText, headText);
if (lost.length === 0) {
console.log(`[changelog-integrity] OK — no base bullets lost vs ${baseRef}.`);
return 0;
}
console.error(
`[changelog-integrity] ${lost.length} bullet(s) present in ${baseRef} are MISSING from this tree's ${CHANGELOG}:`
);
for (const b of lost.slice(0, 15)) console.error(`${b.slice(0, 160)}`);
if (lost.length > 15) console.error(` … and ${lost.length - 15} more`);
console.error(
"\nThis is the CHANGELOG-eat pattern (merge auto-resolve dropping sibling bullets)." +
"\nFix: restore the base CHANGELOG (`git checkout <base> -- CHANGELOG.md`), re-insert ONLY" +
"\nyour own bullet, and prove the net diff is additive. Intentional removals (rare):" +
"\nre-run with ALLOW_CHANGELOG_REMOVALS=1 and justify in the PR body."
);
if (process.env.ALLOW_CHANGELOG_REMOVALS === "1") {
console.error("[changelog-integrity] ALLOW_CHANGELOG_REMOVALS=1 — reporting only, not failing.");
return 0;
}
return 1;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
process.exit(main());
}
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env node
// scripts/check/check-circular-deps.mjs
// Gate: dpdm circular-deps cross-check (segunda opinião complementar ao check-cycles.mjs).
//
// check-cycles.mjs usa AST-TS próprio mas cobre apenas 5 sub-árvores + somente
// imports relativos. Este script usa dpdm (v4) que rastreia path-aliases via
// tsconfig.json e cobre entrypoints de alto risco.
//
// Advisory nesta versão: exit 0 sempre; imprime `circularDeps=N` para baseline.
// Direção da catraca: down (não pode subir). Adicionar ao quality-baseline.json
// como `{ value: N, direction: "down" }` após a primeira run verde no CI.
//
// Escopo limitado a 4 entrypoints principais para manter o tempo de análise
// abaixo de 60s. dpdm rastreia transitivamente todas as deps de cada entry.
// Cobrir mais entries aumenta o tempo sem proporcional ganho (as deps core se
// repetem via transitividade).
//
// Nota: dpdm pode reportar mais ciclos que check-cycles.mjs porque conta
// permutações de paths que passam pelo mesmo SCC, não apenas SCCs únicos.
// Isso é esperado — ferramentas diferentes, métricas complementares.
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import os from "node:os";
import path from "node:path";
import fs from "node:fs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(__dirname, "../..");
// Entrypoints: cobrem o pipeline principal (chat, combo, MCP, DB).
// Mantido enxuto para que `dpdm -T` (transform) termine em < 60s.
const ENTRYPOINTS = [
"open-sse/handlers/chatCore.ts",
"open-sse/services/combo.ts",
"open-sse/mcp-server/index.ts",
"src/lib/db/core.ts",
];
const DPDM_BIN = resolve(projectRoot, "node_modules/.bin/dpdm");
const TSCONFIG = resolve(projectRoot, "tsconfig.json");
/**
* Parseia a saída JSON do dpdm e retorna a contagem de ciclos.
* Função exportada para ser testada isoladamente sem executar o dpdm.
*
* @param {string} jsonStr - string com o JSON de saída do dpdm (campo "circulars").
* @returns {{ count: number, circulars: string[][] }}
*/
export function parseDpdmOutput(jsonStr) {
let parsed;
try {
parsed = JSON.parse(jsonStr);
} catch {
throw new Error(`dpdm JSON parse failed: ${jsonStr.slice(0, 200)}`);
}
const circulars = Array.isArray(parsed.circulars) ? parsed.circulars : [];
return { count: circulars.length, circulars };
}
/**
* Executa o dpdm e retorna a string JSON bruta do arquivo de saída.
*
* @returns {string} conteúdo JSON do arquivo temporário.
*/
function runDpdm() {
if (!existsSync(DPDM_BIN)) {
throw new Error(`dpdm binary not found at ${DPDM_BIN}. Run: npm install`);
}
const tmpFile = path.join(os.tmpdir(), `dpdm-output-${process.pid}.json`);
try {
execFileSync(
"node",
[
DPDM_BIN,
"--circular",
"--no-warning",
"--no-tree",
"-T",
"--tsconfig",
TSCONFIG,
"-o",
tmpFile,
...ENTRYPOINTS,
],
{
cwd: projectRoot,
stdio: "inherit",
timeout: 120_000,
}
);
if (!existsSync(tmpFile)) {
throw new Error(`dpdm did not produce output file at ${tmpFile}`);
}
const raw = readFileSync(tmpFile, "utf8");
return raw;
} finally {
try {
fs.unlinkSync(tmpFile);
} catch {
// best-effort cleanup
}
}
}
function main() {
console.log("[circular-deps] Running dpdm cross-check...");
console.log(`[circular-deps] Entrypoints: ${ENTRYPOINTS.join(", ")}`);
let raw;
try {
raw = runDpdm();
} catch (err) {
console.error(`[circular-deps] ERROR running dpdm: ${err.message}`);
process.exit(1);
}
let result;
try {
result = parseDpdmOutput(raw);
} catch (err) {
console.error(`[circular-deps] ERROR parsing dpdm output: ${err.message}`);
process.exit(1);
}
// Advisory mode: always exit 0. Catraca pode ser adicionada no quality-baseline.json
// após baseline ser estabelecida.
console.log(`[circular-deps] circularDeps=${result.count}`);
console.log(
`[circular-deps] Advisory — add to quality-baseline.json: { "value": ${result.count}, "direction": "down" }`
);
process.exit(0);
}
// Permite que o módulo seja importado em testes sem executar main().
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env node
/**
* Validates that:
* 1. All t("key") calls in bin/cli/commands/ resolve to existing keys in en.json.
* 2. pt-BR.json has the same top-level shape as en.json (no missing top-level sections).
* 3. No raw string literals are passed to .description() in commands without going
* through t() — only warns, does not fail hard (many descriptions use || fallback).
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, "..", "..");
const COMMANDS_DIR = join(ROOT, "bin", "cli", "commands");
const LOCALES_DIR = join(ROOT, "bin", "cli", "locales");
// Paths that look like t() keys but are actually import paths — skip them.
const IGNORE_AS_KEY = new Set([".", ".."]);
const IMPORT_PATH_RE = /^(\.\.?\/|node:|\/)/;
function walk(dir) {
const results = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
results.push(...walk(full));
} else if (entry.endsWith(".mjs") || entry.endsWith(".js")) {
results.push(full);
}
}
return results;
}
function flattenKeys(obj, prefix = "") {
const keys = new Set();
for (const [k, v] of Object.entries(obj)) {
const full = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
for (const sub of flattenKeys(v, full)) keys.add(sub);
} else {
keys.add(full);
}
}
return keys;
}
function collectTKeys(files) {
const used = new Set();
const re = /\bt\(\s*["']([^"']+)["']/g;
for (const file of files) {
const src = readFileSync(file, "utf8");
let m;
re.lastIndex = 0;
while ((m = re.exec(src)) !== null) {
const key = m[1];
if (IGNORE_AS_KEY.has(key) || IMPORT_PATH_RE.test(key)) continue;
used.add(key);
}
}
return used;
}
function loadJson(file) {
return JSON.parse(readFileSync(file, "utf8"));
}
const files = walk(COMMANDS_DIR);
const usedKeys = collectTKeys(files);
const en = loadJson(join(LOCALES_DIR, "en.json"));
const ptBR = loadJson(join(LOCALES_DIR, "pt-BR.json"));
const enKeys = flattenKeys(en);
let errors = 0;
// Check 1: all used keys exist in en.json
const missingInEn = [...usedKeys].filter((k) => !enKeys.has(k));
if (missingInEn.length > 0) {
console.error("[cli-i18n] Keys used in commands but missing in en.json:");
for (const k of missingInEn) console.error(`${k}`);
errors += missingInEn.length;
} else {
console.log(`[cli-i18n] ✓ All ${usedKeys.size} t() keys found in en.json`);
}
// Check 2: pt-BR.json has the same top-level sections as en.json
const enTopLevel = Object.keys(en);
const ptTopLevel = new Set(Object.keys(ptBR));
const missingTopLevel = enTopLevel.filter((k) => !ptTopLevel.has(k));
if (missingTopLevel.length > 0) {
console.error("[cli-i18n] Top-level sections in en.json missing from pt-BR.json:");
for (const k of missingTopLevel) console.error(`${k}`);
errors += missingTopLevel.length;
} else {
console.log(`[cli-i18n] ✓ pt-BR.json has all ${enTopLevel.length} top-level sections`);
}
if (errors > 0) {
console.error(`[cli-i18n] FAIL — ${errors} error(s) found`);
process.exit(1);
} else {
console.log("[cli-i18n] PASS — CLI i18n is consistent");
}
+430
View File
@@ -0,0 +1,430 @@
#!/usr/bin/env node
// scripts/check/check-codeql-ratchet.mjs
// Catraca de alertas CodeQL (Task 7.3 — Fase 7).
//
// Usa a GitHub API via `gh` CLI para buscar alertas de code-scanning abertos e
// não-dismissed (respeita Hard Rule #14: alertas dismissed não contam).
//
// Saída (stdout):
// codeqlAlerts=N — contagem de alertas CodeQL abertos, não-dismissed
// codeqlAlerts=SKIP reason=binary-absent — `gh` não está no PATH
// codeqlAlerts=SKIP reason=no-auth — `gh` presente mas sem autenticação
// codeqlAlerts=SKIP reason=api-error:<code> — erro da API GitHub
//
// RATCHET BLOQUEANTE (default): lê metrics.codeqlAlerts.value de
// config/quality/quality-baseline.json e SAI 1 SE — E SOMENTE SE — a contagem
// MEDIDA for MAIOR que o baseline (regressão real, mais alertas CodeQL abertos).
// Qualquer falha de MEDIÇÃO (gh ausente / sem auth / sem repo / erro de API) é um
// SKIP gracioso que SAI 0 — nunca bloqueia o build por falta de infraestrutura.
// Direction: down (a contagem só pode CAIR). Suporta --update para ratchetar.
//
// Uso:
// node scripts/check/check-codeql-ratchet.mjs
// node scripts/check/check-codeql-ratchet.mjs --json # imprime array de alertas
// node scripts/check/check-codeql-ratchet.mjs --quiet # suprime logs de diagnóstico
// node scripts/check/check-codeql-ratchet.mjs --update # ratcheta o baseline (queda)
// node scripts/check/check-codeql-ratchet.mjs --advisory # nunca falha (modo coletor)
import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const QUIET = process.argv.includes("--quiet");
const PRINT_JSON = process.argv.includes("--json");
const UPDATE = process.argv.includes("--update");
// --advisory: nunca falha pela contagem (modo coletor legado). Sem esta flag o
// gate é BLOQUEANTE: sai 1 numa regressão real (medida > baseline).
const ADVISORY = process.argv.includes("--advisory");
const ROOT = process.cwd();
const BASELINE_PATH = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "config/quality/quality-baseline.json")
);
// ---------------------------------------------------------------------------
// Pure parsing function (exported for tests)
// ---------------------------------------------------------------------------
/**
* Conta alertas CodeQL abertos e não-dismissed a partir do JSON da GitHub API.
*
* A GitHub API /code-scanning/alerts retorna um array de:
* {
* number: number,
* state: "open" | "dismissed" | "fixed",
* dismissed_reason: string | null,
* dismissed_at: string | null,
* tool: { name: string, ... },
* rule: { id: string, severity: string, security_severity_level?: string, ... },
* ...
* }
*
* Hard Rule #14: alertas com `state="dismissed"` NÃO contam, independente da razão.
* Filtramos por state="open" E tool.name contendo "CodeQL" (case-insensitive).
* Alertas de outras ferramentas (ex: Semgrep) são ignorados.
*
* @param {Array|null} alerts - Array de alertas da API GitHub
* @returns {{ alertCount: number, bySeverity: Record<string, number>, byRule: Record<string, number> }}
*/
export function parseCodeQLAlerts(alerts) {
if (!Array.isArray(alerts)) {
return { alertCount: 0, bySeverity: {}, byRule: {} };
}
let alertCount = 0;
const bySeverity = {};
const byRule = {};
for (const alert of alerts) {
// Ignorar alertas não-CodeQL (outras ferramentas de code scanning)
const toolName = alert?.tool?.name ?? "";
if (!toolName.toLowerCase().includes("codeql")) continue;
// Hard Rule #14: alertas dismissed não contam
if (alert.state === "dismissed") continue;
// Só alertas abertos
if (alert.state !== "open") continue;
alertCount++;
// Coletar por severidade (security_severity_level ou severity da rule)
const severity = (
alert?.rule?.security_severity_level ??
alert?.rule?.severity ??
"unknown"
).toLowerCase();
bySeverity[severity] = (bySeverity[severity] ?? 0) + 1;
// Coletar por rule ID
const ruleId = alert?.rule?.id ?? "unknown";
byRule[ruleId] = (byRule[ruleId] ?? 0) + 1;
}
return { alertCount, bySeverity, byRule };
}
/**
* Avalia a contagem MEDIDA de alertas CodeQL contra o baseline.
* Direction: down (a contagem só pode CAIR — mais alertas = regressão).
*
* Exported for unit testing — espelha evaluateDeadCode em check-dead-code.mjs.
*
* @param {number} current - Contagem de alertas medida agora.
* @param {number} baseline - Contagem congelada em quality-baseline.json.
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateCodeqlRatchet(current, baseline) {
return {
regressed: current > baseline,
improved: current < baseline,
};
}
// ---------------------------------------------------------------------------
// Repository detection
// ---------------------------------------------------------------------------
/**
* Detecta o owner/repo do repositório atual usando `gh repo view`.
* Retorna null se `gh` não estiver disponível ou não autenticado.
*
* @param {string} ghBin - Caminho para o binário gh
* @returns {string|null} "owner/repo" ou null
*/
export function detectRepo(ghBin) {
try {
const stdout = execFileSync(
ghBin,
["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"],
{
encoding: "utf8",
timeout: 15_000,
}
);
return stdout.trim() || null;
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Binary detection
// ---------------------------------------------------------------------------
/**
* Detecta se o binário `gh` está disponível no PATH.
* Usa `which` (Unix) sem interpolação de shell — Hard Rule #13.
*
* @returns {string|null} Caminho absoluto para o binário, ou null se ausente.
*/
export function findGhCli() {
try {
const result = spawnSync("which", ["gh"], {
encoding: "utf8",
timeout: 5_000,
});
if (result.status === 0) {
return result.stdout.trim();
}
} catch {
// which não disponível
}
// Fallback: tentar executar diretamente para verificar ENOENT
try {
const result = spawnSync("gh", ["--version"], {
encoding: "utf8",
timeout: 5_000,
});
if (result.error?.code === "ENOENT") return null;
if (result.status !== null) return "gh"; // found in PATH
} catch {
// noop
}
return null;
}
// ---------------------------------------------------------------------------
// API caller
// ---------------------------------------------------------------------------
/**
* Busca alertas CodeQL abertos via `gh api`.
* Pagina automaticamente (GitHub retorna max 100 por página).
*
* @param {string} ghBin - Caminho para o binário gh
* @param {string} repo - "owner/repo"
* @returns {Array} Array de alertas
*/
function fetchCodeQLAlerts(ghBin, repo) {
const allAlerts = [];
let page = 1;
const perPage = 100;
while (true) {
const endpoint = `/repos/${repo}/code-scanning/alerts?state=open&tool_name=CodeQL&per_page=${perPage}&page=${page}`;
if (!QUIET) {
process.stderr.write(`[codeql-ratchet] Buscando alertas: página ${page} ...\n`);
}
let stdout;
try {
stdout = execFileSync(ghBin, ["api", endpoint], {
encoding: "utf8",
timeout: 30_000,
maxBuffer: 16 * 1024 * 1024,
});
} catch (err) {
const errMsg = String(err.stderr ?? err.message ?? "");
// Sem autenticação
if (
errMsg.includes("authentication") ||
errMsg.includes("401") ||
errMsg.includes("not logged")
) {
return { error: "no-auth", message: errMsg };
}
// Rate limit ou outro erro HTTP
const codeMatch = /HTTP (\d{3})/.exec(errMsg);
const code = codeMatch ? codeMatch[1] : "unknown";
return { error: `api-error:${code}`, message: errMsg };
}
let page_alerts;
try {
page_alerts = JSON.parse(stdout);
} catch (parseErr) {
// A malformed (but HTTP-200) API response is a MEASUREMENT failure, not a
// regression. A blocking gate must never red on it — return the same
// {error,message} shape the caller already maps to a graceful SKIP (exit 0).
return { error: "parse-error", message: String(parseErr.message ?? parseErr) };
}
// A API retorna null quando não há mais páginas (ou array vazio)
if (!Array.isArray(page_alerts) || page_alerts.length === 0) break;
allAlerts.push(...page_alerts);
// Se retornou menos que perPage, chegamos à última página
if (page_alerts.length < perPage) break;
page++;
}
return allAlerts;
}
// ---------------------------------------------------------------------------
// Baseline
// ---------------------------------------------------------------------------
/**
* Lê metrics.codeqlAlerts.value do quality-baseline.json.
* Retorna null se o arquivo ou a métrica estiverem ausentes (modo coletor puro:
* sem baseline não há ratchet, só emissão da contagem).
*
* @returns {number|null}
*/
function readBaselineCodeqlValue() {
if (!fs.existsSync(BASELINE_PATH)) return null;
let baselineJson;
try {
baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
} catch {
return null;
}
const metric = baselineJson?.metrics?.codeqlAlerts;
if (!metric || typeof metric.value !== "number") return null;
return metric.value;
}
/**
* Aplica o ratchet (direction:down) sobre a contagem medida vs o baseline.
* Define process.exitCode = 1 numa regressão real (medida > baseline) salvo
* --advisory. Ratcheta o baseline com --update quando a contagem cai.
*
* Exported for unit testing (drives o efeito em process.exitCode).
*
* @param {number} alertCount - Contagem MEDIDA (medição bem-sucedida).
*/
export function applyRatchet(alertCount) {
const baselineValue = readBaselineCodeqlValue();
// Sem baseline → modo coletor puro (emite a contagem, não falha).
if (baselineValue === null) {
if (!QUIET) {
process.stderr.write(
"[codeql-ratchet] baseline ausente (metrics.codeqlAlerts) — modo coletor, sem ratchet.\n"
);
}
process.exitCode = 0;
return;
}
const { regressed, improved } = evaluateCodeqlRatchet(alertCount, baselineValue);
if (UPDATE && improved) {
const baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
baselineJson.metrics.codeqlAlerts.value = alertCount;
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselineJson, null, 2) + "\n");
console.log(`[codeql-ratchet] baseline ratcheado: ${alertCount} (era ${baselineValue})`);
}
if (regressed && !ADVISORY) {
process.stderr.write(
`[codeql-ratchet] REGRESSÃO — ${alertCount} alertas CodeQL abertos > baseline ${baselineValue}\n` +
" → Corrija os novos alertas em Security → Code scanning, ou rode\n" +
" 'node scripts/check/check-codeql-ratchet.mjs --update' se a contagem caiu legitimamente.\n"
);
process.exitCode = 1;
return;
}
if (!QUIET) {
const verdict = regressed ? "ADVISORY — regressão ignorada (--advisory)" : "OK — sem regressão";
process.stderr.write(
`[codeql-ratchet] ${verdict}${alertCount} alertas (baseline ${baselineValue})\n`
);
}
process.exitCode = 0;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const ghBin = findGhCli();
if (!ghBin) {
console.log("codeqlAlerts=SKIP reason=binary-absent");
if (!QUIET) {
process.stderr.write(
"[codeql-ratchet] SKIP — `gh` CLI não encontrado no PATH.\n" +
"[codeql-ratchet] Instale via: https://cli.github.com/\n" +
"[codeql-ratchet] ADVISORY — este gate sai 0 (ratchet entra no CI da Fase 7 INT).\n"
);
}
process.exitCode = 0;
return;
}
// Detectar repositório
const repo = detectRepo(ghBin);
if (!repo) {
console.log("codeqlAlerts=SKIP reason=no-repo");
if (!QUIET) {
process.stderr.write(
"[codeql-ratchet] SKIP — não foi possível detectar o repositório GitHub.\n" +
"[codeql-ratchet] Execute dentro de um repositório GitHub com `gh` autenticado.\n"
);
}
process.exitCode = 0;
return;
}
if (!QUIET) {
process.stderr.write(`[codeql-ratchet] Repositório detectado: ${repo}\n`);
}
// Buscar alertas
const result = fetchCodeQLAlerts(ghBin, repo);
// Tratar erros da API com skip gracioso
if (!Array.isArray(result)) {
const { error, message } = result;
console.log(`codeqlAlerts=SKIP reason=${error}`);
if (!QUIET) {
process.stderr.write(
`[codeql-ratchet] SKIP — erro ao consultar API GitHub: ${message.slice(0, 200)}\n`
);
}
process.exitCode = 0;
return;
}
if (PRINT_JSON) {
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
return;
}
const { alertCount, bySeverity, byRule } = parseCodeQLAlerts(result);
// Emitir em formato KEY=VALUE para o coletor de métricas (collect-metrics.mjs)
console.log(`codeqlAlerts=${alertCount}`);
if (!QUIET) {
const severitySummary =
Object.entries(bySeverity)
.map(([k, v]) => `${k}=${v}`)
.join(", ") || "nenhum";
const topRules =
Object.entries(byRule)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([r, n]) => `${r}(${n})`)
.join(", ") || "nenhum";
process.stderr.write(
`[codeql-ratchet] Alertas CodeQL abertos (não-dismissed): ${alertCount}\n`
);
if (alertCount > 0) {
process.stderr.write(`[codeql-ratchet] Por severidade: ${severitySummary}\n`);
process.stderr.write(`[codeql-ratchet] Top regras: ${topRules}\n`);
}
}
// Medição bem-sucedida → aplica o ratchet (bloqueante salvo --advisory).
// Qualquer falha de MEDIÇÃO acima já retornou com exit 0 (skip gracioso).
applyRatchet(alertCount);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
@@ -0,0 +1,155 @@
#!/usr/bin/env node
// scripts/check/check-cognitive-complexity.mjs
// Ratchet bloqueante para complexidade cognitiva (sonarjs/cognitive-complexity).
// Fase 7 INT: promovido de ADVISORY para RATCHET.
//
// Roda o ESLint sobre src+open-sse usando um config flat STANDALONE
// (eslint.sonarjs.config.mjs) que liga APENAS `sonarjs/cognitive-complexity` —
// mantendo a contagem ISOLADA do orçamento de warnings do lint principal.
//
// Lê o baseline de quality-baseline.json (metrics.cognitiveComplexity).
// Falha com exit 1 se a contagem SUBIR. Suporta --update.
//
// Saída canônica: cognitiveComplexity=N (parseable por collect-metrics.mjs)
//
// Uso:
// node scripts/check/check-cognitive-complexity.mjs
// node scripts/check/check-cognitive-complexity.mjs --quiet # só a linha canônica
// node scripts/check/check-cognitive-complexity.mjs --update # ratcheta baseline se melhorou
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const QUIET = process.argv.includes("--quiet");
const UPDATE = process.argv.includes("--update");
const CONFIG_PATH = path.join(ROOT, "eslint.sonarjs.config.mjs");
const ESLINT_BIN = path.join(ROOT, "node_modules", ".bin", "eslint");
const BASELINE_PATH = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "config/quality/quality-baseline.json")
);
const ESLINT_ARGS = [
"--no-config-lookup",
"--config",
CONFIG_PATH,
"--format",
"json",
"src",
"open-sse",
];
/**
* Parses the ESLint JSON output (array of file results) and counts total
* `sonarjs/cognitive-complexity` violations.
*
* Exported so unit tests can call it directly with synthetic data.
*
* @param {Array<{messages: Array<{ruleId: string}>}>} report
* @returns {number}
*/
export function countCognitiveViolations(report) {
let count = 0;
for (const file of report) {
for (const msg of file.messages) {
if (msg.ruleId === "sonarjs/cognitive-complexity") {
count++;
}
}
}
return count;
}
/**
* Avalia a contagem atual de violações cognitivas contra o baseline.
* Direction: down (contagem só pode CAIR).
*
* Exported for unit testing.
*
* @param {number} current
* @param {number} baseline
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateCognitiveComplexity(current, baseline) {
return {
regressed: current > baseline,
improved: current < baseline,
};
}
function runEslint() {
let stdout;
try {
stdout = execFileSync(ESLINT_BIN, ESLINT_ARGS, {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
} catch (err) {
// ESLint exits non-zero when there are lint errors; the JSON report is still
// in stdout. Re-throw only if there is no parseable output.
stdout = err.stdout ? String(err.stdout) : "";
if (!stdout.trim()) throw err;
}
return JSON.parse(stdout);
}
function main() {
if (!fs.existsSync(BASELINE_PATH)) {
process.stderr.write(
`[cognitive-complexity] FAIL — ${path.basename(BASELINE_PATH)} ausente.\n`
);
process.exit(2);
}
const baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
const baselineMetric = baselineJson.metrics && baselineJson.metrics.cognitiveComplexity;
if (!baselineMetric || typeof baselineMetric.value !== "number") {
process.stderr.write(
"[cognitive-complexity] FAIL — metrics.cognitiveComplexity ausente em quality-baseline.json.\n"
);
process.exit(2);
}
const baselineValue = baselineMetric.value;
const report = runEslint();
const count = countCognitiveViolations(report);
// Canonical machine-readable output consumed by collect-metrics.mjs and shell scripts.
console.log(`cognitiveComplexity=${count}`);
if (!QUIET) {
console.log(
`[cognitive-complexity] ${count} function(s) exceed the cognitive-complexity threshold (15).`
);
}
const { regressed, improved } = evaluateCognitiveComplexity(count, baselineValue);
if (UPDATE && improved) {
baselineJson.metrics.cognitiveComplexity.value = count;
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselineJson, null, 2) + "\n");
console.log(`[cognitive-complexity] baseline ratcheado: ${count} (era ${baselineValue})`);
}
if (regressed) {
process.stderr.write(
`[cognitive-complexity] REGRESSÃO — ${count} violações > baseline ${baselineValue}\n` +
` → Quebre as funções complexas em helpers menores, ou rode\n` +
` 'node scripts/check/check-cognitive-complexity.mjs --update' se a contagem caiu legitimamente.\n`
);
process.exit(1);
}
if (!QUIET) {
console.log(`[cognitive-complexity] OK — ${count} violações (baseline ${baselineValue})`);
}
process.exit(0);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env node
// scripts/check/check-complexity.mjs
// Catraca de complexidade de código. Roda o ESLint sobre src+open-sse usando um config
// flat STANDALONE (eslint.complexity.config.mjs) que liga APENAS duas regras CORE do
// ESLint — `complexity` (ciclomática) e `max-lines-per-function` (tamanho de função) —
// e compara a contagem total de violações contra um baseline congelado
// (complexity-baseline.json). Falha se a contagem SUBIR. Completa a dimensão
// "complexity" do snapshot de qualidade, ao lado de duplicação/tamanho-de-arquivo.
//
// O config dedicado evita poluir a contagem de warnings do lint principal (ratcheada
// em exatamente 3482): este gate roda isolado, com seu próprio par de regras. --update
// ratcheta (a contagem só pode CAIR).
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const BASELINE_PATH = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "config/quality/complexity-baseline.json")
);
const UPDATE = process.argv.includes("--update");
const CONFIG_PATH = path.join(ROOT, "eslint.complexity.config.mjs");
// Exported for the gate's own unit test (tests/unit/build/check-complexity.test.ts), which
// locks the scan scope to the one documented in eslint.complexity.config.mjs `files` and in
// complexity-baseline.json. The positional paths MUST match that scope (src+open-sse+electron+bin)
// — ESLint flat config only walks the directories passed here, so a `files` glob for bin/electron
// is inert unless the directory is also passed as a positional argument.
export const ESLINT_ARGS = [
"eslint",
"--no-config-lookup",
"--config",
CONFIG_PATH,
"--format",
"json",
"src",
"open-sse",
"electron",
"bin",
];
/** Avalia a contagem atual de violações contra o baseline. */
export function evaluateComplexity(current, baseline) {
return {
regressed: current > baseline,
improved: current < baseline,
};
}
function measureComplexityCount() {
let stdout;
try {
stdout = execFileSync("npx", ["--yes", ...ESLINT_ARGS], {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
} catch (err) {
// ESLint sai com código !=0 quando há erros (e nossas regras são "error"); o relatório
// JSON ainda vai no stdout. Só relançamos se não houver stdout parseável.
stdout = err.stdout ? String(err.stdout) : "";
if (!stdout.trim()) throw err;
}
const report = JSON.parse(stdout);
return report.reduce((sum, file) => sum + file.errorCount, 0);
}
function main() {
if (!fs.existsSync(BASELINE_PATH)) {
console.error(`[complexity] FAIL — ${path.basename(BASELINE_PATH)} ausente.`);
process.exit(2);
}
const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
const current = measureComplexityCount();
const { regressed, improved } = evaluateComplexity(current, baseline.count);
if (UPDATE && improved) {
console.log(`[complexity] baseline ratcheado: ${current} (era ${baseline.count})`);
baseline.count = current;
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + "\n");
}
if (regressed) {
console.error(
`[complexity] REGRESSÃO — ${current} violações > baseline ${baseline.count}\n` +
` → quebre a função em helpers menores (reduza ramos/tamanho) ou rode\n` +
` 'node scripts/check/check-complexity.mjs --update' se a contagem caiu legitimamente.`
);
process.exit(1);
}
console.log(`[complexity] OK — ${current} violações (baseline ${baseline.count})`);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+83
View File
@@ -0,0 +1,83 @@
/**
* Compression budget gate (F2.4 / N4 ratchet).
*
* Runs the deterministic compression engines over BENCHMARK_CORPUS and fails if any engine's
* mean compressed-tokens-per-task RISES beyond the tolerance versus the frozen baseline — i.e. a
* change made compression worse. Falling cost (better compression) always passes.
*
* node --import tsx scripts/check/check-compression-budget.ts # check (CI)
* node --import tsx scripts/check/check-compression-budget.ts --update # refresh the baseline
*
* The benchmark is deterministic + API-free (chars/4 estimate over a fixed corpus), so the
* committed baseline is portable across local/CI.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
BENCHMARK_CORPUS,
DEFAULT_BENCHMARK_ENGINES,
benchmarkEngines,
runBenchmarkGate,
} from "../../open-sse/services/compression/harness/benchmark.ts";
import {
tokensPerTask,
type BudgetBaseline,
} from "../../open-sse/services/compression/harness/budgetGate.ts";
const BASELINE_PATH = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"compression-budget-baseline.json"
);
const TOLERANCE_PERCENT = 2;
async function main(): Promise<void> {
const update = process.argv.includes("--update");
const reports = await benchmarkEngines(BENCHMARK_CORPUS, DEFAULT_BENCHMARK_ENGINES);
if (update) {
const baselines: Record<string, BudgetBaseline> = {};
for (const [engine, report] of Object.entries(reports)) {
baselines[engine] = { tasks: tokensPerTask(report) };
}
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselines, null, 2) + "\n");
console.log(`Updated compression budget baseline (${Object.keys(baselines).length} engines).`);
return;
}
if (!fs.existsSync(BASELINE_PATH)) {
console.error("compression budget baseline missing — run with --update to generate it.");
process.exit(1);
}
const baselines = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8")) as Record<
string,
BudgetBaseline
>;
const results = runBenchmarkGate(reports, baselines, TOLERANCE_PERCENT);
const failed = results.filter((r) => !r.gate.passed);
if (failed.length === 0) {
console.log(`✓ compression budget gate: no regressions (tolerance ${TOLERANCE_PERCENT}%)`);
return;
}
console.error("✗ compression budget gate: tokens-per-task regressed (compression got worse):");
for (const { engine, gate } of failed) {
for (const reg of gate.regressions) {
console.error(
` ${engine}/${reg.task}: ${reg.baseline} -> ${reg.current} tokens (+${reg.deltaPercent}%)`
);
}
}
console.error(
"\nIf this is an intentional improvement/change, refresh: npm run check:compression-budget -- --update"
);
process.exit(1);
}
main().catch((err) => {
console.error("compression budget gate failed:", err instanceof Error ? err.message : err);
process.exit(1);
});
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const cwd = process.cwd();
const defaultRoots = [
"src/shared/components",
"src/lib/db",
"src/lib/compliance",
"open-sse/translator",
"open-sse/mcp-server",
];
const roots = process.argv.slice(2).length > 0 ? process.argv.slice(2) : defaultRoots;
const sourceExtensions = [".ts", ".tsx", ".js", ".mjs", ".jsx", ".mts", ".cts"];
function toPosix(filePath) {
return filePath.split(path.sep).join("/");
}
function listSourceFiles(rootDir) {
const absRoot = path.resolve(cwd, rootDir);
if (!fs.existsSync(absRoot)) {
return [];
}
const stack = [absRoot];
const files = [];
while (stack.length > 0) {
const current = stack.pop();
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(fullPath);
continue;
}
if (sourceExtensions.includes(path.extname(entry.name))) {
files.push(path.resolve(fullPath));
}
}
}
return files;
}
function resolveRelativeImport(fromFile, specifier) {
const base = path.resolve(path.dirname(fromFile), specifier);
const ext = path.extname(base);
if (ext && fs.existsSync(base) && fs.statSync(base).isFile()) {
return path.resolve(base);
}
for (const extension of sourceExtensions) {
const candidate = `${base}${extension}`;
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
return path.resolve(candidate);
}
}
for (const extension of sourceExtensions) {
const candidate = path.join(base, `index${extension}`);
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
return path.resolve(candidate);
}
}
return null;
}
function extractImportSpecifiers(fileContents) {
const specs = [];
const regex = /\b(?:import|export)\s+(?:[^"'`]*?\sfrom\s*)?["'`]([^"'`]+)["'`]/g;
let match = regex.exec(fileContents);
while (match) {
specs.push(match[1]);
match = regex.exec(fileContents);
}
return specs;
}
function buildGraph(files) {
const fileSet = new Set(files);
const graph = new Map();
for (const filePath of files) {
const code = fs.readFileSync(filePath, "utf8");
const dependencies = new Set();
const importSpecifiers = extractImportSpecifiers(code);
for (const specifier of importSpecifiers) {
if (!specifier.startsWith(".")) continue;
const resolved = resolveRelativeImport(filePath, specifier);
if (!resolved) continue;
if (!fileSet.has(resolved)) continue;
dependencies.add(resolved);
}
graph.set(filePath, dependencies);
}
return graph;
}
function stronglyConnectedComponents(graph) {
const indexMap = new Map();
const lowLinkMap = new Map();
const onStack = new Set();
const stack = [];
const components = [];
let indexCounter = 0;
function strongConnect(node) {
indexMap.set(node, indexCounter);
lowLinkMap.set(node, indexCounter);
indexCounter += 1;
stack.push(node);
onStack.add(node);
for (const neighbor of graph.get(node) || []) {
if (!indexMap.has(neighbor)) {
strongConnect(neighbor);
lowLinkMap.set(node, Math.min(lowLinkMap.get(node), lowLinkMap.get(neighbor)));
} else if (onStack.has(neighbor)) {
lowLinkMap.set(node, Math.min(lowLinkMap.get(node), indexMap.get(neighbor)));
}
}
if (lowLinkMap.get(node) === indexMap.get(node)) {
const component = [];
while (stack.length > 0) {
const candidate = stack.pop();
onStack.delete(candidate);
component.push(candidate);
if (candidate === node) break;
}
components.push(component);
}
}
for (const node of graph.keys()) {
if (!indexMap.has(node)) {
strongConnect(node);
}
}
return components;
}
function isSelfCycle(component, graph) {
if (component.length !== 1) return false;
const [file] = component;
return (graph.get(file) || new Set()).has(file);
}
const files = roots.flatMap((root) => listSourceFiles(root));
const graph = buildGraph(files);
const components = stronglyConnectedComponents(graph);
const cycles = components.filter(
(component) => component.length > 1 || isSelfCycle(component, graph)
);
if (cycles.length === 0) {
console.log(
`[cycles] OK - no cycles detected across ${graph.size} files in: ${roots.join(", ")}`
);
process.exit(0);
}
console.error(`[cycles] FAIL - detected ${cycles.length} strongly connected component(s):`);
for (const component of cycles) {
const sorted = [...component].sort((a, b) => a.localeCompare(b));
console.error(`\n- SCC (${sorted.length} files)`);
for (const filePath of sorted) {
console.error(` - ${toPosix(path.relative(cwd, filePath))}`);
}
}
process.exit(1);
+285
View File
@@ -0,0 +1,285 @@
#!/usr/bin/env node
// scripts/check/check-db-rules.mjs
// Gate de convenções de banco (CLAUDE.md Hard Rules #2 e #5). Três verificações:
// (a) Todo módulo de domínio em src/lib/db/*.ts deve ser re-exportado por
// src/lib/localDb.ts (camada de compat). Um módulo db NOVO que não é
// re-exportado (e não está congelado) falha — força a decisão consciente
// de expor ou justificar (Hard Rule #2).
// (b) src/lib/localDb.ts é APENAS camada de re-export: nada de lógica
// (function/class/arrow de negócio). Mata o anti-padrão de "só uma
// funçãozinha aqui" que vira regra de negócio fora dos módulos db/.
// (c) Nenhum SQL cru em src/app/api/**/route.ts ou open-sse/handlers/*.ts.
// SQL deve viver em src/lib/db/ (Hard Rule #5). Ofensores pré-existentes
// são congelados; QUALQUER novo SQL cru em rota/handler falha.
// Stale-enforcement (6A.3): entradas em INTENTIONALLY_INTERNAL / EXTERNAL_DB_ALLOWED
// que não suprimem nenhuma violação real → gate falha com instrução de remoção.
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { assertNoStale } from "./lib/allowlist.mjs";
const cwd = process.cwd();
const DB_DIR = path.join(cwd, "src/lib/db");
const LOCAL_DB = path.join(cwd, "src/lib/localDb.ts");
const API_DIR = path.join(cwd, "src/app/api");
const HANDLERS_DIR = path.join(cwd, "open-sse/handlers");
// (a) Módulos db/ que NÃO são re-exportados por localDb.ts por DESIGN (Hard Rule #2:
// "Never barrel-import from localDb.ts — import specific db/ modules instead").
// Cada entrada aqui foi auditada e é consumida via import direto de "@/lib/db/X"
// (estático ou dinâmico) pelos seus consumidores — exatamente o padrão correto.
// Re-exportar esses módulos via localDb.ts INCENTIVARIA o anti-padrão proibido.
// O gate ainda bloqueia QUALQUER módulo db/ NOVO que não seja re-exportado E não
// esteja nessa lista — mantendo a decisão consciente obrigatória (Hard Rule #2).
// Legenda de classificação:
// type-only = exporta apenas tipos (sem runtime API), não há o que re-exportar
// db-internal = importado apenas dentro de src/lib/db/ (coordenação interna)
// intentionally-internal = consumido por import direto fora de db/ (correto per Rule #2)
// DEAD? = zero importers encontrados na auditoria de 2026-06-11; não deletar
// sem investigação — pode ser reserva de schema ou F2 pendente
export const INTENTIONALLY_INTERNAL = new Set([
"_rowTypes", // type-only: 5 importers internos em db/ (AgentBridge/Inspector row types)
"accessTokens", // intentionally-internal: 4 rotas /api/cli/* (connect, whoami, tokens, tokens/[id]) + server/authz/accessTokenAuth.ts via import direto "@/lib/db/accessTokens" (Rule #2)
"apiKeyColumnFallbacks", // db-internal: importado só por db/apiKeys.ts (API_KEY_COLUMN_FALLBACKS — fallbacks de coluna split do apiKeys.ts)
"apiKeyUsageLimitFields", // db-internal: importado só por db/apiKeys.ts (helpers de campo de limite de uso split do apiKeys.ts; mig 101)
"caseMapping", // db-internal: importado só por db/core.ts (toSnakeCase/toCamelCase/objToSnake — column-mapping snake↔camel split do core.ts, #4947)
"cleanup", // intentionally-internal: 3 API routes (purge-quota-snapshots, purge-call-logs, purge-detailed-logs)
"cliToolState", // intentionally-internal: 14+ API routes em /api/cli-tools/*-settings
"comboForecast", // intentionally-internal: src/lib/usage/comboForecast.ts
"commandCodeAuth", // intentionally-internal: 5 API routes em /api/providers/command-code/auth/*
"compression", // intentionally-internal: 2 API routes (settings/compression, context/rtk/config)
"vacuumScheduler", // intentionally-internal: src/instrumentation-node.ts (dynamic import, lifecycle wiring per Rule #2)
"detailedLogs", // intentionally-internal: 3 callers (callLogs.ts, logs/detail route, embeddings handler)
"discovery", // DEAD?: 0 importers na auditoria de 2026-06-11; lib/discovery/index.ts não usa db/discovery
"domainState", // intentionally-internal: 5 callers (batchWriter, circuitBreaker, costRules, fallbackPolicy, lockoutPolicy)
"encryption", // intentionally-internal: 8+ callers (container, webhookDispatcher, cloudAgent/credentials, services/apiKey, 4+ routes, open-sse)
"healthCheck", // db-internal: importado por db/core.ts (runDbHealthCheck)
"jsonMigration", // intentionally-internal: src/app/api/settings/import-json/route.ts
"migrationRunner", // db-internal: importado por db/core.ts (runMigrations ao inicializar o DB)
"notion", // intentionally-internal: settings/notion API route + open-sse/mcp-server/tools/notionTools.ts
"obsidian", // intentionally-internal: src/lib/obsidianSync.ts + settings/obsidian route + MCP obsidianTools.ts
"optimizationSettings", // db-internal: imported by db/core.ts for SQLite PRAGMA application helpers that require the live adapter
"pluginMetrics", // DEAD? (production): write path não foi conectado ainda (documentado no cabeçalho do módulo); testado por tests/unit/plugins-metrics.test.ts
"prompts", // DEAD? (production): zero callers de produção encontrados; domínio domain/prompts.ts é independente; testado por tests/integration/proxy-pipeline.test.ts
"providerNodeSelect", // db-internal: importado só por db/providers.ts (selectProviderNodeForConnection — lógica pura de seleção de provider node split do providers.ts, #4421)
"providerStats", // intentionally-internal: src/app/api/provider-stats/route.ts
"recovery", // intentionally-internal: bin/cli/runtime.mjs (import() dinâmico) + tests
"schemaColumns", // db-internal: importado só por db/core.ts (ensureProviderConnections/UsageHistory/CallLogsColumns + hasColumn/hasTable/getTableColumns — schema-column reconciliation split do core.ts, #4948)
"secrets", // intentionally-internal: src/instrumentation-node.ts (import() dinâmico na inicialização)
"serviceModels", // intentionally-internal: 3 callers (services/modelSync, services/bootstrap, /api/services/9router/models)
"stateReset", // db-internal: 3 callers dentro de src/lib/db/ (core, backup, apiKeys) para coordenação de reset
"stats", // intentionally-internal: src/app/api/settings/database/refresh-stats/route.ts
"tierConfig", // intentionally-internal: open-sse/services/tierResolver.ts (require() dinâmico)
"webSessionDedup", // db-internal: importado só por db/providers.ts (webSessionCredentialKey/parseProviderSpecificData — helpers puros de dedup de credencial web-session split do providers.ts, #3368 PR6)
]);
// Alias para retrocompatibilidade com os testes existentes que importam KNOWN_UNEXPORTED.
// O comportamento do gate é idêntico — só o nome e os comentários mudaram (#3499).
export const KNOWN_UNEXPORTED = INTENTIONALLY_INTERNAL;
// (c) Leituras de SQL contra bancos EXTERNOS, permitidas por design (#3500).
// Estas rotas NÃO consultam o DB do OmniRoute (getDbInstance) — elas abrem o
// SQLite de OUTRO aplicativo (Cursor / Kiro) para auto-importar credenciais.
// Por isso NÃO podem viver em src/lib/db/ (que é o domínio do DB do OmniRoute):
// são leituras read-only de um arquivo externo, com caminho/escopo próprios.
// Continuam no allowlist como exceção DOCUMENTADA — o gate ainda bloqueia
// QUALQUER novo SQL cru contra o DB do OmniRoute em rotas/handlers.
// Toda a dívida real da Hard Rule #5 (15 rotas internas) foi migrada para
// módulos src/lib/db/ nas slices do #3500; este set ficou só com as exceções.
const EXTERNAL_DB_ALLOWED = new Set([
"src/app/api/oauth/cursor/auto-import/route.ts", // read-only no itemTable do SQLite do Cursor (DB externo)
"src/app/api/oauth/kiro/auto-import/route.ts", // read-only no SQLite do Kiro (DB externo)
]);
// Alias de retrocompatibilidade (testes/consumidores que importam KNOWN_RAW_SQL).
// Comportamento do gate idêntico — só o nome e o enquadramento mudaram (#3500).
const KNOWN_RAW_SQL = EXTERNAL_DB_ALLOWED;
// Módulos sempre excluídos da checagem (a): não são domínio re-exportável.
const DB_MODULE_EXCLUDE = new Set(["core", "localDb", "index"]);
function walk(dir, acc = []) {
if (!fs.existsSync(dir)) return acc;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p, acc);
else acc.push(p);
}
return acc;
}
// Lista os módulos de domínio em src/lib/db (top-level *.ts), excluindo
// core/localDb/index, *.d.ts e qualquer subdiretório (migrations/, adapters/, __tests__/).
export function collectDbModules(dbDir = DB_DIR) {
if (!fs.existsSync(dbDir)) return [];
return fs
.readdirSync(dbDir, { withFileTypes: true })
.filter((e) => e.isFile() && /\.ts$/.test(e.name) && !/\.d\.ts$/.test(e.name))
.map((e) => e.name.replace(/\.ts$/, ""))
.filter((name) => !DB_MODULE_EXCLUDE.has(name))
.sort();
}
// Extrai os nomes de módulo re-exportados de localDb.ts a partir de
// `... from "./db/X"` (cobre export {…}, export * e export type {…}).
export function extractReexportedModules(localDbSource) {
const re = /from\s+["']\.\/db\/([A-Za-z0-9_]+)["']/g;
const out = new Set();
let m;
while ((m = re.exec(localDbSource))) out.add(m[1]);
return out;
}
// (a) Módulos db/ que não são re-exportados e não estão na lista de
// intencionalmente-internos (INTENTIONALLY_INTERNAL). O gate falha para
// qualquer módulo NOVO que não seja re-exportado nem justificado.
export function findMissingReexports(dbModules, reexported, allowlist = INTENTIONALLY_INTERNAL) {
return dbModules.filter((mod) => !reexported.has(mod) && !allowlist.has(mod));
}
// (b) localDb.ts deve conter SOMENTE import/export + comentários (sem lógica).
// Remove comentários e strings, depois procura declarações de runtime.
export function hasLogic(localDbSource) {
const stripped = localDbSource
// comentários de bloco
.replace(/\/\*[\s\S]*?\*\//g, "")
// comentários de linha
.replace(/\/\/[^\n]*/g, "")
// template strings
.replace(/`(?:\\[\s\S]|[^\\`])*`/g, '""')
// strings simples/duplas (paths de import etc.)
.replace(/"(?:\\.|[^"\\])*"/g, '""')
.replace(/'(?:\\.|[^'\\])*'/g, '""');
// function/class declaradas, ou atribuição a função (const X = (…) =>, const X = function).
const logicPatterns = [
/(^|[^.\w])function\s+[A-Za-z_$]/, // function decl (não method .foo())
/(^|[^.\w])class\s+[A-Za-z_$]/, // class decl
/(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s*)?\(/, // const X = (…) ... (arrow/call)
/(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s+)?function\b/, // const X = function
];
return logicPatterns.some((rx) => rx.test(stripped));
}
// SQL cru é sempre uma STRING passada a db.prepare()/exec(): casamos os padrões
// SÓ dentro de literais de string (não em código JS — `import … from`, `.set(`,
// `new Set(`, `delete x` etc. são falsos positivos se varrermos o código todo).
const SQL_PATTERNS = [
/\bSELECT\b[\s\S]*?\bFROM\b/i, // SELECT … FROM (multi-linha)
/\bINSERT\s+INTO\b/i,
/\bUPDATE\b[\s\S]*?\bSET\b/i, // UPDATE … SET (multi-linha)
/\bDELETE\s+FROM\b/i,
/\bCREATE\s+TABLE\b/i,
];
// Remove comentários (linha // … e blocos /* */) — SQL em comentário não conta.
function stripComments(source) {
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
}
// Extrai o conteúdo de todos os literais de string (template, aspas duplas, aspas
// simples) de um trecho de código já sem comentários. Retorna a concatenação dos
// corpos — é nesse corpo que SQL cru vive.
export function extractStringLiterals(code) {
const re = /`(?:\\[\s\S]|[^\\`])*`|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g;
const out = [];
let m;
while ((m = re.exec(code))) {
// tira as aspas/crases delimitadoras
out.push(m[0].slice(1, -1));
}
return out.join("\n\n"); // separador que nenhum padrão SQL atravessa
}
// (c) Arquivos com SQL cru dentro de literais de string (linhas não-comentário),
// fora do allowlist.
export function findRawSql(files, allowlist = KNOWN_RAW_SQL) {
const offenders = [];
for (const file of files) {
const rel = path.relative(cwd, file).replace(/\\/g, "/");
if (allowlist.has(rel)) continue;
let src;
try {
src = fs.readFileSync(file, "utf8");
} catch {
continue;
}
const literals = extractStringLiterals(stripComments(src));
if (SQL_PATTERNS.some((rx) => rx.test(literals))) {
offenders.push(rel);
}
}
return offenders;
}
// Coleta os arquivos sujeitos à checagem (c): rotas de API + handlers de stream.
export function collectSqlScanFiles(apiDir = API_DIR, handlersDir = HANDLERS_DIR) {
const routes = walk(apiDir).filter((p) => /(^|\/)route\.tsx?$/.test(p.replace(/\\/g, "/")));
const handlers = fs.existsSync(handlersDir)
? fs
.readdirSync(handlersDir, { withFileTypes: true })
.filter((e) => e.isFile() && /\.tsx?$/.test(e.name))
.map((e) => path.join(handlersDir, e.name))
: [];
return [...routes, ...handlers];
}
function main() {
const failures = [];
const localDbSource = fs.readFileSync(LOCAL_DB, "utf8");
// (a) re-export completeness
const dbModules = collectDbModules();
const reexported = extractReexportedModules(localDbSource);
// Live unexported modules BEFORE allowlist filtering (needed for stale-enforcement).
const liveUnexported = dbModules.filter((mod) => !reexported.has(mod));
assertNoStale(INTENTIONALLY_INTERNAL, liveUnexported, "check-db-rules:unexported");
const missing = findMissingReexports(dbModules, reexported);
if (missing.length) {
failures.push(
`[#2 re-export] ${missing.length} módulo(s) db/ não re-exportado(s) por src/lib/localDb.ts:\n` +
missing.map((m) => ` ✗ src/lib/db/${m}.ts`).join("\n") +
`\n → re-exporte de src/lib/localDb.ts (apenas a lista de re-export, nada de lógica)` +
` ou adicione a INTENTIONALLY_INTERNAL com justificativa (import direto de "@/lib/db/${missing[0]}").`
);
}
// (b) localDb sem lógica
if (hasLogic(localDbSource)) {
failures.push(
`[#2 sem-lógica] src/lib/localDb.ts contém lógica (function/class/arrow). É camada de` +
` re-export apenas — mova a lógica para um módulo src/lib/db/.`
);
}
// (c) SQL cru fora de db/
// Live raw-SQL offenders BEFORE allowlist filtering (needed for stale-enforcement).
const scanFiles = collectSqlScanFiles();
const liveRawSql = findRawSql(scanFiles, new Set());
assertNoStale(EXTERNAL_DB_ALLOWED, liveRawSql, "check-db-rules:raw-sql");
const rawSql = findRawSql(scanFiles);
if (rawSql.length) {
failures.push(
`[#5 sql-cru] ${rawSql.length} arquivo(s) com SQL cru fora de src/lib/db/:\n` +
rawSql.map((f) => `${f}`).join("\n") +
`\n → mova o SQL para um módulo src/lib/db/ (nunca SQL cru em rota/handler)` +
` ou congele em KNOWN_RAW_SQL com justificativa.`
);
}
if (failures.length) {
console.error(`[check-db-rules] FALHOU:\n\n` + failures.join("\n\n"));
process.exitCode = 1;
}
if (!process.exitCode) {
console.log(
`[check-db-rules] OK (${dbModules.length} módulos db/, ${reexported.size} re-exportados, ` +
`${INTENTIONALLY_INTERNAL.size} intencionalmente-internos (Rule #2); ${EXTERNAL_DB_ALLOWED.size} leituras de DB externo permitidas (#3500))`
);
}
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env node
// scripts/check/check-dead-code.mjs
// Gate de dead-code via knip — unused exports, unused files.
// Fase 7 INT: promovido de ADVISORY para RATCHET bloqueante.
// Lê o baseline de quality-baseline.json (metrics.deadExports), compara e
// falha com exit 1 se a contagem SUBIR. Suporta --update para ratchetar o baseline.
//
// Saída (stdout):
// DEAD_EXPORTS=<n> — exports/re-exports/tipos não utilizados
// DEAD_FILES=<n> — arquivos sem nenhum consumidor
// DEAD_TOTAL=<n> — soma de ambos (métrica primária para o ratchet)
//
// Use --json para imprimir o relatório completo do knip em JSON.
// Use --quiet para suprimir logs de diagnóstico.
// Use --update para ratchetar o baseline quando a contagem cair legitimamente.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const KNIP_BIN = path.join(ROOT, "node_modules", ".bin", "knip");
const QUIET = process.argv.includes("--quiet");
const PRINT_JSON = process.argv.includes("--json");
const UPDATE = process.argv.includes("--update");
const BASELINE_PATH = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "config/quality/quality-baseline.json")
);
/**
* Conta dead exports e dead files a partir do output JSON do knip.
*
* O reporter JSON do knip emite:
* { issues: Array<{ file, exports?, files?, types?, nsExports?, nsTypes?, ... }> }
*
* Cada entrada em `exports`, `types`, `nsExports`, `nsTypes` é um símbolo morto naquele
* arquivo. A presença do arquivo em si na lista (campo `files: []` não-vazio ou arquivo
* sem outros campos relevantes com `files: true` no include) indica arquivo morto.
*
* @param {object} knipJson - Objeto JSON parseado do output do knip
* @returns {{ deadExports: number, deadFiles: number, deadTotal: number }}
*/
export function parseKnipMetrics(knipJson) {
if (!knipJson || !Array.isArray(knipJson.issues)) {
return { deadExports: 0, deadFiles: 0, deadTotal: 0 };
}
let deadExports = 0;
let deadFiles = 0;
for (const fileEntry of knipJson.issues) {
// Dead file: o arquivo aparece na lista com campo `files` populado
// (knip emite um entry com files:[] indicando "este arquivo é morto")
if (Array.isArray(fileEntry.files) && fileEntry.files.length > 0) {
deadFiles += fileEntry.files.length;
}
// Alguns reporters indicam arquivo morto sem campo files — o entry existe
// sem exports/types = o arquivo inteiro não tem consumidor
// (conservador: só contar quando files[] está presente e populado)
// Dead exports: somar todos os símbolos mortos por tipo de export
const exportFields = [
"exports",
"types",
"nsExports",
"nsTypes",
"enumMembers",
"namespaceMembers",
"duplicates",
];
for (const field of exportFields) {
if (Array.isArray(fileEntry[field])) {
deadExports += fileEntry[field].length;
}
}
}
return {
deadExports,
deadFiles,
deadTotal: deadExports + deadFiles,
};
}
/**
* Avalia a contagem atual de dead-code total contra o baseline.
* Direction: down (contagem só pode CAIR).
*
* Exported for unit testing.
*
* @param {number} current
* @param {number} baseline
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateDeadCode(current, baseline) {
return {
regressed: current > baseline,
improved: current < baseline,
};
}
function runKnip() {
const args = [
"--reporter",
"json",
"--no-progress",
"--no-exit-code", // não falha por contagem — só coletamos métricas
];
if (!QUIET) {
process.stderr.write("[dead-code] Rodando knip --reporter json ...\n");
}
let stdout;
try {
stdout = execFileSync(KNIP_BIN, args, {
cwd: ROOT,
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
timeout: 300_000, // 5 min (knip pode ser lento em monorepos grandes)
});
} catch (err) {
// knip sai com código != 0 quando encontra issues; o JSON ainda vai no stdout.
stdout = err.stdout ? String(err.stdout) : "";
if (!stdout.trim()) {
process.stderr.write(`[dead-code] ERRO ao executar knip: ${err.message}\n`);
process.exit(2);
}
}
let knipJson;
try {
knipJson = JSON.parse(stdout);
} catch (parseErr) {
process.stderr.write(`[dead-code] ERRO ao parsear JSON do knip: ${parseErr.message}\n`);
process.stderr.write(`[dead-code] stdout (primeiros 500 chars): ${stdout.slice(0, 500)}\n`);
process.exit(2);
}
return knipJson;
}
function main() {
if (!fs.existsSync(BASELINE_PATH)) {
process.stderr.write(`[dead-code] FAIL — ${path.basename(BASELINE_PATH)} ausente.\n`);
process.exit(2);
}
const baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
const baselineMetric = baselineJson.metrics && baselineJson.metrics.deadExports;
if (!baselineMetric || typeof baselineMetric.value !== "number") {
process.stderr.write(
"[dead-code] FAIL — metrics.deadExports ausente em quality-baseline.json.\n"
);
process.exit(2);
}
const baselineValue = baselineMetric.value;
const knipJson = runKnip();
if (PRINT_JSON) {
process.stdout.write(JSON.stringify(knipJson, null, 2) + "\n");
return;
}
const { deadExports, deadFiles, deadTotal } = parseKnipMetrics(knipJson);
// Emitir em formato KEY=VALUE para o coletor de métricas (collect-metrics.mjs)
console.log(`DEAD_EXPORTS=${deadExports}`);
console.log(`DEAD_FILES=${deadFiles}`);
console.log(`DEAD_TOTAL=${deadTotal}`);
const { regressed, improved } = evaluateDeadCode(deadTotal, baselineValue);
if (UPDATE && improved) {
baselineJson.metrics.deadExports.value = deadTotal;
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselineJson, null, 2) + "\n");
console.log(`[dead-code] baseline ratcheado: ${deadTotal} (era ${baselineValue})`);
}
if (regressed) {
process.stderr.write(
`[dead-code] REGRESSÃO — ${deadTotal} símbolos mortos > baseline ${baselineValue}\n` +
` → Remova exports/arquivos não utilizados ou rode\n` +
` 'node scripts/check/check-dead-code.mjs --update' se a contagem caiu legitimamente.\n`
);
process.exit(1);
}
console.log(`[dead-code] OK — ${deadTotal} símbolos mortos (baseline ${baselineValue})`);
process.exitCode = 0;
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env node
// Detects hardcoded old versions / stale dates in docs that should follow the current release.
// Uses hardcoded regexes to avoid dynamic RegExp() (ReDoS concern flagged by semgrep).
// Exits 0 if clean, 1 (in --strict mode) if drift detected.
//
// Run: node scripts/check/check-deprecated-versions.mjs
// Strict: node scripts/check/check-deprecated-versions.mjs --strict
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..", "..");
const DOCS_DIR = path.join(ROOT, "docs");
const PKG_JSON = path.join(ROOT, "package.json");
const STRICT = process.argv.includes("--strict");
function currentVersion() {
try {
return JSON.parse(fs.readFileSync(PKG_JSON, "utf8")).version || "0.0.0";
} catch {
return "0.0.0";
}
}
// Hardcoded set of obviously-stale version patterns. Update when bumping major/minor.
// These match any version <= v3.6.x or any pre-3 major.
const STALE_VERSION_PATTERNS = [
/\bv?[12]\.\d+\.\d+\b/, // 1.x.x / 2.x.x
/\bv?3\.[0-6]\.\d+\b/, // 3.0.x..3.6.x
];
const SAFE_CONTEXTS =
/(historical|archive|legacy|previously|deprecated|since|introduced|was|originally|fix|fixed)/i;
// Dates older than 60 days are considered stale for "Last updated" / "Last consolidated".
const STALE_DATE_DAYS = 60;
const today = new Date();
const LAST_UPDATED_RE = /Last (?:updated|consolidated|generated)[^\d]{0,30}(\d{4}-\d{2}-\d{2})/i;
function isStaleDate(yyyy_mm_dd) {
const d = new Date(yyyy_mm_dd);
if (Number.isNaN(d.getTime())) return false;
const ageDays = (today - d) / (1000 * 60 * 60 * 24);
return ageDays > STALE_DATE_DAYS;
}
const IGNORED_DIRS = new Set(["archive", "i18n", "superpowers"]);
const IGNORED_BASENAMES = new Set(["CHANGELOG.md", "RFC-AUTO-ASSESSMENT-DRAFT.md"]);
function walkDocs(dir) {
const out = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (IGNORED_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...walkDocs(full));
} else if (entry.isFile() && entry.name.endsWith(".md")) {
if (IGNORED_BASENAMES.has(entry.name)) continue;
out.push(full);
}
}
return out;
}
function main() {
const cur = currentVersion();
console.log(`Version drift report (current: v${cur})`);
console.log("=".repeat(40));
const files = walkDocs(DOCS_DIR);
let drift = 0;
for (const file of files) {
const rel = path.relative(ROOT, file);
const txt = fs.readFileSync(file, "utf8");
const lines = txt.split("\n");
const issues = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.length > 500) continue; // skip very long lines (likely code blocks / data)
// 1. Stale version refs
for (const re of STALE_VERSION_PATTERNS) {
const m = re.exec(line);
if (m && !SAFE_CONTEXTS.test(line)) {
issues.push({
line: i + 1,
type: "stale-version",
match: m[0],
text: line.trim().slice(0, 100),
});
break;
}
}
// 2. Stale "Last updated/consolidated/generated" dates
const dateMatch = LAST_UPDATED_RE.exec(line);
if (dateMatch && isStaleDate(dateMatch[1])) {
issues.push({
line: i + 1,
type: "stale-date",
match: dateMatch[1],
text: line.trim().slice(0, 100),
});
}
}
if (issues.length > 0) {
console.log(`\n ${rel}`);
for (const iss of issues.slice(0, 5)) {
console.log(` L${iss.line} [${iss.type}] ${iss.match}: ${iss.text}`);
}
if (issues.length > 5) console.log(` ... and ${issues.length - 5} more`);
drift += issues.length;
}
}
console.log();
if (drift > 0) {
console.warn(`${drift} potential drift(s) detected across ${files.length} doc files.`);
if (STRICT) process.exit(1);
} else {
console.log(`✓ No drift detected across ${files.length} doc files.`);
}
}
main();
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env node
// scripts/check/check-deps.mjs
// Gate anti-slopsquatting: toda dependência em QUALQUER package.json do repo deve
// estar numa allowlist commitada (dependency-allowlist.json). Uma dep nova exige
// adição EXPLÍCITA à allowlist — assim um agente não consegue introduzir um pacote
// alucinado/typosquatted silenciosamente (CSA 2026: 19,7% do código IA cita pacotes
// inexistentes; 43% dos nomes alucinados reaparecem, registráveis por atacantes).
// A revisão humana ao adicionar à allowlist é o ponto de controle.
//
// 6A.8: Expandido de 2 manifests hardcoded (package.json + electron/package.json)
// para descoberta automática de TODOS os package.json do repo, excluindo:
// - node_modules/ (dep tree)
// - .next/, .build/, dist/, dist-electron/ (build artefatos)
// - .claude/ (worktrees de agentes)
// - _references/, _mono_repo/ (código de referência não pertencente ao repo)
// Isso garante que workspaces novos (opencode-plugin, opencode-provider, open-sse, etc.)
// sejam automaticamente cobertos sem edição do script.
//
// Task 7.8: Anti-slopsquatting completo — para deps NOVAS (fora da allowlist),
// dois sub-checks adicionais ANTES de falhar:
// (a) a dep EXISTE no npm registry (npm view <pkg> version)
// (b) foi publicada há ≥72h (age-cooldown contra typosquatting de nomes alucinados)
// Ambas as chamadas são tolerantes a falha de rede: se o registry estiver inacessível,
// emite aviso mas não bloqueia — o gate principal (allowlist) ainda captura a dep nova.
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { execFileSync } from "node:child_process";
import { assertNoStale } from "./lib/allowlist.mjs";
const ROOT = process.cwd();
const ALLOWLIST_PATH = path.join(ROOT, "config/quality/dependency-allowlist.json");
// Directories to exclude when discovering package.json files.
// Using a set of path segment prefixes (relative to ROOT, forward slashes).
const EXCLUDED_SEGMENTS = new Set([
"node_modules",
".next",
".build",
"dist",
"dist-electron",
".claude",
"_references",
"_mono_repo",
]);
/**
* 6A.8: Discover all package.json files in the repo, excluding build artefacts,
* reference code, and agent worktrees. Returns relative paths (forward slashes).
*/
export function discoverManifests(root) {
const out = [];
function walk(dir, depth) {
if (depth > 5) return; // guard against very deep nesting
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
if (EXCLUDED_SEGMENTS.has(e.name)) continue;
const full = path.join(dir, e.name);
if (e.isDirectory()) {
walk(full, depth + 1);
} else if (e.name === "package.json") {
out.push(path.relative(root, full).replace(/\\/g, "/"));
}
}
}
walk(root, 0);
return out.sort();
}
/** Nomes de deps no manifesto que não estão na allowlist (de-dup, ordem preservada). */
export function findUnapprovedDeps(depNames, allowlist) {
const seen = new Set();
const out = [];
for (const name of depNames) {
if (seen.has(name)) continue;
seen.add(name);
if (!allowlist.has(name)) out.push(name);
}
return out;
}
function depNamesFromManifest(root, rel) {
const full = path.join(root, rel);
if (!fs.existsSync(full)) return [];
let pkg;
try {
pkg = JSON.parse(fs.readFileSync(full, "utf8"));
} catch {
return []; // skip malformed manifests (e.g. reference code)
}
return [
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.devDependencies || {}),
...Object.keys(pkg.optionalDependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
];
}
function collectDepNames(root) {
return discoverManifests(root).flatMap((rel) => depNamesFromManifest(root, rel));
}
// ─── Task 7.8: registry-existence + age-cooldown ──────────────────────────────
/**
* Pure function — determines whether a package is old enough to be trusted.
*
* A dep that was just registered (within the last 72h) is a red flag for
* slopsquatting: an attacker can register the name an AI hallucinated within
* minutes of the hallucination becoming public. The 72h window gives the npm
* security team time to act and gives maintainers a chance to notice.
*
* @param {number} timeCreatedMs - Unix timestamp (ms) of when the package was
* first published to the registry (npm `time.created` field).
* @param {number} nowMs - Unix timestamp (ms) for "now" (injectable for tests).
* @param {number} minAgeHours - Minimum acceptable age in hours (default 72).
* @returns {{ ok: boolean; ageHours: number }} ok=true if old enough.
*/
export function evaluateDepAge(timeCreatedMs, nowMs, minAgeHours = 72) {
const ageHours = (nowMs - timeCreatedMs) / (1000 * 60 * 60);
return { ok: ageHours >= minAgeHours, ageHours };
}
/**
* Queries the npm registry for a package.
* Returns { exists: boolean, createdMs: number | null } or null on network error.
* Network failures are treated as "offline" — the caller decides what to do.
*
* @param {string} pkgName
* @param {number} timeoutMs - How long to wait for the registry (default 8 000).
* @returns {{ exists: boolean; createdMs: number | null } | null}
*/
export function queryNpmRegistry(pkgName, timeoutMs = 8000) {
// Scope packages need URL-encoding for the `npm view` command.
// `npm view` accepts scoped packages natively — no encoding needed.
try {
const raw = execFileSync("npm", ["view", pkgName, "time.created", "--json"], {
encoding: "utf8",
timeout: timeoutMs,
// Suppress npm progress/warn output on stderr
stdio: ["ignore", "pipe", "pipe"],
});
// npm view --json emits a quoted string or null/empty for missing fields
const trimmed = raw.trim();
if (!trimmed) {
// Package exists but has no time.created (very unusual; treat as exists, age unknown)
return { exists: true, createdMs: null };
}
const parsed = JSON.parse(trimmed);
if (!parsed) return { exists: true, createdMs: null };
const ms = new Date(parsed).getTime();
return { exists: true, createdMs: Number.isFinite(ms) ? ms : null };
} catch (err) {
// npm exits with code 1 when the package is NOT found ("E404")
const stderr = err.stderr?.toString() || "";
const stdout = err.stdout?.toString() || "";
if (
stderr.includes("E404") ||
stdout.includes("E404") ||
stderr.includes("npm ERR! code E404")
) {
return { exists: false, createdMs: null };
}
// Any other error (ETIMEDOUT, ENOTFOUND, etc.) = network/offline — return null
return null;
}
}
/**
* For a list of new (unapproved) deps, performs registry existence + age checks.
* Returns an object with three lists:
* - notFound: packages that do NOT exist in the registry (likely hallucinated)
* - tooNew: packages that exist but were published within the last 72h
* - offline: packages we could not verify (registry unreachable)
*
* Designed to run AFTER findUnapprovedDeps — only called when there are new deps.
*
* @param {string[]} newDeps
* @param {number} minAgeHours
* @param {number} nowMs
* @returns {{ notFound: string[]; tooNew: Array<{name:string,ageHours:number}>; offline: string[] }}
*/
export function auditNewDepsRegistry(newDeps, minAgeHours = 72, nowMs = Date.now()) {
const notFound = [];
const tooNew = [];
const offline = [];
for (const dep of newDeps) {
const result = queryNpmRegistry(dep);
if (result === null) {
// Network error — skip gracefully
offline.push(dep);
continue;
}
if (!result.exists) {
notFound.push(dep);
continue;
}
if (result.createdMs !== null) {
const { ok, ageHours } = evaluateDepAge(result.createdMs, nowMs, minAgeHours);
if (!ok) {
tooNew.push({ name: dep, ageHours: Math.round(ageHours * 10) / 10 });
}
}
// exists + old enough (or age unknown) → pass silently
}
return { notFound, tooNew, offline };
}
function main() {
if (!fs.existsSync(ALLOWLIST_PATH)) {
console.error(
`[check-deps] FAIL — ${path.basename(ALLOWLIST_PATH)} ausente. Gere com:\n` +
` node -e "require('./scripts/check/check-deps.mjs')" (ou veja o passo de bootstrap no PLANO)`
);
process.exit(1);
}
const allowlist = new Set(JSON.parse(fs.readFileSync(ALLOWLIST_PATH, "utf8")).allowed || []);
const allDepNames = collectDepNames(ROOT);
// 6A.8: stale-allowlist enforcement.
// A dep in the allowlist that is no longer used in ANY manifest is stale — the dep
// was removed, but the allowlist entry was not. Stale entries let the dep silently
// re-appear without triggering the review gate (regression risk).
// Note: only flag entries that appear in NO manifest; a dep may be in the allowlist
// but only transitively installed, so we check against what manifests declare.
const liveDepSet = new Set(allDepNames);
assertNoStale(allowlist, liveDepSet, "check-deps");
const unapproved = findUnapprovedDeps(allDepNames, allowlist);
if (unapproved.length) {
// Task 7.8: For each new dep, run registry-existence + age-cooldown checks.
// This enriches the error message — tells the reviewer whether the package
// even exists and how recently it was published, before they allowlist it.
// Failures here do NOT add extra exit(1) calls — the allowlist gate already
// fails; these are purely informational addenda to the error output.
console.error(
`[check-deps] ${unapproved.length} dependência(s) FORA da allowlist:\n` +
unapproved.map((d) => " ✗ " + d).join("\n") +
`\n → confirme que o pacote é legítimo (existe no registry, publisher conhecido, não é typosquat)\n` +
` e adicione o nome a dependency-allowlist.json ("allowed"). Esse é o ponto de revisão humana.`
);
// Registry audit (Task 7.8) — runs only when there are new deps.
// Failures are non-fatal on network errors; registry check is advisory enrichment
// (the allowlist gate above is the hard block).
console.error(`[check-deps] Verificando deps novas no registry npm (Task 7.8)…`);
const { notFound, tooNew, offline } = auditNewDepsRegistry(unapproved);
if (offline.length) {
console.warn(
`[check-deps] WARN — registry npm inacessível (offline?); ` +
`não foi possível verificar: ${offline.join(", ")}`
);
}
if (notFound.length) {
console.error(
`[check-deps] BLOQUEIO EXTRA — ${notFound.length} dep(s) NÃO encontrada(s) no registry npm ` +
`(provável nome alucinado — NÃO adicionar à allowlist!):\n` +
notFound.map((d) => ` ✗✗ ${d} (não existe no registry)`).join("\n")
);
}
if (tooNew.length) {
console.error(
`[check-deps] BLOQUEIO EXTRA — ${tooNew.length} dep(s) publicada(s) há <72h ` +
`(age-cooldown anti-slopsquatting — aguarde 72h após publicação):\n` +
tooNew.map((d) => ` ✗✗ ${d.name} (publicada há ~${d.ageHours}h)`).join("\n")
);
}
process.exit(1);
}
if (process.exitCode === 1) return; // stale entries already logged
const manifests = discoverManifests(ROOT);
console.log(
`[check-deps] OK — ${allowlist.size} dependências na allowlist, ` +
`${manifests.length} manifests escaneados, nenhuma nova dep`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env node
/**
* OmniRoute — internal documentation link checker.
*
* Scans every Markdown file under `docs/` (excluding mirrored translations,
* screenshots, exported diagrams, and superpowers plans) for relative or
* project-rooted internal links and verifies the referenced files exist on
* disk. External URLs (http/https/mailto), telephone links, and pure
* anchor-only links (#section) are ignored.
*
* Supported syntaxes:
* - Markdown links: [label](path "optional title")
* - Reference links: [label]: path "optional title"
* - HTML anchors: <a href="path">
* - HTML image refs: <img src="path">
*
* Usage:
* node scripts/check/check-doc-links.mjs # strict (CI gate)
* node scripts/check/check-doc-links.mjs --report # human report, exit 0
* node scripts/check/check-doc-links.mjs --json # JSON to stdout
*
* Exit codes:
* 0 ok (or --report mode)
* 1 one or more broken internal links detected
*/
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(SCRIPT_DIR, "..", "..");
const DOCS_ROOT = path.join(REPO_ROOT, "docs");
const EXCLUDE_PREFIXES = [
path.join(DOCS_ROOT, "i18n") + path.sep,
path.join(DOCS_ROOT, "screenshots") + path.sep,
path.join(DOCS_ROOT, "superpowers") + path.sep,
path.join(DOCS_ROOT, "diagrams", "exported") + path.sep,
];
function parseArgs(argv) {
const opts = { report: false, json: false };
for (const arg of argv.slice(2)) {
if (arg === "--report") opts.report = true;
else if (arg === "--json") opts.json = true;
else if (arg === "--help" || arg === "-h") {
console.log(
[
"Usage: node scripts/check/check-doc-links.mjs [options]",
"",
" --report Print findings and exit 0 regardless",
" --json Emit JSON report to stdout",
].join("\n")
);
process.exit(0);
}
}
return opts;
}
function walkDocs(dir, out) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
const prefixed = full + path.sep;
if (EXCLUDE_PREFIXES.some((p) => prefixed.startsWith(p))) continue;
walkDocs(full, out);
} else if (entry.isFile() && full.endsWith(".md")) {
if (EXCLUDE_PREFIXES.some((p) => full.startsWith(p))) continue;
out.push(full);
}
}
}
function isExternal(target) {
return (
/^[a-z][a-z0-9+.-]*:/i.test(target) || // http:, https:, mailto:, tel:, data:, ftp: ...
target.startsWith("//")
);
}
function stripFragmentAndQuery(target) {
let value = target;
const hashAt = value.indexOf("#");
if (hashAt !== -1) value = value.slice(0, hashAt);
const queryAt = value.indexOf("?");
if (queryAt !== -1) value = value.slice(0, queryAt);
return value;
}
function extractLinks(content) {
const links = [];
// Strip fenced code blocks to avoid false positives.
const sanitized = content.replace(/```[\s\S]*?```/g, (m) => " ".repeat(m.length));
const lines = sanitized.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNumber = i + 1;
// Markdown inline links: [text](target) — supports nested parens minimally.
const inlineRe = /(!?)\[(?:[^\]]*)\]\(([^)\s]+(?:\s+"[^"]*")?)\)/g;
let match;
while ((match = inlineRe.exec(line)) !== null) {
const raw = match[2].replace(/\s+"[^"]*"$/, "").trim();
if (raw) links.push({ target: raw, line: lineNumber });
}
// Reference-style links: [label]: target ("optional title")
const refRe = /^\s{0,3}\[[^\]]+\]:\s+([^\s]+)(?:\s+"[^"]*")?\s*$/;
const refMatch = line.match(refRe);
if (refMatch) links.push({ target: refMatch[1], line: lineNumber });
// HTML href / src
const htmlRe = /\b(?:href|src)\s*=\s*"([^"]+)"/g;
while ((match = htmlRe.exec(line)) !== null) {
links.push({ target: match[1], line: lineNumber });
}
}
return links;
}
function resolveTarget(sourceFile, target) {
// /docs/foo.md, /foo, /reference/ENVIRONMENT.md → project-rooted.
if (target.startsWith("/")) {
return path.join(REPO_ROOT, target.replace(/^\/+/, ""));
}
// Otherwise resolve against the source file's directory.
return path.resolve(path.dirname(sourceFile), target);
}
function probeExists(absPath) {
if (fs.existsSync(absPath)) return true;
// Allow links omitting `.md` (some doc viewers do this).
if (!path.extname(absPath) && fs.existsSync(`${absPath}.md`)) return true;
// Allow directory links resolving to an index/README.
if (fs.existsSync(path.join(absPath, "README.md"))) return true;
if (fs.existsSync(path.join(absPath, "index.md"))) return true;
return false;
}
function main() {
const opts = parseArgs(process.argv);
if (!fs.existsSync(DOCS_ROOT)) {
console.error("[doc-links] FAIL — docs/ directory not found");
process.exit(1);
}
const files = [];
walkDocs(DOCS_ROOT, files);
/** @type {Array<{source:string, line:number, target:string, reason:string}>} */
const broken = [];
let checkedLinks = 0;
for (const file of files) {
const content = fs.readFileSync(file, "utf8");
const links = extractLinks(content);
for (const { target, line } of links) {
if (!target) continue;
if (target.startsWith("#")) continue; // anchor-only
if (isExternal(target)) continue;
const clean = stripFragmentAndQuery(target);
if (!clean) continue; // e.g. "?query" alone — ignore
checkedLinks++;
const abs = resolveTarget(file, clean);
if (!probeExists(abs)) {
broken.push({
source: path.relative(REPO_ROOT, file),
line,
target,
reason: "missing",
});
}
}
}
if (opts.json) {
process.stdout.write(
JSON.stringify(
{
ok: broken.length === 0,
scannedFiles: files.length,
checkedLinks,
broken,
},
null,
2
) + "\n"
);
process.exit(broken.length && !opts.report ? 1 : 0);
}
console.log(`[doc-links] scanned ${files.length} docs, checked ${checkedLinks} internal links`);
if (broken.length === 0) {
console.log("[doc-links] PASS — no broken internal links");
process.exit(0);
}
// Group by source for readability.
const bySource = new Map();
for (const entry of broken) {
if (!bySource.has(entry.source)) bySource.set(entry.source, []);
bySource.get(entry.source).push(entry);
}
console.log(`[doc-links] FAIL — ${broken.length} broken link(s) in ${bySource.size} file(s):`);
for (const [source, entries] of bySource) {
console.log(`\n ${source}`);
for (const entry of entries) {
console.log(` line ${entry.line}: ${entry.target}`);
}
}
process.exit(opts.report ? 0 : 1);
}
main();
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env node
// Validates that count-based assertions in docs match the actual code state.
//
// Two tiers of checks:
// • STRICT (always blocking — exit 1 on drift): high-confidence, slow-moving counts
// that historically caused the worst drift across README / AGENTS / docs.
// - provider count (source of truth: docs/reference/PROVIDER_REFERENCE.md total,
// which is auto-generated from src/shared/constants/providers.ts)
// - i18n locale count (source of truth: config/i18n.json `locales`)
// • SOFT (heuristic — only fails with --strict): file-count based assertions that can
// false-positive.
// - executors count in open-sse/executors/
// - routing strategies in src/shared/constants/routingStrategies.ts
// - OAuth providers in src/lib/oauth/providers/
// - A2A skills in src/lib/a2a/skills/
// - Cloud agents in src/lib/cloudAgent/agents/
//
// Exits 0 on success, 1 on STRICT drift (or any drift with --strict).
// Run: node scripts/check/check-docs-counts-sync.mjs
//
// NOTE: the provider check trusts PROVIDER_REFERENCE.md as the canonical total. If a
// provider is added to the code but the reference is not regenerated, this guard will
// not catch it — regenerate with `npm run gen:provider-reference` before relying on it.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..", "..");
const COMMON_NON_IMPL_BASENAMES = new Set([
"index.ts",
"index.mts",
"types.ts",
"base.ts",
"constants.ts",
]);
function countFiles(dir, suffix = ".ts") {
const abs = path.join(ROOT, dir);
if (!fs.existsSync(abs)) return 0;
return fs
.readdirSync(abs)
.filter(
(f) =>
f.endsWith(suffix) &&
!f.endsWith(".test.ts") &&
!f.startsWith("__") &&
!COMMON_NON_IMPL_BASENAMES.has(f)
).length;
}
function countRoutingStrategies() {
const file = path.join(ROOT, "src", "shared", "constants", "routingStrategies.ts");
if (!fs.existsSync(file)) return 0;
const txt = fs.readFileSync(file, "utf8");
const m = txt.match(/ROUTING_STRATEGY_VALUES\s*=\s*\[([^\]]*)\]/);
if (!m) return 0;
return (m[1].match(/"[^"]+"/g) || []).length;
}
// PURE: parse the canonical provider total out of the auto-generated catalog text.
export function parseProviderTotal(referenceText) {
if (!referenceText) return 0;
const m = referenceText.match(/Total providers:\s*\*\*(\d+)\*\*/);
return m ? Number(m[1]) : 0;
}
// STRICT: canonical provider total, read from the auto-generated catalog.
export function readProviderTotal() {
const abs = path.join(ROOT, "docs", "reference", "PROVIDER_REFERENCE.md");
if (!fs.existsSync(abs)) return 0;
return parseProviderTotal(fs.readFileSync(abs, "utf8"));
}
// STRICT: canonical i18n locale count, read from the shared config.
export function countLocales() {
const abs = path.join(ROOT, "config", "i18n.json");
if (!fs.existsSync(abs)) return 0;
try {
const cfg = JSON.parse(fs.readFileSync(abs, "utf8"));
return Array.isArray(cfg.locales) ? cfg.locales.length : 0;
} catch {
return 0;
}
}
// PURE: tally STRICT vs SOFT drift for a list of checks, given a content lookup.
// `getContent(file) -> string | null`. A check whose `actual` is 0 is skipped (the
// source count could not be determined). Returns { strict, soft, lines }.
export function tallyDrift(checks, getContent) {
let strict = 0;
let soft = 0;
const lines = [];
for (const c of checks) {
const tier = c.strict ? "STRICT" : "soft";
lines.push(`\n${c.label}: ${c.actual} (real) [${tier}]`);
if (!c.actual) {
lines.push(` ⚠ could not determine ${c.docKey} count from source — skipping`);
continue;
}
for (const f of c.files) {
const content = getContent(f);
const found = content != null && content.includes(String(c.actual));
if (found) {
lines.push(`${f} mentions "${c.actual}"`);
} else {
lines.push(` ${c.strict ? "✗" : "⚠"} ${f} does NOT mention "${c.actual}" for ${c.docKey}`);
if (c.strict) strict++;
else soft++;
}
}
}
return { strict, soft, lines };
}
export function buildChecks() {
return [
{
label: "Provider count",
actual: readProviderTotal(),
docKey: "providers",
strict: true,
files: ["README.md", "AGENTS.md", "CLAUDE.md"],
},
{
label: "i18n locales count",
actual: countLocales(),
docKey: "i18n locales",
strict: true,
files: ["docs/README.md", "docs/guides/I18N.md", "AGENTS.md"],
},
{
label: "Executors count",
actual: countFiles("open-sse/executors"),
docKey: "executors",
strict: false,
files: ["docs/architecture/ARCHITECTURE.md", "docs/architecture/CODEBASE_DOCUMENTATION.md"],
},
{
label: "Routing strategies count",
actual: countRoutingStrategies(),
docKey: "strategies",
strict: false,
files: ["docs/routing/AUTO-COMBO.md", "docs/architecture/RESILIENCE_GUIDE.md"],
},
{
label: "OAuth providers count",
actual: countFiles("src/lib/oauth/providers"),
docKey: "OAuth providers",
strict: false,
files: ["docs/architecture/ARCHITECTURE.md"],
},
{
label: "A2A skills count",
actual: countFiles("src/lib/a2a/skills"),
docKey: "A2A skills",
strict: false,
files: ["docs/frameworks/A2A-SERVER.md"],
},
{
label: "Cloud agents count",
actual: countFiles("src/lib/cloudAgent/agents"),
docKey: "cloud agents",
strict: false,
files: ["docs/frameworks/CLOUD_AGENT.md", "docs/frameworks/AGENT_PROTOCOLS_GUIDE.md"],
},
];
}
function main() {
const checks = buildChecks();
const getContent = (relPath) => {
const abs = path.join(ROOT, relPath);
return fs.existsSync(abs) ? fs.readFileSync(abs, "utf8") : null;
};
console.log("Docs counts sync report");
console.log("=======================");
const { strict, soft, lines } = tallyDrift(checks, getContent);
for (const l of lines) console.log(l);
console.log();
if (strict > 0) {
console.error(
`${strict} STRICT drift(s) detected. ` +
`Update the docs above to the real counts, or regenerate auto-generated sources ` +
`(npm run gen:provider-reference).`
);
process.exit(1);
}
if (soft > 0) {
console.warn(`${soft} potential (soft) drift(s) detected. Review the docs above.`);
if (process.argv.includes("--strict")) process.exit(1);
} else {
console.log("✓ All checks pass.");
}
}
const invokedDirectly =
process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (invokedDirectly) main();
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env node
// scripts/check/check-docs-symbols.mjs
// Gate anti-alucinação (docs → código): toda referência a uma rota `/api/...` dentro de
// docs/**/*.md deve resolver para um `route.ts` real em src/app/api/. Pega endpoint
// INVENTADO/obsoleto que a IA escreve em docs/PRs descrevendo uma rota que não existe —
// o padrão recorrente das PRs de docs (ex.: oyi77) que fabricam endpoints/APIs.
//
// Complementa os outros gates anti-alucinação:
// - check-fetch-targets.mjs : fetch("/api/...") na UI → route.ts (código → código)
// - check-openapi-routes.mjs : path da openapi.yaml → route.ts (spec → código)
// - este gate : /api/... na prosa/markdown → route.ts (docs → código)
//
// LOW-NOISE por design: escopo APENAS a paths de rota `/api/...` (sinal mais alto).
// Tudo que é ruído conhecido (superfície proxy OpenAI-compat, refs a arquivos-fonte,
// APIs upstream de terceiros, placeholders) vai para IGNORE com justificativa, NÃO para
// a allowlist. A allowlist congela só drift REAL pré-existente de docs.
// Stale-enforcement (6A.3): entrada em KNOWN_STALE_DOC_REFS que não suprime nenhum miss
// real → gate falha com instrução de remoção (evita furo de regressão silencioso).
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { assertNoStale } from "./lib/allowlist.mjs";
const ROOT = process.cwd();
const DOCS = path.join(ROOT, "docs");
const API = path.join(ROOT, "src/app/api");
// Padrões que NÃO são rotas internas do OmniRoute (ruído estrutural, não drift).
// Adicione aqui (com justificativa) em vez da allowlist quando uma categoria gera
// falsos positivos — a allowlist é só para endpoints stale REAIS.
const IGNORE = [
/^\/api\/v1\//, // superfície OpenAI-compat (proxy), não rota interna
/^\/api\/v1beta\//, // superfície Gemini-compat (proxy)
/^\/api\/v0\//, // APIs upstream de terceiros citadas em docs de pesquisa
/^\/api\/v2\//, // idem (deployments etc.)
/^\/api\/(organizations|map-image|graphql|gql)\b/, // APIs de provedores externos documentadas
/your-/i, // placeholder de exemplo
/example/i, // placeholder de exemplo
/\.{3}/, // placeholder "..."
/\{\}/, // placeholder de param vazio
/_(POST|GET|PUT|DELETE|PATCH)$/, // refs estilo trace de rede (gql_POST)
];
// Refs a ARQUIVOS-FONTE, não a URLs (ex.: src/app/api/.../route.ts citado em prosa).
// O gate só valida URLs de rota, não caminhos de arquivo.
function isFileRef(p) {
return /\.(ts|tsx|js|mjs|jsx)$/.test(p) || /\/route$/.test(p);
}
// Refs a `/api/...` que NÃO resolvem para rota real, congeladas para triagem
// (catraca: bloqueia QUALQUER nova ref inventada em docs). Estas são achados REAIS de
// drift/alucinação em docs pré-existentes — cada uma precisa de: criar a rota, corrigir
// o path na doc, ou remover a menção. NÃO adicione novas aqui sem justificativa — esse
// é o ponto do gate. Issues de tracking devem ser abertas para cada cluster.
export const KNOWN_STALE_DOC_REFS = new Set([
// docs/reference/API_REFERENCE.md — guardrails/shadow doc-fiction RESOLVED in #3496:
// GET /api/guardrails + POST /api/guardrails/test are now REAL routes (wrapping the
// existing guardrailRegistry); the fictional enable/disable/logs rows and the entire
// shadow table were removed from the doc (shadow A-B comparison is combo-config +
// /api/combos/metrics). No allowlist entries needed for these anymore.
// (DISCOVERY_TOOL_DESIGN.md saiu de docs/research/ para o repo isolado _tasks/research/
// — gitignored, fora do escopo deste gate. As 4 entradas /api/discovery/* viraram
// obsoletas e foram removidas para satisfazer o stale-enforcement da allowlist.)
]);
function walk(dir, filter, acc = []) {
if (!fs.existsSync(dir)) return acc;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p, filter, acc);
else if (filter(e.name)) acc.push(p);
}
return acc;
}
export function collectRouteFiles() {
return new Set(
walk(API, (n) => /^route\.tsx?$/.test(n)).map((p) => path.relative(ROOT, p).replace(/\\/g, "/"))
);
}
/** Normaliza um segmento dinâmico ({param} / [param] / [...param] / :param) para wildcard. */
function normSeg(seg) {
if (/^\[\[?\.{3}.+\]\]?$/.test(seg)) return ""; // catch-all [...x] / [[...x]]
if (/^\{[^}]+\}$/.test(seg) || /^\[[^\]]+\]$/.test(seg) || /^:[^/]+$/.test(seg)) return " ";
return seg;
}
// /api/providers/{id}/models → src/app/api/providers/[id]/models/route.ts
// Casa por contagem de segmentos OU por prefixo (uma doc pode citar só o prefixo de
// uma rota mais profunda, ex.: /api/auth descrevendo a família /api/auth/login). Qualquer
// segmento dinâmico ([..]/{..}/:..) casa com um segmento dinâmico real.
export function resolveApiDocPathToRoute(apiPath, routeFiles) {
const segs = apiPath
.replace(/^\//, "")
.replace(/[?#].*$/, "")
.split("/")
.map(normSeg);
for (const rf of routeFiles) {
const rsegs = rf
.replace(/^src\/app\//, "")
.replace(/\/route\.tsx?$/, "")
.split("/");
const rnorm = rsegs.map((rs) => {
if (/^\[\[?\.{3}.+\]\]?$/.test(rs)) return ""; // catch-all
if (/^\[[^\]]+\]$/.test(rs)) return " "; // [param]
return rs;
});
const catchAll = rnorm.includes("");
const effLen = catchAll ? rnorm.indexOf("") : rnorm.length;
if (!catchAll && segs.length > rnorm.length) continue; // doc mais profunda que a rota
if (catchAll && segs.length < effLen) continue;
const cmpLen = Math.min(segs.length, effLen || rnorm.length);
let match = true;
for (let i = 0; i < cmpLen; i++) {
const rs = rnorm[i];
if (rs === "") break; // catch-all absorve o resto
if (!(rs === segs[i] || rs === " " || segs[i] === " ")) {
match = false;
break;
}
}
if (match) return true;
}
return false;
}
/** Limpa o path capturado: remove pontuação/ênfase de prosa, fecha brackets pendentes. */
function cleanCapturedPath(raw) {
let p = raw.replace(/[.,:;_)>]+$/, "");
const ob = (p.match(/\[/g) || []).length;
const cb = (p.match(/\]/g) || []).length;
const oc = (p.match(/\{/g) || []).length;
const cc = (p.match(/\}/g) || []).length;
if (ob !== cb || oc !== cc) {
// segmento final truncado pelo regex (bracket aberto sem fechar na prosa) → descarta
p = p.replace(/\/[^/]*[[{][^/]*$/, "");
}
return p.replace(/\/$/, ""); // remove barra final (forma de prefixo)
}
// /api/... só conta como URL quando NÃO é a cauda de um caminho de arquivo-fonte
// (src/lib/api/, @/app/api/, app/api/). O grupo 2 é o path.
const API_PATH_RE = /(^|[^A-Za-z0-9_/])(\/api\/[A-Za-z0-9_\-/{}\[\].:]+)/g;
/** Extrai os paths /api/... distintos de um arquivo markdown (forma URL, não arquivo). */
export function extractDocApiPaths(src) {
const out = new Set();
let m;
API_PATH_RE.lastIndex = 0;
while ((m = API_PATH_RE.exec(src))) {
const p = cleanCapturedPath(m[2]);
if (p && p !== "/api") out.add(p);
}
return [...out];
}
/**
* Núcleo puro/testável.
* @param {{file: string, paths: string[]}[]} docPathsByFile
* @param {Set<string>} routeFiles conjunto de "src/app/api/.../route.ts"
* @param {Set<string>} allowlist paths stale congelados
* @returns {string[]} misses no formato "file → /api/path"
*/
export function findStaleDocApiRefs(docPathsByFile, routeFiles, allowlist) {
const misses = [];
for (const { file, paths } of docPathsByFile) {
for (const p of paths) {
if (IGNORE.some((rx) => rx.test(p))) continue;
if (isFileRef(p)) continue;
if (allowlist.has(p)) continue;
if (!resolveApiDocPathToRoute(p, routeFiles)) {
misses.push(`${file}${p}`);
}
}
}
return misses;
}
function main() {
const routeFiles = collectRouteFiles();
// docs/i18n/** são espelhos auto-gerados das docs canônicas — validar só o canônico
// evita 40× de ruído duplicado (e os mirrors herdam qualquer fix do canônico).
// docs/superpowers/** são planos internos de implementação (snapshots históricos
// de intenção — podem citar rotas planejadas/abandonadas), não claims sobre o
// código atual; fora do escopo do gate (drift surgiu no ciclo v3.8.18).
const docFiles = walk(DOCS, (n) => /\.md$/.test(n)).filter((f) => {
const rel = path.relative(ROOT, f).replace(/\\/g, "/");
return !rel.startsWith("docs/i18n/") && !rel.startsWith("docs/superpowers/");
});
const docPathsByFile = docFiles.map((f) => ({
file: path.relative(ROOT, f).replace(/\\/g, "/"),
paths: extractDocApiPaths(fs.readFileSync(f, "utf8")),
}));
// Live misses BEFORE allowlist filtering — used for stale-enforcement.
// The paths (not "file → path" strings) are the unit that the allowlist keys on.
const allMisses = findStaleDocApiRefs(docPathsByFile, routeFiles, new Set());
const liveMissPaths = allMisses.map((m) => m.split(" → ")[1]);
assertNoStale(KNOWN_STALE_DOC_REFS, liveMissPaths, "check-docs-symbols");
const misses = findStaleDocApiRefs(docPathsByFile, routeFiles, KNOWN_STALE_DOC_REFS);
if (misses.length) {
console.error(
`[check-docs-symbols] ${misses.length} ref(s) /api em docs sem rota real:\n` +
misses.map((m) => " ✗ " + m).join("\n") +
`\n → crie o route.ts, corrija o path na doc, ou (se for upstream/placeholder)` +
` adicione um padrão a IGNORE com justificativa. NÃO adicione à allowlist sem` +
` confirmar que é drift pré-existente real.`
);
process.exitCode = 1;
}
if (!process.exitCode) {
console.log(
`[check-docs-symbols] OK — ${docFiles.length} docs canônicas, ` +
`${routeFiles.size} rotas conhecidas, ${KNOWN_STALE_DOC_REFS.size} stale congeladas`
);
}
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+270
View File
@@ -0,0 +1,270 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const cwd = process.cwd();
const packageJsonPath = path.resolve(cwd, "package.json");
const openApiPath = path.resolve(cwd, "docs/openapi.yaml");
const changelogPath = path.resolve(cwd, "CHANGELOG.md");
const llmPath = path.resolve(cwd, "llm.txt");
const i18nDocsPath = path.resolve(cwd, "docs/i18n");
function readText(filePath) {
if (!fs.existsSync(filePath)) {
throw new Error(`File not found: ${path.relative(cwd, filePath)}`);
}
return fs.readFileSync(filePath, "utf8");
}
function extractOpenApiVersion(content) {
const lines = content.split(/\r?\n/);
let inInfoBlock = false;
for (const line of lines) {
const trimmed = line.trim();
if (!inInfoBlock) {
if (trimmed === "info:") {
inInfoBlock = true;
}
continue;
}
if (line.length > 0 && !line.startsWith(" ")) {
break;
}
const match = line.match(/^\s{2}version:\s*["']?([^"'\s]+)["']?\s*$/);
if (match) {
return match[1];
}
}
return null;
}
function extractChangelogSections(content) {
const headings = [...content.matchAll(/^##\s+\[([^\]]+)\](?:\s+[-—–].*)?$/gm)];
return headings.map((match) => match[1]);
}
function stripTopHeading(content) {
return content.replace(/^# .+\r?\n+/, "");
}
function extractI18nMirrorBody(content) {
const separator = content.match(/^---\s*$/m);
if (!separator || separator.index === undefined) {
return null;
}
return content.slice(separator.index + separator[0].length).replace(/^\r?\n+/, "");
}
function normalizeMirrorBody(content) {
return content.replace(/\r\n/g, "\n").trim();
}
function isSemver(value) {
// Accept X.Y.Z and X.Y.Z-prerelease.N (e.g. 3.0.0-rc.1, 3.0.0-beta.2)
return /^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/.test(value);
}
let hasFailure = false;
function fail(message) {
hasFailure = true;
console.error(`[docs-sync] FAIL - ${message}`);
}
function checkI18nMirrorFile(fileName, sourcePath) {
if (!fs.existsSync(i18nDocsPath)) {
fail("docs/i18n directory is missing");
return;
}
const sourceBody = normalizeMirrorBody(stripTopHeading(readText(sourcePath)));
const locales = fs
.readdirSync(i18nDocsPath, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
let checked = 0;
for (const locale of locales) {
const targetPath = path.join(i18nDocsPath, locale, fileName);
if (!fs.existsSync(targetPath)) {
fail(`docs/i18n/${locale}/${fileName} is missing`);
continue;
}
const body = extractI18nMirrorBody(readText(targetPath));
if (body === null) {
fail(`docs/i18n/${locale}/${fileName} is missing the i18n mirror separator`);
continue;
}
if (normalizeMirrorBody(body) !== sourceBody) {
fail(`docs/i18n/${locale}/${fileName} differs from root ${fileName}`);
continue;
}
checked += 1;
}
if (checked > 0) {
console.log(`[docs-sync] ${fileName} i18n mirrors match root content: ${checked} locales`);
}
}
/**
* Check i18n CHANGELOG mirrors by verifying that all version sections from the
* root CHANGELOG exist in each translation. Unlike strict mirror files (llm.txt),
* CHANGELOG translations have translated section headings (e.g. "Security" →
* "Segurança"), so byte-for-byte comparison is intentionally skipped.
*
* Validates:
* 1. File exists in each locale
* 2. Has the i18n mirror separator (---)
* 3. Contains all version sections (## [X.Y.Z]) from root, in the same order
* 4. Body is non-empty and within a reasonable size tolerance of the source
*/
function checkI18nChangelogFile(sourcePath) {
const fileName = "CHANGELOG.md";
if (!fs.existsSync(i18nDocsPath)) {
fail("docs/i18n directory is missing");
return;
}
const sourceContent = readText(sourcePath);
const sourceBody = normalizeMirrorBody(stripTopHeading(sourceContent));
const sourceVersions = extractChangelogSections(sourceContent);
const locales = fs
.readdirSync(i18nDocsPath, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
let checked = 0;
for (const locale of locales) {
const targetPath = path.join(i18nDocsPath, locale, fileName);
if (!fs.existsSync(targetPath)) {
fail(`docs/i18n/${locale}/${fileName} is missing`);
continue;
}
const targetContent = readText(targetPath);
const body = extractI18nMirrorBody(targetContent);
if (body === null) {
fail(`docs/i18n/${locale}/${fileName} is missing the i18n mirror separator`);
continue;
}
const normalizedBody = normalizeMirrorBody(body);
if (normalizedBody.length === 0) {
fail(`docs/i18n/${locale}/${fileName} has empty body after separator`);
continue;
}
// Verify all version sections from root exist in the translation
const targetVersions = extractChangelogSections(targetContent);
const missingVersions = sourceVersions.filter((v) => !targetVersions.includes(v));
if (missingVersions.length > 0) {
fail(
`docs/i18n/${locale}/${fileName} is missing version sections: ${missingVersions.slice(0, 3).join(", ")}${missingVersions.length > 3 ? ` (+${missingVersions.length - 3} more)` : ""}`
);
continue;
}
// Verify body line count is within 25% tolerance of source (translations
// should preserve structure — drastic line-count differences indicate
// stale or missing content)
const sourceLines = sourceBody.split("\n").length;
const targetLines = normalizedBody.split("\n").length;
const sizeDiff = Math.abs(targetLines - sourceLines) / sourceLines;
if (sizeDiff > 0.25) {
fail(
`docs/i18n/${locale}/${fileName} body line count differs by ${(sizeDiff * 100).toFixed(0)}% from root (expected within 25%)`
);
continue;
}
checked += 1;
}
if (checked > 0) {
console.log(
`[docs-sync] ${fileName} i18n translations validated: ${checked} locales (version sections + size check)`
);
}
}
try {
const packageJson = JSON.parse(readText(packageJsonPath));
const packageVersion = packageJson.version;
if (!isSemver(packageVersion)) {
fail(`package.json version is not valid semver: "${packageVersion}"`);
} else {
console.log(`[docs-sync] package.json version: ${packageVersion}`);
}
const openApiVersion = extractOpenApiVersion(readText(openApiPath));
if (!openApiVersion) {
fail("could not extract docs/openapi.yaml info.version");
} else if (openApiVersion !== packageVersion) {
fail(`OpenAPI version (${openApiVersion}) differs from package.json (${packageVersion})`);
} else {
console.log(`[docs-sync] openapi.yaml info.version matches: ${openApiVersion}`);
}
const changelogSections = extractChangelogSections(readText(changelogPath));
if (changelogSections.length === 0) {
fail("CHANGELOG.md has no version sections");
} else {
if (changelogSections[0] !== "Unreleased") {
fail('CHANGELOG.md first section must be "## [Unreleased]"');
} else {
console.log("[docs-sync] changelog has top Unreleased section");
}
const semverSections = changelogSections.filter((section) => isSemver(section));
if (semverSections.length === 0) {
fail("CHANGELOG.md has no semver release section");
} else if (semverSections[0] !== packageVersion) {
fail(
`Latest changelog release (${semverSections[0]}) differs from package.json (${packageVersion})`
);
} else {
console.log(
`[docs-sync] latest changelog release matches package version: ${packageVersion}`
);
}
}
// llm.txt mirrors must be exact copies (no translation)
checkI18nMirrorFile("llm.txt", llmPath);
// CHANGELOG.md mirrors are translations — check version sections and size, not exact content
checkI18nChangelogFile(changelogPath);
// Anti-regression: legacy duplicate docs that have been superseded must not return.
// Use docs/reference/* as the source of truth.
const supersededDocs = [{ legacy: "docs/CLI-TOOLS.md", current: "docs/reference/CLI-TOOLS.md" }];
for (const { legacy, current } of supersededDocs) {
const legacyAbs = path.resolve(cwd, legacy);
if (fs.existsSync(legacyAbs)) {
fail(
`legacy duplicate ${legacy} reappeared — use ${current} instead (single source of truth)`
);
}
}
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}
if (hasFailure) {
process.exit(1);
}
console.log("[docs-sync] PASS - documentation version sync is consistent.");
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env node
// scripts/check/check-duplication.mjs
// Catraca de duplicação de código. Roda jscpd@4 (PINADO — o v5 é um rewrite Rust com
// CLI/JSON incompatíveis) sobre src+open-sse e compara a % atual contra um baseline
// congelado (duplication-baseline.json). Falha se a duplicação SUBIR. Ataca a assinatura
// nº1 de slop de IA (GitClear 2026: duplicação 4-8x na era IA) — no nosso caso, o
// copy-paste dos executors (48/50 sobrescrevem execute() inteiro). --update ratcheta.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const BASELINE_PATH = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "config/quality/duplication-baseline.json")
);
const UPDATE = process.argv.includes("--update");
const EPS = 0.05; // tolerância de ruído de float (jscpd é determinístico; isto é margem)
// Use local binary (pinned in package.json devDependencies — no registry download at CI time)
const JSCPD_BIN = path.join(ROOT, "node_modules", ".bin", "jscpd");
const JSCPD_FIXED_ARGS = [
"src",
"open-sse",
"--reporters",
"json",
"--silent",
"--min-tokens",
"50",
"--ignore",
"**/*.test.ts,**/*.test.tsx,**/__tests__/**",
];
/** Avalia a % atual contra o baseline. */
export function evaluateDuplication(current, baseline, eps = EPS) {
return {
regressed: current > baseline + eps,
improved: current < baseline - eps,
};
}
function measureDuplicationPct() {
const out = fs.mkdtempSync(path.join(os.tmpdir(), "jscpd-"));
execFileSync(JSCPD_BIN, [...JSCPD_FIXED_ARGS, "--output", out], { stdio: "ignore" });
const report = JSON.parse(fs.readFileSync(path.join(out, "jscpd-report.json"), "utf8"));
return report.statistics.total.percentage;
}
function main() {
if (!fs.existsSync(BASELINE_PATH)) {
console.error(`[duplication] FAIL — ${path.basename(BASELINE_PATH)} ausente.`);
process.exit(2);
}
const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
const current = measureDuplicationPct();
const { regressed, improved } = evaluateDuplication(current, baseline.percentage, EPS);
if (UPDATE && improved) {
baseline.percentage = current;
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + "\n");
console.log(`[duplication] baseline ratcheado: ${current}% (era ${baseline.percentage}%)`);
}
if (regressed) {
console.error(
`[duplication] REGRESSÃO — ${current}% > baseline ${baseline.percentage}% (+${EPS} tolerância)\n` +
` → extraia o trecho duplicado (helper compartilhado) ou ajuste duplication-baseline.json com justificativa.`
);
process.exit(1);
}
console.log(`[duplication] OK — ${current}% (baseline ${baseline.percentage}%)`);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+377
View File
@@ -0,0 +1,377 @@
#!/usr/bin/env node
/**
* Strict environment variable contract checker.
*
* Enforces that every env var referenced in OmniRoute source code appears in
* both `.env.example` and `docs/reference/ENVIRONMENT.md`, and that the two files agree
* on the documented var set. Falls back to a small allowlist for variables
* that are intentionally documented but not literally referenced (legacy
* aliases, future-supported hooks) or vice versa.
*
* Usage:
* node scripts/check/check-env-doc-sync.mjs # strict (CI mode)
* node scripts/check/check-env-doc-sync.mjs --lenient # legacy report-only mode
*
* Strict mode exits non-zero if any of these are non-empty:
* - vars in code but missing from .env.example
* - vars in .env.example but missing from ENVIRONMENT.md
* - vars in ENVIRONMENT.md but missing from .env.example
*
* Programmatic API:
* Other Node tests can `import { runEnvDocSync } from "./check-env-doc-sync.mjs"`
* and pass `{ root, envExample, envDoc, codeVars, ignore, docOnlyAllowlist,
* envOnlyAllowlist }` to drive the checker against fixtures.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execSync } from "node:child_process";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "..", "..");
// ─── Allowlists ────────────────────────────────────────────────────────────
// Env vars referenced in code that should NOT trigger documentation drift.
// These are usually system/process vars or harness-only knobs.
const IGNORE_FROM_CODE = new Set([
"NODE_ENV",
"PATH",
"HOME",
"USER",
"LOGNAME",
"XDG_CURRENT_DESKTOP",
"PWD",
"SHELL",
"TERM",
"TZ",
"LANG",
"LC_ALL",
"LC_MESSAGES",
"CI",
"GITHUB_ACTIONS",
"RUNNER_OS",
// Agent environment / system execution paths.
"PROJECT_ROOT",
"ARTIFACTS_DIR",
// OS / Node internals frequently surfaced by indirect dependencies.
"APPDATA",
"LOCALAPPDATA",
"XDG_CONFIG_HOME",
"USERPROFILE",
"PREFIX",
// X11 display server — set by the OS/session manager, not OmniRoute config.
"DISPLAY",
// POSIX session vars surfaced by cloudflaredTunnel.ts (env passthrough).
"LOGNAME",
"XDG_CURRENT_DESKTOP",
// Next.js / Node test runners — these are framework-managed.
"NEXT_DIST_DIR",
"NEXT_PHASE",
"NEXT_RUNTIME",
// Set/read by Next.js's own dev server (next-dev-server.js) when the turbopack
// bundler is active — framework-internal. The OmniRoute-facing knob is
// OMNIROUTE_USE_TURBOPACK (scripts/dev/run-next.mjs), which IS documented.
"TURBOPACK",
"NODE_TEST_CONTEXT",
"VITEST",
// Instruction snippet shown to users (Traffic Inspector HttpProxySnippetCard) — not OmniRoute config.
"NODE_TLS_REJECT_UNAUTHORIZED",
// Claude Code's own auth env var — read from the CLI environment to detect
// existing auth and written into the generated Claude Code settings (so the CLI
// points at OmniRoute). A downstream client-tool var, not an OmniRoute server
// input (src/shared/services/claudeCliConfig.ts, api/cli-tools/claude-settings).
"ANTHROPIC_AUTH_TOKEN",
// CI providers (set by the runner).
"GITHUB_BASE_REF",
"GITHUB_BASE_SHA",
// CI passes BASE_REF=${{ github.base_ref }} to the OpenAPI breaking-change gate
// (scripts/check/check-openapi-breaking.mjs) — a build/check signal, not OmniRoute runtime config.
"BASE_REF",
// PR body injected by GitHub Actions into the pr-evidence gate (github.event.pull_request.body);
// a CI-only signal, never an OmniRoute runtime config (Phase 7.10).
"PR_BODY",
// CLI machine-id token opt-out (server-side flag; not user-configurable via .env).
"OMNIROUTE_DISABLE_CLI_TOKEN",
// Gated combo live-smoke harness (scripts/test/_vpsClient.mjs) — override the VPS HTTP
// smoke target host/key. Test/CI-only signals with safe defaults
// ("http://192.168.0.15:20128" / null), never OmniRoute runtime config (#5151).
"COMBO_LIVE_BASE_URL",
"COMBO_LIVE_API_KEY",
// update-notifier opt-out for the CLI binary.
"OMNIROUTE_NO_UPDATE_NOTIFIER",
// Headless CLI execution flag for Electron.
"OMNIROUTE_HEADLESS",
// Platform / OS detection vars read by CLI environment helper (bin/cli/utils/environment.mjs).
// These are external signals set by the host OS or cloud provider — not OmniRoute config.
"CODESPACES",
"GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN",
"GITPOD_WORKSPACE_ID",
"NO_COLOR",
"REPL_ID",
"REPL_SLUG",
"WSL_DISTRO_NAME",
"WSL_INTEROP",
// X11/Wayland display server vars used by tray heuristic (isTraySupported).
"DISPLAY",
"WAYLAND_DISPLAY",
// Build-time override for OpenAPI spec path used by generate-api-commands.mjs.
"OPENAPI_SPEC",
// Aliases for documented vars handled via fallback ordering.
"API_KEY",
"APP_URL",
"PUBLIC_URL",
"ANTHROPIC_API_URL",
"OPENAI_API_URL",
"LOG_LEVEL",
// Internal QA helpers used only by scripts/ and Playwright.
"QA_BASE_URL",
"QA_LOCALES",
"QA_REPORT_SUFFIX",
"QA_ROUTES",
// Doctor diagnostic flags (no runtime behavior yet — placeholders).
"OMNIROUTE_DOCTOR_HOST",
"OMNIROUTE_DOCTOR_LIVENESS_URL",
"OMNIROUTE_PROVIDER_CATALOG_PATH",
"OMNIROUTE_PROVIDER_TEST_MODEL",
// Test-only opt-out: instructs bin/omniroute.mjs to skip auto-loading the
// repository .env so isolation tests get a deterministic environment.
"OMNIROUTE_CLI_SKIP_REPO_ENV",
// Eval-harness only: operator-supplied provider credentials JSON read by the
// opt-in `npm run eval:compression` CLI (scripts/compression-eval/index.ts).
// A dev/ops measurement tool, never OmniRoute runtime config.
"OMNIROUTE_EVAL_CREDENTIALS",
// Build-time only: set by `build:release` (git short SHA) and read by
// write-build-sha.mjs to stamp dist/BUILD_SHA — injected by the build, never
// configured by users in .env.
"OMNIROUTE_BUILD_SHA",
// Source typo / placeholder.
"OMNIROUT",
// Static config alias path (the canonical var is OMNIROUTE_PAYLOAD_RULES_PATH).
"PAYLOAD_RULES_PATH",
// Node.js module resolution path — OS/Node internal, not an OmniRoute config var.
// Referenced in resolveSpawnArgs (ninerouter) to pass bundled native modules to subprocess.
"NODE_PATH",
// NVIDIA diagnostic/test helpers used only by ad-hoc scripts.
"NVIDIA_BASE_URL",
"NVIDIA_MODEL",
// XDG standard data directory — set by OS/desktop session, not OmniRoute config.
// Read by setup-open-code.mjs to locate platform-specific OpenCode data dir.
"XDG_DATA_HOME",
// Test-only override: points setup-open-code.mjs at a fixture plugin dir without
// requiring the real bundled plugin to be built.
"OMNIROUTE_OPENCODE_PLUGIN_DIR",
]);
// Vars documented in ENVIRONMENT.md but intentionally absent from .env.example.
// Used for past-tense documentation (Audit / Dead vars section), legacy aliases
// with no runtime hook, and section anchors that look like vars to the regex.
const DOC_ONLY_ALLOWLIST = new Set([
// Audit history (Removed / Dead Variables section).
"CEREBRAS_API_KEY",
"COHERE_API_KEY",
"FIREWORKS_API_KEY",
"GROQ_API_KEY",
"MISTRAL_API_KEY",
"NEBIUS_API_KEY",
"PERPLEXITY_API_KEY",
"TOGETHER_API_KEY",
"XAI_API_KEY",
"QIANFAN_API_KEY",
"CURSOR_PROTOBUF_DEBUG",
"CLI_COMPAT_KIRO",
"CLI_KIMI_CODING_BIN",
"CLI_ROO_BIN",
"IFLOW_OAUTH_CLIENT_ID",
"IFLOW_OAUTH_CLIENT_SECRET",
// Source-code constants accidentally captured by the doc regex.
"CLI_COMPAT_OMITTED_PROVIDER_IDS",
// The stream-recovery tuning object in open-sse/config/constants.ts (`STREAM_RECOVERY.HOLDBACK_MS`
// etc.) — documented for reference; the real operator-facing env vars are STREAM_RECOVERY_ENABLED /
// STREAM_RECOVERY_MIDSTREAM_ENABLED (both in .env.example). The bare prefix is not an env var.
"STREAM_RECOVERY",
// Sample default values that look like SHOUTY_NAMES (not env vars).
"CHANGEME",
// Legacy aliases — present in docs as "would be aliases" but read-only
// through their canonical names today.
"OMNIROUTE_CRYPT_KEY",
"OMNIROUTE_API_KEY_BASE64",
// Future-supported hooks: documented but currently hardcoded constants.
"MAX_RETRY_INTERVAL_SEC",
"REQUEST_RETRY",
"SKILLS_EXECUTION_TIMEOUT_MS",
"SKILLS_SANDBOX_DOCKER_IMAGE",
// Source-code constants referenced in the docs narrative for the local
// endpoints / route-guard classification (PR-3 in #3932).
"LOCAL_ONLY_API_PREFIXES",
// SQL keyword mentioned in the new VACUUM scheduler docs (#4437).
// The check's regex picks up the bare word in description text.
"VACUUM",
]);
// Vars present in .env.example but intentionally absent from ENVIRONMENT.md.
// Empty today — kept for forward compatibility / explicit exemption.
const ENV_ONLY_ALLOWLIST = new Set([
// Documented in .env.example but not yet in docs/reference/ENVIRONMENT.md
"CODEX_REFRESH_SPACING_MS",
"DEBUG",
"HEAP_PRESSURE_THRESHOLD_MB",
"OMNIROUTE_TRACE",
"PII_TEST_BYPASS_MIN_WINDOW",
"PII_WINDOW_SIZE",
"TRAE_STREAM_TIMEOUT_MS",
"TRAE_TOKEN",
]);
// ─── Parsing helpers ───────────────────────────────────────────────────────
/**
* Extract VAR= entries from a `.env`-style file (handles commented examples).
*/
export function parseEnvExampleVars(text) {
const vars = new Set();
for (const line of String(text ?? "").split("\n")) {
const m = line.match(/^#?\s*([A-Z][A-Z0-9_]+)\s*=/);
if (m) vars.add(m[1]);
}
return vars;
}
/**
* Extract `VARNAME` tokens from a markdown doc — matches anything in backticks
* that looks like an env var (uppercase + digit + underscore).
*/
export function parseEnvDocVars(text) {
const vars = new Set();
for (const m of String(text ?? "").matchAll(/`([A-Z][A-Z0-9_]{2,})`/g)) {
vars.add(m[1]);
}
return vars;
}
/**
* Collect environment variable references in source code via grep against
* the `process.env` member access pattern.
*/
function scanCodeVars({ cwd } = {}) {
const repoRoot = cwd ?? REPO_ROOT;
const stdout = execSync(
"grep -rhoE 'process\\.env\\.[A-Z][A-Z0-9_]+' " +
"src/ open-sse/ bin/ scripts/ electron/main.js electron/preload.js 2>/dev/null || true",
{ cwd: repoRoot, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 }
);
const vars = new Set();
for (const line of stdout.split("\n")) {
const m = line.match(/^process\.env\.([A-Z][A-Z0-9_]+)$/);
if (m) vars.add(m[1]);
}
return vars;
}
/**
* Diff helper.
*/
function diff(set, against) {
return [...set].filter((v) => !against.has(v)).sort((a, b) => a.localeCompare(b));
}
// ─── Programmatic entry point ──────────────────────────────────────────────
/**
* Run the contract checker. All inputs are overridable for tests.
*
* Returns `{ ok: boolean, summary, problems: { codeMissingEnv, envMissingDoc,
* docMissingEnv } }`.
*/
export function runEnvDocSync(options = {}) {
const ignore = options.ignore ?? IGNORE_FROM_CODE;
const docOnly = options.docOnlyAllowlist ?? DOC_ONLY_ALLOWLIST;
const envOnly = options.envOnlyAllowlist ?? ENV_ONLY_ALLOWLIST;
const envExampleText =
options.envExampleText ??
(options.envExamplePath
? fs.readFileSync(options.envExamplePath, "utf8")
: fs.readFileSync(path.join(REPO_ROOT, ".env.example"), "utf8"));
const envDocText =
options.envDocText ??
(options.envDocPath
? fs.readFileSync(options.envDocPath, "utf8")
: fs.readFileSync(path.join(REPO_ROOT, "docs", "reference", "ENVIRONMENT.md"), "utf8"));
const envVars = parseEnvExampleVars(envExampleText);
const docVars = parseEnvDocVars(envDocText);
const codeVars = new Set(
[...(options.codeVars ?? scanCodeVars({ cwd: options.root }))].filter((v) => !ignore.has(v))
);
const codeMissingEnv = diff(codeVars, envVars);
const envMissingDoc = diff(envVars, docVars).filter((v) => !envOnly.has(v));
const docMissingEnv = diff(docVars, envVars).filter((v) => !docOnly.has(v));
const ok =
codeMissingEnv.length === 0 && envMissingDoc.length === 0 && docMissingEnv.length === 0;
return {
ok,
summary: {
code: codeVars.size,
envExample: envVars.size,
doc: docVars.size,
},
problems: {
codeMissingEnv,
envMissingDoc,
docMissingEnv,
},
};
}
// ─── CLI ───────────────────────────────────────────────────────────────────
function printList(label, list, marker) {
if (list.length === 0) {
console.log(` ${marker || "✓"} ${label}: none`);
return;
}
console.log(`${label}: ${list.length}`);
for (const v of list.slice(0, 50)) console.log(` - ${v}`);
if (list.length > 50) console.log(` ... and ${list.length - 50} more`);
}
function main() {
const lenient = process.argv.includes("--lenient");
const result = runEnvDocSync();
console.log("Env var contract sync report");
console.log("============================");
console.log(`Code references: ${result.summary.code} unique vars`);
console.log(`In .env.example: ${result.summary.envExample} unique vars`);
console.log(`In docs/reference/ENVIRONMENT.md: ${result.summary.doc} unique vars`);
console.log();
printList("In code but missing from .env.example", result.problems.codeMissingEnv);
printList("In .env.example but missing from ENVIRONMENT.md", result.problems.envMissingDoc);
printList("In ENVIRONMENT.md but missing from .env.example", result.problems.docMissingEnv);
if (result.ok) {
console.log("\n✓ Env / docs contract is in sync.");
process.exit(0);
}
if (lenient) {
console.log("\n⚠ Drift detected (lenient mode — exit 0).");
process.exit(0);
}
console.log(
"\n✗ Env / docs contract is out of sync. Update .env.example, docs/reference/ENVIRONMENT.md,"
);
console.log(" or the allowlists in scripts/check/check-env-doc-sync.mjs and try again.");
process.exit(1);
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env node
// scripts/check/check-error-helper.mjs
// Gate Hard Rule #12 (error sanitization): error responses/results built in
// open-sse/executors/ and open-sse/handlers/ MUST route through the helpers in
// open-sse/utils/error.ts (buildErrorBody / errorResponse / sanitizeErrorMessage /
// sanitizeUpstreamDetails / makeExecutorErrorResult / formatProviderError / …) so
// raw err.stack / err.message / upstream body.error.message never reach a client.
//
// The risk: a file that builds its own `new Response(JSON.stringify({ error: {
// message: err.message } }))` (or a result object with `error: <raw msg>`) and does
// NOT import the sanitizer leaks stack traces / absolute paths / upstream internals.
// CodeQL's js/stack-trace-exposure does not understand the custom sanitizer, so this
// static gate is the canonical enforcement. See docs/security/ERROR_SANITIZATION.md.
//
// Conservative by design: a file is flagged ONLY when it both (a) appears to forward
// a RAW error value into a response/result body AND (b) imports nothing from a
// utils/error path. Files that import the helper are trusted (the `body.error.message`
// they reference is the sanitized output of buildErrorBody, not raw upstream).
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { assertNoStale } from "./lib/allowlist.mjs";
const cwd = process.cwd();
// Directories to scan (Hard Rule #12 applies to ALL error-response-building surfaces).
// 6A.8: expanded from executors+handlers to include MCP server tools and API route files.
const SCAN_DIRS = [
path.join(cwd, "open-sse/executors"),
path.join(cwd, "open-sse/handlers"),
path.join(cwd, "open-sse/mcp-server"),
];
// Glob-style pattern for API route files under src/app/api/ (matched by path test below).
const IS_API_ROUTE = /^src\/app\/api\/.+\/route\.tsx?$/;
// Pre-existing violators frozen so the gate is green NOW and blocks only NEW leaks.
// Each entry is a real Rule #12 gap (raw err.message forwarded into a response body
// with no utils/error import) and should become a tracked cleanup issue: route the
// message through sanitizeErrorMessage()/buildErrorBody()/makeExecutorErrorResult().
// Do NOT add new entries without a justification — that defeats the gate.
export const KNOWN_MISSING_ERROR_HELPER = new Set([
// --- original open-sse/executors + handlers scope (pre-6A.8) ---
// --- 6A.8 expanded scope: src/app/api/**/route.ts pre-existing violations ---
// TODO(6A.8): pre-existing, triage — route through buildErrorBody()/sanitizeErrorMessage()
]);
// Import specifiers that count as "uses the error helper" (path ends in utils/error).
const ERROR_HELPER_IMPORT =
/\bfrom\s*["'](?:\.{1,2}\/)*(?:open-sse\/)?utils\/error(?:\.[tj]s)?["']|@omniroute\/open-sse\/utils\/error/;
// A caught-error identifier whose .message/.stack is RAW (not sanitized): the leading
// token must be exactly `err` / `error` / `e` (optionally `(err as Error)` cast), and
// NOT preceded by a member access — so `event.error.message` (an upstream-event read)
// does not match, only our own caught `err.message` / `error.stack` / `(err as …).msg`.
// The `(?<![.\w])` lookbehind is non-consuming so it works mid-template (e.g. `${err…`).
const RAW_ERR = String.raw`(?:\((?:err|error|e)\s+as\s+[^)]+\)|(?<![.\w])(?:err|error|e))\.(?:message|stack)\b`;
// Lines that are internal sinks (never reach the client) — excluded so the gate does
// not false-positive on logging, DB audit rows, thrown Errors, or rejected promises.
const INTERNAL_SINK =
/\b(?:log\??\.\w+\??\.?\(|console\.\w+\(|saveCallLog\s*\(|reqLogger\.|throw\s+new\s+\w*Error|reject\s*\(|\.error\??\.\(|finish\s*\()/;
// Internal-sink CALL openers — when a raw-error field sits inside one of these calls'
// argument object (e.g. `saveCallLog({ … error: err.message … })`), it is a DB audit
// row / log entry, not a client response. Matched against the line that opens the
// nearest still-unclosed call enclosing the flagged line.
const INTERNAL_SINK_CALL =
/\b(?:saveCallLog|log\??\.\w+|console\.\w+|reqLogger\.\w+)\s*\(\s*\{?\s*$/;
// A line that is constructing a client-facing response/result body.
const RESPONSE_LINE =
/new\s+Response\s*\(|\bresponse\s*:|\berrResp\s*\(|\bmakeErrorResponse\s*\(|\berrorResponse\s*\(/;
function walk(dir, acc = []) {
if (!fs.existsSync(dir)) return acc;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p, acc);
else if (/\.tsx?$/.test(e.name) && !/\.test\.tsx?$/.test(e.name)) acc.push(p);
}
return acc;
}
// A raw caught-error value assigned to / interpolated into a `message:`/`error:` field.
const RAW_ERR_FIELD = new RegExp(String.raw`\b(?:message|error)\s*:\s*` + RAW_ERR);
const RAW_ERR_FIELD_INTERP = new RegExp(
String.raw`\b(?:message|error)\s*:\s*[\`"'][^\n]*\$\{[^}]*` + RAW_ERR
);
// A raw caught-error value interpolated anywhere on a line that also builds a Response.
const RAW_ERR_INTERP = new RegExp(String.raw`\$\{[^}]*` + RAW_ERR);
// Upstream `body.error.message` forwarded into a field without a sanitize call.
const RAW_BODY_ERR = /\b(?:message|error)\s*:\s*[^,}\n]*\bbody\.error\.message\b/;
// A response-builder CALL that takes a message argument (client-facing). A tainted
// local variable (assigned from a raw error) passed here is a leak.
const RESPONSE_BUILDER_CALL =
/\b(?:errResp|makeErrorResponse|errorResponse)\s*\(|\bresponse\s*:\s*(?:errResp|makeErrorResponse|errorResponse|new\s+Response)\s*\(/;
// `const|let <id> = <expr containing a raw caught-error>` — a tainted local holding a
// raw, unsanitized error string. Captures the variable name for downstream tracking.
const TAINT_DECL = new RegExp(
String.raw`\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*[^;\n]*` + RAW_ERR
);
/**
* Does this source forward a RAW error value into a CLIENT-FACING response/result body?
*
* Line-anchored + sink-aware so it does not false-positive on logging, DB audit rows
* (saveCallLog), thrown Errors, rejected promises, or parsed upstream-event reads.
*
* A line is a violation when, after skipping internal-sink lines, it either:
* - assigns/interpolates a raw caught-error into a `message:`/`error:` field, or
* - interpolates a raw caught-error AND is itself a Response/result-builder line, or
* - forwards upstream `body.error.message` into a field without sanitizing, or
* - passes a TAINTED local (a var assigned from a raw error, never sanitized) into a
* response-builder call (errResp / makeErrorResponse / errorResponse / new Response).
*/
function forwardsRawError(source) {
const lines = source.split("\n").map((l) => l.replace(/\/\/.*$/, ""));
// Pass 1: collect tainted local variables (raw error, no sanitize on the line).
const tainted = new Set();
for (const line of lines) {
if (INTERNAL_SINK.test(line)) continue;
const m = line.match(TAINT_DECL);
if (m && !/sanitize/i.test(line)) tainted.add(m[1]);
}
const taintedUse =
tainted.size > 0 ? new RegExp(String.raw`\b(?:${[...tainted].join("|")})\b`) : null;
// Pass 2: scan for leak lines.
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.trim()) continue;
if (INTERNAL_SINK.test(line)) continue; // log / audit / throw / reject
if (TAINT_DECL.test(line)) continue; // the assignment itself is not the leak
const directLeak =
RAW_ERR_FIELD.test(line) ||
RAW_ERR_FIELD_INTERP.test(line) ||
(RAW_ERR_INTERP.test(line) && RESPONSE_LINE.test(line)) ||
// Multi-line OpenAI error envelope: a raw-error interpolation that sits inside
// an enclosing `error: {` / `message:` field of a `new Response(` body.
(RAW_ERR_INTERP.test(line) && enclosedByErrorResponseBody(lines, i)) ||
(RAW_BODY_ERR.test(line) && !/sanitize/i.test(line));
const taintedLeak =
taintedUse !== null && RESPONSE_BUILDER_CALL.test(line) && taintedUse.test(line);
// The raw error reaches a client body unless it lives inside an internal-sink
// call's argument object (saveCallLog / log / console / reqLogger).
if ((directLeak || taintedLeak) && !enclosedByInternalSinkCall(lines, i)) return true;
}
return false;
}
/**
* Walk back from `idx`, tracking net brace/paren depth, to find the line that opens
* the call enclosing `idx`. Returns true if that opener is an internal-sink call.
* Bounded lookback (sink-call argument objects are small) keeps this cheap.
*/
function enclosedByInternalSinkCall(lines, idx) {
let depth = 0;
for (let j = idx; j >= 0 && idx - j < 80; j--) {
const l = lines[j].replace(/\/\/.*$/, "");
for (let k = l.length - 1; k >= 0; k--) {
const ch = l[k];
if (ch === ")" || ch === "}") depth++;
else if (ch === "(" || ch === "{") {
if (depth === 0) {
// Unbalanced opener at this position — the enclosing construct starts here.
return INTERNAL_SINK_CALL.test(l.slice(0, k + 1));
}
depth--;
}
}
}
return false;
}
// Field opener that is part of an OpenAI-style error envelope (`error: {` / `message:`).
const ERROR_FIELD_OPENER = /\b(?:error|message)\s*:\s*[`{]?\s*$/;
/**
* Walk back from `idx` to the nearest enclosing `{`/`(` opener; if it opens an error
* envelope field (`error: {` / `message:`) AND a `new Response(` / `response:` builder
* appears just above it, the raw error reaches a client error body. Conservative: only
* the canonical error-envelope shape qualifies (not `content:` / data fields).
*/
function enclosedByErrorResponseBody(lines, idx) {
let depth = 0;
for (let j = idx; j >= 0 && idx - j < 80; j--) {
const l = lines[j].replace(/\/\/.*$/, "");
for (let k = l.length - 1; k >= 0; k--) {
const ch = l[k];
if (ch === ")" || ch === "}") depth++;
else if (ch === "(" || ch === "{") {
if (depth === 0) {
if (!ERROR_FIELD_OPENER.test(l.slice(0, k + 1))) return false;
// Confirm a Response builder sits in the few lines above the envelope.
const window = lines.slice(Math.max(0, j - 8), j + 1).join("\n");
return /new\s+Response\s*\(|\bresponse\s*:/.test(window);
}
depth--;
}
}
}
return false;
}
export function findErrorHelperViolations(files, allowlist) {
const violations = [];
for (const { path: rel, source } of files) {
if (allowlist.has(rel)) continue;
if (ERROR_HELPER_IMPORT.test(source)) continue; // trusts the helper
if (forwardsRawError(source)) violations.push(rel);
}
return violations;
}
function collectFiles() {
const files = [];
// Standard scan dirs (open-sse/executors, handlers, mcp-server).
for (const dir of SCAN_DIRS) {
for (const p of walk(dir)) {
files.push({
path: path.relative(cwd, p).replace(/\\/g, "/"),
source: fs.readFileSync(p, "utf8"),
});
}
}
// 6A.8: also scan all src/app/api/**/route.ts files.
const apiRoot = path.join(cwd, "src/app/api");
for (const p of walk(apiRoot)) {
const rel = path.relative(cwd, p).replace(/\\/g, "/");
if (IS_API_ROUTE.test(rel)) {
files.push({ path: rel, source: fs.readFileSync(p, "utf8") });
}
}
return files;
}
function main() {
const files = collectFiles();
// 6A.8: stale-allowlist enforcement.
// Compute live violations WITHOUT the allowlist so we can detect entries that are
// now stale (the violation was fixed, but the freeze entry was not removed).
const liveViolations = findErrorHelperViolations(files, new Set());
assertNoStale(KNOWN_MISSING_ERROR_HELPER, liveViolations, "check-error-helper");
// Suppress known pre-existing violations so only NEW leaks fail the gate.
const violations = findErrorHelperViolations(files, KNOWN_MISSING_ERROR_HELPER);
if (violations.length) {
console.error(
`[check-error-helper] ${violations.length} file(s) build an error response/result with a ` +
`raw err.message/err.stack/body.error.message but do NOT import open-sse/utils/error:\n` +
violations.map((v) => " ✗ " + v).join("\n") +
`\n → route the message through buildErrorBody()/sanitizeErrorMessage()/` +
`makeExecutorErrorResult() (see docs/security/ERROR_SANITIZATION.md), or — if it is a ` +
`false positive — add it to KNOWN_MISSING_ERROR_HELPER with a justification.`
);
process.exit(1);
}
if (process.exitCode === 1) return; // stale entries already logged
console.log(
`[check-error-helper] OK (${files.length} files scanned, ${KNOWN_MISSING_ERROR_HELPER.size} known-missing frozen)`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+903
View File
@@ -0,0 +1,903 @@
#!/usr/bin/env node
// Doc accuracy gate — catches fabricated API/endpoint/function/env-var claims in docs.
//
// Scans every `docs/{*,*/*}.md` and `AGENTS.md` for concrete code references and
// verifies each one against the source. Reports drift as warnings (soft-fail
// by default) and exits 1 with `--strict` so CI can block fabricated claims.
//
// What it checks:
// 1. /api/... endpoint paths → must match a route.ts file under src/app/api/
// 2. UPPER_SNAKE env var names → must have a process.env.X or env.X read
// 3. CLI commands `omniroute ...` → must exist in bin/cli/commands/ or bin/
// 4. BUILTIN_EVENTS hook names → must be exported from hooks.ts
// 5. `src/.../foo.ts` file refs → must exist (relative to repo root)
// 6. `open-sse/.../bar.ts` file refs → must exist
// 7. `bin/...` file refs → must exist
//
// Out of scope (covered by other scripts):
// - File-size / line-count claims → scripts/check/check-docs-counts-sync.mjs
// - Env var → doc table sync → scripts/check/check-env-doc-sync.mjs
// - Cross-doc link integrity → scripts/check/check-doc-links.mjs
// - openapi.yaml ↔ routes sync → scripts/check/check-openapi-coverage.mjs
//
// Exit codes:
// 0 no drift (or soft warnings only)
// 1 strict mode and any drift was found
//
// Usage:
// node scripts/check/check-fabricated-docs.mjs # soft report
// node scripts/check/check-fabricated-docs.mjs --strict # fail on any drift
// node scripts/check/check-fabricated-docs.mjs --json # machine-readable output
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..", "..");
const ARGS = new Set(process.argv.slice(2));
const STRICT = ARGS.has("--strict");
const JSON_OUT = ARGS.has("--json");
// ── Config ─────────────────────────────────────────────────────────────────
/** Paths to scan recursively. AGENTS.md is checked too. */
const SCAN_PATHS = ["docs", "AGENTS.md", "open-sse/AGENTS.md", "src/lib/db/AGENTS.md"];
/** Built-in event names that AGENTS.md / docs are allowed to mention. */
const KNOWN_HOOKS = new Set([
"onRequest",
"onResponse",
"onError",
"onModelSelect",
"onComboResolve",
"onRateLimit",
"onQuotaExhaust",
"onProviderError",
"onStreamStart",
"onStreamEnd",
"onInstall",
"onActivate",
"onDeactivate",
"onUninstall",
// Real callbacks wired in code that docs reference (verified present in src/):
// onChunk/onFirstChunk — streaming callbacks (src/shared/utils/streamTracker.ts,
// playground ChatTab.tsx); onServerStatus/onPortChanged/onUpdateStatus — Electron
// IPC callbacks (src/shared/hooks/useElectron.ts, HomePageClient.tsx);
// onEmpty — model-metadata registry callback (src/lib/modelMetadataRegistry.ts).
"onChunk",
"onFirstChunk",
"onServerStatus",
"onPortChanged",
"onUpdateStatus",
"onEmpty",
]);
// Common false-positives the heuristic would otherwise flag. Add to this
// list as the script matures — keep it small and well-justified.
const ENV_VAR_ALLOWLIST = new Set([
"PATH",
"HOME",
"USER",
"SHELL",
"PWD",
"LANG",
"NODE_ENV",
"NODE_PATH",
"NODE_OPTIONS",
"NODE_EXTRA_CA_CERTS", // Node runtime CA var: read by Node itself + passed to a spawned subprocess (cloudflaredTunnel.ts), not via process.env.X (AGENTBRIDGE.md)
"DEBUG",
"VERBOSE",
"LOG_LEVEL",
"PORT", // generic, not OmniRoute-specific
"DATA_DIR",
"REQUIRE_API_KEY",
"OMNIROUTE_BUILD_PROFILE", // build-time only
"OMNIROUTE_BUILD_SHA",
"OMNIROUTE_URL", // used by ad-hoc tooling, validated elsewhere
"OMNIROUTE_KEY", // ditto
"OPENCODE_API_KEY", // ditto
// ── External-tool / spawn-injected / ops env vars ────────────────────────
// Real environment variables, but they belong to an UPSTREAM CLI/tool, a
// docker-compose/electron-build pipeline, or are injected into a spawned
// subprocess — never read via `process.env.X` in OmniRoute's own source, so the
// code-read index can't see them. Documented (correctly) in the relevant guides.
"COPILOT_PROVIDER_BASE_URL", // GitHub Copilot CLI ≥v1.0.19's own env var (AGENTBRIDGE.md)
"OPENAI_BASE_URL", // env var OmniRoute passes to downstream CLIs (AGENT_PROTOCOLS_GUIDE.md)
"NINEROUTER_API_KEY", // injected into the 9router subprocess at spawn (EMBEDDED-SERVICES.md)
"CLAUDE_CODE_MAX_OUTPUT_TOKENS", // Claude Code CLI's own env var (CODEX-CLI-CONFIGURATION.md)
"CODEX_HOME", // Codex CLI's own config-home env var (CODEX-CLI-CONFIGURATION.md)
"OPENAI_API_BASE", // legacy OpenAI base-URL env var some downstream tools (e.g. Aider) read (CLI-INTEGRATIONS.md)
"PROMPTFOO_PROVIDER_KEY", // promptfoo's own provider-key env var, used by the red-team suite (GUARDRAILS.md)
"REDIS_PORT", // docker-compose host-port override (DOCKER_GUIDE.md)
"AUTO_UPDATE_HOST_REPO_DIR", // docker-compose self-update mount (DOCKER_GUIDE.md)
"LINUX_GPG_KEY", // electron AppImage signing key, CI/build only (ELECTRON_GUIDE.md)
"BRANCH_LOCK_TOKEN", // release branch-protection ops token (QUALITY_GATE_PLAYBOOK.md)
"NEXT_LOCALE", // next-intl locale cookie name (I18N.md)
]);
// Common pluralized / column-header all-caps that aren't env vars
const ENV_VAR_DENYLIST = new Set([
"API_DOCS",
"API_REFERENCE",
"API_GUIDE",
"PROVIDERS",
"FREE_TIERS",
"CHANGELOG",
"CONTRIBUTING",
"ARCHITECTURE",
"CODEBASE_DOCUMENTATION",
"REPOSITORY_MAP",
"AUTHZ_GUIDE",
"RESILIENCE_GUIDE",
"COMPRESSION_GUIDE",
"MCP_SERVER",
"MCP_AUDIT",
"MCP_TOOLS",
"MCP_SCOPES",
"BUILTIN_EVENTS",
"LIFECYCLE_HOOKS",
"OBSERVABILITY",
"TELEMETRY",
"TRACING",
"METRICS",
"WEB_COOKIE_PROVIDERS",
"WEB_SEARCH",
"WEB_FETCH",
"WEB_SOCKET",
"WEBSOCKET",
"WEBHOOKS",
"WEBHOOK_EVENTS",
"GUARDRAILS",
"PROVIDER_NODES",
"PROVIDER_NODES_VALIDATE",
"PROVIDER_HEALTH_AUTOPILOT",
"PROVIDER_HEALTH_MATRIX",
"PROVIDER_HEALTH_PROBE",
"PROVIDER_HEALTH_HISTORY",
"PROVIDER_QUOTA_WINDOWS",
"PROVIDER_STATS",
"PROVIDER_MODELS",
"PROVIDER_TYPE",
"PROVIDER_CREDENTIALS",
"PROVIDER_BULK",
"PROVIDER_VALIDATE",
"PROVIDER_TEST_BATCH",
"PROVIDER_TEST_ALL",
"PROVIDER_BULK_WEB_SESSION",
"PROVIDER_EXPIRATION",
"FREE_PROVIDERS",
"FREE_PROXIES",
"PROXY_POOLS",
"ONE_PROXY",
"ONE_PROXY_FETCH",
"ONE_PROXY_STATS",
"ONE_PROXY_ROTATE",
"PROXY_FALLBACK",
"PROXY_HEALTH",
"PROXY_STATS",
"PROXY_MARKETPLACE",
"PROVIDER_REGISTRY",
"PROVIDER_CATALOG",
"PROVIDER_COST",
"PROVIDER_LIMITS",
"PROVIDER_CONFIG",
"PROVIDER_CONNECTION",
"PROVIDER_CONNECTIONS",
"PROVIDER_REFRESH",
"PROVIDER_SYNC_MODELS",
"PROVIDER_TEST",
"PROVIDER_TESTS",
"PROVIDER_FETCH",
"PROVIDER_IMPORT",
"PROVIDER_EXPORT",
"PROVIDER_LIST",
"PROVIDER_ADD",
"PROVIDER_REMOVE",
"PROVIDER_CREATE",
"PROVIDER_UPDATE",
"PROVIDER_DELETE",
"PROVIDER_DISABLE",
"PROVIDER_ENABLE",
"PROVIDER_RESET",
"PROVIDER_RUN",
"PROVIDER_GET",
"PROVIDER_SET",
"PROVIDER_REVOKE",
"MODEL_REGISTRY",
"MODEL_COMBO_MAPPINGS",
"MODEL_ALIASES",
"COMBO_TARGETS",
"COMBO_HEALTH",
"COMBO_DEFAULTS",
"COMBO_FORECAST",
"COMBO_SCORING",
"COMBO_INSPECTOR",
"RATE_LIMITS",
"RATE_LIMIT_CONFIG",
"TASK_FACTORY",
"TASK_MANAGER",
"AGENT_BASE",
"AGENT_BUILDER",
"AGENT_SKILL",
"AGENT_SKILLS",
"AGENT_BRIDGE",
"MENU_ITEM",
"MENU_ITEMS",
"MENU_ICON",
"MENU_ICONS",
"FAVICON",
"FEATURE_FLAG",
"FEATURE_FLAGS",
"VERSION_MANAGER",
"VM_DEPLOY",
"VPS_DEPLOY",
"I18N_CONFIG",
"I18N_LOCALES",
"PROXY_GUIDE",
"OPENAPI_SPEC",
"OPENAPI_GUIDE",
"WAF_RULES",
"WAF_BYPASS",
"WAF_PROTECTION",
"SOCIAL_OAUTH",
"OAUTH_FLOWS",
"OAUTH_TOKENS",
"STORAGE_BACKEND",
"STORAGE_HEALTH",
"DATABASE_SETTINGS",
"TUNNELS",
"TUNNEL_CLOUDFLARED",
"TUNNEL_NGROK",
"TUNNEL_TAILSCALE",
"PRICING_CATALOG",
"PRICING_SYNC",
"PRICING_DEFAULTS",
"USAGE_ANALYTICS",
"USAGE_QUOTA",
"USAGE_BUDGET",
"QUOTA_SNAPSHOT",
"QUOTA_SNAPSHOTS",
"QUOTA_POOL",
"QUOTA_POOLS",
"QUOTA_PLAN",
"QUOTA_PLANS",
"QUOTA_MONITOR",
"QUOTA_MONITORS",
"DOMAIN_BUDGET",
"DOMAIN_BUDGETS",
"DOMAIN_COST",
"DOMAIN_COSTS",
"DOMAIN_FALLBACK",
"DOMAIN_FALLBACKS",
"DOMAIN_LOCKOUT",
"DOMAIN_LOCKOUTS",
"DOMAIN_CIRCUIT",
"DOMAIN_CIRCUITS",
"DOMAIN_RESET",
"DOMAIN_RESETS",
"PROVIDER_HEALTH_AUTOPILOT_ACTIONS",
"PROVIDER_HEALTH_AUTOPILOT_HISTORY",
"PROVIDER_HEALTH_AUTOPILOT_STATS",
"PROVIDER_HEALTH_AUTOPILOT_CONFIG",
"PROVIDER_HEALTH_AUTOPILOT_INTERVAL",
"PROVIDER_HEALTH_AUTOPILOT_TIMEOUT",
"PROVIDER_HEALTH_AUTOPILOT_THRESHOLD",
"PROVIDER_HEALTH_AUTOPILOT_COOLDOWN",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_TIMEOUT",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_THRESHOLD",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_COOLDOWN",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY_BACKOFF",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY_BACKOFF_MAX",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY_BACKOFF_MIN",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY_BACKOFF_BASE",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY_BACKOFF_FACTOR",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY_BACKOFF_JITTER",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY_BACKOFF_THRESHOLD",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY_BACKOFF_LIMIT",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY_BACKOFF_FLOOR",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY_BACKOFF_CEILING",
"PROVIDER_HEALTH_AUTOPILOT_RECOVERY_RETRY_BACKOFF_CAP",
// Gate allowlist constant names (JS identifiers, not env vars) — documented in
// docs/architecture/QUALITY_GATES.md and docs/research/DISCOVERY_TOOL_DESIGN.md
"KNOWN_STALE_DOC_REFS", // export const in check-docs-symbols.mjs
"KNOWN_MISSING", // export const in check-fetch-targets.mjs
"KNOWN_RAW_SQL", // export const in check-db-rules.mjs
"ROUTER_BACKENDS", // typed router-backend registry constant documented in the ADR (ROUTER_BACKENDS.md); code lands with PR #5868 (#5798)
// ── Error / Node codes documented in prose (string-literal codes, not env vars) ──
"URL_GUARD_BLOCKED", // HTTP 422 guard-violation code (ARCHITECTURE.md)
"AUTHZ_NOT_INITIALIZED", // AuthzAssertionError code (AUTHZ_GUIDE.md)
"MODULE_NOT_FOUND", // Node runtime error code watched by service supervisor (ELECTRON_GUIDE.md)
"ERR_DLOPEN_FAILED", // Node native-module load error code (ELECTRON_GUIDE.md)
// ── Code-symbol / naming-convention examples documented in prose ─────────────
"UPPER_SNAKE", // the literal naming-convention token in the style guide (CODEBASE_DOCUMENTATION.md)
"DEFAULT_TIMEOUT", // example constant name in the UPPER_SNAKE convention row (AGENTS.md)
"SIDEBAR_DEFINITIONS", // code constant referenced in prose (MONITORING_SECTIONS.md)
"LOCAL_ONLY", // routeGuard classification label (AGENTBRIDGE.md)
"SPAWN_CAPABLE", // routeGuard classification label (AGENTBRIDGE.md)
"ZEROGRAVITY_SENSITIVE_WORDS", // cross-project constant named in a comparison (STEALTH_GUIDE.md)
]);
/** Endpoints that don't follow the standard route.ts pattern. */
const ENDPOINT_ALLOWLIST = new Set([
"/api/v1/models",
"/api/v1/chat/completions",
"/api/v1/embeddings",
"/api/v1/responses",
"/api/v1/images/generations",
"/api/v1/audio/transcriptions",
"/api/v1/audio/speech",
"/api/v1/videos/generations",
"/api/v1/music/generations",
"/api/v1/moderations",
"/api/v1/rerank",
"/api/v1/search",
"/api/v1/messages",
"/api/v1/agents/tasks",
"/api/v1/agents/tasks/{id}",
"/api/v1/agents/credentials",
"/api/v1/agents/health",
"/.well-known/agent.json",
"/v1/models",
"/v1/chat/completions",
"/v1/embeddings",
"/v1/responses",
"/v1/ws", // WebSocket bridge, not standard route.ts
"/a2a", // JSON-RPC 2.0 entry
"/api/mcp/stream", // Streamable HTTP MCP transport
"/api/mcp/sse", // SSE MCP transport
"/api/health",
// Upstream/external provider endpoints documented in provider guides — these are
// paths on the UPSTREAM service (Claude.ai web, Blackbox), not OmniRoute routes.
"/api/organizations/{orgId}/chat_conversations/{convId}/completion", // claude-web upstream
"/api/chat", // Blackbox Web upstream (validated-token target)
]);
/** Doc files to skip (auto-generated, vendored, or third-party). */
const SKIP_DOC_FILES = new Set([
"docs/reference/PROVIDER_REFERENCE.md", // auto-generated from providers.ts
"docs/openapi.yaml",
"docs/i18n", // translations — separate workflow
// Design / research / plan docs: by definition describe not-yet-built files and
// proposed (not-yet-shipped) endpoints (each carries a `Status: Design`/`Active
// research`/`Plano` header). Same rationale as the audit report above — these are
// forward-looking specs, not living API docs, so their forward references are
// expected, not fabrications.
"docs/research", // DISCOVERY_TOOL_DESIGN.md, UNLIMITED_LLM_ACCESS.md, …
"docs/superpowers/plans", // dated implementation plans (files described before they exist)
"docs/superpowers/specs", // dated research/spec reports (point-in-time findings, may cite proposed/not-yet-built endpoints, env vars, and files) — same rationale as the plans/research dirs above
// Release notes are historical, point-in-time records: they intentionally describe
// modules/paths as they were at that release (e.g. a module later moved or renamed).
// Rewriting them to today's layout would falsify history — out of scope for a
// living-docs accuracy gate.
"docs/releases",
// Forward-looking coverage plan: a `- [ ]` checklist of test targets and helper
// components to be created. Same rationale as the design/plan docs above.
"docs/ops/COVERAGE_PLAN.md",
]);
// ── File discovery ─────────────────────────────────────────────────────────
function walkMarkdown(dir, out = [], root = ROOT) {
const abs = path.join(root, dir);
if (!fs.existsSync(abs)) return out;
const stat = fs.statSync(abs);
if (stat.isFile()) {
if (abs.endsWith(".md") || abs.endsWith(".mdx")) out.push(abs);
return out;
}
for (const name of fs.readdirSync(abs)) {
if (name === "node_modules" || name.startsWith(".")) continue;
const childAbs = path.join(abs, name);
const s = fs.statSync(childAbs);
if (s.isDirectory()) walkMarkdown(path.relative(root, childAbs), out, root);
else if (childAbs.endsWith(".md") || childAbs.endsWith(".mdx")) out.push(childAbs);
}
return out;
}
function allScanFiles(root = ROOT) {
const files = [];
for (const p of SCAN_PATHS) walkMarkdown(p, files, root);
return files.filter((f) => {
const rel = path.relative(root, f);
for (const skip of SKIP_DOC_FILES) {
if (rel === skip || rel.startsWith(skip + path.sep)) return false;
}
return true;
});
}
// ── Codebase index ─────────────────────────────────────────────────────────
// Env var helper wrappers used across OmniRoute — envInt(NAME, 5), envBool(NAME),
// envStr(NAME), … — read the named var from the environment, so a string-literal
// argument is a genuine env-var read, equivalent to a direct process.env member read.
// (Comment avoids a literal `process.env.<NAME>` token so the sibling env-doc-sync
// grep does not mistake this example for a real env-var read.)
const ENV_HELPER_CALL =
/\benv(?:Int|Bool|Str|Num|Float|String|Flag|Raw|List|Json)?\(\s*["'`]([A-Z][A-Z0-9_]+)["'`]/g;
export function buildCodebaseIndex(root = ROOT) {
// Set of /api/... paths that have a route.ts handler.
const apiRoutes = new Set();
// Set of /api/... prefixes that are an ancestor of (or equal to) a real route.ts.
// A documented prefix like /api/cloud/ is valid even without a route.ts at that
// exact level, as long as some src/app/api/cloud/**/route.ts exists.
const apiPrefixes = new Set();
// Map of /api/... → methods implemented in route.ts
const apiMethods = new Map();
function dynToBrace(seg) {
// [id] → {id}, [...path] → {path} — match the doc convention for dynamic segments.
return seg.replace(/^\[\.\.\.(.+)\]$/, "{$1}").replace(/^\[(.+)\]$/, "{$1}");
}
function walkApiRoutes(dir) {
const abs = path.join(root, dir);
if (!fs.existsSync(abs)) return;
for (const name of fs.readdirSync(abs)) {
const child = path.join(abs, name);
const s = fs.statSync(child);
if (s.isDirectory()) walkApiRoutes(path.relative(root, child));
else if (name === "route.ts" || name === "route.mjs") {
// Build the route path from the directory hierarchy
const rel = path.relative(root, child).replace(/\\/g, "/");
const parts = rel.split("/");
// drop "src/app/api" and "route.ts"
parts.shift(); // src
parts.shift(); // app
parts.shift(); // api
parts.pop(); // route.ts
const routePath = "/api/" + parts.join("/");
apiRoutes.add(routePath);
apiRoutes.add(routePath + "/"); // trailing slash variant
// Register every ancestor prefix (and its {brace} dynamic-segment variant)
// so documented prefixes-with-subroutes resolve.
for (let i = 1; i <= parts.length; i++) {
const prefix = "/api/" + parts.slice(0, i).join("/");
const bracePrefix = "/api/" + parts.slice(0, i).map(dynToBrace).join("/");
apiPrefixes.add(prefix);
apiPrefixes.add(bracePrefix);
}
// Read the file to find exported HTTP methods
try {
const content = fs.readFileSync(child, "utf8");
const methods = new Set();
for (const m of ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]) {
const re = new RegExp(`export\\s+(?:async\\s+)?function\\s+${m}\\b`);
if (re.test(content)) methods.add(m);
const re2 = new RegExp(`export\\s+const\\s+${m}\\b`);
if (re2.test(content)) methods.add(m);
}
if (methods.size > 0) apiMethods.set(routePath, methods);
} catch {
/* ignore read errors */
}
}
}
}
walkApiRoutes("src/app/api");
// Set of env var names that are actually read in code, and the set of
// ALL_CAPS code identifiers (export const / enum / object-literal keys). A
// documented `UPPER_SNAKE` that resolves to a code identifier (e.g.
// LOCAL_ONLY_API_PREFIXES, HALF_OPEN) is NOT a fabricated env var.
const envVars = new Set();
const codeIdentifiers = new Set();
function walkForEnv(dir) {
const abs = path.join(root, dir);
if (!fs.existsSync(abs)) return;
const skipDirs = new Set(["node_modules", ".next", "dist", ".build", "coverage"]);
for (const name of fs.readdirSync(abs)) {
if (skipDirs.has(name)) continue;
const child = path.join(abs, name);
const s = fs.statSync(child);
if (s.isDirectory()) walkForEnv(path.relative(root, child));
else if (/\.(ts|tsx|js|mjs|cjs)$/.test(name)) {
try {
const content = fs.readFileSync(child, "utf8");
// process.env.X
for (const m of content.matchAll(/process\.env\.([A-Z][A-Z0-9_]+)/g)) envVars.add(m[1]);
// process.env["X"] / process.env['X'] (bracket notation)
for (const m of content.matchAll(/process\.env\[\s*["'`]([A-Z][A-Z0-9_]+)["'`]\s*\]/g))
envVars.add(m[1]);
// env.X (destructured in some handlers)
for (const m of content.matchAll(/\benv\.([A-Z][A-Z0-9_]+)\b/g)) envVars.add(m[1]);
// env["X"] (bracket on a destructured env binding)
for (const m of content.matchAll(/\benv\[\s*["'`]([A-Z][A-Z0-9_]+)["'`]\s*\]/g))
envVars.add(m[1]);
// envInt("X", …) / envBool("X") / envStr("X") … helper wrappers
for (const m of content.matchAll(ENV_HELPER_CALL)) envVars.add(m[1]);
// import.meta.env.X (Vite-style, unlikely here but cheap)
for (const m of content.matchAll(/import\.meta\.env\.([A-Z][A-Z0-9_]+)/g))
envVars.add(m[1]);
// export const / const / let / var / enum NAME — JS identifiers, not env vars.
for (const m of content.matchAll(
/\b(?:export\s+)?(?:const|let|var|enum)\s+([A-Z][A-Z0-9_]{2,})\b/g
))
codeIdentifiers.add(m[1]);
// Object-literal / enum members on their own line: `HALF_OPEN: "HALF_OPEN"`.
for (const m of content.matchAll(/^[ \t]*([A-Z][A-Z0-9_]{2,})[ \t]*[:=]/gm))
codeIdentifiers.add(m[1]);
} catch {
/* ignore */
}
}
}
}
walkForEnv("src");
walkForEnv("open-sse");
walkForEnv("bin");
walkForEnv("scripts");
// Env vars that are only read by the test harness (e.g. RUN_CHAOS_INT) are still
// real env vars and must not be flagged as fabricated.
walkForEnv("tests");
// Env contract maintained by the sibling gate (check-env-doc-sync.mjs): a var
// listed in .env.example or docs/reference/ENVIRONMENT.md is, by definition, a
// documented OmniRoute env var (including external-CLI / docker / electron vars
// that are not read via process.env in our own source).
function readEnvContract() {
try {
const t = fs.readFileSync(path.join(root, ".env.example"), "utf8");
for (const line of t.split("\n")) {
const m = line.match(/^#?\s*([A-Z][A-Z0-9_]+)\s*=/);
if (m) envVars.add(m[1]);
}
} catch {
/* ignore */
}
try {
const t = fs.readFileSync(path.join(root, "docs", "reference", "ENVIRONMENT.md"), "utf8");
for (const m of t.matchAll(/`([A-Z][A-Z0-9_]{2,})`/g)) envVars.add(m[1]);
} catch {
/* ignore */
}
}
readEnvContract();
// Set of `omniroute <subcommand>` strings that exist in bin/
const cliCommands = new Set();
function walkCli(dir) {
const abs = path.join(root, dir);
if (!fs.existsSync(abs)) return;
for (const name of fs.readdirSync(abs)) {
const child = path.join(abs, name);
const s = fs.statSync(child);
if (s.isDirectory()) walkCli(path.relative(root, child));
else if (/\.(mjs|js|ts)$/.test(name)) {
try {
const content = fs.readFileSync(child, "utf8");
// Programmatic API: `command('foo', ...)`, `.command('bar')`, and
// arg-bearing forms `.command('connect <host>')` / `.command('chat [msg]')`
// — capture the leading subcommand token regardless of trailing args.
const m1 = content.matchAll(/\.command\(\s*['"`]([a-z][a-z0-9-]+)/g);
for (const m of m1) cliCommands.add(m[1]);
// Subcommand names: `${name}Cmd`, `name = "foo"`, etc.
const m2 = content.matchAll(/name:\s*['"`]([a-z][a-z0-9-]+)['"`]/g);
for (const m of m2) cliCommands.add(m[1]);
// `.name('foo')` (commander pattern)
const m3 = content.matchAll(/\.name\(\s*['"`]([a-z][a-z0-9-]+)['"`]\s*\)/g);
for (const m of m3) cliCommands.add(m[1]);
} catch {
/* ignore */
}
}
}
}
walkCli("bin");
return { apiRoutes, apiPrefixes, apiMethods, envVars, codeIdentifiers, cliCommands };
}
// ── Doc scanning ───────────────────────────────────────────────────────────
const COARSE_PATTERNS = {
apiPath: /(?<!\w)\/api\/[A-Za-z0-9_\-\/\[\]\{\}]+(?!\w)/g,
// Catches ALL_CAPS env var names of length >= 3
envVar: /\b([A-Z][A-Z0-9_]{2,})\b/g,
// omniroute <verb> <sub> ... — only on the same line, captures first 2 tokens
cliCmd: /\bomniroute\s+([a-z][a-z0-9-]+)(?:\s+([a-z][a-z0-9-]+))?/g,
// Built-in event names like onRequest, onFoo
hookName: /\b(on[A-Z][a-zA-Z]+)\b/g,
// File references like src/lib/foo.ts, open-sse/handlers/bar.ts, bin/cli/baz.mjs
fileRef:
/\b((?:src|open-sse|bin|scripts|tests|electron)\/[A-Za-z0-9_\-\/\.]+\.(?:ts|tsx|mjs|js|cjs|sh|sql))\b/g,
};
function stripCodeBlocksAndFences(text) {
// Remove fenced code blocks (``` ... ```) but KEEP inline backticks so
// we can still detect `BACKTICKED_LIKE_THIS` env-var/hook/CLI claims.
return text.replace(/```[\s\S]*?```/g, "");
}
function lineOf(text, idx) {
let line = 1;
for (let i = 0; i < idx && i < text.length; i++) if (text[i] === "\n") line++;
return line;
}
export function scanDocFile(absPath, index, root = ROOT) {
const rel = path.relative(root, absPath);
const text = fs.readFileSync(absPath, "utf8");
const textNoCode = stripCodeBlocksAndFences(text);
const findings = [];
// 1) API endpoints
for (const m of textNoCode.matchAll(COARSE_PATTERNS.apiPath)) {
// Normalize wildcard segments to {brace} form so [id] / [...path] / {id} all
// compare equally against the indexed routes/prefixes.
const normalized = m[0].replace(/\[\.\.\.(.+?)\]/g, "{$1}").replace(/\[(.+?)\]/g, "{$1}");
const candidate = normalized.replace(/\/$/, "");
const stripped = m[0].replace(/[\[\]\{\}]/g, "").replace(/\/$/, ""); // legacy lookup form
if (
ENDPOINT_ALLOWLIST.has(candidate) ||
ENDPOINT_ALLOWLIST.has(candidate + "/") ||
ENDPOINT_ALLOWLIST.has(stripped) ||
ENDPOINT_ALLOWLIST.has(stripped + "/")
)
continue;
if (
index.apiRoutes.has(stripped) ||
index.apiRoutes.has(stripped + "/") ||
index.apiRoutes.has(candidate) ||
index.apiRoutes.has(candidate + "/")
)
continue;
// Prefix-with-subroutes: a documented prefix like /api/cloud/ or /api/services/{name}/
// is valid when some src/app/api/<prefix>/**/route.ts exists. Accept when the
// documented path is (or is an ancestor of) a real route prefix, or vice-versa.
let isPrefix = false;
for (const pre of index.apiPrefixes) {
if (pre === candidate || pre.startsWith(candidate + "/") || candidate.startsWith(pre + "/")) {
isPrefix = true;
break;
}
}
if (isPrefix) continue;
// Allow docs that describe intended-but-not-yet-shipped routes by skipping lines that say "planned" / "TBD" / "future"
const ln = lineOf(text, m.index);
const lineText = text.split("\n")[ln - 1] || "";
if (/\b(planned|tbd|future|coming|proposed|not yet|will be)\b/i.test(lineText)) continue;
findings.push({
kind: "api-path",
value: m[0],
line: ln,
msg: `endpoint ${m[0]} not found in src/app/api/`,
});
}
// 2) Env vars — only flag names wrapped in backticks AND containing an
// underscore. The maintainer's actual fabricated env vars (PR #3456)
// were always in `BACKTICKS` inside tables; bare all-caps tokens
// inside markdown link display text are doc references, not env vars.
// Example of TRUE positive: | `ACP_MAX_CONCURRENT_SESSIONS` | 5 | ... |
// Example of false positive: | [STEALTH_GUIDE](security/...) |
for (const m of textNoCode.matchAll(/`([A-Z][A-Z0-9_]{4,})`/g)) {
const name = m[1];
if (ENV_VAR_ALLOWLIST.has(name)) continue;
if (!/_/.test(name)) continue; // real env vars have an underscore
if (index.envVars.has(name)) continue;
// Documented ALL_CAPS that resolves to a JS identifier (export const / enum /
// object-literal key) is a code symbol, not a fabricated env var.
// E.g. LOCAL_ONLY_API_PREFIXES, HALF_OPEN, A2A_SKILL_HANDLERS, MCP_SCOPE_PRESETS.
if (index.codeIdentifiers.has(name)) continue;
if (/^X-[A-Z]/.test(name)) continue;
if (ENV_VAR_DENYLIST.has(name)) continue;
const ln = lineOf(text, m.index);
// Use the line as seen in the stripped text (where m.index is valid) for context
// checks — fenced-code removal shifts offsets, so text.split()[ln-1] can be wrong.
const lineStart = textNoCode.lastIndexOf("\n", m.index) + 1;
let lineEnd = textNoCode.indexOf("\n", m.index);
if (lineEnd === -1) lineEnd = textNoCode.length;
const lineText = textNoCode.slice(lineStart, lineEnd);
if (/example|placeholder|todo|tbd|\.\.\./i.test(lineText)) continue;
// A doc that explicitly states a var does NOT exist / is not implemented is
// documenting its absence, not fabricating it. Examples:
// "`MEMORY_RRF_VECTOR_WEIGHT` … do not exist"
// "a `ZED_CONFIG_PATH` environment variable override is not yet implemented"
if (
/\b(?:do(?:es)?\s+not\s+exist|no\s+such|not\s+a\s+real|isn't\s+a\s+real|never\s+(?:read|exists?)|not\s+(?:yet\s+)?(?:implemented|supported))\b/i.test(
lineText
)
)
continue;
findings.push({
kind: "env-var",
value: name,
line: ln,
msg: `env var \`${name}\` is never read via process.env / env / import.meta.env`,
});
}
// 3) CLI commands: `omniroute foo bar` — only flag when the line is in
// a code-like context (inside backticks or a shell block). Bare prose
// like "we use omniroute and..." is not a command claim.
for (const m of textNoCode.matchAll(COARSE_PATTERNS.cliCmd)) {
const sub = m[1];
if (index.cliCommands.has(sub)) continue;
if (["help", "--help", "-h", "version", "--version", "doctor", "setup", "chat"].includes(sub))
continue;
const ln = lineOf(text, m.index);
const lineText = text.split("\n")[ln - 1] || "";
// Only flag when on a line that looks like a shell command (starts with $, or
// inside a shell block, or wrapped in `code`)
const isShellLike = /^[ \t]*\$\s|^```sh|^```bash|^```shell|`omniroute/.test(lineText);
if (!isShellLike) continue;
if (/example|placeholder|tbd/i.test(lineText)) continue;
findings.push({
kind: "cli-cmd",
value: `omniroute ${sub}`,
line: ln,
msg: `omniroute subcommand '${sub}' not registered in bin/`,
});
}
// 4) Hook names — only flag when wrapped in backticks/code, since bare
// "onFoo" prose is common English.
for (const m of textNoCode.matchAll(/`?(on[A-Z][a-zA-Z]+)`?/g)) {
const name = m[1];
if (KNOWN_HOOKS.has(name)) continue;
// Require backticks to reduce noise (text mentions are usually casual)
if (!m[0].startsWith("`")) continue;
const ln = lineOf(text, m.index);
const lineText = text.split("\n")[ln - 1] || "";
if (/example|placeholder|tbd/i.test(lineText)) continue;
findings.push({
kind: "hook",
value: name,
line: ln,
msg: `hook ${name} not in BUILTIN_EVENTS (hooks.ts) — is this a real hook?`,
});
}
// 5) File references
for (const m of textNoCode.matchAll(COARSE_PATTERNS.fileRef)) {
const ref = m[1].replace(/\\/g, "/");
const abs = path.join(root, ref);
if (fs.existsSync(abs)) continue;
// Allow README/AGENTS to mention example files explicitly in a non-verified way
if (/\{\{|\.\.\./.test(ref)) continue; // templated / placeholder
// Tutorial placeholders in the "how to add a …" scenarios are intentional
// stand-ins, not real files: src/app/api/your-route/route.ts,
// src/lib/db/yourModule.ts, src/lib/guardrails/myGuardrail.ts, etc.
if (/(?:^|\/)(?:your-|your[A-Z]|my[A-Z])/.test(ref)) continue;
// Skip matches that are only the tail of a longer path, not a repo-root ref:
// the fileRef regex anchors on the `src`/`open-sse`/… token, so a leading `/`
// means the real reference is `<something>/src/...` — either a relative example
// path (`./src/index.ts`, a PII-pattern sample) or a workspace-package path
// (`@omniroute/opencode-provider/src/index.ts`). Neither resolves from repo root.
if (m.index > 0 && textNoCode[m.index - 1] === "/") continue;
const ln = lineOf(text, m.index);
findings.push({
kind: "file-ref",
value: ref,
line: ln,
msg: `file ${ref} does not exist`,
});
}
return { rel, findings };
}
// ── Main ───────────────────────────────────────────────────────────────────
export function runFabricatedDocsCheck(opts = {}) {
// `root` lets tests run the full pipeline against a fixture tree (with its own
// docs/, src/, .env.example) instead of the live repo.
const root = opts.root ?? ROOT;
const index = opts.index ?? buildCodebaseIndex(root);
const files = allScanFiles(root);
const allFindings = [];
for (const f of files) {
const result = scanDocFile(f, index, root);
if (result.findings.length > 0) {
allFindings.push(result);
}
}
const totalFindings = allFindings.reduce((acc, r) => acc + r.findings.length, 0);
return { totalFindings, files: allFindings, fileCount: files.length, index };
}
export function formatHumanReport(result) {
const { totalFindings, files, fileCount, index } = result;
const lines = [];
lines.push("Doc accuracy gate — fabricated-claim detection");
lines.push("================================================");
lines.push(`Scanned ${fileCount} markdown file(s)`);
lines.push(
`Codebase: ${index.apiRoutes.size} api routes · ${index.envVars.size} env vars · ${index.cliCommands.size} cli commands`
);
lines.push("");
if (totalFindings === 0) {
lines.push("✓ No fabricated API/env/CLI/hook/file references found.");
return lines.join("\n");
}
// Dedupe identical findings across files (report once, with file list)
const deduped = new Map();
for (const r of files) {
for (const f of r.findings) {
const key = `${f.kind}::${f.value}::${f.msg}`;
if (!deduped.has(key)) deduped.set(key, { ...f, files: new Set() });
deduped.get(key).files.add(r.rel);
}
}
const groups = { "api-path": [], "env-var": [], "cli-cmd": [], hook: [], "file-ref": [] };
for (const f of deduped.values()) groups[f.kind].push(f);
const KIND_LABELS = {
"api-path": "API endpoint paths not in src/app/api/",
"env-var": "Env vars never read in code",
"cli-cmd": "omniroute subcommands not registered",
hook: "Hook names not in BUILTIN_EVENTS",
"file-ref": "File references that don't exist",
};
for (const [kind, items] of Object.entries(groups)) {
if (items.length === 0) continue;
lines.push(`\n## ${KIND_LABELS[kind]} (${items.length})`);
for (const f of items.slice(0, 20)) {
const fileList = [...f.files]
.slice(0, 3)
.map((r) => `${r}:${f.line}`)
.join(", ");
const more = f.files.size > 3 ? ` (+${f.files.size - 3} more)` : "";
lines.push(`${f.value.padEnd(40)} ${f.msg}`);
lines.push(` ${fileList}${more}`);
}
if (items.length > 20) lines.push(` ... and ${items.length - 20} more`);
}
return lines.join("\n");
}
// CLI entry — only run when invoked directly (not when imported for tests).
const isMain = import.meta.url === `file://${process.argv[1]}`;
if (isMain) {
main();
}
function main() {
const result = runFabricatedDocsCheck();
const { totalFindings, files } = result;
if (JSON_OUT) {
console.log(
JSON.stringify(
{
totalFindings,
files: files.length,
results: files,
},
null,
2
)
);
if (STRICT && totalFindings > 0) process.exit(1);
process.exit(0);
}
console.log(formatHumanReport(result));
console.log();
if (totalFindings === 0) {
// Clean run — pass regardless of mode.
process.exit(0);
}
if (STRICT) {
console.error(`${totalFindings} claim(s) drift from source. Failing (--strict).`);
process.exit(1);
} else {
console.warn(`${totalFindings} claim(s) drift from source. Re-run with --strict to fail.`);
process.exit(0);
}
}
+300
View File
@@ -0,0 +1,300 @@
#!/usr/bin/env node
// scripts/check/check-fetch-targets.mjs v2
// Gate anti-alucinação: todo fetch("/api/...") em src/ (client-side) deve
// resolver para um route.ts real em src/app/api/. Mata rotas inventadas.
//
// Três subchecks (6A.7):
// 1. Paths estáticos literais: fetch("/api/foo") → rota deve existir
// 2. Prefixo de template literal: fetch(`/api/x/${id}`) → prefixo estático deve ter
// ao menos uma rota filha/irmã
// 3. Método HTTP literal: fetch("/api/foo", { method: "POST" }) → route.ts
// deve exportar POST (inclui re-exports)
//
// Escopo (v2): todo src/**/*.{ts,tsx} client-side
// Excluídos: src/app/api/**, src/lib/db/**, *.test.ts, *.spec.ts, node_modules
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { assertNoStale } from "./lib/allowlist.mjs";
const cwd = process.cwd();
const SRC = path.join(cwd, "src");
const API = path.join(cwd, "src/app/api");
// Paths que o checker não resolve estaticamente (allowlist com justificativa):
// - /api/v1/* é a superfície OpenAI-compat (proxy), não rotas internas do dashboard.
const IGNORE = [
/^\/api\/v1\//, // superfície OpenAI-compat
];
// Mismatches src/**→rota PRÉ-EXISTENTES ou não-resolvíveis estaticamente.
// Congelados para a catraca ficar verde e bloquear qualquer nova rota inventada.
// NÃO adicione novos sem justificativa — esse é o ponto do gate.
//
// Format for stale-enforcement: entries must match the string produced by
// the checkers below (i.e. the raw apiPath or prefix string, not the file+arrow).
const KNOWN_MISSING = new Set([
// src/lib/evals/evalRunner.ts → /api/data (server-side eval runner calling a
// local data endpoint that is not a Next.js route; needs a real route or fix)
"/api/data",
// src/app/(dashboard)/…/AgentBridgePageClient.tsx calls bypass with PUT but
// the route only exports GET/POST/DELETE — real method miss, tracked for fix.
"/api/tools/agent-bridge/bypass::PUT",
]);
// ─── filesystem helpers ───────────────────────────────────────────────────────
function walk(dir, acc = []) {
if (!fs.existsSync(dir)) return acc;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p, acc);
else if (/\.(ts|tsx)$/.test(e.name)) acc.push(p);
}
return acc;
}
/** Is this file excluded from client-side scanning? */
function isExcluded(filePath) {
const rel = path.relative(cwd, filePath).replace(/\\/g, "/");
return (
rel.startsWith("src/app/api/") ||
rel.includes("node_modules") ||
rel.includes(".next") ||
rel.startsWith("src/lib/db/") ||
/\.test\.(ts|tsx)$/.test(rel) ||
/\.spec\.(ts|tsx)$/.test(rel)
);
}
function collectRouteFiles() {
return new Set(
walk(API)
.filter((p) => /route\.tsx?$/.test(p))
.map((p) => path.relative(cwd, p).replace(/\\/g, "/"))
);
}
// ─── route resolution helpers (exported for tests) ───────────────────────────
/**
* Resolves an API path to the most specific matching route file.
* Prefers static routes over dynamic [param] routes when both match.
*
* @param {string} apiPath - e.g. "/api/combos/test"
* @param {Set<string>} routeFiles - relative paths like "src/app/api/…/route.ts"
* @returns {string | null} matched route file path, or null
*/
export function resolveApiPathToRouteFile(apiPath, routeFiles) {
const segs = apiPath
.replace(/^\//, "")
.replace(/[?#].*$/, "")
.split("/");
let staticMatch = null;
let dynamicMatch = null;
for (const rf of routeFiles) {
const rsegs = rf
.replace(/^src\/app\//, "")
.replace(/\/route\.tsx?$/, "")
.split("/");
if (rsegs.length !== segs.length) continue;
const isDynamic = rsegs.some((rs) => /^\[.*\]$/.test(rs));
const ok = rsegs.every((rs, i) => rs === segs[i] || /^\[.*\]$/.test(rs));
if (ok) {
if (!isDynamic) staticMatch = rf;
else if (!dynamicMatch) dynamicMatch = rf;
}
}
return staticMatch || dynamicMatch;
}
/**
* Returns true if the API path resolves to any known route file.
* Exported for backward compatibility with existing tests.
*/
export function resolveApiPathToRoute(apiPath, routeFiles) {
return resolveApiPathToRouteFile(apiPath, routeFiles) !== null;
}
/**
* Prefix-match for template literals: checks whether any route exists whose
* path starts with the static prefix (same depth or deeper).
*
* @param {string} prefix - static prefix extracted from a template literal,
* e.g. "/api/providers/" or "/api/usage/analytics?since="
* @param {Set<string>} routeFiles
* @returns {boolean}
*/
export function resolveApiPrefixToRoute(prefix, routeFiles) {
// Strip query params and trailing slash from the prefix
const cleanPrefix = prefix.replace(/[?#].*$/, "").replace(/\/$/, "");
const prefixSegs = cleanPrefix.replace(/^\//, "").split("/");
for (const rf of routeFiles) {
const rsegs = rf
.replace(/^src\/app\//, "")
.replace(/\/route\.tsx?$/, "")
.split("/");
// Route must be at least as deep as the prefix
if (rsegs.length < prefixSegs.length) continue;
const ok = prefixSegs.every((ps, i) => ps === rsegs[i] || /^\[.*\]$/.test(rsegs[i]));
if (ok) return true;
}
return false;
}
/**
* Checks whether a route file's source exports the given HTTP method.
* Handles:
* - `export async function POST(…)`
* - `export const DELETE = …`
* - `export { GET, PUT } from "…"` (re-exports)
*
* @param {string} routeSource - the CONTENT of the route file (string)
* @param {string} method - uppercase HTTP method, e.g. "POST"
* @returns {boolean}
*/
export function routeExportsMethod(routeSource, method) {
// Direct export: `export [async] function METHOD` or `export const METHOD`
const directRe = new RegExp(
`export\\s+(?:async\\s+)?(?:function|const)\\s+${method}\\b`
);
if (directRe.test(routeSource)) return true;
// Re-export: `export { GET, PUT } from "…"`
const reExportRe = /export\s*\{([^}]+)\}\s*from/g;
let m;
while ((m = reExportRe.exec(routeSource))) {
const names = m[1]
.split(",")
.map((s) => s.trim().split(/\s+as\s+/)[0].trim());
if (names.includes(method)) return true;
}
return false;
}
// ─── extraction helpers ───────────────────────────────────────────────────────
/**
* Extracts static /api/ paths from fetch/fetchJson/apiFetch calls.
* Returns only full static literals (no template expressions).
*/
function extractStaticFetchPaths(content) {
// Matches: fetch("/api/foo"), fetch('/api/foo'), fetch(`/api/foo`) (no ${ })
// The negative lookahead (?!.*\$\{) is applied on the matched string itself
const re = /(?:fetch|fetchJson|apiFetch)\(\s*["'`](\/api\/[A-Za-z0-9_\-/[\]]+)["'`]/g;
const out = [];
let m;
while ((m = re.exec(content))) out.push(m[1]);
return out;
}
/**
* Extracts static prefixes from template-literal fetch calls that contain
* at least one dynamic expression (${…}).
*/
function extractTemplateFetchPrefixes(content) {
const re = /(?:fetch|fetchJson|apiFetch)\(\s*`(\/api\/[^`]*)`/g;
const out = [];
let m;
while ((m = re.exec(content))) {
const full = m[1];
const dynIdx = full.indexOf("${");
if (dynIdx !== -1) {
out.push(full.substring(0, dynIdx));
}
}
return out;
}
/**
* Extracts static fetch paths together with the HTTP method literal, when
* present in the options object (2nd argument of fetch).
* Returns { apiPath, method } pairs where method defaults to "GET".
*/
function extractStaticFetchPathsWithMethod(content) {
// Match static path + optional second argument block (up to 500 chars)
const re =
/(?:fetch|fetchJson|apiFetch)\(\s*["'](\/api\/[A-Za-z0-9_\-/[\]]+)["']\s*(?:,\s*(\{[^)]{0,500}))?/g;
const out = [];
let m;
while ((m = re.exec(content))) {
const apiPath = m[1];
const optStr = m[2] || "";
const methodMatch = /method\s*:\s*["']([A-Z]+)["']/.exec(optStr);
const method = methodMatch ? methodMatch[1] : "GET";
out.push({ apiPath, method });
}
return out;
}
// ─── main ─────────────────────────────────────────────────────────────────────
function main() {
const routeFiles = collectRouteFiles();
const liveMissesStatic = new Set();
const liveMissesMethod = new Set();
for (const f of walk(SRC)) {
if (isExcluded(f)) continue;
const content = fs.readFileSync(f, "utf8");
// Subcheck 1: static paths
for (const apiPath of extractStaticFetchPaths(content)) {
if (IGNORE.some((rx) => rx.test(apiPath))) continue;
if (KNOWN_MISSING.has(apiPath)) {
liveMissesStatic.add(apiPath); // record as live so stale-check works
continue;
}
if (!resolveApiPathToRoute(apiPath, routeFiles)) {
console.error(
`[check-fetch-targets] ✗ rota inexistente: ${path.relative(cwd, f)}${apiPath}`
);
process.exitCode = 1;
liveMissesStatic.add(apiPath);
}
}
// Subcheck 2: template literal prefixes
for (const prefix of extractTemplateFetchPrefixes(content)) {
if (IGNORE.some((rx) => rx.test(prefix))) continue;
if (!resolveApiPrefixToRoute(prefix, routeFiles)) {
console.error(
`[check-fetch-targets] ✗ prefixo de template inexistente: ${path.relative(cwd, f)} → "${prefix}"`
);
process.exitCode = 1;
}
}
// Subcheck 3: HTTP method on static paths
for (const { apiPath, method } of extractStaticFetchPathsWithMethod(content)) {
if (method === "GET") continue;
if (IGNORE.some((rx) => rx.test(apiPath))) continue;
const routeFile = resolveApiPathToRouteFile(apiPath, routeFiles);
if (!routeFile) continue; // Already caught by subcheck 1
const key = `${apiPath}::${method}`;
if (KNOWN_MISSING.has(key)) {
liveMissesMethod.add(key); // record as live for stale-check
continue;
}
const routeSource = fs.readFileSync(path.join(cwd, routeFile), "utf8");
if (!routeExportsMethod(routeSource, method)) {
console.error(
`[check-fetch-targets] ✗ método ${method} não exportado: ${path.relative(cwd, f)}${apiPath} (em ${routeFile})`
);
process.exitCode = 1;
liveMissesMethod.add(key);
}
}
}
// Stale-enforcement: any entry in KNOWN_MISSING that was NOT seen as a live
// violation means the problem was fixed — the entry must be removed to lock
// in the improvement (6A.3 pattern).
const allLive = new Set([...liveMissesStatic, ...liveMissesMethod]);
assertNoStale([...KNOWN_MISSING], allLive, "fetch-targets");
if (!process.exitCode) {
console.log(`[check-fetch-targets] OK (${routeFiles.size} rotas conhecidas)`);
}
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env node
// scripts/check/check-file-size.mjs
// Catraca de tamanho de arquivo (mata o god-component). Modelado no
// check-t11-any-budget.mjs: um baseline congelado por arquivo (file-size-baseline.json).
// - arquivo congelado: só pode ENCOLHER (nunca crescer);
// - arquivo NOVO (fora do baseline): não pode passar do CAP.
// Assim o próximo arquivo de 12.760 linhas é impossível, e os 91 atuais só melhoram.
// --update ratcheta o baseline para baixo (encolhimentos + remove quem caiu < cap).
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
function getArg(name, fallback) {
const i = process.argv.indexOf(name);
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
}
const BASELINE_PATH = path.resolve(
getArg("--baseline", path.join(ROOT, "config/quality/file-size-baseline.json"))
);
const UPDATE = process.argv.includes("--update");
const SCAN_DIRS = ["src", "open-sse", "electron", "bin"];
// Test files live under tests/ plus co-located *.test.ts(x) inside the source dirs.
const TEST_SCAN_DIRS = ["tests", ...SCAN_DIRS];
// Directories to skip when walking — build artifacts and installed packages.
const SKIP_DIRS = new Set(["node_modules", "dist-electron", ".next", ".build", "dist", "coverage"]);
/**
* Avalia LOC atuais contra o baseline congelado.
* @returns {{violations: string[], improvements: [string, number][]}}
*/
export function evaluateFileSizes(currentLocByFile, frozen, cap) {
const violations = [];
const improvements = [];
for (const [file, loc] of Object.entries(currentLocByFile)) {
if (file in frozen) {
if (loc > frozen[file])
violations.push(`${file}: ${loc} > congelado ${frozen[file]} (não pode crescer)`);
else if (loc < frozen[file]) improvements.push([file, loc]);
} else if (loc > cap) {
violations.push(`${file}: ${loc} > cap ${cap} (arquivo novo acima do limite)`);
}
}
return { violations, improvements };
}
function countLines(file) {
return fs.readFileSync(file, "utf8").split("\n").length;
}
function walk(dir, acc = []) {
if (!fs.existsSync(dir)) return acc;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
if (!SKIP_DIRS.has(e.name)) walk(p, acc);
} else if (
/\.(ts|tsx)$/.test(e.name) &&
!/\.test\.tsx?$/.test(e.name) &&
!/\.d\.ts$/.test(e.name)
) {
acc.push(p);
}
}
return acc;
}
function collectLoc() {
const out = {};
for (const d of SCAN_DIRS)
for (const f of walk(path.join(ROOT, d)))
out[path.relative(ROOT, f).replace(/\\/g, "/")] = countLines(f);
return out;
}
// Walk for TEST files: collects *.test.ts / *.test.tsx (the inverse of walk(),
// which deliberately excludes them). Skips .d.ts and the same SKIP_DIRS.
function walkTests(dir, acc = []) {
if (!fs.existsSync(dir)) return acc;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
if (!SKIP_DIRS.has(e.name)) walkTests(p, acc);
} else if (/\.test\.tsx?$/.test(e.name) && !/\.d\.ts$/.test(e.name)) {
acc.push(p);
}
}
return acc;
}
function collectTestLoc() {
const out = {};
for (const d of TEST_SCAN_DIRS)
for (const f of walkTests(path.join(ROOT, d)))
out[path.relative(ROOT, f).replace(/\\/g, "/")] = countLines(f);
return out;
}
function main() {
if (!fs.existsSync(BASELINE_PATH)) {
console.error(`[file-size] FAIL — ${path.basename(BASELINE_PATH)} ausente.`);
process.exit(2);
}
const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
const cap = baseline.cap;
const frozen = baseline.frozen || {};
const current = collectLoc();
const { violations, improvements } = evaluateFileSizes(current, frozen, cap);
// Test-file gate (Layer 1 anti-reinflation): same shrink-only + new-≤cap semantics,
// reusing evaluateFileSizes against the testFrozen baseline + testCap.
const testCap = baseline.testCap;
const testFrozen = baseline.testFrozen || {};
const currentTests = collectTestLoc();
const { violations: testViolations, improvements: testImprovements } =
typeof testCap === "number"
? evaluateFileSizes(currentTests, testFrozen, testCap)
: { violations: [], improvements: [] };
if (UPDATE) {
let changed = false;
if (violations.length === 0 && improvements.length) {
for (const [file, loc] of improvements) {
if (loc <= cap)
delete frozen[file]; // caiu para dentro do cap → sai do baseline
else frozen[file] = loc; // continua grande mas encolheu → trava no novo valor
}
baseline.frozen = Object.fromEntries(Object.entries(frozen).sort());
changed = true;
console.log(`[file-size] baseline ratcheado: ${improvements.length} arquivo(s) encolheram`);
}
if (typeof testCap === "number" && testViolations.length === 0 && testImprovements.length) {
for (const [file, loc] of testImprovements) {
if (loc <= testCap)
delete testFrozen[file]; // caiu para dentro do testCap → sai do baseline
else testFrozen[file] = loc; // continua grande mas encolheu → trava no novo valor
}
baseline.testFrozen = Object.fromEntries(Object.entries(testFrozen).sort());
changed = true;
console.log(
`[test-file-size] baseline ratcheado: ${testImprovements.length} arquivo(s) de teste encolheram`
);
}
if (changed) fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + "\n");
}
let failed = false;
if (violations.length) {
console.error(
`[file-size] ${violations.length} violação(ões):\n` +
violations.map((v) => " ✗ " + v).join("\n") +
`\n → modularize/extraia (DRY) para encolher, ou (último caso) ajuste file-size-baseline.json com justificativa.`
);
failed = true;
} else {
console.log(
`[file-size] OK — ${Object.keys(frozen).length} arquivos congelados, cap ${cap} para novos (${Object.keys(current).length} arquivos verificados)`
);
}
if (typeof testCap === "number") {
if (testViolations.length) {
console.error(
`[test-file-size] ${testViolations.length} test file violation(s) (testCap ${testCap}):\n` +
testViolations.map((v) => " ✗ " + v).join("\n") +
`\n → split the test file (extract helpers/sub-suites) to shrink it, or (last resort) adjust testFrozen in file-size-baseline.json with justification.`
);
failed = true;
} else {
console.log(
`[test-file-size] OK — ${Object.keys(testFrozen).length} test files congelados, testCap ${testCap} para novos (${Object.keys(currentTests).length} test files verificados)`
);
}
}
if (failed) process.exit(1);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+707
View File
@@ -0,0 +1,707 @@
#!/usr/bin/env node
// scripts/check/check-known-symbols.ts
// Gate anti-alucinação: known-symbol allow-lists. Mata o padrão "símbolo inventado
// que silenciosamente vira no-op" em seis superfícies de despacho por-string/por-chave:
//
// (1) EXECUTOR CONFORMANCE — toda entrada registrada no mapa de executores
// (open-sse/executors/index.ts) DEVE resolver, via getExecutor(), para uma
// instância de BaseExecutor que expõe execute() + getProvider(). Um alias que
// não resolve para um executor válido é um símbolo morto (roteia para fallback
// silencioso em vez de falhar).
//
// (2) COMBO STRATEGIES — a cadeia de despacho `strategy === "..."` em
// open-sse/services/combo.ts DEVE tratar exatamente o conjunto canônico de
// ROUTING_STRATEGY_VALUES (src/shared/constants/routingStrategies.ts), exceto
// as estratégias-default implícitas documentadas em IMPLICIT_DEFAULT_STRATEGIES
// (estratégias canônicas sem NENHUMA referência `strategy === "..."`; caem no
// ordenamento padrão). Adicionar um valor canônico sem fiá-lo no despacho, ou
// fiar uma string de estratégia que não é canônica (inventada), falha aqui.
//
// (3) TRANSLATOR PAIRS — os pares from:to registrados em runtime no registry de
// tradutores (após bootstrap) são congelados em KNOWN_TRANSLATOR_PAIRS. Catraca:
// se um par registrado some, falha (regressão de cobertura de formato). Pares
// novos não falham — apenas são reportados — para não bloquear adições legítimas.
//
// (4) MCP TOOLS — todos os tools registrados em createMcpServer() (base MCP_TOOLS +
// memoryTools + skillTools + gamificationTools + pluginTools + notionTools +
// obsidianTools) DEVEM ter ao menos um escopo atribuído (scope-enforcement). Os
// nomes são congelados em KNOWN_MCP_TOOL_NAMES. Catraca: tool removido = fail.
// Tool novo = report (não bloqueia adições legítimas).
//
// (5) A2A SKILLS — chaves de A2A_SKILL_HANDLERS (src/lib/a2a/taskExecution.ts) DEVEM
// bater bidirecionalmente com skills[].id expostos no Agent Card
// (src/app/.well-known/agent.json/route.ts). Divergência em qualquer direção = fail.
//
// (6) CLOUD AGENTS — entradas de AGENTS em src/lib/cloudAgent/registry.ts DEVEM bater
// bidirecionalmente com os arquivos de classe em src/lib/cloudAgent/agents/
// (basename sem extensão). Divergência = fail.
//
// Catraca: cada divergência pré-existente fica numa allowlist documentada e sai 0 hoje.
// Padrão herdado de scripts/check/check-provider-consistency.ts (gate .ts via
// `node --import tsx` que IMPORTA módulos reais + funções puras + main() guardado).
//
// Stale-enforcement (6A.3): a ÚNICA allowlist de SUPRESSÃO deste gate é
// IMPLICIT_DEFAULT_STRATEGIES — cada entrada suprime uma violação `canonicalNotHandled`
// (estratégia canônica sem branch de despacho). Uma entrada que não suprime mais
// nenhuma violação real (porque a estratégia ganhou um branch `strategy === "..."`)
// é obsoleta → o gate falha com instrução de remoção, fechando o furo de regressão
// silenciosa. As demais listas (KNOWN_TRANSLATOR_PAIRS, KNOWN_MCP_TOOL_NAMES) NÃO são
// allowlists de supressão e sim snapshots-catraca (falham na REMOÇÃO, não na presença):
// uma entrada nelas exige que o par/tool continue VIVO no registry — o oposto de
// supressão — então a semântica de stale-enforcement não se aplica a elas.
import { readFileSync, readdirSync } from "node:fs";
import { fileURLToPath, pathToFileURL } from "node:url";
import { dirname, resolve as resolvePath, basename, extname } from "node:path";
import { assertNoStale } from "./lib/allowlist.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolvePath(HERE, "..", "..");
// ───────────────────────────────────────────────────────────────────────────
// (2) COMBO STRATEGIES — fonte canônica + defaults implícitos
// ───────────────────────────────────────────────────────────────────────────
/**
* Estratégias canônicas que NÃO têm um branch `strategy === "..."` na cadeia de
* despacho porque são o comportamento padrão (sem reordenamento explícito). Cada
* uma documentada. Adicionar aqui se uma estratégia canônica não tiver NENHUMA
* referência `strategy === "..."` em combo.ts (do contrário extractHandledStrategies
* já a considera tratada e a entrada vira obsoleta — stale-enforcement abaixo falha).
*
* Atualmente vazio: a entrada `priority` foi removida porque combo.ts passou a
* referenciar `strategy === "priority"` (pre-screen de latência em resolveComboTargets),
* o que torna `priority` já-tratada por extractHandledStrategies — a supressão não
* suprimia mais nenhuma violação `canonicalNotHandled` (era stale). Se o pre-screen
* for removido no futuro, `priority` reaparecerá como `canonicalNotHandled` e o gate
* pedirá para refiá-la no despacho OU redocumentá-la aqui.
*/
export const IMPLICIT_DEFAULT_STRATEGIES: Record<string, string> = {};
/** Extrai todas as strings literais de `strategy === "..."` da fonte do combo. */
export function extractHandledStrategies(comboSource: string): Set<string> {
const handled = new Set<string>();
const re = /strategy\s*===\s*"([a-z0-9-]+)"/g;
let match: RegExpExecArray | null;
while ((match = re.exec(comboSource)) !== null) {
handled.add(match[1]);
}
return handled;
}
export type StrategyMismatch = {
canonicalNotHandled: string[];
handledNotCanonical: string[];
};
/**
* Compara o conjunto canônico (ROUTING_STRATEGY_VALUES) com o conjunto efetivamente
* tratado (branches do despacho defaults implícitos).
* - canonicalNotHandled: estratégia canônica adicionada sem fiação no despacho.
* - handledNotCanonical: branch de despacho para uma string não-canônica (inventada).
*/
export function diffComboStrategies(
canonical: readonly string[],
handled: Set<string>,
implicitDefaults: Record<string, string>
): StrategyMismatch {
const canonicalSet = new Set(canonical);
const effectivelyHandled = new Set<string>(handled);
for (const id of Object.keys(implicitDefaults)) effectivelyHandled.add(id);
const canonicalNotHandled = [...canonicalSet].filter((s) => !effectivelyHandled.has(s));
// Strings tratadas que não são canônicas NEM defaults implícitos = inventadas.
const handledNotCanonical = [...handled].filter(
(s) => !canonicalSet.has(s) && !(s in implicitDefaults)
);
return { canonicalNotHandled, handledNotCanonical };
}
// ───────────────────────────────────────────────────────────────────────────
// (1) EXECUTOR CONFORMANCE — parse do mapa + validação de conformidade
// ───────────────────────────────────────────────────────────────────────────
/**
* Extrai as chaves (aliases) do objeto literal `const executors = { ... }` da fonte
* de open-sse/executors/index.ts. O mapa não é exportado, então enumeramos pela fonte
* (determinístico — é um literal simples). Cada chave é validada em runtime via
* getExecutor() na função main().
*/
export function extractExecutorAliases(indexSource: string): string[] {
const start = indexSource.indexOf("const executors = {");
if (start < 0) throw new Error("could not find `const executors = {` in executors/index.ts");
const end = indexSource.indexOf("\n};", start);
if (end < 0) throw new Error("could not find end of executors map (`\\n};`)");
const block = indexSource.slice(start, end);
const keyRe = /^\s*(?:"([^"]+)"|([A-Za-z0-9_$-]+))\s*:/gm;
const keys: string[] = [];
let match: RegExpExecArray | null;
while ((match = keyRe.exec(block)) !== null) {
keys.push(match[1] ?? match[2]);
}
return keys;
}
/** Superfície pública mínima que todo executor registrado deve expor. */
export type ExecutorLike = {
execute?: unknown;
getProvider?: unknown;
};
/**
* Dada a lista de aliases e um resolvedor (getExecutor), retorna os aliases que NÃO
* resolvem para um BaseExecutor válido (não é instância, ou falta execute/getProvider).
* isInstance é injetado para manter a função pura/testável com inputs sintéticos.
*/
export function findNonConformingExecutors(
aliases: string[],
resolve: (alias: string) => ExecutorLike | null | undefined,
isInstance: (value: unknown) => boolean
): string[] {
return aliases.filter((alias) => {
const ex = resolve(alias);
if (!ex || !isInstance(ex)) return true;
return typeof ex.execute !== "function" || typeof ex.getProvider !== "function";
});
}
// ───────────────────────────────────────────────────────────────────────────
// (3) TRANSLATOR PAIRS — snapshot congelado (catraca: pares não somem)
// ───────────────────────────────────────────────────────────────────────────
/**
* Pares from:to congelados, registrados no registry de tradutores após bootstrap.
* Snapshot real medido em 2026-06-09 (18 pares). Catraca: se um par some, falha.
* Adicionar um par NÃO falha aqui (apenas reportado) — só remoções são regressões.
* Para regravar após adicionar/remover legitimamente um adapter, atualize esta lista.
*/
export const KNOWN_TRANSLATOR_PAIRS: readonly string[] = [
"antigravity:claude",
"antigravity:openai",
"claude:gemini",
"claude:openai",
"cursor:openai",
"gemini:claude",
"gemini:openai",
"kiro:openai",
"openai-responses:openai",
"openai:antigravity",
"openai:claude",
"openai:cursor",
"openai:gemini",
"openai:kiro",
"openai:openai-responses",
];
/**
* Pares frozen que sumiram do registry vivo (regressão). frozen = snapshot;
* live = pares observados em runtime. Retorna os que estão no frozen mas não no live.
*/
export function findMissingTranslatorPairs(frozen: readonly string[], live: Set<string>): string[] {
return frozen.filter((pair) => !live.has(pair));
}
/** Pares vivos que ainda não estão no snapshot frozen (informativo, não falha). */
export function findNewTranslatorPairs(frozen: readonly string[], live: Set<string>): string[] {
const frozenSet = new Set(frozen);
return [...live].filter((pair) => !frozenSet.has(pair)).sort();
}
// ───────────────────────────────────────────────────────────────────────────
// (4) MCP TOOLS — scope check + snapshot catraca
// ───────────────────────────────────────────────────────────────────────────
export type McpToolLike = {
name: string;
scopes?: string[] | readonly string[];
};
/**
* Returns the names of tools that have no scopes assigned (empty array or
* undefined). Every registered MCP tool must declare at least one scope so
* that scope-enforcement can filter callers correctly.
*/
export function checkMcpToolsHaveScopes(tools: McpToolLike[]): string[] {
return tools.filter((t) => !t.scopes || t.scopes.length === 0).map((t) => t.name);
}
/**
* Returns tools in the frozen snapshot that are no longer in the live
* registry (removals are regressions).
*/
export function findMissingMcpTools(frozen: readonly string[], live: Set<string>): string[] {
return frozen.filter((name) => !live.has(name));
}
/**
* Returns live tools not present in the frozen snapshot (additions are
* informative, not failures).
*/
export function findNewMcpTools(frozen: readonly string[], live: Set<string>): string[] {
const frozenSet = new Set(frozen);
return [...live].filter((name) => !frozenSet.has(name)).sort();
}
/**
* Snapshot of all MCP tool names registered by createMcpServer() as of
* 2026-06-13. Catraca: tool removed = fail; tool added = informative report.
* To update after an intentional removal/rename: edit this list and document
* the reason in the commit message.
*
* Sources:
* - MCP_TOOLS (33 base tools: omniroute_* + compression + agent_skills)
* - memoryTools (3): omniroute_memory_*
* - skillTools (4): omniroute_skills_*
* - gamificationTools (8): gamification_*
* - pluginTools (8): plugin_*
* - notionTools (6): notion_*
* - obsidianTools (22): obsidian_*
* agentSkillTools and compressionTools are included in MCP_TOOLS (deduped by RESERVED_MCP_NAMES).
*/
export const KNOWN_MCP_TOOL_NAMES: readonly string[] = [
// MCP_TOOLS base (33)
"omniroute_get_health",
"omniroute_list_combos",
"omniroute_get_combo_metrics",
"omniroute_switch_combo",
"omniroute_check_quota",
"omniroute_route_request",
"omniroute_cost_report",
"omniroute_list_models_catalog",
"omniroute_web_search",
"omniroute_simulate_route",
"omniroute_set_budget_guard",
"omniroute_set_routing_strategy",
"omniroute_set_resilience_profile",
"omniroute_test_combo",
"omniroute_get_provider_metrics",
"omniroute_best_combo_for_task",
"omniroute_explain_route",
"omniroute_get_session_snapshot",
"omniroute_db_health_check",
"omniroute_sync_pricing",
"omniroute_cache_stats",
"omniroute_cache_flush",
"omniroute_compression_status",
"omniroute_compression_configure",
"omniroute_set_compression_engine",
"omniroute_list_compression_combos",
"omniroute_compression_combo_stats",
"omniroute_oneproxy_fetch",
"omniroute_oneproxy_rotate",
"omniroute_oneproxy_stats",
"omniroute_agent_skills_list",
"omniroute_agent_skills_get",
"omniroute_agent_skills_coverage",
// memoryTools (3)
"omniroute_memory_search",
"omniroute_memory_add",
"omniroute_memory_clear",
// skillTools (4)
"omniroute_skills_list",
"omniroute_skills_enable",
"omniroute_skills_execute",
"omniroute_skills_executions",
// gamificationTools (8)
"gamification_leaderboard",
"gamification_rank",
"gamification_profile",
"gamification_badges",
"gamification_transfer",
"gamification_invite",
"gamification_servers",
"gamification_anomalies",
// pluginTools (8)
"plugin_list",
"plugin_install",
"plugin_activate",
"plugin_deactivate",
"plugin_uninstall",
"plugin_configure",
"plugin_executions",
"plugin_scan",
// notionTools (6)
"notion_search",
"notion_get_page",
"notion_list_block_children",
"notion_query_database",
"notion_get_database",
"notion_append_blocks",
// obsidianTools (22)
"obsidian_check_status",
"obsidian_search_simple",
"obsidian_search_structured",
"obsidian_read_note",
"obsidian_list_vault",
"obsidian_get_document_map",
"obsidian_get_note_metadata",
"obsidian_get_active_file",
"obsidian_get_periodic_note",
"obsidian_get_tags",
"obsidian_list_commands",
"obsidian_write_note",
"obsidian_append_note",
"obsidian_patch_note",
"obsidian_delete_note",
"obsidian_move_note",
"obsidian_execute_command",
"obsidian_open_file",
"obsidian_sync_status",
"obsidian_sync_trigger",
"obsidian_sync_conflicts",
"obsidian_sync_resolve_conflict",
];
// ───────────────────────────────────────────────────────────────────────────
// (5) A2A SKILLS — bidirectional diff between handlers and agent card
// ───────────────────────────────────────────────────────────────────────────
export type A2ASkillDiff = {
/** Skills registered in A2A_SKILL_HANDLERS but not exposed in the Agent Card */
inHandlersNotCard: string[];
/** Skills exposed in the Agent Card but not registered in A2A_SKILL_HANDLERS */
inCardNotHandlers: string[];
};
/**
* Bidirectionally diffs A2A skill handler keys against Agent Card skill IDs.
* Both directions matter:
* - inHandlersNotCard: skill is routable but agents can't discover it
* - inCardNotHandlers: skill is advertised but calling it fails silently
*/
export function diffA2ASkills(handlers: Set<string>, agentCard: Set<string>): A2ASkillDiff {
const inHandlersNotCard = [...handlers].filter((s) => !agentCard.has(s)).sort();
const inCardNotHandlers = [...agentCard].filter((s) => !handlers.has(s)).sort();
return { inHandlersNotCard, inCardNotHandlers };
}
// ───────────────────────────────────────────────────────────────────────────
// (6) CLOUD AGENTS — registry keys vs agent class files
// ───────────────────────────────────────────────────────────────────────────
export type CloudAgentDiff = {
/** Registry keys with no corresponding agent file in agents/ */
inRegistryNotFiles: string[];
/** Agent files with no corresponding registry key */
inFilesNotRegistry: string[];
};
/**
* Bidirectionally diffs cloud agent registry keys against agent file basenames
* (filename without extension, e.g. "codex.ts" → "codex"). Note: registry key
* "codex-cloud" maps to file "codex.ts" — this mapping is handled by the
* caller (main) which reads the actual class-name-to-key binding from registry.ts.
* The pure function here just diffs two already-normalised sets.
*/
export function diffCloudAgents(
registryKeys: Set<string>,
agentFiles: Set<string>
): CloudAgentDiff {
const inRegistryNotFiles = [...registryKeys].filter((k) => !agentFiles.has(k)).sort();
const inFilesNotRegistry = [...agentFiles].filter((f) => !registryKeys.has(f)).sort();
return { inRegistryNotFiles, inFilesNotRegistry };
}
/**
* Reads the registry.ts source file and returns the set of provider IDs
* (keys in the AGENTS object literal).
*/
export function extractCloudAgentRegistryKeys(registrySource: string): Set<string> {
// Match the AGENTS object: find start, extract keys
const start = registrySource.indexOf("const AGENTS: Record<string, CloudAgentBase> = {");
if (start < 0) throw new Error("could not find `const AGENTS:` in cloudAgent/registry.ts");
const end = registrySource.indexOf("\n};", start);
if (end < 0) throw new Error("could not find end of AGENTS map");
const block = registrySource.slice(start, end);
// Match quoted or bare keys: "codex-cloud": or jules:
const keyRe = /^\s*(?:"([^"]+)"|([A-Za-z0-9_$-]+))\s*:/gm;
const keys = new Set<string>();
let match: RegExpExecArray | null;
while ((match = keyRe.exec(block)) !== null) {
const key = match[1] ?? match[2];
// Skip the TypeScript type annotation line
if (key && key !== "Record") keys.add(key);
}
return keys;
}
/**
* Lists agent file basenames (without extension) from the agents/ directory.
* Maps known filename→registryKey aliases (e.g. "codex" → "codex-cloud").
*/
export const AGENT_FILE_TO_REGISTRY_KEY: Record<string, string> = {
codex: "codex-cloud",
// #4227: file agents/cursor.ts ↔ registry key "cursor-cloud" (distinct from the
// OAuth chat provider `cursor`).
cursor: "cursor-cloud",
};
// ───────────────────────────────────────────────────────────────────────────
// main() — importa módulos reais, lê fontes, roda as seis sub-checagens
// ───────────────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
const failures: string[] = [];
// ── (1) Executor conformance ──────────────────────────────────────────────
const executorsMod = await import("@omniroute/open-sse/executors/index.ts");
const getExecutor = executorsMod.getExecutor as (alias: string) => ExecutorLike;
const BaseExecutor = executorsMod.BaseExecutor as new (...args: never[]) => unknown;
const indexSource = readFileSync(resolvePath(REPO_ROOT, "open-sse/executors/index.ts"), "utf8");
const aliases = extractExecutorAliases(indexSource);
if (aliases.length === 0) {
failures.push(
"[executor] parse do mapa `executors` não encontrou nenhum alias (regex quebrada?)"
);
}
const isExecutorInstance = (value: unknown) => value instanceof BaseExecutor;
const badExecutors = findNonConformingExecutors(aliases, getExecutor, isExecutorInstance);
if (badExecutors.length) {
failures.push(
`[executor] ${badExecutors.length} alias(es) registrado(s) não resolvem para um BaseExecutor válido (instância + execute() + getProvider()):\n` +
badExecutors.map((a) => `${a}`).join("\n") +
`\n → verifique a entrada em open-sse/executors/index.ts (classe importada/exportada e estende BaseExecutor).`
);
}
// ── (2) Combo strategies ──────────────────────────────────────────────────
// Canonical = user-facing ROUTING_STRATEGY_VALUES INTERNAL_ROUTING_STRATEGY_VALUES
// (system-only strategies like "quota-share" are registered but hidden from the UI;
// they still must have a real dispatch branch in combo.ts — enforced below).
const strategiesMod = await import("@/shared/constants/routingStrategies.ts");
const canonical = [
...(strategiesMod.ROUTING_STRATEGY_VALUES as readonly string[]),
...(strategiesMod.INTERNAL_ROUTING_STRATEGY_VALUES as readonly string[]),
];
// The combo dispatch was decomposed (Block J): the `strategy === "..."` branches
// now live across combo.ts + its strategy-ordering leaves, so scan all of them.
const comboDispatchFiles = [
"open-sse/services/combo.ts",
"open-sse/services/combo/applyStrategyOrdering.ts",
"open-sse/services/combo/resolveAutoStrategy.ts",
];
const comboSource = comboDispatchFiles
.map((rel) => readFileSync(resolvePath(REPO_ROOT, rel), "utf8"))
.join("\n");
const handled = extractHandledStrategies(comboSource);
// Stale-enforcement (6A.3): IMPLICIT_DEFAULT_STRATEGIES is a suppression allowlist —
// each entry exists ONLY to suppress a `canonicalNotHandled` violation (a canonical
// strategy with no `strategy === "..."` dispatch reference). The live violations it
// suppresses are the canonical strategies NOT already in `handled` (computed with an
// EMPTY implicit-defaults map). An entry whose key IS already in `handled` suppresses
// nothing → it is stale and the gate must fail asking for its removal.
const liveImplicitNeeded = diffComboStrategies(canonical, handled, {}).canonicalNotHandled;
assertNoStale(
Object.keys(IMPLICIT_DEFAULT_STRATEGIES),
liveImplicitNeeded,
"known-symbols:combo"
);
const { canonicalNotHandled, handledNotCanonical } = diffComboStrategies(
canonical,
handled,
IMPLICIT_DEFAULT_STRATEGIES
);
if (canonicalNotHandled.length) {
failures.push(
`[combo] ${canonicalNotHandled.length} estratégia(s) canônica(s) sem branch de despacho em combo.ts:\n` +
canonicalNotHandled.map((s) => `${s}`).join("\n") +
`\n → fie no despacho (\`strategy === "${canonicalNotHandled[0]}"\`) ou documente em IMPLICIT_DEFAULT_STRATEGIES.`
);
}
if (handledNotCanonical.length) {
failures.push(
`[combo] ${handledNotCanonical.length} string(s) de estratégia tratada(s) no despacho mas ausente(s) de ROUTING_STRATEGY_VALUES (inventada/órfã):\n` +
handledNotCanonical.map((s) => `${s}`).join("\n") +
`\n → registre em src/shared/constants/routingStrategies.ts ou remova o branch morto.`
);
}
// ── (3) Translator pairs ──────────────────────────────────────────────────
await import("@omniroute/open-sse/translator/bootstrap.ts").then((m) =>
(m.bootstrapTranslatorRegistry as () => void)()
);
const formatsMod = await import("@omniroute/open-sse/translator/formats.ts");
const registryMod = await import("@omniroute/open-sse/translator/registry.ts");
const FORMATS = formatsMod.FORMATS as Record<string, string>;
const getRequestTranslator = registryMod.getRequestTranslator as (
from: string,
to: string
) => unknown;
const getResponseTranslator = registryMod.getResponseTranslator as (
from: string,
to: string
) => unknown;
const formatIds = Object.values(FORMATS);
const livePairs = new Set<string>();
for (const from of formatIds) {
for (const to of formatIds) {
if (from === to) continue;
if (getRequestTranslator(from, to) || getResponseTranslator(from, to)) {
livePairs.add(`${from}:${to}`);
}
}
}
const missingPairs = findMissingTranslatorPairs(KNOWN_TRANSLATOR_PAIRS, livePairs);
if (missingPairs.length) {
failures.push(
`[translator] ${missingPairs.length} par(es) from:to congelado(s) sumiram do registry vivo (regressão):\n` +
missingPairs.map((p) => `${p}`).join("\n") +
`\n → restaure o adapter em open-sse/translator/ ou, se a remoção foi intencional, atualize KNOWN_TRANSLATOR_PAIRS.`
);
}
const newPairs = findNewTranslatorPairs(KNOWN_TRANSLATOR_PAIRS, livePairs);
// ── (4) MCP tools scope + snapshot ───────────────────────────────────────
const { MCP_TOOLS } = await import("@omniroute/open-sse/mcp-server/schemas/tools.ts");
const { memoryTools } = await import("@omniroute/open-sse/mcp-server/tools/memoryTools.ts");
const { skillTools } = await import("@omniroute/open-sse/mcp-server/tools/skillTools.ts");
const { gamificationTools } =
await import("@omniroute/open-sse/mcp-server/tools/gamificationTools.ts");
const { pluginTools } = await import("@omniroute/open-sse/mcp-server/tools/pluginTools.ts");
const { notionTools } = await import("@omniroute/open-sse/mcp-server/tools/notionTools.ts");
const { obsidianTools } = await import("@omniroute/open-sse/mcp-server/tools/obsidianTools.ts");
// Build the full live set of registered tools (deduped by RESERVED_MCP_NAMES logic:
// agentSkillTools + compressionTools are already in MCP_TOOLS).
const liveMcpTools: McpToolLike[] = [
...(MCP_TOOLS as unknown as McpToolLike[]),
...Object.values(memoryTools as Record<string, McpToolLike>),
...Object.values(skillTools as Record<string, McpToolLike>),
...(gamificationTools as unknown as McpToolLike[]),
...(pluginTools as unknown as McpToolLike[]),
...(notionTools as unknown as McpToolLike[]),
...(obsidianTools as unknown as McpToolLike[]),
];
const liveMcpToolNames = new Set(liveMcpTools.map((t) => t.name));
// 4a. Every registered tool must have at least one scope.
const toolsWithoutScopes = checkMcpToolsHaveScopes(liveMcpTools);
if (toolsWithoutScopes.length) {
failures.push(
`[mcp-tools] ${toolsWithoutScopes.length} tool(s) sem scope(s) atribuído(s) — todo tool registrado deve ter ao menos 1 scope para scope-enforcement:\n` +
toolsWithoutScopes.map((n) => `${n}`).join("\n") +
`\n → adicione o campo scopes: [...] na definição do tool.`
);
}
// 4b. Snapshot catraca: tools removed are regressions.
const missingMcpTools = findMissingMcpTools(KNOWN_MCP_TOOL_NAMES, liveMcpToolNames);
if (missingMcpTools.length) {
failures.push(
`[mcp-tools] ${missingMcpTools.length} tool(s) congelado(s) sumiram do registry vivo (regressão):\n` +
missingMcpTools.map((n) => `${n}`).join("\n") +
`\n → restaure o tool ou, se a remoção foi intencional, atualize KNOWN_MCP_TOOL_NAMES.`
);
}
const newMcpTools = findNewMcpTools(KNOWN_MCP_TOOL_NAMES, liveMcpToolNames);
// ── (5) A2A skills ───────────────────────────────────────────────────────
const { A2A_SKILL_HANDLERS } = await import("@/lib/a2a/taskExecution.ts");
const handlerKeys = new Set(Object.keys(A2A_SKILL_HANDLERS as Record<string, unknown>));
// Parse the Agent Card route statically (the skills array is a literal in the source).
const agentCardSource = readFileSync(
resolvePath(REPO_ROOT, "src/app/.well-known/agent.json/route.ts"),
"utf8"
);
// Extract skill IDs: `id: "..."` lines inside the skills array.
const skillIdRe = /\bid:\s*"([^"]+)"/g;
const agentCardSkills = new Set<string>();
let skillMatch: RegExpExecArray | null;
while ((skillMatch = skillIdRe.exec(agentCardSource)) !== null) {
agentCardSkills.add(skillMatch[1]);
}
if (agentCardSkills.size === 0) {
failures.push(
`[a2a-skills] parse do Agent Card não encontrou nenhum skill id (regex quebrada ou arquivo movido?)`
);
}
const { inHandlersNotCard, inCardNotHandlers } = diffA2ASkills(handlerKeys, agentCardSkills);
if (inHandlersNotCard.length) {
failures.push(
`[a2a-skills] ${inHandlersNotCard.length} skill(s) em A2A_SKILL_HANDLERS mas ausente(s) do Agent Card (agentes não conseguem descobrir):\n` +
inHandlersNotCard.map((s) => `${s}`).join("\n") +
`\n → adicione o skill em src/app/.well-known/agent.json/route.ts (skills array).`
);
}
if (inCardNotHandlers.length) {
failures.push(
`[a2a-skills] ${inCardNotHandlers.length} skill(s) expostos no Agent Card mas ausente(s) de A2A_SKILL_HANDLERS (chamada silenciosamente falha):\n` +
inCardNotHandlers.map((s) => `${s}`).join("\n") +
`\n → registre o handler em src/lib/a2a/taskExecution.ts (A2A_SKILL_HANDLERS).`
);
}
// ── (6) Cloud agents ─────────────────────────────────────────────────────
const registrySource = readFileSync(
resolvePath(REPO_ROOT, "src/lib/cloudAgent/registry.ts"),
"utf8"
);
const registryKeys = extractCloudAgentRegistryKeys(registrySource);
// Read agent file basenames from agents/ directory, applying the alias map.
const agentsDir = resolvePath(REPO_ROOT, "src/lib/cloudAgent/agents");
const agentFileBases = new Set(
readdirSync(agentsDir)
.filter((f) => /\.(ts|js)$/.test(f) && !f.endsWith(".d.ts") && !f.endsWith(".test.ts"))
.map((f) => {
const base = basename(f, extname(f));
return AGENT_FILE_TO_REGISTRY_KEY[base] ?? base;
})
);
const { inRegistryNotFiles, inFilesNotRegistry } = diffCloudAgents(registryKeys, agentFileBases);
if (inRegistryNotFiles.length) {
failures.push(
`[cloud-agents] ${inRegistryNotFiles.length} chave(s) no registry sem arquivo de classe em agents/:\n` +
inRegistryNotFiles.map((k) => `${k}`).join("\n") +
`\n → crie o arquivo src/lib/cloudAgent/agents/<name>.ts ou atualize AGENT_FILE_TO_REGISTRY_KEY.`
);
}
if (inFilesNotRegistry.length) {
failures.push(
`[cloud-agents] ${inFilesNotRegistry.length} arquivo(s) em agents/ sem entrada no registry:\n` +
inFilesNotRegistry.map((f) => `${f}`).join("\n") +
`\n → registre o agente em src/lib/cloudAgent/registry.ts ou adicione o alias em AGENT_FILE_TO_REGISTRY_KEY.`
);
}
// ── Resultado ─────────────────────────────────────────────────────────────
if (failures.length) {
console.error(
`[known-symbols] ${failures.length} sub-checagem(ns) falharam:\n\n${failures.join("\n\n")}`
);
process.exit(1);
}
// assertNoStale (combo) seta process.exitCode=1 sem lançar — não imprima o OK
// enganoso; a mensagem de stale já foi logada no stderr pelo helper.
if (process.exitCode === 1) return;
const newPairsNote = newPairs.length
? ` (${newPairs.length} par(es) novo(s) não-congelado(s): ${newPairs.join(", ")} — atualize KNOWN_TRANSLATOR_PAIRS se intencional)`
: "";
const newMcpNote = newMcpTools.length
? ` (${newMcpTools.length} tool(s) novo(s) não-congelado(s): ${newMcpTools.join(", ")} — atualize KNOWN_MCP_TOOL_NAMES se intencional)`
: "";
console.log(
`[known-symbols] OK — ` +
`${aliases.length} executores conformes; ` +
`${canonical.length} estratégias canônicas (${handled.size} via despacho + ${Object.keys(IMPLICIT_DEFAULT_STRATEGIES).length} default(s) implícito(s)); ` +
`${livePairs.size} pares de tradutor vivos vs ${KNOWN_TRANSLATOR_PAIRS.length} congelados${newPairsNote}; ` +
`${liveMcpToolNames.size} tools MCP (${toolsWithoutScopes.length === 0 ? "todos com scope" : `${toolsWithoutScopes.length} sem scope`}) vs ${KNOWN_MCP_TOOL_NAMES.length} congelados${newMcpNote}; ` +
`${handlerKeys.size} A2A skills (handlers↔card OK); ` +
`${registryKeys.size} cloud agents (registry↔files OK)`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
main().catch((err) => {
console.error(
`[known-symbols] erro fatal: ${err instanceof Error ? err.message : String(err)}`
);
process.exit(1);
});
}
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env node
// scripts/check/check-licenses.mjs
// Gate de license compliance — PLANO-QUALITY-GATES-FASE7.md, Task 20.
//
// Política: OmniRoute é MIT. Dependências de PRODUÇÃO com licença fora da allowlist SPDX
// e sem exceção registrada em .license-allowlist.json => FALHA (policy violation).
// devDependencies com licença não-padrão => advisory (impressas, não falham).
//
// Ferramenta: license-checker-rseidelsohn v4+ (node_modules/.bin/license-checker-rseidelsohn).
//
// Uso:
// node scripts/check/check-licenses.mjs # modo normal
// node scripts/check/check-licenses.mjs --verbose # lista todos os pacotes classificados
// node scripts/check/check-licenses.mjs --json # emite o raw JSON do license-checker
//
// Sair com código 0 = tudo OK (ou apenas advisory).
// Sair com código 1 = violação de política em dep de produção.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const ALLOWLIST_PATH = path.join(ROOT, "config/quality/.license-allowlist.json");
const CHECKER_BIN = path.join(ROOT, "node_modules", ".bin", "license-checker-rseidelsohn");
const VERBOSE = process.argv.includes("--verbose");
const PRINT_JSON = process.argv.includes("--json");
// ---------------------------------------------------------------------------
// Allowlist loading
// ---------------------------------------------------------------------------
/**
* Loads and returns the license allowlist from .license-allowlist.json.
*
* @returns {{ allowed: string[], allowedExpressions: string[], exceptions: Record<string, {license:string, justification:string, risk:string}> }}
*/
export function loadAllowlist() {
if (!fs.existsSync(ALLOWLIST_PATH)) {
throw new Error(
`Allowlist not found: ${ALLOWLIST_PATH}. Create .license-allowlist.json first.`
);
}
const raw = fs.readFileSync(ALLOWLIST_PATH, "utf-8");
const parsed = JSON.parse(raw);
return {
allowed: parsed.allowed ?? [],
allowedExpressions: parsed.allowedExpressions ?? [],
exceptions: parsed.exceptions ?? {},
};
}
// ---------------------------------------------------------------------------
// Classification logic (pure, testable)
// ---------------------------------------------------------------------------
/**
* Classifies a package+license against the allowlist.
*
* @param {string} packageName - Package name without version, e.g. "lightningcss"
* @param {string} license - License string from license-checker, e.g. "MPL-2.0"
* @param {{ allowed: string[], allowedExpressions: string[], exceptions: Record<string,any> }} allowlist
* @returns {{ status: "allowed" | "exception" | "denied", reason: string }}
*/
export function classifyLicense(packageName, license, allowlist) {
const { allowed, allowedExpressions, exceptions } = allowlist;
// 1. Direct SPDX match
if (allowed.includes(license)) {
return { status: "allowed", reason: `SPDX match: ${license}` };
}
// 2. Expression match (e.g. "(MIT OR Apache-2.0)")
if (allowedExpressions.includes(license)) {
return { status: "allowed", reason: `allowed expression: ${license}` };
}
// 3. Per-package exception (strip version suffix for lookup)
const baseName = stripVersion(packageName);
if (exceptions[baseName]) {
const exc = exceptions[baseName];
return {
status: "exception",
reason: `exception: ${exc.justification} [risk=${exc.risk}]`,
};
}
// 4. Denied
return {
status: "denied",
reason: `license '${license}' not in allowlist and no exception registered for '${baseName}'`,
};
}
/**
* Strips the @version suffix from a package key returned by license-checker.
* e.g. "lightningcss@1.32.0" => "lightningcss"
* "@img/sharp-libvips-linux-x64@1.2.4" => "@img/sharp-libvips-linux-x64"
*
* @param {string} pkgKey - Package key as returned by license-checker
* @returns {string}
*/
export function stripVersion(pkgKey) {
// Handle scoped packages: @scope/name@version
const scopedMatch = pkgKey.match(/^(@[^/]+\/[^@]+)(?:@.*)?$/);
if (scopedMatch) return scopedMatch[1];
// Regular: name@version
const regularMatch = pkgKey.match(/^([^@]+)(?:@.*)?$/);
if (regularMatch) return regularMatch[1];
return pkgKey;
}
// ---------------------------------------------------------------------------
// Runner
// ---------------------------------------------------------------------------
/**
* Runs license-checker-rseidelsohn --production and returns parsed JSON.
*
* @returns {Record<string, { licenses: string, path: string }>}
*/
function runLicenseChecker() {
if (!fs.existsSync(CHECKER_BIN)) {
throw new Error(
`license-checker-rseidelsohn not found at ${CHECKER_BIN}.\n` +
`Install it: npm install --save-dev license-checker-rseidelsohn`
);
}
const output = execFileSync(CHECKER_BIN, ["--production", "--json"], {
cwd: ROOT,
encoding: "utf-8",
maxBuffer: 32 * 1024 * 1024, // 32 MB
});
return JSON.parse(output);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
/** @type {Record<string, { licenses: string, path: string }>} */
let licenseData;
try {
licenseData = runLicenseChecker();
} catch (err) {
console.error("[check-licenses] Falha ao rodar license-checker-rseidelsohn:");
console.error(err.message ?? err);
process.exitCode = 1;
return;
}
if (PRINT_JSON) {
console.log(JSON.stringify(licenseData, null, 2));
return;
}
/** @type {{ allowed: string[], allowedExpressions: string[], exceptions: Record<string,any> }} */
let allowlist;
try {
allowlist = loadAllowlist();
} catch (err) {
console.error("[check-licenses]", err.message);
process.exitCode = 1;
return;
}
const violations = [];
const exceptions = [];
const advisory = [];
let allowedCount = 0;
for (const [pkgKey, info] of Object.entries(licenseData)) {
const license = info.licenses ?? "UNKNOWN";
const result = classifyLicense(pkgKey, license, allowlist);
if (result.status === "allowed") {
allowedCount++;
if (VERBOSE) {
console.log(`${pkgKey}: ${license}`);
}
} else if (result.status === "exception") {
exceptions.push({ pkgKey, license, reason: result.reason });
} else {
violations.push({ pkgKey, license, reason: result.reason });
}
}
const total = Object.keys(licenseData).length;
// Print summary
console.log(`[check-licenses] Escaneados ${total} pacotes de produção.`);
console.log(` Permitidos: ${allowedCount}`);
console.log(` Exceções registradas: ${exceptions.length}`);
console.log(` Violações de política: ${violations.length}`);
// Print exceptions (informational)
if (exceptions.length > 0) {
console.log(
"\n[check-licenses] Exceções registradas (não bloqueantes, revisar periodicamente):"
);
for (const { pkgKey, license } of exceptions) {
const baseName = stripVersion(pkgKey);
const exc = allowlist.exceptions[baseName];
const riskTag = exc?.risk === "medium" ? " ⚠️ RISK=medium" : "";
console.log(`${pkgKey}: ${license}${riskTag}`);
if (exc?.risk === "medium") {
console.log(`${exc.justification}`);
}
}
}
// Print advisory (devDep non-standard — empty here since we run --production)
if (advisory.length > 0) {
console.log("\n[check-licenses] Advisory (devDeps com licença não-padrão):");
for (const { pkgKey, license } of advisory) {
console.log(` ${pkgKey}: ${license}`);
}
}
// Print violations and fail
if (violations.length > 0) {
console.error(
"\n[check-licenses] ❌ VIOLAÇÕES DE POLÍTICA — deps de produção com licença não permitida:"
);
for (const { pkgKey, license, reason } of violations) {
console.error(`${pkgKey}: ${license}`);
console.error(`${reason}`);
}
console.error(
"\nAdicione a licença à allowlist 'allowed' em .license-allowlist.json (se SPDX-permissiva)\n" +
"ou registre uma exceção por-pacote em 'exceptions' com justificativa e 'reviewAt'.\n" +
"NÃO mascare copyleft forte sem registrar a justificativa. Ver PLANO-QUALITY-GATES-FASE7.md § Task 20."
);
process.exitCode = 1;
return;
}
console.log(
"\n[check-licenses] ✅ Todos os pacotes de produção estão em conformidade com a política de licenças."
);
}
// Run only when invoked directly (not when imported by tests)
const isMain =
process.argv[1] === pathToFileURL(import.meta.url).pathname ||
process.argv[1]?.endsWith("check-licenses.mjs");
if (isMain) {
main();
}
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env node
// scripts/check/check-lockfile.mjs
// Gate de política de lockfile (CLAUDE.md — extensão Hard Rule #1).
//
// Objetivo: detectar supply-chain poisoning no package-lock.json antes que código
// malicioso entre no repo. Verifica:
// --validate-https → toda URL "resolved" deve usar HTTPS (bloqueia http://)
// --validate-integrity → todo pacote deve ter hash de integridade sha512
// --allowed-hosts npm → apenas registry.npmjs.org é host permitido
//
// Complementa check-deps (Fase 2 / allowlist de nomes): aquele garante que só
// nomes aprovados entram; este garante que os pacotes instalados vieram do registry
// legítimo com integridade verificável.
//
// Referência: PLANO-QUALITY-GATES-FASE7.md, Task 7.7.
// Tool: lockfile-lint v5 (node_modules/.bin/lockfile-lint).
import { execFileSync } from "node:child_process";
import path from "node:path";
import fs from "node:fs";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
/**
* Returns the canonical lockfile-lint configuration used by this gate.
* Exporting this object makes the policy auditable and unit-testable without
* spawning a child process.
*
* @returns {{
* lockfilePath: string,
* type: string,
* validateHttps: boolean,
* validateIntegrity: boolean,
* allowedHosts: string[],
* }}
*/
export function getLockfileLintConfig() {
return {
lockfilePath: path.join(ROOT, "package-lock.json"),
type: "npm",
validateHttps: true,
validateIntegrity: true,
// Only the official npm registry is permitted.
// registry.npmjs.org resolves to the "npm" shorthand in lockfile-lint.
// If the project ever adopts a scoped/private registry, add its hostname here
// and document the justification.
allowedHosts: ["npm"],
};
}
/**
* Builds the argv array to pass to the lockfile-lint binary, derived from
* the config returned by getLockfileLintConfig().
*
* @param {ReturnType<typeof getLockfileLintConfig>} cfg
* @returns {string[]}
*/
export function buildLockfileLintArgs(cfg) {
const args = [
"--path", cfg.lockfilePath,
"--type", cfg.type,
];
if (cfg.validateHttps) args.push("--validate-https");
if (cfg.validateIntegrity) args.push("--validate-integrity");
if (cfg.allowedHosts.length) {
args.push("--allowed-hosts", ...cfg.allowedHosts);
}
return args;
}
function main() {
const cfg = getLockfileLintConfig();
if (!fs.existsSync(cfg.lockfilePath)) {
console.error(
`[check-lockfile] FAIL — lockfile not found: ${cfg.lockfilePath}\n` +
" → Run `npm install` to generate package-lock.json"
);
process.exit(1);
}
const bin = path.join(ROOT, "node_modules", ".bin", "lockfile-lint");
if (!fs.existsSync(bin)) {
console.error(
`[check-lockfile] FAIL — lockfile-lint binary not found at:\n ${bin}\n` +
" → Run `npm install` to install dev dependencies"
);
process.exit(1);
}
const args = buildLockfileLintArgs(cfg);
try {
const output = execFileSync(bin, args, { encoding: "utf8" });
// lockfile-lint outputs a green ✔ message on success
console.log("[check-lockfile] OK —", output.trim());
} catch (err) {
const stdout = err.stdout ?? "";
const stderr = err.stderr ?? "";
console.error("[check-lockfile] FAIL — lockfile-lint found policy violations:");
if (stdout) console.error(stdout);
if (stderr) console.error(stderr);
console.error(
"\n Possible causes:\n" +
" • A package was resolved from a non-HTTPS URL (http:// poisoning attempt)\n" +
" • A package is missing its integrity hash (tampered or legacy entry)\n" +
" • A package was resolved from a host other than registry.npmjs.org\n" +
" If a scoped/private registry is intentionally used, add its hostname\n" +
" to getLockfileLintConfig().allowedHosts in scripts/check/check-lockfile.mjs"
);
process.exit(1);
}
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env node
// scripts/check/check-migration-numbering.mjs
// Gate de numeração de migrations: protege src/lib/db/migrations/ contra regressões
// de numeração. WHY: um incidente destrutivo aconteceu DUAS VEZES — um `git rm` de
// uma migration duplicada durante um merge apagou uma migration REAL da release
// (094/095 em #3365/#3371). Este gate cria uma ligação de CI entre o disco e as
// anomalias já reconhecidas em migrationRunner.ts, falhando em QUALQUER:
// - nome de arquivo sem prefixo numérico zero-padded (NNN_*.sql);
// - prefixo de versão DUPLICADO no disco (exceto duplicatas já reconhecidas);
// - NOVO gap inexplicado na sequência (gaps conhecidos 026/055 são congelados).
// As anomalias conhecidas são derivadas das listas de migrationRunner.ts
// (LEGACY_VERSION_SLOT_MIGRATIONS / SUPERSEDED_DUPLICATE_MIGRATIONS) + a auditoria
// de gaps de sequência. NÃO adicione novos itens sem justificativa — esse é o ponto.
// Stale-enforcement (6A.3): entrada em KNOWN_GAPS / KNOWN_DUPLICATE_VERSIONS que não
// suprime nenhuma anomalia real → gate falha com instrução de remoção.
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { assertNoStale } from "./lib/allowlist.mjs";
const cwd = process.cwd();
const MIGRATIONS_DIR = path.join(cwd, "src/lib/db/migrations");
// Convenção de nome: NNN_descricao.sql (prefixo numérico zero-padded de >= 3 dígitos).
// Mesmo regex usado pelo runner de produção (migrationRunner.ts ~linha 282).
const MIGRATION_NAME_RE = /^(\d{3,})_(.+)\.sql$/;
// ---------------------------------------------------------------------------
// ALLOWLIST 1 — duplicatas de versão CONHECIDAS.
// Fonte: src/lib/db/migrationRunner.ts → SUPERSEDED_DUPLICATE_MIGRATIONS (~L188).
// O runner já aceita estes slots de versão reutilizados (a migration "renomeada"
// foi promovida para um número novo, e o slot antigo é tolerado). Adicionar aqui
// SOMENTE se houver um arquivo físico duplicado no disco (stale-enforcement 6A.3
// detecta entradas sem duplicata física viva e força remoção).
// ---------------------------------------------------------------------------
export const KNOWN_DUPLICATE_VERSIONS = new Set([
// "041" was removed: 041_session_account_affinity.sql no longer exists on disk
// (only 041_compression_receipts.sql remains), so no physical duplicate is present.
// The SUPERSEDED_DUPLICATE_MIGRATIONS entry in migrationRunner.ts handles the runner
// compatibility at runtime without needing an allowlist here. (#6A.3 stale cleanup)
]);
// ---------------------------------------------------------------------------
// ALLOWLIST 2 — gaps de sequência CONHECIDOS.
// Fonte: auditoria do disco (src/lib/db/migrations/) — a sequência pula 026 e 055.
// Estes números nunca tiveram arquivo físico (slots legados que viraram outros
// números via RENAMED_MIGRATION_COMPATIBILITY em migrationRunner.ts). Congelados
// para que o gate bloqueie apenas NOVOS buracos inexplicados na sequência.
// ---------------------------------------------------------------------------
export const KNOWN_GAPS = new Set(["026", "055"]);
function pad3(n) {
return String(n).padStart(3, "0");
}
/**
* Função pura — detecta anomalias de numeração de migrations.
*
* @param {string[]} filenames nomes de arquivo (basename) em src/lib/db/migrations/
* @param {Set<string>} knownDuplicates versões com duplicata reconhecida (ex.: "041")
* @param {Set<string>} knownGaps gaps de sequência reconhecidos (ex.: "026")
* @returns {{ duplicates: Array<{version:string,names:string[]}>, gaps: string[], badNames: string[] }}
*/
export function findMigrationAnomalies(filenames, knownDuplicates, knownGaps) {
const dups = knownDuplicates || new Set();
const gapsAllow = knownGaps || new Set();
const badNames = [];
const byVersion = new Map();
for (const filename of filenames) {
if (!filename.endsWith(".sql")) continue;
const match = filename.match(MIGRATION_NAME_RE);
if (!match) {
badNames.push(filename);
continue;
}
const version = match[1];
if (!byVersion.has(version)) byVersion.set(version, []);
byVersion.get(version).push(filename);
}
// Duplicatas: dois arquivos físicos com o mesmo prefixo, exceto os reconhecidos.
const duplicates = [];
for (const [version, names] of byVersion.entries()) {
if (names.length <= 1) continue;
if (dups.has(version)) continue;
duplicates.push({ version, names: [...names].sort() });
}
duplicates.sort((a, b) => a.version.localeCompare(b.version));
// Gaps: buracos na sequência min..max que não estão na allowlist.
const versions = [...byVersion.keys()].map((v) => parseInt(v, 10)).sort((a, b) => a - b);
const gaps = [];
if (versions.length > 0) {
const min = versions[0];
const max = versions[versions.length - 1];
const present = new Set(versions);
for (let n = min + 1; n < max; n++) {
if (present.has(n)) continue;
const padded = pad3(n);
if (gapsAllow.has(padded)) continue;
gaps.push(padded);
}
}
return { duplicates, gaps, badNames };
}
function listMigrationFilenames() {
if (!fs.existsSync(MIGRATIONS_DIR)) return [];
return fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith(".sql"));
}
function main() {
const filenames = listMigrationFilenames();
// Compute raw anomalies WITHOUT allowlists — needed for stale-enforcement (6A.3).
const raw = findMigrationAnomalies(filenames, new Set(), new Set());
const liveGaps = raw.gaps;
const liveDupVersions = raw.duplicates.map((d) => d.version);
assertNoStale(KNOWN_GAPS, liveGaps, "check-migration-numbering:gaps");
assertNoStale(KNOWN_DUPLICATE_VERSIONS, liveDupVersions, "check-migration-numbering:duplicates");
const { duplicates, gaps, badNames } = findMigrationAnomalies(
filenames,
KNOWN_DUPLICATE_VERSIONS,
KNOWN_GAPS
);
const problems = [];
for (const b of badNames) {
problems.push(` ✗ nome inválido (esperado NNN_descricao.sql): ${b}`);
}
for (const d of duplicates) {
problems.push(` ✗ prefixo de versão duplicado ${d.version}: [${d.names.join(", ")}]`);
}
for (const g of gaps) {
problems.push(` ✗ gap inexplicado na sequência: faltando ${g}`);
}
if (problems.length > 0) {
console.error(
`[check-migration-numbering] ${problems.length} anomalia(s) de numeração:\n` +
problems.join("\n") +
`\n → renomeie o arquivo colidente, preencha o gap, ou — se for legítimo — ` +
`adicione o número às allowlists KNOWN_DUPLICATE_VERSIONS / KNOWN_GAPS com ` +
`justificativa rastreável a src/lib/db/migrationRunner.ts.`
);
process.exitCode = 1;
}
if (!process.exitCode) {
console.log(
`[check-migration-numbering] OK (${filenames.length} migrations, ` +
`${KNOWN_GAPS.size} gap(s) conhecido(s), ${KNOWN_DUPLICATE_VERSIONS.size} duplicata(s) conhecida(s))`
);
}
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env node
// scripts/check/check-mutation-ratchet.mjs
// Catraca de mutationScore (Quality Gate v2 / Fase 9 T5 — Onda 2, Task 3).
//
// Mirrors check-bundle-size.mjs: ADVISORY by default (always exit 0), BLOCKING only
// with --ratchet — and even then exits 1 SE — E SOMENTE SE — um módulo medido REGREDIU
// vs o baseline (direction: UP, o score só pode subir). Skip gracioso (exit 0) quando
// não há mutation.json (ex.: o nightly não rodou) ou não há baseline para o módulo —
// falta de dados NUNCA bloqueia, só uma regressão medida bloqueia.
//
// Score por módulo = COVERED score = detected / (detected + survived), onde
// detected = Killed + Timeout. NoCoverage é EXCLUÍDO do denominador (é uma lacuna de
// cobertura, não um sinal de qualidade-de-teste) — mesmo denominador que a radiografia
// (scripts/quality/mutation-radiography.mjs).
//
// O nightly divide o `mutate` em batches paralelos (um reports/mutation/mutation.json
// por job). Este script roda DENTRO de cada job sobre o report daquele batch e compara
// só os módulos presentes nele. Aceita vários paths para uso local/agregado.
//
// Uso:
// node scripts/check/check-mutation-ratchet.mjs (advisory; report default)
// node scripts/check/check-mutation-ratchet.mjs reports/mutation/mutation.json
// node scripts/check/check-mutation-ratchet.mjs <a.json> <b.json> ... --ratchet
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = process.cwd();
const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json");
const DEFAULT_REPORT = path.join(ROOT, "reports/mutation/mutation.json");
const BASELINE_PREFIX = "mutationScore.";
const RATCHET = process.argv.includes("--ratchet");
// Anti-flake tolerance (percentage points). The tap-runner runs at concurrency 4 and a
// mutant whose tests sit near the `timeoutMS` boundary can flip Killed/Timeout/Survived
// between runs, jittering a module's covered score by a fraction of a point. Only a drop
// LARGER than this counts as a regression — same idea as the coverage ratchet's `eps`. It
// does NOT lower the baseline; it absorbs run-to-run noise so the blocking gate stays
// deterministic. Real test-quality regressions (the headers.ts/telemetry-scale drops that
// motivated the tap.testFiles coverage fix) are far larger than EPS and still fire.
export const MUTATION_RATCHET_EPS = 1.0;
const DETECTED = new Set(["Killed", "Timeout"]);
const SURVIVED = new Set(["Survived"]);
/**
* Avalia o score MEDIDO de um módulo contra o baseline. Direction: UP (o score só
* pode subir — menor = regressão), com tolerância anti-flake `eps`: só conta como
* regressão uma queda MAIOR que `eps` pontos.
* @param {number} current
* @param {number} baseline
* @param {number} [eps] tolerância anti-flake em pontos percentuais
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateMutationRatchet(current, baseline, eps = MUTATION_RATCHET_EPS) {
return {
regressed: current < baseline - eps,
improved: current > baseline,
};
}
/**
* Covered mutation score de um arquivo: detected/(detected+survived)*100.
* NoCoverage/Ignored/RuntimeError/CompileError ficam fora do denominador.
* @param {{mutants?: Array<{status: string}>}} fileData
* @returns {number|null} score em %, ou null se não houver mutantes cobertos
*/
export function mutationScoreForFile(fileData) {
let detected = 0;
let survived = 0;
for (const m of fileData?.mutants || []) {
if (DETECTED.has(m.status)) detected += 1;
else if (SURVIVED.has(m.status)) survived += 1;
}
const denom = detected + survived;
return denom === 0 ? null : (detected / denom) * 100;
}
/**
* Score por arquivo a partir de um ou mais reports (batches). Arquivos sem mutante
* coberto (score null) são omitidos.
*
* ⭐ Sibling batches que fatiam o MESMO arquivo (auth.ts split em a1:1-1109 + a2:1110-2218
* por mutation range; accountFallback em b1/b2) carregam fatias DISJUNTAS dos mutantes
* daquele arquivo. O score verdadeiro do arquivo precisa de TODAS as fatias juntas, então
* unimos `files[<arquivo>].mutants` entre os reports ANTES de pontuar — não sobrescrever
* (senão a última fatia venceria e reportaria só metade do arquivo).
* @param {object|object[]} reportOrReports parsed mutation.json (ou array)
* @returns {Record<string, number>}
*/
export function measureMutationScores(reportOrReports) {
const reports = Array.isArray(reportOrReports) ? reportOrReports : [reportOrReports];
const mutantsByFile = {};
for (const report of reports) {
for (const [file, data] of Object.entries(report?.files || {})) {
(mutantsByFile[file] ||= []).push(...(data?.mutants || []));
}
}
const out = {};
for (const [file, mutants] of Object.entries(mutantsByFile)) {
const score = mutationScoreForFile({ mutants });
if (score !== null) out[file] = score;
}
return out;
}
/**
* Lê metrics["mutationScore.<path>"].value do quality-baseline.json.
* Retorna {} se o arquivo ou as chaves estiverem ausentes (sem baseline não há
* ratchet possível — o caller trata como SKIP gracioso, exit 0).
* @param {string} baselinePath
* @returns {Record<string, number>}
*/
export function readBaselineMutationScores(baselinePath = BASELINE_PATH) {
if (!fs.existsSync(baselinePath)) return {};
let baselineJson;
try {
baselineJson = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
} catch {
return {};
}
const metrics = baselineJson?.metrics || {};
const out = {};
for (const [key, val] of Object.entries(metrics)) {
if (key.startsWith(BASELINE_PREFIX) && val && typeof val.value === "number") {
out[key.slice(BASELINE_PREFIX.length)] = val.value;
}
}
return out;
}
function loadReport(p) {
return JSON.parse(fs.readFileSync(p, "utf8"));
}
function main(argv) {
const paths = argv.slice(2).filter((a) => !a.startsWith("--"));
const reportPaths = paths.length > 0 ? paths : [DEFAULT_REPORT];
const existing = reportPaths.filter((p) => fs.existsSync(p));
if (existing.length === 0) {
process.stdout.write("mutationScore=SKIP reason=no-report\n");
process.exit(0);
}
const measured = measureMutationScores(existing.map(loadReport));
const baseline = readBaselineMutationScores();
const regressions = [];
const modules = Object.keys(measured).sort();
for (const mod of modules) {
const current = measured[mod];
if (!(mod in baseline)) {
process.stdout.write(`mutationScore.${mod}=${current.toFixed(2)} (no baseline — advisory)\n`);
continue;
}
const { regressed } = evaluateMutationRatchet(current, baseline[mod]);
const tag = regressed ? "REGRESSED" : "ok";
process.stdout.write(
`mutationScore.${mod}=${current.toFixed(2)} baseline=${baseline[mod].toFixed(2)} ${tag}\n`
);
if (regressed) regressions.push({ mod, current, baseline: baseline[mod] });
}
if (regressions.length > 0 && RATCHET) {
process.stderr.write(
`\nMutation ratchet FAILED — ${regressions.length} module(s) dropped below baseline:\n`
);
for (const r of regressions) {
process.stderr.write(` ${r.mod}: ${r.current.toFixed(2)} < ${r.baseline.toFixed(2)}\n`);
}
process.exit(1);
}
process.exit(0);
}
if (
import.meta.url === `file://${process.argv[1]}` ||
process.argv[1] === fileURLToPath(import.meta.url)
) {
main(process.argv);
}
@@ -0,0 +1,121 @@
#!/usr/bin/env node
// check-mutation-test-coverage — guards against tap.testFiles drift.
//
// WHY: Stryker (nightly-mutation) only runs the test files listed in
// stryker.conf.json `tap.testFiles` against each mutant. When a NEW unit test
// that covers a mutated module is added (or an existing one is split/renamed)
// but NOT added to tap.testFiles, that test's kills stop counting. The mutants
// it would kill then go COVERED-but-unkilled = SURVIVED on a cold run, so the
// module's COVERED mutation score collapses and the blocking mutationScore
// ratchet (nightly-mutation.yml) false-fails — but only on cold-cache nights,
// because the warm incremental run reuses the older (passing) verdicts. The
// pass/fail then tracks GitHub cache state, not code quality. Root cause:
// tap.testFiles is a hand-maintained list with no drift guard. This is it.
//
// INVARIANT: every UNIT test (tests/unit/**) that imports a mutated module
// (stryker.conf.json `mutate`) MUST be in `tap.testFiles`. Integration/e2e
// tests are intentionally excluded (the tap-runner runs node:test units only).
//
// MODE: advisory by default (prints drift, exit 0). `--strict` exits 1 on drift
// so CI can block. Skip-graceful (exit 0) if stryker.conf.json is absent.
//
// USAGE:
// node scripts/check/check-mutation-test-coverage.mjs # advisory
// node scripts/check/check-mutation-test-coverage.mjs --strict # blocking
import fs from "node:fs";
import { execFileSync } from "node:child_process";
/** 3-segment path suffix without extension (unique enough; matches relative + alias imports). */
export function moduleFragment(modulePath) {
return modulePath.replace(/\.ts$/, "").split("/").slice(-3).join("/");
}
/**
* True if `content` imports the module identified by `fragment`, in any form:
* static `... from "…fragment…"`, dynamic `import("…fragment…")` (incl. split
* across lines), or `require("…fragment…")`. The fragment must appear INSIDE the
* import string literal — a bare mention in a comment does not match.
*/
export function testImportsModule(content, fragment) {
const esc = fragment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(
"(?:from\\s+|\\bimport\\s*\\(\\s*|\\brequire\\s*\\(\\s*)[\"'][^\"']*" + esc
);
return re.test(content);
}
/**
* @param {{mutate: string[], tapTestFiles: string[], unitTests: {path:string, content:string}[]}} input
* @returns {Record<string,string[]>} module -> covering unit tests NOT in tap.testFiles (drift)
*/
export function findCoverageDrift({ mutate, tapTestFiles, unitTests }) {
const tap = new Set(tapTestFiles);
const drift = {};
for (const mod of mutate) {
if (mod.startsWith("_") || !mod.endsWith(".ts")) continue; // skip comment/non-ts entries
const frag = moduleFragment(mod);
const missing = unitTests
.filter((t) => testImportsModule(t.content, frag) && !tap.has(t.path))
.map((t) => t.path)
.sort();
if (missing.length > 0) drift[mod] = missing;
}
return drift;
}
function listUnitTests() {
// Static argv — no shell, no interpolation.
const out = execFileSync("git", ["ls-files", "tests/unit"], { encoding: "utf8" });
return out
.split("\n")
.filter((f) => /\.test\.ts$/.test(f))
// Exclude tests/unit/build/: these test the build TOOLING (scripts/), not the
// mutated runtime modules. They legitimately embed module paths as fixture
// strings (e.g. this gate's own test), which would otherwise false-match.
.filter((f) => !f.startsWith("tests/unit/build/"))
.map((path) => ({ path, content: fs.readFileSync(path, "utf8") }));
}
function main() {
const STRICT = process.argv.includes("--strict");
let conf;
try {
conf = JSON.parse(fs.readFileSync("stryker.conf.json", "utf8"));
} catch {
console.warn("[mutation-test-coverage] stryker.conf.json not found — skipping (advisory).");
process.exit(0);
}
const mutate = (conf.mutate || []).filter((m) => typeof m === "string");
const tapTestFiles = conf.tap?.testFiles || [];
const unitTests = listUnitTests();
const drift = findCoverageDrift({ mutate, tapTestFiles, unitTests });
const modules = Object.keys(drift);
const total = modules.reduce((n, m) => n + drift[m].length, 0);
console.log("Mutation test-coverage gate — tap.testFiles drift detection");
console.log("===========================================================");
console.log(
`Scanned ${unitTests.length} unit test file(s) against ${mutate.filter((m) => m.endsWith(".ts")).length} mutated module(s).`
);
if (total === 0) {
console.log("✓ No drift — every covering unit test is listed in tap.testFiles.");
process.exit(0);
}
for (const m of modules) {
console.log(`\n ${m}`);
for (const t of drift[m]) console.log(` + ${t}`);
}
const msg = `${total} covering unit test(s) across ${modules.length} module(s) are missing from stryker.conf.json tap.testFiles.`;
if (STRICT) {
console.error(`\n${msg} Add them so their mutant kills count (--strict).`);
process.exit(1);
}
console.warn(`\n${msg} Re-run with --strict to fail. Add them to tap.testFiles.`);
process.exit(0);
}
if (import.meta.url === `file://${process.argv[1]}`) main();
+497
View File
@@ -0,0 +1,497 @@
#!/usr/bin/env node
// scripts/check/check-openapi-breaking.mjs
// Catraca de breaking-change na API pública (Fase 8 B.4 — backlog opcional).
//
// Diffa a spec do PR (docs/openapi.yaml na working tree = HEAD) contra
// a MESMA spec no branch base, via `oasdiff breaking`. Pega regressões de contrato:
// endpoint removido, parâmetro novo obrigatório, campo de resposta removido, enum
// estreitado, etc. — mudanças que quebram clientes existentes.
//
// Complementa os gates anti-alucinação existentes:
// • check-openapi-routes.mjs — toda `path` na spec resolve a uma rota real.
// • check-openapi-coverage.mjs — % de rotas reais documentadas (ratchet).
// Nenhum dos dois compara DUAS versões da spec; este sim.
//
// Saída (stdout, KEY=VALUE para o coletor de métricas collect-metrics.mjs):
// openapiBreaking=N — número de breaking changes
// openapiBreaking=SKIP reason=binary-absent — oasdiff não está no PATH
// openapiBreaking=SKIP reason=base-unresolved — a spec base não pôde ser lida
// (arquivo não existia no base, ou
// clone shallow sem o ref base)
//
// Por default é ADVISORY (sai 0 SEMPRE, mesmo com N>0). Passe --ratchet para
// tornar BLOQUEANTE: lê metrics.openapiBreaking.value de
// config/quality/quality-baseline.json, compara a contagem MEDIDA e SAI 1 SE — E
// SOMENTE SE — a medida for MAIOR que o baseline (regressão real, direction:down).
// Qualquer SKIP gracioso (oasdiff ausente do PATH, spec base não resolvível em
// clone shallow, JSON inválido) SAI 0 MESMO com --ratchet — uma falha de MEDIÇÃO
// nunca bloqueia, só uma regressão MEDIDA bloqueia (mesma trajetória de todo gate
// neste repo: report → ratchet → block).
//
// Base ref:
// • CI passa BASE_REF=${{ github.base_ref }} (ex.: "release/vX.Y.Z").
// • Local: default derivado da versão do package.json (releaseBranchForVersion),
// ex.: package 3.8.29 → "origin/release/v3.8.29" — nunca fica stale entre ciclos.
// A spec base é extraída com `git show <BASE_REF>:docs/openapi.yaml`.
//
// Uso:
// node scripts/check/check-openapi-breaking.mjs
// BASE_REF=origin/release/vX.Y.Z node scripts/check/check-openapi-breaking.mjs
// node scripts/check/check-openapi-breaking.mjs --json # imprime JSON bruto do oasdiff
// node scripts/check/check-openapi-breaking.mjs --quiet # suprime logs de diagnóstico
// node scripts/check/check-openapi-breaking.mjs --ratchet # falha (exit 1) numa regressão
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync, spawnSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const QUIET = process.argv.includes("--quiet");
const PRINT_JSON = process.argv.includes("--json");
const RATCHET = process.argv.includes("--ratchet");
const SPEC_REL = "docs/openapi.yaml";
const SPEC_PATH = path.join(ROOT, "docs", "openapi.yaml");
/**
* Deriva o branch base de release a partir de uma versão semver
* (ex.: "3.8.29" → "origin/release/v3.8.29"). Mantém o default sincronizado com
* o ciclo de release SEM hard-code: o version-bump atualiza package.json a cada
* ciclo, então o default nunca fica stale (era "origin/release/v3.8.27" fixo).
* Ignora sufixos de prerelease/build (ex.: "3.8.29-dev.2" → v3.8.29).
*
* @param {string|null|undefined} version
* @returns {string|null} branch base (sem `origin/` ausente) ou null se não-semver
*/
export function releaseBranchForVersion(version) {
const m = String(version ?? "")
.trim()
.match(/^(\d+)\.(\d+)\.(\d+)/);
return m ? `origin/release/v${m[1]}.${m[2]}.${m[3]}` : null;
}
function readPackageVersion() {
try {
return JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
} catch {
return null;
}
}
// CI sempre passa BASE_REF=${{ github.base_ref }} e vence; este default só vale
// para runs locais. Derivado da versão para não re-driftar a cada release.
const DEFAULT_BASE_REF = releaseBranchForVersion(readPackageVersion()) || "origin/release/v3.8.29";
const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json");
// ---------------------------------------------------------------------------
// Pure parsing function (exported for tests)
// ---------------------------------------------------------------------------
/**
* Conta breaking changes no JSON emitido por `oasdiff breaking --format json`.
*
* O oasdiff emite um array de objetos (ou array vazio quando não há breaking
* change). Cada objeto tem a forma:
* [
* {
* id: string, // ex.: "api-path-removed-without-deprecation"
* text: string, // descrição legível
* level: number, // 3 = ERR, 2 = WARN, 1 = INFO
* operation: string, // ex.: "GET"
* path: string, // ex.: "/api/bar"
* section: string,
* source?: string,
* baseSource?: { file, line, column },
* fingerprint: string
* },
* ...
* ]
*
* @param {Array|null} oasdiffJson - Array de breaking changes do oasdiff (ou null).
* @returns {{ count: number, byId: Record<string, number>, byPath: Record<string, number>, items: Array }}
*/
export function parseOasdiffBreaking(oasdiffJson) {
// null, undefined ou array vazio = nenhum breaking change.
if (
oasdiffJson === null ||
oasdiffJson === undefined ||
(Array.isArray(oasdiffJson) && oasdiffJson.length === 0)
) {
return { count: 0, byId: {}, byPath: {}, items: [] };
}
// Defensivo: qualquer coisa que não seja array é tratada como "sem dados".
if (!Array.isArray(oasdiffJson)) {
return { count: 0, byId: {}, byPath: {}, items: [] };
}
let count = 0;
const byId = {};
const byPath = {};
const items = [];
for (const change of oasdiffJson) {
if (!change || typeof change !== "object") continue;
count++;
items.push(change);
const id = change.id ?? change.ID ?? "unknown";
byId[id] = (byId[id] ?? 0) + 1;
const p = change.path ?? change.Path ?? "unknown";
byPath[p] = (byPath[p] ?? 0) + 1;
}
return { count, byId, byPath, items };
}
// ---------------------------------------------------------------------------
// Ratchet (direction:down) — exported for tests
// ---------------------------------------------------------------------------
/**
* Avalia a contagem MEDIDA de breaking changes contra o baseline.
* Direction: down (a contagem só pode CAIR — mais breaking changes = regressão).
*
* Uma medição ausente (current null/undefined) OU um baseline ausente
* (baseline null/undefined) → { regressed:false, skipped:true }: sem uma das
* duas pontas não há ratchet possível, então o caller trata como SKIP gracioso
* (exit 0 mesmo com --ratchet). Uma falha de MEDIÇÃO nunca bloqueia.
*
* @param {object} args
* @param {number|null} args.current - Breaking changes medidos agora (null = sem medição).
* @param {number|null} args.baseline - Contagem congelada em quality-baseline.json (null = sem baseline).
* @returns {{ regressed: boolean, skipped: boolean }}
*/
export function evaluateOpenapiRatchet({ current, baseline }) {
if (current === null || current === undefined || baseline === null || baseline === undefined) {
return { regressed: false, skipped: true };
}
return { regressed: current > baseline, skipped: false };
}
/**
* Lê metrics.openapiBreaking.value do quality-baseline.json.
* Retorna null se o arquivo ou a métrica estiverem ausentes/inválidos (sem
* baseline não há ratchet possível — o caller trata como SKIP gracioso, exit 0).
*
* @param {string} baselinePath
* @returns {number|null}
*/
export function readBaselineOpenapiValue(baselinePath = BASELINE_PATH) {
if (!fs.existsSync(baselinePath)) return null;
let baselineJson;
try {
baselineJson = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
} catch {
return null;
}
const metric = baselineJson?.metrics?.openapiBreaking;
if (!metric || typeof metric.value !== "number") return null;
return metric.value;
}
// ---------------------------------------------------------------------------
// Binary detection
// ---------------------------------------------------------------------------
/**
* Detecta se o binário `oasdiff` está disponível no PATH.
* Usa `which` (Unix) sem interpolação de shell — Hard Rule #13.
*
* @returns {string|null} Caminho para o binário, ou null se ausente.
*/
export function findOasdiff() {
try {
const result = spawnSync("which", ["oasdiff"], {
encoding: "utf8",
timeout: 5_000,
});
if (result.status === 0 && result.stdout.trim()) {
return result.stdout.trim();
}
} catch {
// which não disponível
}
// Fallback: tentar executar diretamente para distinguir ENOENT de "existe".
try {
const result = spawnSync("oasdiff", ["--version"], {
encoding: "utf8",
timeout: 5_000,
});
if (result.error?.code === "ENOENT") return null;
if (result.status !== null) return "oasdiff"; // encontrado no PATH
} catch {
// noop
}
return null;
}
// ---------------------------------------------------------------------------
// Base spec resolution
// ---------------------------------------------------------------------------
/**
* Extrai a spec base via `git show <BASE_REF>:<SPEC_REL>` para um arquivo temp.
* Retorna o caminho do temp (chamador é responsável por limpar), ou null se a
* spec não pôde ser resolvida (ref ausente em clone shallow, ou arquivo não
* existia naquele ref). NUNCA lança — falha → null → SKIP gracioso.
*
* @param {string} baseRef
* @returns {string|null} caminho do arquivo temp com a spec base, ou null.
*/
export function resolveBaseSpec(baseRef) {
let stdout;
try {
stdout = execFileSync("git", ["show", `${baseRef}:${SPEC_REL}`], {
cwd: ROOT,
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
timeout: 30_000,
stdio: ["ignore", "pipe", "ignore"], // descarta stderr ruidoso do git
});
} catch {
// ref desconhecido (shallow clone), arquivo inexistente no base, etc.
return null;
}
if (!stdout || !stdout.trim()) return null;
const tmpFile = path.join(
os.tmpdir(),
`oasdiff-base-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.yaml`
);
try {
fs.writeFileSync(tmpFile, stdout, "utf8");
} catch {
return null;
}
return tmpFile;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const baseRef = (process.env.BASE_REF || "").trim() || DEFAULT_BASE_REF;
// 1) HEAD spec precisa existir (working tree).
if (!fs.existsSync(SPEC_PATH)) {
console.log("openapiBreaking=SKIP reason=head-spec-absent");
if (!QUIET) {
process.stderr.write(`[openapi-breaking] SKIP — spec não encontrada: ${SPEC_PATH}\n`);
}
process.exitCode = 0;
return;
}
// 2) Binário oasdiff precisa estar no PATH.
const oasdiffBin = findOasdiff();
if (!oasdiffBin) {
console.log("openapiBreaking=SKIP reason=binary-absent");
if (!QUIET) {
process.stderr.write(
"[openapi-breaking] SKIP — oasdiff não encontrado no PATH.\n" +
"[openapi-breaking] Instale via: https://github.com/oasdiff/oasdiff\n" +
"[openapi-breaking] ADVISORY — este gate sai 0 (promove a bloqueante depois).\n"
);
}
process.exitCode = 0;
return;
}
// 3) Resolver a spec base (git show → temp). SKIP se não der.
const baseTmp = resolveBaseSpec(baseRef);
if (!baseTmp) {
console.log(`openapiBreaking=SKIP reason=base-unresolved ref=${baseRef}`);
if (!QUIET) {
process.stderr.write(
`[openapi-breaking] SKIP — não consegui ler ${SPEC_REL} em '${baseRef}'.\n` +
"[openapi-breaking] Causas: clone shallow sem o ref base, arquivo novo (não existia no base),\n" +
"[openapi-breaking] ou ref inválido. Em CI use fetch-depth: 0 ou git fetch do base ref.\n"
);
}
process.exitCode = 0;
return;
}
try {
// 4) Rodar `oasdiff breaking --format json <baseTmp> <headSpec>`.
// oasdiff sai 0 por padrão mesmo com breaking changes (só com --fail-on
// é que sai 1). Capturamos stdout independentemente do exit code.
const args = ["breaking", "--format", "json", baseTmp, SPEC_PATH];
if (!QUIET) {
process.stderr.write(
`[openapi-breaking] Rodando: oasdiff breaking --format json <base:${baseRef}> ${SPEC_REL} ...\n`
);
}
let stdout = "";
try {
stdout = execFileSync(oasdiffBin, args, {
cwd: ROOT,
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
timeout: 90_000,
});
} catch (err) {
// oasdiff PODE sair !=0 (ex.: com --fail-on em versões futuras, ou erro real).
// Capturamos stdout de qualquer jeito: se ele tem JSON parseável, é o resultado.
stdout = err.stdout ? String(err.stdout) : "";
const stderr = err.stderr ? String(err.stderr) : "";
if (!stdout.trim()) {
// Sem stdout = erro real do oasdiff (spec inválida, etc.). Advisory → SKIP.
console.log("openapiBreaking=SKIP reason=oasdiff-error");
if (!QUIET) {
process.stderr.write(`[openapi-breaking] SKIP — oasdiff falhou: ${err.message}\n`);
if (stderr) {
process.stderr.write(`[openapi-breaking] stderr: ${stderr.slice(0, 500)}\n`);
}
}
process.exitCode = 0;
return;
}
}
const trimmed = stdout.trim();
let parsed = [];
if (trimmed && trimmed !== "null") {
try {
parsed = JSON.parse(trimmed);
} catch (parseErr) {
// JSON inesperado — advisory, não derruba o build.
console.log("openapiBreaking=SKIP reason=parse-error");
if (!QUIET) {
process.stderr.write(
`[openapi-breaking] SKIP — JSON do oasdiff não parseável: ${parseErr.message}\n` +
`[openapi-breaking] stdout (primeiros 500): ${trimmed.slice(0, 500)}\n`
);
}
process.exitCode = 0;
return;
}
}
if (PRINT_JSON) {
process.stdout.write(JSON.stringify(parsed, null, 2) + "\n");
return;
}
const { count, byId, byPath, items } = parseOasdiffBreaking(parsed);
// Emitir KEY=VALUE para o coletor de métricas.
console.log(`openapiBreaking=${count}`);
if (!QUIET) {
if (count > 0) {
const topIds = Object.entries(byId)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([id, n]) => `${id}(${n})`)
.join(", ");
process.stderr.write(
`[openapi-breaking] ⚠️ ${count} breaking change(s) vs '${baseRef}' (top: ${topIds})\n`
);
for (const it of items.slice(0, 20)) {
const op = it.operation ?? it.Operation ?? "?";
const p = it.path ?? it.Path ?? "?";
const txt = it.text ?? it.Text ?? it.id ?? "";
process.stderr.write(`[openapi-breaking] ✗ ${op} ${p}${txt}\n`);
}
if (items.length > 20) {
process.stderr.write(`[openapi-breaking] … +${items.length - 20} more\n`);
}
// Pista de mitigação: por path.
const topPaths = Object.entries(byPath)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([p, n]) => `${p}(${n})`)
.join(", ");
process.stderr.write(`[openapi-breaking] affected paths: ${topPaths}\n`);
if (RATCHET) {
process.stderr.write(
"[openapi-breaking] Se a quebra é intencional (major bump), documente no PR e\n" +
"[openapi-breaking] re-baseline metrics.openapiBreaking em config/quality/quality-baseline.json\n" +
"[openapi-breaking] com justificativa + issue de tracking; senão, ajuste a spec.\n"
);
} else {
process.stderr.write(
"[openapi-breaking] ADVISORY — passe --ratchet para BLOQUEAR uma regressão. Se a quebra é\n" +
"[openapi-breaking] intencional (major bump), documente no PR; senão, ajuste a spec.\n"
);
}
} else {
process.stderr.write(
`[openapi-breaking] OK — nenhuma breaking change na spec vs '${baseRef}'.\n`
);
}
}
// Medição bem-sucedida → aplica o ratchet (bloqueante só com --ratchet).
applyRatchet(count);
} finally {
// Limpa o arquivo temp da spec base.
try {
fs.unlinkSync(baseTmp);
} catch {
// best-effort
}
}
}
/**
* Aplica o ratchet (direction:down) sobre a contagem medida vs o baseline.
* Sem --ratchet: advisory (exit 0). Com --ratchet: exit 1 numa regressão real
* (medida > baseline). Baseline ausente → SKIP gracioso (exit 0).
*
* @param {number} count - Contagem MEDIDA de breaking changes (medição bem-sucedida).
*/
function applyRatchet(count) {
if (!RATCHET) {
process.exitCode = 0;
return;
}
const baselineValue = readBaselineOpenapiValue(BASELINE_PATH);
const { regressed, skipped } = evaluateOpenapiRatchet({
current: count,
baseline: baselineValue,
});
if (skipped) {
if (!QUIET) {
process.stderr.write(
"[openapi-breaking] baseline ausente (metrics.openapiBreaking) — SKIP gracioso, sai 0.\n"
);
}
process.exitCode = 0;
return;
}
if (regressed) {
process.stderr.write(
`[openapi-breaking] REGRESSÃO — ${count} breaking change(s) > baseline ${baselineValue}\n` +
" → Ajuste a spec para não quebrar clientes existentes. Se a quebra é intencional\n" +
" (major bump), re-baseline metrics.openapiBreaking em\n" +
" config/quality/quality-baseline.json com justificativa + issue de tracking.\n"
);
process.exitCode = 1;
return;
}
if (!QUIET) {
process.stderr.write(
`[openapi-breaking] OK — sem regressão (${count} breaking change(s), baseline ${baselineValue}).\n`
);
}
process.exitCode = 0;
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env node
/**
* Validates that openapi.yaml documents ≥ 99% of implemented routes.
* Routes marked x-internal: true in openapi.yaml count as "covered" because
* they are acknowledged as existing — just not part of the public API surface.
*
* Fails if coverage < 99%.
*/
import fs from "node:fs";
import path from "node:path";
import * as yaml from "js-yaml";
const ROOT = process.cwd();
const API_ROOT = path.join(ROOT, "src", "app", "api");
const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml");
// Floor recorded on 2026-05-26 for release/v3.8.4: 137/365 routes documented.
// The original ≥99% target tracks the OpenAPI audit follow-up (#2701);
// until the backlog (services, free-proxies, relay-tokens, key-groups,
// middleware/hooks, etc.) is documented, the gate enforces "no regressions"
// instead of the absolute target. Raise this back to 99 once the backlog clears.
const THRESHOLD = 36;
function collectRoutePaths(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const paths = [];
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
paths.push(...collectRoutePaths(fullPath));
continue;
}
if (entry.isFile() && entry.name === "route.ts") {
const apiPath = path
.dirname(fullPath)
.replace(API_ROOT, "")
.replace(/\[([^\]]+)\]/g, "{$1}");
paths.push(`/api${apiPath}`);
}
}
return paths;
}
function normalizePath(p) {
return p.replace(/\/\[\.\.\.([^\]]+)\]/g, "/{$1}").replace(/\[([^\]]+)\]/g, "{$1}");
}
if (!fs.existsSync(API_ROOT)) {
console.error(`[openapi-coverage] FAIL — API root not found: ${API_ROOT}`);
process.exit(1);
}
if (!fs.existsSync(OPENAPI_PATH)) {
console.error(`[openapi-coverage] FAIL — openapi.yaml not found: ${OPENAPI_PATH}`);
process.exit(1);
}
const implementedPaths = collectRoutePaths(API_ROOT).map(normalizePath).sort();
const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8"));
const documentedPaths = new Set(Object.keys(raw.paths || {}));
let covered = 0;
const missing = [];
for (const p of implementedPaths) {
if (documentedPaths.has(p)) {
covered++;
} else {
missing.push(p);
}
}
const total = implementedPaths.length;
const coverage = (covered / total) * 100;
if (coverage >= THRESHOLD) {
console.log(
`[openapi-coverage] PASS — ${coverage.toFixed(1)}% (${covered}/${total} routes documented)`
);
process.exit(0);
} else {
console.error(`[openapi-coverage] FAIL — coverage ${coverage.toFixed(1)}% < ${THRESHOLD}%`);
console.error(`Missing routes (${missing.length}):`);
missing.forEach((p) => console.error(` - ${p}`));
process.exit(1);
}
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env node
// scripts/check/check-openapi-routes.mjs
// Gate anti-alucinação (docs): toda `path` documentada em docs/openapi.yaml
// deve resolver para um route.ts real em src/app/api/. Pega endpoint INVENTADO/obsoleto
// na spec (a IA escreve docs descrevendo rota que não existe). Complementa
// check-openapi-coverage.mjs (que mede a direção inversa: % de rotas documentadas).
// Stale-enforcement (6A.3): entrada em KNOWN_STALE_SPEC que não suprime nenhum path
// órfão real → gate falha com instrução de remoção (evita furo de regressão silencioso).
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import * as yaml from "js-yaml";
import { assertNoStale } from "./lib/allowlist.mjs";
const ROOT = process.cwd();
const API_ROOT = path.join(ROOT, "src", "app", "api");
const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml");
// Entradas da spec sem rota real, congeladas para triagem (catraca: bloqueia NOVAS).
export const KNOWN_STALE_SPEC = new Set([
// openapi.yaml documenta um state por-agente, mas a rota real é o state GLOBAL
// (/api/tools/agent-bridge/state); por-agente só há /{id}, /{id}/detect, /mappings, /dns.
]);
/** Normaliza qualquer {param} para {} para casar independente do nome do parâmetro. */
export function normalizeParams(p) {
return p.replace(/\{[^}]+\}/g, "{}");
}
/** Paths da spec que não casam com nenhuma rota implementada (param-insensitive). */
export function findSpecPathsWithoutRoute(specPaths, implPaths) {
const impl = new Set(implPaths.map(normalizeParams));
return specPaths.filter((p) => !impl.has(normalizeParams(p)));
}
function collectRoutePaths(dir) {
const paths = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
paths.push(...collectRoutePaths(full));
} else if (entry.isFile() && entry.name === "route.ts") {
const apiPath = path
.dirname(full)
.replace(API_ROOT, "")
.replace(/\/\[\.\.\.([^\]]+)\]/g, "/{$1}")
.replace(/\[([^\]]+)\]/g, "{$1}");
paths.push(`/api${apiPath}`);
}
}
return paths;
}
function main() {
if (!fs.existsSync(OPENAPI_PATH)) {
console.error(`[openapi-routes] FAIL — openapi.yaml não encontrado: ${OPENAPI_PATH}`);
process.exit(1);
}
const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8"));
const specPaths = Object.keys(raw.paths || {}).filter((p) => p.startsWith("/api"));
const implPaths = collectRoutePaths(API_ROOT);
// Live orphans BEFORE allowlist filtering (needed for stale-enforcement).
const liveOrphans = findSpecPathsWithoutRoute(specPaths, implPaths);
assertNoStale(KNOWN_STALE_SPEC, liveOrphans, "openapi-routes");
const orphans = liveOrphans.filter((p) => !KNOWN_STALE_SPEC.has(p));
if (orphans.length) {
console.error(
`[openapi-routes] ${orphans.length} path(s) documentado(s) sem rota real:\n` +
orphans.map((p) => " ✗ " + p).join("\n") +
`\n → crie a rota, corrija/remova a entrada na spec, ou adicione a KNOWN_STALE_SPEC com justificativa.`
);
process.exitCode = 1;
}
if (!process.exitCode) {
console.log(
`[openapi-routes] OK — ${specPaths.length} paths na spec, todos com rota real (${implPaths.length} rotas)`
);
}
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
@@ -0,0 +1,120 @@
#!/usr/bin/env node
/**
* Cross-references openapi.yaml x-loopback-only / x-always-protected annotations
* against the compile-time constants in src/server/authz/routeGuard.ts.
*
* Fails if any YAML annotation disagrees with the routeGuard.ts constants.
*/
import fs from "node:fs";
import path from "node:path";
import * as yaml from "js-yaml";
const ROOT = process.cwd();
const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml");
const ROUTE_GUARD_PATH = path.join(ROOT, "src", "server", "authz", "routeGuard.ts");
function parseStringArray(match) {
if (!match) return [];
// Strip line comments before splitting — array entries in routeGuard.ts often
// carry inline `// T-XX:` annotations that would otherwise pollute the parsed tokens.
return match[1]
.replace(/\/\/[^\n]*/g, "")
.split(",")
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
.filter(Boolean);
}
const guardSrc = fs.readFileSync(ROUTE_GUARD_PATH, "utf-8");
const LOCAL_ONLY_PREFIXES = parseStringArray(
guardSrc.match(/export const LOCAL_ONLY_API_PREFIXES.*?=\s*\[([^\]]+)\]/s)
);
const ALWAYS_PROTECTED_PATHS = parseStringArray(
guardSrc.match(/export const ALWAYS_PROTECTED_API_PATHS.*?=\s*\[([^\]]+)\]/s)
);
if (LOCAL_ONLY_PREFIXES.length === 0 || ALWAYS_PROTECTED_PATHS.length === 0) {
console.error("[openapi-security-tiers] FAIL — could not parse routeGuard.ts constants");
process.exit(1);
}
const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8"));
const paths = raw.paths || {};
const errors = [];
for (const [pathStr, methods] of Object.entries(paths)) {
if (!methods || typeof methods !== "object") continue;
for (const [method, spec] of Object.entries(methods)) {
if (!["get", "post", "put", "patch", "delete"].includes(method) || !spec) continue;
if (spec["x-loopback-only"] === true) {
const matchesPrefix = LOCAL_ONLY_PREFIXES.some((prefix) => {
const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
return pathStr === norm || pathStr.startsWith(norm + "/");
});
if (!matchesPrefix) {
errors.push(
`${method.toUpperCase()} ${pathStr}: has x-loopback-only but is NOT covered by ` +
`LOCAL_ONLY_API_PREFIXES [${LOCAL_ONLY_PREFIXES.join(", ")}]`
);
}
}
if (spec["x-always-protected"] === true) {
const matchesPath = ALWAYS_PROTECTED_PATHS.some(
(p) => pathStr === p || pathStr.startsWith(`${p}/`)
);
if (!matchesPath) {
errors.push(
`${method.toUpperCase()} ${pathStr}: has x-always-protected but is NOT in ` +
`ALWAYS_PROTECTED_API_PATHS [${ALWAYS_PROTECTED_PATHS.join(", ")}]`
);
}
}
}
}
// Reverse pass: every YAML path that falls under a LOCAL_ONLY prefix should
// carry `x-loopback-only: true` on every method, otherwise external API
// consumers have no signal that the route is loopback-restricted. Closes the
// "new spawn-capable route added without annotation" regression class.
//
// Currently reported as warnings (non-fatal) because the v3.8.4 release ships
// with a known annotation gap on /api/services/* and /api/cli-tools/runtime/*
// that will be patched in a follow-up doc-only PR. Promote to errors once the
// backlog is cleared.
const reverseWarnings = [];
for (const [pathStr, methods] of Object.entries(paths)) {
if (!methods || typeof methods !== "object") continue;
const fallsUnderLocalOnly = LOCAL_ONLY_PREFIXES.some((prefix) => {
const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
return pathStr === norm || pathStr.startsWith(norm + "/");
});
if (!fallsUnderLocalOnly) continue;
for (const [method, spec] of Object.entries(methods)) {
if (!["get", "post", "put", "patch", "delete"].includes(method) || !spec) continue;
if (spec["x-loopback-only"] !== true) {
reverseWarnings.push(
`${method.toUpperCase()} ${pathStr}: falls under LOCAL_ONLY_API_PREFIXES ` +
`but is missing x-loopback-only: true annotation`
);
}
}
}
if (reverseWarnings.length > 0) {
console.warn(
`[openapi-security-tiers] WARN — ${reverseWarnings.length} LOCAL_ONLY paths missing x-loopback-only annotation (non-fatal, follow-up doc PR):`
);
reverseWarnings.forEach((w) => console.warn(` - ${w}`));
}
if (errors.length === 0) {
console.log("[openapi-security-tiers] PASS — all security tier annotations match routeGuard.ts");
process.exit(0);
} else {
console.error(`[openapi-security-tiers] FAIL — ${errors.length} annotation mismatches:`);
errors.forEach((e) => console.error(` - ${e}`));
process.exit(1);
}
+258
View File
@@ -0,0 +1,258 @@
#!/usr/bin/env node
// scripts/check/check-pr-evidence.mjs
// Gate: Hard Rule #18 — "evidence before assertions".
//
// When a PR body makes claims about test/validation success (e.g. "tests pass",
// "all green", "fixed", "added endpoint") it MUST include a block of command
// output as proof. A PR body with claims but no attached evidence => FAIL.
//
// Skip policy: when not running in a PR context (no PR_BODY env var and `gh pr
// view` is unavailable), exits 0 silently so the gate never blocks local dev.
//
// Conservative by design (two-signal requirement):
// - A PR body with NO claim-trigger terms always passes.
// - A PR body with a claim but also an evidence block always passes.
// - Only the combination of claim(s) + NO evidence block => FAIL.
//
// What counts as a TRIGGER (claim term)?
// See CLAIM_TRIGGERS below. We require at least ONE strong trigger OR two
// weak triggers to fire (reduces false positives from casual phrases).
//
// What counts as EVIDENCE?
// - A fenced code block (``` ... ```) that contains ≥1 line of output
// characters (not just whitespace/backticks). The block must look like
// terminal/command output: numbers, colons, path separators, status words.
// - OR an explicit "Evidence" / "Validation" / "Test output" / "Output"
// section header (##/### prefix) followed by non-empty content.
// - OR an inline `code span` that matches common test-runner patterns
// (e.g. "passing", "failed 0", "✓", "PASS", "ok").
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
// ---------------------------------------------------------------------------
// Claim triggers
// ---------------------------------------------------------------------------
// STRONG triggers: single occurrence is sufficient to fire.
// These are explicit outcome/validation assertions.
const STRONG_TRIGGERS = [
/\ball\s+(?:tests?\s+)?(?:pass(?:ed|ing)?|green)\b/i,
/\btests?\s+(?:pass(?:ed|ing)?|are\s+(?:green|passing))\b/i,
/\b(?:typecheck|lint|build)\s+(?:pass(?:ed|ing)?|(?:is\s+)?clean|(?:is\s+)?green|(?:is\s+)?ok)\b/i,
/\bvalidated\s+(?:on|in|via|with|against|locally|on\s+vps)\b/i,
/\b(?:zero|0)\s+(?:errors?|failures?|regressions?)\b/i,
/\btdd\b.*\b(?:pass(?:ed|ing)?|green)\b/i,
/\bfixed\b.*\band\s+(?:verified|confirmed|tested)\b/i,
/\bproof\s*:/i,
];
// WEAK triggers: need at least 2 present to fire.
// These are common "seems fine" phrases that alone don't warrant evidence.
const WEAK_TRIGGERS = [
/\b(?:added|implemented|created)\s+(?:a\s+)?(?:new\s+)?(?:endpoint|route|test|handler|check)\b/i,
/\bfixed\b/i,
/\bresolves?\s+#\d+/i,
/\bshould\s+(?:work|pass|be\s+(?:fine|ok|green))\b/i,
/\b(?:verified|confirmed|validated)\b/i,
/\b(?:all|no)\s+(?:regressions?|breaking\s+changes?)\b/i,
/\blooks?\s+(?:good|fine|ok)\b/i,
/\bworks?\s+(?:correctly|fine|as\s+expected)\b/i,
];
// ---------------------------------------------------------------------------
// Evidence patterns
// ---------------------------------------------------------------------------
// 1. A fenced code block with "command-output-like" content.
// The block content must have at least one line that looks like output
// (contains digit sequences, colons, path separators, or known status tokens).
const FENCED_BLOCK_RE = /```[^\n]*\n([\s\S]*?)```/g;
const OUTPUT_LINE_RE =
/(?:\d+\s+(?:pass(?:ing)?|fail(?:ing)?|pending)|[✓✗ו]\s|\bPASS\b|\bFAIL\b|\bok\b|\berror\b.*\d|\bwarning\b.*\d|\d+\s+(?:test|spec|suite)|exit\s+code\s*[0-9]|at\s+\S+:\d+|[A-Za-z]+:\s*\d+|^\s*\d+\s+\w)/im;
// 2. An explicit evidence/validation section header followed by non-blank content.
const EVIDENCE_SECTION_RE =
/^#{1,4}\s+(?:evidence|validation|test\s+(?:output|results?|run)|output|verified|proof)\b[^\n]*/im;
// 3. An inline code span matching common test-runner result tokens.
const INLINE_RESULT_RE =
/`[^`]*(?:\d+\s+(?:pass(?:ing)?|fail(?:ing)?|test)|✓|✗|PASS(?:ED)?|FAIL(?:ED)?|all\s+\d+)[^`]*`/i;
// ---------------------------------------------------------------------------
// Pure evaluation function (exported for tests)
// ---------------------------------------------------------------------------
/**
* Evaluate a PR body string.
*
* @param {string} body
* @returns {{ result: "pass" | "fail" | "skip"; reason: string }}
*/
export function evaluatePrBody(body) {
if (!body || body.trim().length === 0) {
return { result: "skip", reason: "PR body is empty — nothing to evaluate." };
}
// --- 1. Detect claims ---
const strongMatches = STRONG_TRIGGERS.filter((re) => re.test(body));
const weakMatches = WEAK_TRIGGERS.filter((re) => re.test(body));
const hasClaim = strongMatches.length >= 1 || weakMatches.length >= 2;
if (!hasClaim) {
return { result: "pass", reason: "No outcome-claim terms detected — no evidence required." };
}
// --- 2. Detect evidence ---
const hasEvidence = hasEvidenceBlock(body);
if (hasEvidence) {
return {
result: "pass",
reason: "Outcome claim detected and evidence block found.",
};
}
// Build a helpful message listing which triggers fired.
const firedStrong = strongMatches.map((re) => re.source);
const firedWeak = weakMatches.map((re) => re.source);
return {
result: "fail",
reason:
"PR body contains outcome claims but no evidence block (command output, Evidence section, or inline result span).\n" +
(firedStrong.length > 0 ? ` Strong triggers matched: ${firedStrong.join(", ")}\n` : "") +
(firedWeak.length >= 2 ? ` Weak triggers matched (≥2): ${firedWeak.join(", ")}\n` : "") +
"\n" +
"Hard Rule #18 requires proof that the fix works:\n" +
" a) Add a fenced code block (```) containing test-runner or command output, OR\n" +
" b) Add a section headed '## Evidence', '## Validation', '## Test output', etc., OR\n" +
" c) Add an inline code span with a result token (e.g. `42 passing`, `PASSED`).\n" +
"See CLAUDE.md → Hard Rule #18.",
};
}
/**
* Returns true if the body contains at least one recognised evidence block.
* @param {string} body
* @returns {boolean}
*/
function hasEvidenceBlock(body) {
// Check fenced code blocks for output-like content.
FENCED_BLOCK_RE.lastIndex = 0;
let match;
while ((match = FENCED_BLOCK_RE.exec(body)) !== null) {
const blockContent = match[1] ?? "";
if (blockContent.trim().length > 0 && OUTPUT_LINE_RE.test(blockContent)) {
return true;
}
}
// Check for explicit evidence/validation section header with non-empty body.
if (EVIDENCE_SECTION_RE.test(body)) {
// Ensure there's actual content after the header.
const afterHeader = body.replace(EVIDENCE_SECTION_RE, "").trim();
if (afterHeader.length > 20) {
return true;
}
}
// Check for inline code span containing result tokens.
if (INLINE_RESULT_RE.test(body)) {
return true;
}
return false;
}
// ---------------------------------------------------------------------------
// CLI entry point
// ---------------------------------------------------------------------------
function getArg(name, fallbackValue = "") {
const index = process.argv.indexOf(name);
if (index === -1 || index === process.argv.length - 1) return fallbackValue;
return process.argv[index + 1];
}
function buildReport(lines) {
return `${lines.join("\n")}\n`;
}
// Only run as CLI entry point.
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) {
const summaryFile = getArg("--summary-file", "");
// --- Resolve PR body ---
let prBody = null;
// Priority 1: PR_BODY env var (set by CI / manual invocation).
if (typeof process.env.PR_BODY === "string") {
prBody = process.env.PR_BODY;
}
// Priority 2: --body-file argument.
const bodyFile = getArg("--body-file", "");
if (prBody === null && bodyFile && existsSync(bodyFile)) {
prBody = readFileSync(bodyFile, "utf8");
}
// Priority 3: `gh pr view` (only available inside a PR context).
if (prBody === null) {
const ghResult = spawnSync("gh", ["pr", "view", "--json", "body", "--jq", ".body"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (ghResult.status === 0 && ghResult.stdout.trim()) {
prBody = ghResult.stdout.trim();
}
}
// No PR context available — skip silently.
if (prBody === null) {
const report = buildReport([
"## PR Evidence Gate",
"",
"Skipped: no PR body available (PR_BODY not set, --body-file not provided, gh pr view unavailable).",
]);
if (summaryFile) {
mkdirSync(path.dirname(summaryFile), { recursive: true });
writeFileSync(summaryFile, report);
}
process.stdout.write(report);
process.exit(0);
}
const { result, reason } = evaluatePrBody(prBody);
const reportLines = ["## PR Evidence Gate", ""];
if (result === "skip") {
reportLines.push("Result: SKIP", "", reason);
} else if (result === "pass") {
reportLines.push("Result: PASS", "", reason);
} else {
reportLines.push(
"Result: FAIL",
"",
reason,
"",
"> ️ Editing the PR body to add the evidence does NOT re-run this gate — `ci.yml` " +
"does not listen to the `edited` event. Add the `## Evidence` block, then **push a " +
"commit** (or re-run this job) to re-validate. For releases, put the Evidence block in " +
"the body BEFORE the first push (see the generate-release skill, Phase 0)."
);
}
const report = buildReport(reportLines);
if (summaryFile) {
mkdirSync(path.dirname(summaryFile), { recursive: true });
writeFileSync(summaryFile, report);
}
process.stdout.write(report);
if (result === "fail") {
process.exit(1);
}
}
+136
View File
@@ -0,0 +1,136 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
const SOURCE_ROOTS = ["src/", "open-sse/", "electron/", "bin/"];
const TEST_PATTERNS = [/^tests\//, /(?:^|\/)__tests__\//, /\.(?:test|spec)\.[cm]?[jt]sx?$/];
// Test files for specific source types (e.g., Python validation scripts for i18n)
const TEST_FILE_PATTERNS = {
"src/i18n/messages/": [
/\/scripts\/validate_translation\.py$/,
/\/scripts\/check_translations\.py$/,
],
};
// Exclude directories that don't require tests (i18n has Python validation, docs, config)
const EXCLUDED_PATTERNS = [
/\/i18n\/messages\//, // i18n files have their own Python test scripts
/\.md$/, // Documentation
/\.yaml$/, // Config files
/\.yml$/, // Config files
/(?:^|\/)package(?:-lock)?\.json$/, // Dependency manifests/lockfiles (e.g. Dependabot bumps) — not testable code
];
function getArg(name, fallbackValue = "") {
const index = process.argv.indexOf(name);
if (index === -1 || index === process.argv.length - 1) {
return fallbackValue;
}
return process.argv[index + 1];
}
function runGit(args) {
return execFileSync("git", args, { encoding: "utf8" }).trim();
}
function isSourceFile(filePath) {
// Exclude patterns that don't require tests
if (EXCLUDED_PATTERNS.some((pattern) => pattern.test(filePath))) {
return false;
}
return SOURCE_ROOTS.some((root) => filePath.startsWith(root));
}
function isTestFile(filePath) {
// Check standard test patterns
if (TEST_PATTERNS.some((pattern) => pattern.test(filePath))) {
return true;
}
// Check custom test file patterns for specific directories
for (const [dir, patterns] of Object.entries(TEST_FILE_PATTERNS)) {
if (filePath.startsWith(dir) && patterns.some((pattern) => pattern.test(filePath))) {
return true;
}
}
return false;
}
function buildReport(lines) {
return `${lines.join("\n")}\n`;
}
const summaryFile = getArg("--summary-file", "");
const baseRef = process.env.GITHUB_BASE_REF;
if (!baseRef) {
const report = buildReport([
"## PR Test Policy",
"",
"Skipped: not running in a pull request context.",
]);
if (summaryFile) {
mkdirSync(path.dirname(summaryFile), { recursive: true });
writeFileSync(summaryFile, report);
}
process.stdout.write(report);
process.exit(0);
}
const baseTarget = process.env.GITHUB_BASE_SHA || `origin/${baseRef}`;
const changedFiles = runGit(["diff", "--name-only", "--diff-filter=ACMR", `${baseTarget}...HEAD`])
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const changedSourceFiles = changedFiles.filter(isSourceFile);
const changedTestFiles = changedFiles.filter(isTestFile);
const hasRequiredTests = changedSourceFiles.length === 0 || changedTestFiles.length > 0;
const reportLines = [
"## PR Test Policy",
"",
`Base ref: \`${baseRef}\``,
`Changed production files: ${changedSourceFiles.length}`,
`Changed automated test files: ${changedTestFiles.length}`,
"",
];
if (changedSourceFiles.length > 0) {
reportLines.push("### Production files in scope", "");
for (const filePath of changedSourceFiles.slice(0, 20)) {
reportLines.push(`- \`${filePath}\``);
}
reportLines.push("");
}
if (changedTestFiles.length > 0) {
reportLines.push("### Tests in this PR", "");
for (const filePath of changedTestFiles.slice(0, 20)) {
reportLines.push(`- \`${filePath}\``);
}
reportLines.push("");
}
if (hasRequiredTests) {
reportLines.push("Result: PASS");
} else {
reportLines.push(
"Result: FAIL",
"",
"This PR changes production code under `src/`, `open-sse/`, `electron/`, or `bin/` but does not add or update automated tests."
);
}
const report = buildReport(reportLines);
if (summaryFile) {
mkdirSync(path.dirname(summaryFile), { recursive: true });
writeFileSync(summaryFile, report);
}
process.stdout.write(report);
if (!hasRequiredTests) {
process.exit(1);
}
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env node
import { readFile, readdir, stat } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = fileURLToPath(new URL("../..", import.meta.url));
const providerDir = join(repoRoot, "public", "providers");
const MAX_RASTER_BYTES = 128 * 1024;
const MAX_RASTER_DIMENSION = 256;
const RASTER_EXTENSIONS = new Set([".png", ".jpg", ".jpeg"]);
function extensionOf(fileName) {
const dot = fileName.lastIndexOf(".");
return dot >= 0 ? fileName.slice(dot).toLowerCase() : "";
}
function readPngDimensions(buffer) {
if (buffer.length < 24 || buffer.toString("ascii", 1, 4) !== "PNG") return null;
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
}
function readJpegDimensions(buffer) {
let offset = 2;
while (offset + 9 < buffer.length) {
if (buffer[offset] !== 0xff) return null;
const marker = buffer[offset + 1];
const length = buffer.readUInt16BE(offset + 2);
if (marker >= 0xc0 && marker <= 0xc3) {
return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) };
}
offset += 2 + length;
}
return null;
}
async function readDimensions(filePath, extension) {
const buffer = await readFile(filePath);
if (buffer.length >= 4 && buffer.toString("ascii", 1, 4) === "PNG") {
return readPngDimensions(buffer);
}
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xd8) {
return readJpegDimensions(buffer);
}
return null;
}
const failures = [];
const files = await readdir(providerDir);
for (const fileName of files) {
const extension = extensionOf(fileName);
if (!RASTER_EXTENSIONS.has(extension)) continue;
const filePath = join(providerDir, fileName);
const info = await stat(filePath);
const dimensions = await readDimensions(filePath, extension);
if (!dimensions) {
if (info.size > 4 * 1024) {
failures.push(`${fileName}: could not read image dimensions`);
}
continue;
}
const width = dimensions.width || 0;
const height = dimensions.height || 0;
if (info.size > MAX_RASTER_BYTES) {
failures.push(
`${fileName}: ${(info.size / 1024).toFixed(1)} KiB exceeds ${MAX_RASTER_BYTES / 1024} KiB`
);
}
if (width > MAX_RASTER_DIMENSION || height > MAX_RASTER_DIMENSION) {
failures.push(
`${fileName}: ${width}x${height} exceeds ${MAX_RASTER_DIMENSION}px max dimension`
);
}
}
if (failures.length > 0) {
console.error("Provider asset budget failed:");
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log("Provider asset budget passed.");
@@ -0,0 +1,52 @@
#!/usr/bin/env node
// scripts/check/check-provider-consistency.ts
// Gate anti-alucinação nº1: toda entrada em REGISTRY (open-sse/config/providerRegistry.ts)
// deve corresponder a um provider canônico em src/shared/constants/providers.ts.
// Pega entradas de registry inventadas/meia-registradas (provider com baseUrl+models
// mas ausente da lista canônica → não selecionável pela máquina normal de providers).
// Catraca: exceções pré-existentes ficam em KNOWN_REGISTRY_ONLY; só NOVOS órfãos falham.
// Stale-enforcement (6A.3): entrada em KNOWN_REGISTRY_ONLY que não suprime nenhum órfão
// real → gate falha com instrução de remoção (evita furo de regressão silencioso).
import { pathToFileURL } from "node:url";
import { AI_PROVIDERS, getProviderById } from "@/shared/constants/providers.ts";
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
import { assertNoStale } from "./lib/allowlist.mjs";
// Entradas registry-only conhecidas (meia-registro pré-existente). Cada uma com
// justificativa. Remover daqui ao registrar o provider em providers.ts.
export const KNOWN_REGISTRY_ONLY: Record<string, string> = {};
/** Ids do REGISTRY que não são providers canônicos e não estão na allowlist. */
export function findOrphanRegistryIds(
registryIds: string[],
isKnownProvider: (id: string) => boolean,
allowlist: Record<string, string>
): string[] {
return registryIds.filter((id) => !isKnownProvider(id) && !(id in allowlist));
}
function main(): void {
const canonical = new Set(Object.keys(AI_PROVIDERS));
const isKnown = (id: string) => canonical.has(id) || Boolean(getProviderById(id));
// Live orphans BEFORE allowlist filtering (needed for stale-enforcement).
const liveOrphans = Object.keys(REGISTRY).filter((id) => !isKnown(id));
assertNoStale(Object.keys(KNOWN_REGISTRY_ONLY), liveOrphans, "provider-consistency");
const orphans = liveOrphans.filter((id) => !(id in KNOWN_REGISTRY_ONLY));
if (orphans.length) {
console.error(
`[provider-consistency] ${orphans.length} entrada(s) no REGISTRY sem provider canônico em providers.ts:\n` +
orphans.map((id) => `${id}`).join("\n") +
`\n → registre o provider em src/shared/constants/providers.ts ou adicione a KNOWN_REGISTRY_ONLY (scripts/check/check-provider-consistency.ts) com justificativa.`
);
process.exitCode = 1;
}
if (!process.exitCode) {
console.log(
`[provider-consistency] OK — ${Object.keys(REGISTRY).length} entradas REGISTRY, ${canonical.size} providers canônicos, ${Object.keys(KNOWN_REGISTRY_ONLY).length} exceção(ões) conhecida(s)`
);
}
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env node
// scripts/check/check-public-creds.mjs
// Gate de segurança — CLAUDE.md Hard Rule #11.
//
// Credenciais públicas de upstream (OAuth client_id/client_secret de CLIs públicas
// + Firebase web keys) DEVEM ser embutidas via `resolvePublicCred()` /
// `resolvePublicCredMulti()` (de open-sse/utils/publicCreds.ts), NUNCA como string
// literal no código. Ver docs/security/PUBLIC_CREDS.md.
//
// Literais embutidos (a) disparam scanners de secret/CodeQL a cada release, gerando
// ruído, e (b) acoplam o valor ao texto-fonte em vez de ao decodificador central —
// se o upstream rotacionar o client_id público, há N cópias para atualizar e o
// override por `process.env` deixa de ser a única fonte de verdade.
//
// Este gate varre os arquivos que carregam configuração de credencial e bloqueia
// QUALQUER atribuição NOVA de uma chave de credencial a uma string literal não-vazia.
// Os literais pré-existentes (auditados abaixo) ficam congelados em
// KNOWN_LITERAL_CREDS para a catraca sair 0 hoje e bloquear regressões.
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { assertNoStale } from "./lib/allowlist.mjs";
const cwd = process.cwd();
// 6A.8: Instead of a static hardcoded list, scan the two credential-bearing subtrees
// dynamically so new files (new executor, new OAuth provider) are caught automatically.
// Anchor files (providerRegistry.ts, oauth.ts) are the canonical credential config;
// the broader scan covers new additions in open-sse/ and src/lib/oauth/.
// Exclusions: test files, node_modules, .next.
const SCAN_ROOTS = [path.join(cwd, "open-sse"), path.join(cwd, "src", "lib", "oauth")];
function walkTs(dir, acc = []) {
if (!fs.existsSync(dir)) return acc;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
if (e.name !== "node_modules" && e.name !== ".next") walkTs(p, acc);
} else if (/\.tsx?$/.test(e.name) && !/\.test\.tsx?$/.test(e.name)) {
acc.push(p);
}
}
return acc;
}
function collectScannedFiles() {
const files = [];
for (const root of SCAN_ROOTS) {
for (const abs of walkTs(root)) {
files.push(path.relative(cwd, abs).replace(/\\/g, "/"));
}
}
return files;
}
// Chaves de objeto cujo valor é uma credencial. Atribuir qualquer uma destas a uma
// string literal não-vazia viola a Hard Rule #11.
// - clientIdDefault / clientSecretDefault: forma do providerRegistry (entry.oauth)
// - clientId / clientSecret: forma dos *_CONFIG em oauth.ts
// - apiKey / apiKeyDefault: chaves de API embutidas (mesmo princípio)
const CRED_KEY_RE =
/(?:^|[\s{,([])(clientIdDefault|clientSecretDefault|clientId|clientSecret|apiKeyDefault|apiKey)\s*:/;
// Chaves de ambiente (clientIdEnv, clientSecretEnv, …) terminam em "Env" e carregam
// o NOME da variável de ambiente, não a credencial — nunca devem ser flagadas.
const ENV_KEY_RE = /(clientId|clientSecret|apiKey)Env\s*:/;
// Literais pré-existentes auditados (DISCOVERY 2026-06-09). Cada um é uma credencial
// pública de upstream embutida ANTES deste gate existir. Ficam congelados aqui para
// a catraca sair 0 agora e bloquear QUALQUER literal NOVO. CADA UM é dívida de
// segurança Rule #11 a ser migrada para resolvePublicCred() — NÃO adicione novos
// sem justificativa; esse é o ponto do gate.
//
// A allowlist casa por VALOR do literal (o mesmo client_id público aparece nos dois
// arquivos, então congelar por valor cobre ambas as cópias). Para congelar um valor
// só num arquivo:linha específico, use a chave "arquivo:linha:valor".
//
// All five public client_ids (9 call-sites) were migrated to resolvePublicCred() in
// #3493 (embedded as claude_id/codex_id/qwen_id/kimi_id/github_copilot_id in
// open-sse/utils/publicCreds.ts), matching the Gemini/Antigravity pattern.
//
// 6A.8: Expanded scope to open-sse/** + src/lib/oauth/**. Newly discovered FPs:
//
// open-sse/services/usage/minimax.ts L213: `getMiniMaxUsage(apiKey: string, provider: "minimax" | "minimax-cn")`
// The CRED_KEY_RE matches `apiKey:` in the TypeScript function-parameter type annotation.
// "minimax" and "minimax-cn" are provider-name strings in the type annotation, NOT credentials.
// This is a false positive (the gate was designed for object-literal assignments, not fn params).
// TODO(6A.8): Consider tightening CRED_KEY_RE to exclude function-signature contexts — but
// that adds complexity; the FP rate is low (1 file). Frozen by file:line:value key.
// The MiniMax family was extracted from services/usage.ts into services/usage/minimax.ts
// (god-file decomposition), so the FP moved with the getMiniMaxUsage signature.
export const KNOWN_LITERAL_CREDS = new Set([
"open-sse/services/usage/minimax.ts:213:minimax", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (getMiniMaxUsage signature)
"open-sse/services/usage/minimax.ts:213:minimax-cn", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (getMiniMaxUsage signature)
]);
/**
* Encontra atribuições de uma chave de credencial a uma string literal não-vazia.
*
* Pura: recebe o texto-fonte e a allowlist, devolve a lista de violações. Não toca
* em I/O. Cada violação é "L<linha>: <key> = \"<literal>\"".
*
* Regras de detecção (linha a linha):
* 1. A linha precisa atribuir uma das CRED_KEY (clientIdDefault, clientId, …)
* e não ser uma chave *Env (que carrega só o nome da env-var).
* 2. Se o RHS chama resolvePublicCred()/resolvePublicCredMulti(), está CORRETO
* (o literal ali é a CHAVE do default embutido, não a credencial) → ignora.
* 3. Caso contrário, qualquer string literal NÃO-VAZIA no RHS é uma violação
* — cobre tanto `key: "literal"` quanto `key: process.env.X || "literal"`.
* 4. Literais vazios ("" / '') são fallback legítimo de process.env → ignorados.
* 5. Literais presentes na allowlist (por valor OU por chave "arquivo:linha:valor")
* ficam congelados → ignorados.
*
* @param {string} source conteúdo do arquivo
* @param {Set<string>} allowlist valores de literal (ou chaves arquivo:linha:valor) congelados
* @param {string} [relFile] caminho relativo do arquivo (para chaves arquivo:linha:valor)
* @returns {string[]} violações legíveis
*/
export function findLiteralCreds(source, allowlist, relFile = "") {
const violations = [];
const lines = String(source).split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const keyMatch = CRED_KEY_RE.exec(line);
if (!keyMatch) continue;
if (ENV_KEY_RE.test(line)) continue;
const key = keyMatch[1];
// RHS = tudo após o primeiro ":" da chave de credencial.
const colonIdx = line.indexOf(":", keyMatch.index);
const rhs = colonIdx >= 0 ? line.slice(colonIdx + 1) : line;
// Forma correta: embutido via decodificador central. Não inspeciona literais.
if (/resolvePublicCred(?:Multi)?\s*\(/.test(rhs)) continue;
// Extrai todo literal de string do RHS (aspas simples, duplas ou crase).
const litRe = /(["'`])((?:\\.|(?!\1).)*)\1/g;
let lit;
while ((lit = litRe.exec(rhs))) {
const value = lit[2];
if (!value) continue; // "" / '' — fallback de env, legítimo
const lineNo = i + 1;
const fileLineKey = relFile ? `${relFile}:${lineNo}:${value}` : "";
if (allowlist.has(value)) continue;
if (fileLineKey && allowlist.has(fileLineKey)) continue;
violations.push(`L${lineNo}: ${key} = ${JSON.stringify(value)}`);
}
}
return violations;
}
function main() {
const scannedFiles = collectScannedFiles();
// 6A.8: stale-allowlist enforcement.
// Compute all live violations WITHOUT the allowlist, then check for stale entries.
const liveViolationKeys = new Set();
for (const rel of scannedFiles) {
const src = fs.readFileSync(path.join(cwd, rel), "utf8");
for (const v of findLiteralCreds(src, new Set(), rel)) {
// v is like "L543: apiKey = \"minimax\"" — generate the same file:line:value key
// that the allowlist uses so stale detection matches by canonical key form.
const lineMatch = v.match(/^L(\d+):/);
const lineNo = lineMatch ? lineMatch[1] : "?";
const valMatch = v.match(/"([^"]+)"$/);
const val = valMatch ? valMatch[1] : v;
liveViolationKeys.add(`${rel}:${lineNo}:${val}`);
liveViolationKeys.add(val); // also track plain value for backward compat
}
}
assertNoStale(KNOWN_LITERAL_CREDS, liveViolationKeys, "check-public-creds");
const allMisses = [];
for (const rel of scannedFiles) {
const src = fs.readFileSync(path.join(cwd, rel), "utf8");
for (const v of findLiteralCreds(src, KNOWN_LITERAL_CREDS, rel)) {
allMisses.push(`${rel} ${v}`);
}
}
if (allMisses.length) {
console.error(
`[check-public-creds] ${allMisses.length} credencial(is) pública(s) como string literal ` +
`(viola CLAUDE.md Hard Rule #11):\n` +
allMisses.map((m) => " ✗ " + m).join("\n") +
`\n → embuta via resolvePublicCred()/resolvePublicCredMulti() ` +
`(open-sse/utils/publicCreds.ts). Ver docs/security/PUBLIC_CREDS.md.\n` +
` → se for um literal pré-existente já auditado, congele em KNOWN_LITERAL_CREDS ` +
`com justificativa (e abra tracking de migração).`
);
process.exit(1);
}
if (process.exitCode === 1) return; // stale entries already logged
console.log(
`[check-public-creds] OK (${scannedFiles.length} arquivo(s) em ${SCAN_ROOTS.length} raiz(es), ` +
`${KNOWN_LITERAL_CREDS.size} literal(is) congelado(s))`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
@@ -0,0 +1,261 @@
#!/usr/bin/env node
// scripts/check/check-route-guard-membership.ts
// Quality gate: route-guard membership (CLAUDE.md Hard Rules #15 + #17).
//
// WHY: routes that spawn child processes (`npm install`, `node`, MITM/Playwright,
// worker_threads) MUST be classified loopback-only by `isLocalOnlyPath()` in
// src/server/authz/routeGuard.ts. Loopback enforcement runs unconditionally
// BEFORE any auth check — so a leaked JWT over a tunnel cannot reach a spawn.
// A single spawn-capable `route.ts` that `isLocalOnlyPath()` does NOT match is an
// RCE-via-tunnel hole (the GHSA-fhh6-4qxv-rpqj surface the LOCAL_ONLY tier closes).
//
// This gate enumerates every `route.ts` under the spawn-capable prefixes and
// asserts each resolved URL path is classified local-only by the REAL predicate.
//
// Ratchet: any pre-existing unclassified route is frozen in KNOWN_UNCLASSIFIED
// with a justification so the gate exits 0 today; only NEW spawn-capable routes
// that slip past the guard fail. KNOWN_UNCLASSIFIED is empty today (clean
// baseline) — keep it that way; an entry here is a documented security debt.
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { pathToFileURL } from "node:url";
import { isLocalOnlyPath } from "@/server/authz/routeGuard.ts";
// Inline stale-allowlist helper (mirrors scripts/check/lib/allowlist.mjs).
// The TypeScript gate cannot import the .mjs helper directly; keep this in sync.
function assertNoStaleEntries(
allowlist: string[] | Record<string, string>,
liveItems: string[],
gateName: string
): void {
const liveSet = new Set(liveItems);
const keys = Array.isArray(allowlist) ? allowlist : Object.keys(allowlist);
const stale = keys.filter((k) => !liveSet.has(k));
if (stale.length > 0) {
console.error(
`[${gateName}] ${stale.length} entrada(s) obsoleta(s) na allowlist ` +
`— a violação foi corrigida; REMOVA a entrada para travar a correção:\n` +
stale.map((e) => `${e}`).join("\n")
);
process.exitCode = 1;
}
}
// Spawn-capable route roots (relative to repo root). Mirrors the spawn-capable
// prefixes documented in routeGuard.ts (SPAWN_CAPABLE_PREFIXES) and CLAUDE.md
// Hard Rules #15/#17 for the dirs that physically exist under src/app/api/.
export const SPAWN_CAPABLE_ROUTE_ROOTS: ReadonlyArray<string> = [
"src/app/api/services",
"src/app/api/mcp",
"src/app/api/cli-tools/runtime",
"src/app/api/local", // T-12: 1-click local service launchers (Redis today) — every child here spawns podman/docker (Hard Rules #15 + #17)
];
// Frozen pre-existing exceptions: spawn-capable routes NOT yet classified
// local-only. Each entry is a documented security debt — the route is reachable
// past the loopback gate. Empty today (every spawn-capable route is classified).
// Adding an entry here REQUIRES a justification + a follow-up to classify it in
// LOCAL_ONLY_API_PREFIXES / LOCAL_ONLY_API_PATTERNS (src/server/authz/routeGuard.ts).
export const KNOWN_UNCLASSIFIED: Record<string, string> = {};
/**
* Map a Next.js App Router `route.ts` file path to the URL path the route
* serves, in the exact shape `isLocalOnlyPath()` expects (a plain `/api/...`
* path). Dynamic `[param]` segments become a concrete `_param_` placeholder —
* `isLocalOnlyPath` matches prefixes via `startsWith`, so any non-empty segment
* satisfies the classification (e.g. `/api/services/_name_/logs` still starts
* with `/api/services/`).
*/
export function routeFileToApiPath(routeFile: string): string {
return routeFile
.replace(/\\/g, "/")
.replace(/^src\/app/, "")
.replace(/\/route\.ts$/, "")
.replace(/\[([^\]]+)\]/g, "_$1_");
}
/**
* Pure matching core: given resolved URL paths, a classifier predicate, and an
* allowlist, return the paths that are NEITHER classified local-only NOR
* allowlisted (input order preserved). These are the RCE-via-tunnel holes.
*/
export function findUnclassifiedSpawnRoutes(
apiPaths: string[],
isLocalOnly: (path: string) => boolean,
allowlist: Record<string, string>
): string[] {
return apiPaths.filter((p) => !isLocalOnly(p) && !(p in allowlist));
}
// --- 6A.8: source-based spawn detection ---
// Patterns that indicate a route.ts spawns child processes.
// Matches: import from "child_process" / "node:child_process" / "worker_threads" /
// "node:worker_threads" or a spawn( / execFile( / exec( call.
const SPAWN_SOURCE_RE =
/\b(?:from\s+["'](?:node:)?(?:child_process|worker_threads)["']|require\s*\(\s*["'](?:node:)?(?:child_process|worker_threads)["']\s*\)|spawn\s*\(|execFile\s*\(|execFileSync\s*\(|exec\s*\()/;
/**
* Returns true if the given source text of a route.ts file directly imports
* from child_process / worker_threads or calls spawn()/execFile()/exec().
* Used by the 6A.8 source-scan subcheck to find spawn-capable routes outside
* the static SPAWN_CAPABLE_ROUTE_ROOTS list.
*/
export function isSpawnCapableSource(source: string): boolean {
return SPAWN_SOURCE_RE.test(source);
}
/**
* Walk all route.ts files under src/app/api/ from repoRoot and return those whose
* source matches isSpawnCapableSource. Returns relative paths (forward slashes).
*/
export function findSpawnCapableRoutes(repoRoot: string): string[] {
const apiDir = join(repoRoot, "src", "app", "api");
const out: string[] = [];
function walk(dir: string): void {
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return;
}
for (const entry of entries) {
const full = join(dir, entry);
try {
if (statSync(full).isDirectory()) {
walk(full);
} else if (entry === "route.ts") {
const src = readFileSync(full, "utf8");
if (isSpawnCapableSource(src)) {
out.push(relative(repoRoot, full).replace(/\\/g, "/"));
}
}
} catch {
// skip unreadable
}
}
}
walk(apiDir);
return out.sort();
}
/**
* 6A.8: pre-existing spawn-capable route.ts files that live OUTSIDE
* SPAWN_CAPABLE_ROUTE_ROOTS but are NOT yet classified local-only.
* Each entry is documented security debt (Hard Rules #15/#17):
* the route can spawn child processes and is reachable past the loopback gate.
*
* TODO(6A.8): classify these in LOCAL_ONLY_API_PREFIXES / LOCAL_ONLY_API_PATTERNS
* or add specific auth-only enforcement (no loopback, but require-auth before spawn).
* Adding an entry here requires a justification + follow-up issue.
*/
export const KNOWN_UNCLASSIFIED_SOURCE_SPAWN: Record<string, string> = {
// RESOLVED (6A.8 P1, 2026-06-13): /api/system/version and /api/db-backups/exportAll
// are now classified in LOCAL_ONLY_API_PREFIXES (loopback-enforced before auth).
// The stale-enforcement guard requires this set to stay empty until a NEW
// unclassified spawn-capable route appears.
// NOTE: cli-tools/antigravity-mitm/route.ts triggers child_process INDIRECTLY via
// dynamic import to @/mitm/manager.runtime, but does NOT directly import child_process.
// The source-scan gate covers DIRECT imports/calls only; this route is NOT in the
// spawn-capable set by source analysis. Kept as a comment for documentation but
// NOT in the allowlist (stale-enforcement would flag it). The route has requireCliToolsAuth()
// for auth gating; the underlying spawn happens in mitm/manager.runtime.
// If /api/cli-tools/ is ever added to LOCAL_ONLY_API_PREFIXES, revisit this note.
};
/** Recursively collect every `route.ts` under `dir` (returns [] if dir absent). */
function collectRouteFiles(dir: string): string[] {
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return []; // dir does not exist — nothing to enumerate
}
const out: string[] = [];
for (const entry of entries) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
out.push(...collectRouteFiles(full));
} else if (entry === "route.ts") {
out.push(full);
}
}
return out;
}
function main(): void {
const cwd = process.cwd();
// --- Subcheck 1 (original): SPAWN_CAPABLE_ROUTE_ROOTS ---
const apiPaths = SPAWN_CAPABLE_ROUTE_ROOTS.flatMap(collectRouteFiles)
.map(routeFileToApiPath)
.sort();
const unclassified = findUnclassifiedSpawnRoutes(apiPaths, isLocalOnlyPath, KNOWN_UNCLASSIFIED);
// --- Subcheck 2 (6A.8): source-based scan — ALL route.ts files ---
// Find every route.ts that imports child_process / worker_threads and verify it is
// either classified local-only or frozen in KNOWN_UNCLASSIFIED_SOURCE_SPAWN.
const spawnCapableFiles = findSpawnCapableRoutes(cwd);
// Stale-enforcement: if a route was fixed (no longer spawn-capable, or was classified),
// the KNOWN_UNCLASSIFIED_SOURCE_SPAWN entry must be removed.
assertNoStaleEntries(
KNOWN_UNCLASSIFIED_SOURCE_SPAWN,
spawnCapableFiles,
"route-guard-membership/source-spawn"
);
// Find spawn-capable routes outside SPAWN_CAPABLE_ROUTE_ROOTS that are not classified
// local-only and not in the source-spawn allowlist.
const unclassifiedSourceSpawn = spawnCapableFiles.filter((rel) => {
const apiPath = routeFileToApiPath(rel);
// Already covered by subcheck 1 (in a SPAWN_CAPABLE_ROUTE_ROOT)? Skip.
if (SPAWN_CAPABLE_ROUTE_ROOTS.some((root) => rel.startsWith(root + "/"))) return false;
// In the source-spawn allowlist? Skip.
if (rel in KNOWN_UNCLASSIFIED_SOURCE_SPAWN) return false;
// Classified local-only? Skip.
if (isLocalOnlyPath(apiPath)) return false;
return true;
});
// Report
let failed = false;
if (unclassified.length) {
console.error(
`[route-guard-membership] CRITICAL — ${unclassified.length} spawn-capable route(s) in SPAWN_CAPABLE_ROUTE_ROOTS NOT classified local-only (RCE-via-tunnel risk, Hard Rules #15/#17):\n` +
unclassified.map((p) => `${p}`).join("\n") +
`\n → add a matching prefix to LOCAL_ONLY_API_PREFIXES or a pattern to LOCAL_ONLY_API_PATTERNS in src/server/authz/routeGuard.ts, or freeze in KNOWN_UNCLASSIFIED with justification.`
);
failed = true;
}
if (unclassifiedSourceSpawn.length) {
console.error(
`[route-guard-membership] CRITICAL — ${unclassifiedSourceSpawn.length} route.ts file(s) contain child_process/worker_threads but are NOT classified local-only (Hard Rules #15/#17):\n` +
unclassifiedSourceSpawn.map((p) => `${p} (${routeFileToApiPath(p)})`).join("\n") +
`\n → classify in LOCAL_ONLY_API_PREFIXES / LOCAL_ONLY_API_PATTERNS, or freeze in KNOWN_UNCLASSIFIED_SOURCE_SPAWN with justification.`
);
failed = true;
}
if (failed) process.exit(1);
if (process.exitCode === 1) return; // stale entries already logged
console.log(
`[route-guard-membership] OK — ` +
`${apiPaths.length} route(s) in ${SPAWN_CAPABLE_ROUTE_ROOTS.length} root(s) all local-only; ` +
`${spawnCapableFiles.length} source-spawn route(s) scanned, ` +
`${Object.keys(KNOWN_UNCLASSIFIED_SOURCE_SPAWN).length} frozen as security debt, ` +
`0 new gaps`
);
// Explicit exit: importing routeGuard.ts pulls in runtime settings, which opens
// the SQLite DB and starts a background health-check timer that would otherwise
// keep the process alive. The gate's work is done — exit cleanly.
process.exit(0);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const ROOT = process.cwd();
const API_ROOT = path.join(ROOT, "src", "app", "api");
const FILE_NAME = "route.ts";
const REQUEST_JSON_REGEX = /request\.json\s*\(/;
const VALIDATE_BODY_REGEX = /\bvalidateBody\s*\(/;
const SAFE_PARSE_REGEX = /\.safeParse\s*\(/;
/**
* Walk directory recursively and collect route files.
* @param {string} dir
* @returns {string[]}
*/
function collectRouteFiles(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...collectRouteFiles(fullPath));
continue;
}
if (entry.isFile() && entry.name === FILE_NAME) {
files.push(fullPath);
}
}
return files;
}
if (!fs.existsSync(API_ROOT)) {
console.error(`[t06:route-validation] FAIL - API root not found: ${API_ROOT}`);
process.exit(1);
}
const routeFiles = collectRouteFiles(API_ROOT).sort();
const missingValidation = [];
for (const fullPath of routeFiles) {
const source = fs.readFileSync(fullPath, "utf8");
if (!REQUEST_JSON_REGEX.test(source)) continue;
// Accept either validateBody() or .safeParse() as validation
if (!VALIDATE_BODY_REGEX.test(source) && !SAFE_PARSE_REGEX.test(source)) {
missingValidation.push(path.relative(ROOT, fullPath));
}
}
if (missingValidation.length > 0) {
console.error(
"[t06:route-validation] FAIL - routes with request.json() without validateBody() or .safeParse():"
);
for (const file of missingValidation) {
console.error(` - ${file}`);
}
process.exit(1);
}
console.log(
`[t06:route-validation] PASS - ${routeFiles.length} route files scanned, all request.json() usages are validated.`
);
+375
View File
@@ -0,0 +1,375 @@
#!/usr/bin/env node
// scripts/check/check-secrets.mjs
// Catraca de secret scanning via gitleaks (Task 7.18 — Fase 7).
//
// Complementa `check-public-creds.mjs` (Fase 6, cobre credenciais OAuth públicas
// conhecidas em 2 arquivos específicos): este gate pega a classe geral de secrets —
// `const API_KEY = "sk-…"`, tokens em config/teste/docs, secrets em histórico.
//
// Saída (stdout):
// secretFindings=N — número de findings do gitleaks
// secretFindings=SKIP reason=binary-absent — gitleaks não está no PATH
//
// Por default é ADVISORY (sai 0 sempre). Passe --ratchet para tornar BLOQUEANTE:
// lê metrics.secretFindings.value de config/quality/quality-baseline.json, compara
// a contagem MEDIDA e SAI 1 SE — E SOMENTE SE — a medida for MAIOR que o baseline
// (regressão real, direction:down). Qualquer SKIP gracioso (binário ausente, nenhum
// dir de fonte) SAI 0 mesmo com --ratchet — falta de infraestrutura nunca bloqueia,
// só uma regressão medida bloqueia.
//
// Uso:
// node scripts/check/check-secrets.mjs
// node scripts/check/check-secrets.mjs --json # imprime JSON bruto do gitleaks
// node scripts/check/check-secrets.mjs --quiet # suprime logs de diagnóstico
// node scripts/check/check-secrets.mjs --ratchet # falha (exit 1) numa regressão
import fs from "node:fs";
import { execFileSync, spawnSync } from "node:child_process";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const QUIET = process.argv.includes("--quiet");
const PRINT_JSON = process.argv.includes("--json");
const RATCHET = process.argv.includes("--ratchet");
const GITLEAKS_CONFIG = path.join(ROOT, ".gitleaks.toml");
const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json");
// Source directories to scan for secrets. We deliberately scope to the
// production/source trees instead of scanning the whole working dir:
// • `gitleaks dir .` (and `detect --no-git --source .`) WALKS the entire tree
// and READS every file — including a real `node_modules/` (90k+ files) when
// present (CI runs `npm ci`). gitleaks has no traversal-exclude flag: the
// `.gitleaks.toml [allowlist].paths` list filters FINDINGS *after* each file
// is read, so it does NOT speed up the walk. The full walk blows past the
// timeout in CI (confirmed: ETIMEDOUT) → the gate silently never produces a
// value. Scoping the scan to the source dirs keeps it fast (~6s) while still
// covering every place an embedded secret would actually be a risk (the same
// dirs Hard Rule #8 governs: src/open-sse/electron/bin, plus scripts/).
// • We also drop git-history mode (scanning 4500+ commits is slow and grows
// unbounded); the current working tree is what ships.
const SECRET_SCAN_DIRS = ["src", "open-sse", "bin", "electron", "scripts"];
// ---------------------------------------------------------------------------
// Pure parsing function (exported for tests)
// ---------------------------------------------------------------------------
/**
* Conta findings no JSON emitido por `gitleaks detect --report-format json`.
*
* O gitleaks emite um array de findings (ou array vazio / null quando limpo):
* [
* {
* Description: string,
* StartLine: number,
* EndLine: number,
* Match: string, // valor mascarado ou trecho
* Secret: string, // valor mascarado
* File: string, // caminho relativo
* Commit: string,
* Entropy: number,
* Author: string,
* Email: string,
* Date: string,
* Tags: string[],
* RuleID: string,
* Fingerprint: string
* },
* ...
* ]
*
* @param {Array|null} gitleaksJson - Array de findings do gitleaks (ou null)
* @returns {{ findingCount: number, byRule: Record<string, number>, byFile: Record<string, number> }}
*/
export function parseGitleaksJson(gitleaksJson) {
// null ou array vazio = nenhum finding
if (gitleaksJson === null || (Array.isArray(gitleaksJson) && gitleaksJson.length === 0)) {
return { findingCount: 0, byRule: {}, byFile: {} };
}
if (!Array.isArray(gitleaksJson)) {
return { findingCount: 0, byRule: {}, byFile: {} };
}
let findingCount = 0;
const byRule = {};
const byFile = {};
for (const finding of gitleaksJson) {
if (!finding || typeof finding !== "object") continue;
findingCount++;
// Agrupar por RuleID (gitleaks usa PascalCase)
const ruleId = finding.RuleID ?? finding.ruleId ?? "unknown";
byRule[ruleId] = (byRule[ruleId] ?? 0) + 1;
// Agrupar por arquivo
const file = finding.File ?? finding.file ?? "unknown";
byFile[file] = (byFile[file] ?? 0) + 1;
}
return { findingCount, byRule, byFile };
}
// ---------------------------------------------------------------------------
// Ratchet (direction:down) — exported for tests
// ---------------------------------------------------------------------------
/**
* Avalia a contagem MEDIDA de secrets contra o baseline.
* Direction: down (a contagem só pode CAIR — mais secrets = regressão).
*
* @param {number} current - Contagem de findings medida agora.
* @param {number} baseline - Contagem congelada em quality-baseline.json.
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateSecretsRatchet(current, baseline) {
return {
regressed: current > baseline,
improved: current < baseline,
};
}
/**
* Lê metrics.secretFindings.value do quality-baseline.json.
* Retorna null se o arquivo ou a métrica estiverem ausentes (sem baseline não há
* ratchet possível — o caller trata como SKIP gracioso, exit 0).
*
* @param {string} baselinePath
* @returns {number|null}
*/
export function readBaselineSecretsValue(baselinePath = BASELINE_PATH) {
if (!fs.existsSync(baselinePath)) return null;
let baselineJson;
try {
baselineJson = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
} catch {
return null;
}
const metric = baselineJson?.metrics?.secretFindings;
if (!metric || typeof metric.value !== "number") return null;
return metric.value;
}
// ---------------------------------------------------------------------------
// Binary detection
// ---------------------------------------------------------------------------
/**
* Detecta se o binário `gitleaks` está disponível no PATH.
* Usa `which` (Unix) sem interpolação de shell — Hard Rule #13.
*
* @returns {string|null} Caminho para o binário, ou null se ausente.
*/
export function findGitleaks() {
try {
const result = spawnSync("which", ["gitleaks"], {
encoding: "utf8",
timeout: 5_000,
});
if (result.status === 0) {
return result.stdout.trim();
}
} catch {
// which não disponível
}
// Fallback: tentar executar diretamente para verificar ENOENT
try {
const result = spawnSync("gitleaks", ["version"], {
encoding: "utf8",
timeout: 5_000,
});
if (result.error?.code === "ENOENT") return null;
if (result.status !== null) return "gitleaks"; // encontrado no PATH
} catch {
// noop
}
return null;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const gitleaksBin = findGitleaks();
if (!gitleaksBin) {
console.log("secretFindings=SKIP reason=binary-absent");
if (!QUIET) {
process.stderr.write(
"[check-secrets] SKIP — gitleaks não encontrado no PATH.\n" +
"[check-secrets] Instale via: https://github.com/gitleaks/gitleaks\n" +
"[check-secrets] SKIP gracioso — sai 0 mesmo com --ratchet (binário ausente nunca bloqueia).\n"
);
}
process.exitCode = 0;
return;
}
// Resolver os diretórios de fonte que realmente existem (robusto se um sumir).
const scanDirs = SECRET_SCAN_DIRS.filter((d) => fs.existsSync(path.join(ROOT, d)));
if (scanDirs.length === 0) {
// Nenhum dir de fonte encontrado — nada a escanear (advisory, sai 0).
console.log("secretFindings=0");
if (!QUIET) {
process.stderr.write("[check-secrets] Nenhum diretório de fonte encontrado para escanear.\n");
}
process.exitCode = 0;
return;
}
if (!QUIET) {
process.stderr.write(
`[check-secrets] Rodando gitleaks dir <dir> --report-format json para: ${scanDirs.join(", ")} ...\n`
);
}
// `gitleaks dir` aceita UM ÚNICO path posicional (uso: `gitleaks dir [flags]
// [path]`). Passar múltiplos paths faz o gitleaks ignorar os extras e cair para
// escanear o CWD inteiro (`.`) — o que re-traz node_modules/docs/tests e o
// timeout original. Por isso escaneamos CADA diretório de fonte em uma invocação
// separada e concatenamos os findings.
const gitleaksJson = [];
for (const dir of scanDirs) {
const args = [
"dir",
dir,
"--report-format",
"json",
"--report-path",
"-", // output para stdout
"--no-banner",
];
if (fs.existsSync(GITLEAKS_CONFIG)) {
args.push("--config", GITLEAKS_CONFIG);
}
let stdout = "";
try {
stdout = execFileSync(gitleaksBin, args, {
cwd: ROOT,
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
timeout: 90_000, // 90s por dir — o scan escopado completa em ~10s; folga ampla
});
} catch (err) {
// exit 1 com stdout = findings encontrados (comportamento esperado do gitleaks)
stdout = err.stdout ? String(err.stdout) : "";
const stderr = err.stderr ? String(err.stderr) : "";
if (err.status === 1 && stdout.trim()) {
// Normal: gitleaks achou findings neste dir e saiu com exit 1
} else if (!stdout.trim()) {
process.stderr.write(
`[check-secrets] ERRO ao executar gitleaks em '${dir}': ${err.message}\n`
);
if (stderr) process.stderr.write(`[check-secrets] stderr: ${stderr.slice(0, 500)}\n`);
process.exit(2);
}
}
if (!stdout.trim() || stdout.trim() === "null") {
continue; // sem findings neste dir
}
let parsed;
try {
parsed = JSON.parse(stdout.trim());
} catch (parseErr) {
process.stderr.write(
`[check-secrets] ERRO ao parsear JSON do gitleaks em '${dir}': ${parseErr.message}\n`
);
process.stderr.write(
`[check-secrets] stdout (primeiros 500 chars): ${stdout.slice(0, 500)}\n`
);
process.exit(2);
}
if (Array.isArray(parsed)) {
gitleaksJson.push(...parsed);
}
}
if (PRINT_JSON) {
process.stdout.write(JSON.stringify(gitleaksJson, null, 2) + "\n");
return;
}
const { findingCount, byRule, byFile } = parseGitleaksJson(gitleaksJson);
// Emitir em formato KEY=VALUE para o coletor de métricas (collect-metrics.mjs)
console.log(`secretFindings=${findingCount}`);
if (!QUIET) {
if (findingCount > 0) {
const topRules = Object.entries(byRule)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([r, n]) => `${r}(${n})`)
.join(", ");
process.stderr.write(`[check-secrets] Findings: ${findingCount} (top rules: ${topRules})\n`);
process.stderr.write(
"[check-secrets] Para allowlistar findings legítimos (fixtures de teste, creds públicas),\n" +
"[check-secrets] adicione entradas em .gitleaks.toml [[allowlist]] com comentário.\n"
);
} else {
process.stderr.write("[check-secrets] Nenhum finding detectado.\n");
}
}
// Medição bem-sucedida → aplica o ratchet (bloqueante só com --ratchet).
applyRatchet(findingCount);
}
/**
* Aplica o ratchet (direction:down) sobre a contagem medida vs o baseline.
* Sem --ratchet: advisory (exit 0). Com --ratchet: exit 1 numa regressão real
* (medida > baseline). Baseline ausente → SKIP gracioso (exit 0).
*
* @param {number} findingCount - Contagem MEDIDA (medição bem-sucedida).
*/
function applyRatchet(findingCount) {
if (!RATCHET) {
if (!QUIET) {
process.stderr.write(
"[check-secrets] ADVISORY — não falha pela contagem (passe --ratchet para bloquear regressão).\n"
);
}
process.exitCode = 0;
return;
}
const baselineValue = readBaselineSecretsValue(BASELINE_PATH);
if (baselineValue === null) {
if (!QUIET) {
process.stderr.write(
"[check-secrets] baseline ausente (metrics.secretFindings) — SKIP gracioso, sai 0.\n"
);
}
process.exitCode = 0;
return;
}
const { regressed } = evaluateSecretsRatchet(findingCount, baselineValue);
if (regressed) {
process.stderr.write(
`[check-secrets] REGRESSÃO — ${findingCount} secret findings > baseline ${baselineValue}\n` +
" → Remova o novo secret (ou allowliste em .gitleaks.toml se for falso-positivo legítimo),\n" +
" depois re-baseline metrics.secretFindings em config/quality/quality-baseline.json.\n"
);
process.exitCode = 1;
return;
}
if (!QUIET) {
process.stderr.write(
`[check-secrets] OK — sem regressão (${findingCount} findings, baseline ${baselineValue}).\n`
);
}
process.exitCode = 0;
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
@@ -0,0 +1,20 @@
#!/usr/bin/env node
import {
getNodeRuntimeSupport,
getNodeRuntimeWarning,
} from "../../src/shared/utils/nodeRuntimeSupport.ts";
const support = getNodeRuntimeSupport();
if (!support.nodeCompatible) {
console.error(`Unsupported or insecure Node.js runtime detected: ${support.nodeVersion}`);
console.error(getNodeRuntimeWarning() || "Unsupported Node.js runtime.");
console.error(`Supported secure runtimes: ${support.supportedDisplay}`);
console.error(`Recommended version: ${support.recommendedVersion}`);
process.exit(1);
}
console.log(
`Node.js ${support.nodeVersion} satisfies OmniRoute secure runtime policy (${support.supportedRange}).`
);
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const cwd = process.cwd();
/**
* T11 Phase-A budget:
* keep explicit `any` at zero in files already hardened.
*/
const budget = [
{ file: "src/app/api/settings/proxy/route.ts", maxAny: 0 },
{ file: "src/app/api/settings/proxy/test/route.ts", maxAny: 0 },
{ file: "src/shared/components/OAuthModal.tsx", maxAny: 0 },
{ file: "open-sse/translator/index.ts", maxAny: 0 },
{ file: "open-sse/translator/registry.ts", maxAny: 0 },
// Freeze legacy hot spots to avoid any-regression while strict migration continues.
{ file: "src/lib/db/apiKeys.ts", maxAny: 0 },
{ file: "src/lib/db/cliToolState.ts", maxAny: 0 },
{ file: "src/lib/db/encryption.ts", maxAny: 0 },
{ file: "src/lib/db/prompts.ts", maxAny: 0 },
{ file: "src/lib/db/providers.ts", maxAny: 0 },
{ file: "src/lib/db/settings.ts", maxAny: 0 },
{ file: "open-sse/config/providerRegistry.ts", maxAny: 0 },
{ file: "open-sse/config/providerModels.ts", maxAny: 0 },
{ file: "open-sse/mcp-server/audit.ts", maxAny: 0 },
// 3 `(toolDef: any)` in the dynamic memory/skill/compression tool-registration
// loops (#3077) — heterogeneous tool defs accessed via existing `@ts-ignore`
// dynamic-zod paths; pragmatic dynamic dispatch, not a type-safety regression.
{ file: "open-sse/mcp-server/server.ts", maxAny: 3 },
{ file: "open-sse/mcp-server/tools/advancedTools.ts", maxAny: 0 },
{ file: "open-sse/services/signatureCache.ts", maxAny: 0 },
{ file: "open-sse/services/comboMetrics.ts", maxAny: 0 },
{ file: "open-sse/services/sessionManager.ts", maxAny: 0 },
{ file: "open-sse/services/provider.ts", maxAny: 0 },
{ file: "open-sse/services/contextManager.ts", maxAny: 0 },
{ file: "open-sse/services/comboConfig.ts", maxAny: 0 },
{ file: "open-sse/services/accountSelector.ts", maxAny: 0 },
{ file: "open-sse/services/wildcardRouter.ts", maxAny: 0 },
{ file: "open-sse/services/rateLimitSemaphore.ts", maxAny: 0 },
{ file: "open-sse/services/roleNormalizer.ts", maxAny: 0 },
{ file: "open-sse/services/usage.ts", maxAny: 0 },
{ file: "open-sse/services/rateLimitManager.ts", maxAny: 0 },
{ file: "open-sse/services/tokenRefresh.ts", maxAny: 0 },
{ file: "open-sse/services/backgroundTaskDetector.ts", maxAny: 0 },
{ file: "open-sse/services/accountFallback.ts", maxAny: 0 },
{ file: "open-sse/handlers/responseSanitizer.ts", maxAny: 0 },
{ file: "open-sse/handlers/responseTranslator.ts", maxAny: 0 },
{ file: "open-sse/utils/stream.ts", maxAny: 0 },
{ file: "open-sse/translator/request/openai-responses.ts", maxAny: 0 },
// 2 FALSE POSITIVES: #4389 compares the Anthropic `tool_choice` value against the
// STRING literal "any" (`tb.tool_choice === "any"` and `.type === "any"`) to detect
// forced tool use. The checker strips comments but not strings, and there are zero
// actual TypeScript `any` types in this file. Budget set to the matched count.
{ file: "open-sse/executors/base.ts", maxAny: 2 },
{ file: "open-sse/executors/kiro.ts", maxAny: 0 },
// 3 FALSE POSITIVES: the word "any" appears in #3104's tool-commit / output-
// constraint prompt STRINGS ("not any other tool", "any text", "any of these
// sequences"). The checker strips comments but not strings, and there are zero
// actual TypeScript `any` types in this file. Budget set to the matched count.
{ file: "open-sse/executors/cursor.ts", maxAny: 3 },
{ file: "open-sse/executors/qoder.ts", maxAny: 0 },
{ file: "open-sse/utils/comfyuiClient.ts", maxAny: 0 },
{ file: "open-sse/utils/tlsClient.ts", maxAny: 0 },
{ file: "open-sse/utils/proxyFetch.ts", maxAny: 0 },
{ file: "open-sse/utils/error.ts", maxAny: 0 },
{ file: "open-sse/translator/request/openai-to-gemini.ts", maxAny: 0 },
{ file: "open-sse/translator/request/antigravity-to-openai.ts", maxAny: 0 },
{ file: "open-sse/translator/request/claude-to-openai.ts", maxAny: 0 },
{ file: "open-sse/handlers/audioTranscription.ts", maxAny: 0 },
{ file: "open-sse/handlers/sseParser.ts", maxAny: 0 },
{ file: "open-sse/handlers/chatCore.ts", maxAny: 0 },
{ file: "open-sse/config/codexInstructions.ts", maxAny: 0 },
{ file: "open-sse/config/imageRegistry.ts", maxAny: 0 },
{ file: "open-sse/config/registryUtils.ts", maxAny: 0 },
{ file: "open-sse/executors/antigravity.ts", maxAny: 0 },
{ file: "open-sse/executors/default.ts", maxAny: 0 },
{ file: "open-sse/handlers/audioSpeech.ts", maxAny: 0 },
{ file: "open-sse/handlers/embeddings.ts", maxAny: 0 },
{ file: "open-sse/handlers/imageGeneration.ts", maxAny: 3 },
{ file: "open-sse/handlers/moderations.ts", maxAny: 0 },
{ file: "open-sse/handlers/rerank.ts", maxAny: 0 },
{ file: "open-sse/handlers/responsesHandler.ts", maxAny: 0 },
{ file: "open-sse/mcp-server/__tests__/advancedTools.test.ts", maxAny: 0 },
{ file: "open-sse/mcp-server/__tests__/essentialTools.test.ts", maxAny: 0 },
{ file: "open-sse/services/combo.ts", maxAny: 0 },
{ file: "open-sse/services/thinkingBudget.ts", maxAny: 0 },
{ file: "open-sse/translator/helpers/geminiHelper.ts", maxAny: 0 },
{ file: "open-sse/translator/helpers/openaiHelper.ts", maxAny: 0 },
{ file: "open-sse/translator/helpers/responsesApiHelper.ts", maxAny: 0 },
{ file: "open-sse/translator/request/claude-to-gemini.ts", maxAny: 0 },
{ file: "open-sse/translator/request/gemini-to-openai.ts", maxAny: 0 },
{ file: "open-sse/translator/request/openai-to-claude.ts", maxAny: 1 }, // 1 = string literal "any" (Claude tool_choice value, not a TS type) — #1072
{ file: "open-sse/translator/request/openai-to-cursor.ts", maxAny: 0 },
{ file: "open-sse/translator/request/openai-to-kiro.ts", maxAny: 0 },
{ file: "open-sse/translator/response/claude-to-openai.ts", maxAny: 0 },
{ file: "open-sse/translator/response/gemini-to-openai.ts", maxAny: 0 },
{ file: "open-sse/translator/response/kiro-to-openai.ts", maxAny: 0 },
{ file: "open-sse/translator/response/openai-responses.ts", maxAny: 0 },
{ file: "open-sse/translator/response/openai-to-antigravity.ts", maxAny: 0 },
{ file: "open-sse/utils/bypassHandler.ts", maxAny: 0 },
{ file: "open-sse/utils/logger.ts", maxAny: 0 },
{ file: "open-sse/utils/networkProxy.ts", maxAny: 0 },
{ file: "open-sse/utils/ollamaTransform.ts", maxAny: 0 },
{ file: "open-sse/utils/proxyDispatcher.ts", maxAny: 0 },
{ file: "open-sse/utils/requestLogger.ts", maxAny: 0 },
{ file: "open-sse/utils/streamHandler.ts", maxAny: 0 },
{ file: "open-sse/utils/usageTracking.ts", maxAny: 0 },
];
const anyRegex = /\bany\b/g;
let hasFailure = false;
for (const item of budget) {
const absolutePath = path.resolve(cwd, item.file);
if (!fs.existsSync(absolutePath)) {
console.error(`[t11:any-budget] FAIL - file not found: ${item.file}`);
hasFailure = true;
continue;
}
const content = fs.readFileSync(absolutePath, "utf8");
// Remove block and line comments to avoid false positives with the word "any" in comments
let cleanContent = content.replace(/\/\*[\s\S]*?\*\//g, "");
cleanContent = cleanContent.replace(/\/\/.*$/gm, "");
const matches = cleanContent.match(anyRegex);
const count = matches ? matches.length : 0;
const status = count <= item.maxAny ? "OK" : "FAIL";
if (status === "FAIL") {
hasFailure = true;
}
console.log(
`[t11:any-budget] ${status} - ${item.file} (explicit any: ${count}, budget: ${item.maxAny})`
);
}
if (hasFailure) {
process.exit(1);
}
console.log("[t11:any-budget] PASS - explicit any budget respected.");
+290
View File
@@ -0,0 +1,290 @@
#!/usr/bin/env node
// scripts/check/check-test-discovery.mjs
// Gate 6A.1 — test discovery: todo arquivo *.test.ts|tsx / *.spec.ts|tsx do repo deve
// ser COLETADO por pelo menos um runner que efetivamente RODA via npm script ou CI.
//
// WHY: a auditoria 2026-06-09 encontrou ≈135 testes em subdiretórios de tests/unit/
// que nenhum runner coleta (o glob `tests/unit/*.test.ts` é top-level-only), incluindo
// tests/unit/authz/routeGuard.test.ts (Hard Rules #15/#17) — cujos asserts JÁ FALHAM,
// apodrecidos sem ninguém ver. Teste que não roda é o falso verde definitivo: todo o
// investimento anti test-masking protege asserts que nem executam.
//
// Modelo: COLLECTORS declara explicitamente o glob de cada runner REAL + as fontes
// (package.json / ci.yml / vitest configs) onde o padrão deve aparecer textualmente
// (drift-check: mudou o glob na fonte sem atualizar aqui → o gate falha pedindo sync).
// "Coletado" = casado pelo glob de um runner executado por script npm ou job de CI.
// Includes de config que NENHUM script executa (ex.: vitest.config.ts sem filtro) NÃO
// contam — config morta não roda teste.
//
// Catraca: órfãos pré-existentes ficam congelados em test-discovery-baseline.json
// (dívida visível, decrescente). Órfão NOVO → fail. Entrada do baseline que deixou de
// ser órfã (religada/deletada) → fail pedindo remoção (stale-allowlist enforcement).
// --update regrava o baseline com o estado atual (use só para REMOVER religados;
// adições novas devem ser corrigidas, não congeladas — esse é o ponto do gate).
//
// Limitações documentadas (v1):
// - `exclude` de arquivo individual em vitest configs não é modelado (1 caso hoje:
// providerDiversity.test.ts — coletado pelo include, deliberadamente excluído).
// - @omniroute/* ficam fora do walk (têm CI próprio: opencode-*-ci.yml).
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const BASELINE_PATH = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "config/quality/test-discovery-baseline.json")
);
const UPDATE = process.argv.includes("--update");
// Raízes varridas em busca de arquivos de teste.
const WALK_ROOTS = ["tests", "src", "open-sse", "electron", "bin"];
const WALK_EXCLUDE = new Set(["node_modules", ".next", "dist", "coverage", ".git"]);
const TEST_FILE_RE = /\.(test|spec)\.(ts|tsx|mjs)$/;
// Runners REAIS e seus globs. `sources`: arquivos onde `anchor` (default: o próprio
// glob) deve aparecer textualmente — se o runner mudar, este gate exige o sync.
export const COLLECTORS = [
// Node native runner — test:unit / test:unit:fast / shards / test:coverage. O CI
// (test-unit ×8 + quality.yml fast-unit) agora chama o npm script test:unit:ci:shard
// (fonte única, plano mestre testes+CI QW-d) — o wiring é ancorado pelo NOME do script
// nos workflows (entrada dedicada abaixo), e os globs vivem SÓ no package.json.
{ glob: "tests/unit/*.test.ts", sources: ["package.json"] },
// Node native runner — subdiretórios religados pela 6A.1c (2026-06-09). Braces
// explícitos para NÃO incluir tests/unit/autoCombo/** (testes vitest — importam
// "vitest" e explodem no node runner) NEM tests/unit/dashboard/** (invocação própria
// abaixo). Subdir novo: adicione aqui E nos scripts (o drift-check + o gate de
// órfãos forçam a manutenção em sincronia).
{
glob: "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts",
sources: ["package.json"],
},
// Node native runner — tests/unit/dashboard/** roda numa invocação separada com o hook
// COMPLETO do tsx (--import tsx): o grafo dos componentes de dashboard puxa
// @lobehub/icons, cujo build es/ faz require() interno de arquivos com sintaxe ESM —
// sem o patch CJS do tsx isso estoura "Unexpected token 'export'" (visto no Node
// 24.18 do CI; no 24.16 local vira um crawl de ~60s/arquivo). O resto da suíte roda
// sob tsx/esm (~-50% de bootstrap por processo). Plano mestre testes+CI, QW-b.
{ glob: "tests/unit/dashboard/**/*.test.ts", sources: ["package.json"] },
// Quarentena de flakes de concorrência (plano melhorias v3.8.46, P0.3): arquivos
// sensíveis a contenção de CPU/timing (classe glm-3580 / quota-division /
// provider-health-autopilot) rodam num passo dedicado --test-concurrency=1 ao FIM
// de cada runner. Fora dos globs paralelos acima por diretório próprio.
{ glob: "tests/unit/serial/**/*.test.ts", sources: ["package.json"] },
// Órfãos religados (plano mestre QW-c): arquivos .test.mjs (top-level + db/ + feature-triage/) — fora do glob
// *.test.ts histórico, nunca rodava em job nenhum (53 casos recuperados).
{ glob: "tests/unit/**/*.test.mjs", sources: ["package.json"] },
// Wiring CI→npm script (fonte única): os jobs de unit do ci.yml e o fast-unit do
// quality.yml DEVEM invocar o script canônico — se renomearem/inlinarem, este gate
// exige o sync (substitui as âncoras textuais de glob que existiam nos workflows).
{
glob: "tests/unit/*.test.ts",
sources: ["package.json", ".github/workflows/ci.yml", ".github/workflows/quality.yml"],
anchors: {
".github/workflows/ci.yml": "test:unit:ci:shard",
".github/workflows/quality.yml": "test:unit:ci:shard",
},
},
// Node native runner — test:integration (top-level only; tests/integration/services/ NÃO roda)
{ glob: "tests/integration/*.test.ts", sources: ["package.json"] },
// Node native runner — test:combo:matrix / test:integration (combo strategy decision matrix, 17 strategies)
{ glob: "tests/integration/combo-matrix/*.test.ts", sources: ["package.json"] },
// Node native runner — test:combo:live (gated real-upstream smoke; RUN_COMBO_LIVE=1 + VPS creds)
{ glob: "tests/integration/combo-live/*.live.test.ts", sources: ["package.json"] },
// Node native runner — test:system
{ glob: "tests/e2e/system-failover.test.ts", sources: ["package.json"] },
// vitest.mcp.config.ts — test:vitest
{ glob: "open-sse/mcp-server/__tests__/**/*.test.ts", sources: ["vitest.mcp.config.ts"] },
{ glob: "open-sse/services/autoCombo/__tests__/**/*.test.ts", sources: ["vitest.mcp.config.ts"] },
{ glob: "open-sse/services/combo/__tests__/**/*.test.ts", sources: ["vitest.mcp.config.ts"] },
// Single-file include: the rest of open-sse/services/__tests__/ are frozen orphans
// (empty/dormant stubs); only this one is wired to run under test:vitest.
{
glob: "open-sse/services/__tests__/antigravity-quota-family.test.ts",
sources: ["vitest.mcp.config.ts"],
},
{ glob: "tests/unit/autoCombo/**/*.test.ts", sources: ["vitest.mcp.config.ts"] },
{ glob: "tests/unit/encryption.spec.ts", sources: ["vitest.mcp.config.ts"] },
{ glob: "src/shared/components/**/*.test.tsx", sources: ["vitest.mcp.config.ts"] },
{ glob: "src/shared/hooks/__tests__/**/*.test.tsx", sources: ["vitest.mcp.config.ts"] },
{ glob: "src/app/(dashboard)/**/__tests__/**/*.test.tsx", sources: ["vitest.mcp.config.ts"] },
// vitest.config.ts via test:vitest:ui (roda com path-filter `tests/unit/ui`, então o
// conjunto EFETIVO é a interseção do include `tests/unit/**/*.test.tsx` com o filtro)
{
glob: "tests/unit/ui/**/*.test.tsx",
sources: ["package.json", "vitest.config.ts"],
anchors: { "package.json": "tests/unit/ui", "vitest.config.ts": "tests/unit/**/*.test.tsx" },
},
// Playwright — test:e2e (o script passa tests/e2e/*.spec.ts; testMatch **/*.spec.ts)
{ glob: "tests/e2e/*.spec.ts", sources: ["package.json"] },
// Runners custom — test:ecosystem / test:protocols:e2e (spawnam vitest com o arquivo)
{ glob: "tests/e2e/ecosystem.test.ts", sources: ["scripts/dev/run-ecosystem-tests.mjs"] },
{
glob: "tests/e2e/protocol-clients.test.ts",
sources: ["scripts/dev/run-protocol-clients-tests.mjs"],
},
];
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
/** Converte um glob em RegExp ancorada. Suporta `*`, `**` (com ou sem barra) e `{a,b}`. */
export function globToRegExp(glob) {
let re = "";
for (let i = 0; i < glob.length; i++) {
const c = glob[i];
if (c === "*") {
if (glob[i + 1] === "*") {
if (glob[i + 2] === "/") {
re += "(?:.*/)?"; // "**/" — zero ou mais diretórios
i += 2;
} else {
re += ".*"; // "**" solto
i += 1;
}
} else {
re += "[^/]*"; // "*" não atravessa "/"
}
} else if (c === "{") {
const end = glob.indexOf("}", i);
const alts = glob
.slice(i + 1, end)
.split(",")
.map(escapeRe);
re += "(?:" + alts.join("|") + ")";
i = end;
} else {
re += escapeRe(c);
}
}
return new RegExp("^" + re + "$");
}
/** Arquivos de teste não casados por NENHUM glob de collector (ordem preservada). */
export function findOrphans(files, globs) {
const regexes = globs.map(globToRegExp);
return files.filter((f) => !regexes.some((re) => re.test(f)));
}
/**
* Compara os órfãos atuais com o baseline congelado.
* - newOrphans: órfão atual fora do baseline → teste novo que NÃO RODA (fail).
* - stale: entrada do baseline que não é mais órfã (religada/deletada) → remova (fail).
*/
export function evaluateAgainstBaseline(orphans, baselineList) {
const baseSet = new Set(baselineList);
const orphanSet = new Set(orphans);
return {
newOrphans: orphans.filter((o) => !baseSet.has(o)),
stale: baselineList.filter((b) => !orphanSet.has(b)),
};
}
/**
* Drift-check: cada glob declarado (ou seu anchor por fonte) deve aparecer textualmente
* em TODAS as suas fontes. Retorna mensagens de drift.
*/
export function findCollectorDrift(collectors, contents) {
const drift = [];
for (const c of collectors) {
for (const source of c.sources) {
const anchor = c.anchors?.[source] ?? c.glob;
const body = contents[source];
if (body === undefined || !body.includes(anchor)) {
drift.push(
`glob "${c.glob}" (anchor "${anchor}") não encontrado em ${source} — o runner mudou? Sincronize COLLECTORS em check-test-discovery.mjs`
);
}
}
}
return drift;
}
function walk(dir, acc = []) {
if (!fs.existsSync(dir)) return acc;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
if (WALK_EXCLUDE.has(e.name)) continue;
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p, acc);
else if (TEST_FILE_RE.test(e.name)) acc.push(p);
}
return acc;
}
function collectTestFiles() {
const out = [];
for (const root of WALK_ROOTS) {
for (const f of walk(path.join(ROOT, root))) {
out.push(path.relative(ROOT, f).replace(/\\/g, "/"));
}
}
return out.sort();
}
function main() {
// 1) drift dos collectors vs fontes reais
const contents = {};
for (const c of COLLECTORS) {
for (const s of c.sources) {
if (contents[s] === undefined) {
const p = path.join(ROOT, s);
contents[s] = fs.existsSync(p) ? fs.readFileSync(p, "utf8") : undefined;
}
}
}
const drift = findCollectorDrift(COLLECTORS, contents);
// 2) órfãos vs baseline
const files = collectTestFiles();
const orphans = findOrphans(
files,
COLLECTORS.map((c) => c.glob)
);
if (!fs.existsSync(BASELINE_PATH) && !UPDATE) {
console.error(
`[test-discovery] FAIL — ${path.basename(BASELINE_PATH)} ausente. Bootstrap:\n` +
` node scripts/check/check-test-discovery.mjs --update (gera o baseline com os órfãos atuais)`
);
process.exit(2);
}
const baseline = fs.existsSync(BASELINE_PATH)
? JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"))
: {
_comment:
"Catraca de test-discovery (check-test-discovery.mjs). Cada entrada e um arquivo de teste que NENHUM runner coleta (ele nunca roda) — divida congelada na auditoria 6A.1 (2026-06-09). So pode DIMINUIR: religue o teste (ajustando o glob do runner ou movendo o arquivo) e remova a entrada via --update. NAO adicione novos orfaos — corrija o runner.",
orphans: [],
};
const { newOrphans, stale } = evaluateAgainstBaseline(orphans, baseline.orphans || []);
if (UPDATE && drift.length === 0) {
baseline.orphans = orphans;
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + "\n");
console.log(
`[test-discovery] baseline regravado: ${orphans.length} órfão(s) (${stale.length} removido(s), ${newOrphans.length} adicionado(s) — adições devem ser corrigidas, não congeladas)`
);
return;
}
const problems = [];
for (const d of drift) problems.push(` ✗ [drift] ${d}`);
for (const o of newOrphans) {
problems.push(
` ✗ [órfão NOVO] ${o} — nenhum runner coleta este arquivo (ele NUNCA roda). Mova-o para um path coletado ou ajuste o runner.`
);
}
for (const s of stale) {
problems.push(
` ✗ [stale] ${s} — não é mais órfão (religado/removido). Remova do baseline: node scripts/check/check-test-discovery.mjs --update`
);
}
if (problems.length) {
console.error(`[test-discovery] ${problems.length} problema(s):\n` + problems.join("\n"));
process.exit(1);
}
console.log(
`[test-discovery] OK — ${files.length} arquivos de teste, ${COLLECTORS.length} collectors, ${(baseline.orphans || []).length} órfão(s) congelado(s) (dívida rastreada, só decresce)`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+483
View File
@@ -0,0 +1,483 @@
#!/usr/bin/env node
// scripts/check/check-test-masking.mjs
// Gate anti test-masking (a preocupação nº1 do CLAUDE.md: "subagente não pode
// enfraquecer/remover asserts pra ficar verde"). Para cada arquivo de teste MODIFICADO
// num PR, compara a contagem de asserts base vs HEAD: sinaliza REMOÇÃO LÍQUIDA de asserts
// e NOVAS tautologias `assert.ok(true)`. Heurístico mas alto-sinal. Espelha o plumbing
// de check-pr-test-policy.mjs (diff base...HEAD); no-op fora de contexto de PR.
//
// v2 (6A.10): acrescenta 3 novos subchecks:
// 1. Arquivos de teste DELETADOS: --diff-filter=MDR com detecção de rename.
// 2. Aumento líquido de .skip/.todo/.only/{skip:true}: esconde asserts sem remover.
// 3. Tautologias extras: expect(true).toBe(true), assert.equal(1,1), assert.ok(true).
import fs from "node:fs";
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const TEST_RE = /\.(test|spec)\.(ts|tsx)$/;
// Production TypeScript sources (excludes test files, handled separately via TEST_RE).
const PROD_SRC_RE = /\.(ts|tsx|mts|cts)$/;
/** Conta chamadas de assert.*( / assert( / expect( . */
export function countAssertions(src) {
const a = (src.match(/\bassert\b\s*[.(]/g) || []).length;
const e = (src.match(/\bexpect\s*\(/g) || []).length;
return a + e;
}
/** Conta tautologias assert.ok(true). */
export function countTautologies(src) {
return (src.match(/\bassert\s*\.\s*ok\s*\(\s*true\s*\)/g) || []).length;
}
/**
* (6A.10 subcheck 2) Conta marcadores de skip/todo/only que silenciam testes:
* - .skip(, .todo(, .only( — em qualquer runner (node:test, jest, vitest)
* - { skip: true } — opção de objeto node:test
*/
export function countSkips(src) {
const modifiers = (src.match(/\.\s*(?:skip|todo|only)\s*\(/g) || []).length;
const skipOpt = (src.match(/\{\s*skip\s*:\s*true\s*\}/g) || []).length;
return modifiers + skipOpt;
}
/**
* (6A.10 subcheck 3) Conta tautologias que mantêm os asserts no texto mas nunca
* verificam nada real:
* - expect(true).toBe(true)
* - assert.equal(1, 1) / assert.strictEqual(1, 1)
* - assert.ok(true) (já coberto por countTautologies; incluído aqui para completude)
*/
export function countExtendedTautologies(src) {
let count = 0;
// expect(true).toBe(true)
count += (src.match(/\bexpect\s*\(\s*true\s*\)\s*\.\s*toBe\s*\(\s*true\s*\)/g) || []).length;
// assert.equal(1, 1) / assert.strictEqual(1, 1) — literal numeric identity
count += (src.match(/\bassert\s*\.\s*(?:strict)?[Ee]qual\s*\(\s*1\s*,\s*1\s*\)/g) || []).length;
// assert.ok(true)
count += (src.match(/\bassert\s*\.\s*ok\s*\(\s*true\s*\)/g) || []).length;
return count;
}
// ─── (6348) Subcheck 4: inline-reimplemented prod conditions (REPORT-ONLY) ───
// A test that copies a conditional expression out of production code (instead of
// importing and exercising the symbol that owns it) is the wrong-shape-contract-test
// class (#6216): the assertion re-encodes the branch locally, so it stays green even
// when the real prod condition drifts. This subcheck is a HEURISTIC, textual gate
// mirroring the count* helpers above — it never parses an AST. It is REPORT-ONLY for
// now (warns, does not fail the gate).
/** Collapse all runs of whitespace to a single space and trim. */
function normalizeWhitespace(s) {
return (s || "").replace(/\s+/g, " ").trim();
}
/**
* Count "significant" tokens in a normalized condition. Single-char identifiers
* (`x`, `i`) and single-digit numeric literals (`0`, `1`) are treated as noise and
* NOT counted; operators and multi-char identifiers/numbers ARE. This is what makes
* `x > 0` trivial (1 significant token) while `status >= 500` is meaningful (3):
* - `status >= 500` → status, >=, 500 → 3
* - `x === LIMIT && y` → ===, LIMIT, && → 3
* - `x > 0` → > → 1
*/
export function countSignificantTokens(cond) {
const tokens =
(cond || "").match(
/===|!==|==|!=|>=|<=|&&|\|\||[<>+\-*/%!]|[A-Za-z_$][\w$]*|\d+(?:\.\d+)?/g
) || [];
let count = 0;
for (const tk of tokens) {
if (/^[A-Za-z_$]/.test(tk)) {
if (tk.length >= 2) count++; // multi-char identifier
} else if (/^\d/.test(tk)) {
if (tk.length >= 2) count++; // multi-digit number
} else {
count++; // operator
}
}
return count;
}
/** A condition is "meaningful" when it carries ≥3 significant tokens. */
function isSignificantCondition(cond) {
return countSignificantTokens(cond) >= 3;
}
/**
* Extract meaningful (≥3-token) conditional expressions from a production source,
* paired with the nearest enclosing declared symbol that "owns" them. Covers
* `if (...)` (via paren balancing) and comparison-bearing ternaries (`a === b ? … : …`).
* Returns [{ condition (whitespace-normalized), owner }].
*/
export function extractProdConditions(src) {
const results = [];
if (!src) return results;
// Declarations (function / const / let / var) with their positions, so each
// condition can be attributed to the symbol whose body it lives in.
const decls = [];
const declRe =
/(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)|(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g;
let dm;
while ((dm = declRe.exec(src))) {
decls.push({ index: dm.index, name: dm[1] || dm[2] });
}
const ownerAt = (idx) => {
let owner = "";
for (const d of decls) {
if (d.index <= idx) owner = d.name;
else break;
}
return owner;
};
const seen = new Set();
const pushCond = (raw, owner) => {
const norm = normalizeWhitespace(raw);
if (!norm || seen.has(norm) || !isSignificantCondition(norm)) return;
seen.add(norm);
results.push({ condition: norm, owner });
};
// if (...) — balance parentheses to capture the full condition.
const ifRe = /\bif\s*\(/g;
let m;
while ((m = ifRe.exec(src))) {
let depth = 1;
let i = m.index + m[0].length;
for (; i < src.length && depth > 0; i++) {
const ch = src[i];
if (ch === "(") depth++;
else if (ch === ")") depth--;
}
pushCond(src.slice(m.index + m[0].length, i - 1), ownerAt(m.index));
}
// Comparison-bearing ternaries: `<lhs> <cmp> <rhs> ? … : …` (best-effort, low-noise).
const ternRe =
/([A-Za-z_$][\w$).\]]*\s*(?:===|!==|==|!=|>=|<=|>|<)\s*[^?;{}\n]+?)\s*\?/g;
let t;
while ((t = ternRe.exec(src))) {
pushCond(t[1], ownerAt(t.index));
}
return results;
}
/**
* Collect the identifiers/module specifiers a test file imports, so we can tell
* whether it exercises a prod symbol through the real import (clean) or merely
* re-implements one of its conditions locally (masked). Returns a Set of names:
* imported bindings, module paths, and module basenames.
*/
export function extractImports(src) {
const names = new Set();
if (!src) return names;
const addModule = (mod) => {
names.add(mod);
const base = mod.split("/").pop().replace(/\.\w+$/, "");
if (base) names.add(base);
};
let m;
const importRe = /import\s+(?:type\s+)?([^;]*?)\s+from\s+['"]([^'"]+)['"]/g;
while ((m = importRe.exec(src))) {
addModule(m[2]);
for (const id of m[1].match(/[A-Za-z_$][\w$]*/g) || []) {
if (id !== "as" && id !== "type") names.add(id);
}
}
const dynRe = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
while ((m = dynRe.exec(src))) addModule(m[1]);
const reqRe = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
while ((m = reqRe.exec(src))) addModule(m[1]);
return names;
}
/**
* PURE core of subcheck 4. Given the sources of the prod files changed in a PR,
* one test file's source, and the set of names that test imports: return the prod
* conditions the test re-implements textually WITHOUT importing the symbol that owns
* them. Whitespace is squashed on both sides so spacing differences never mask a hit.
* Returns [{ condition, owner }].
*/
export function findReimplementedConditions(prodSources, testSource, testImports) {
const flags = [];
if (!testSource) return flags;
const imports =
testImports instanceof Set ? testImports : new Set(testImports || []);
const squash = (s) => (s || "").replace(/\s+/g, "");
const testSq = squash(testSource);
const seen = new Set();
for (const prod of prodSources || []) {
for (const { condition, owner } of extractProdConditions(prod)) {
if (owner && imports.has(owner)) continue; // exercised through the real import
if (seen.has(condition)) continue;
if (testSq.includes(squash(condition))) {
seen.add(condition);
flags.push({ condition, owner: owner || null });
}
}
}
return flags;
}
/**
* (6A.10 subcheck 1) Sinaliza arquivos de teste DELETADOS ou renomeados-e-não-
* substituídos. Recebe lista de paths de arquivos de teste que foram deletados
* (filtro D do git diff --diff-filter=MDR).
*
* `deletionAllowlist` (`_deletedWithReplacement` no test-masking-allowlist.json)
* isenta uma deleção SOMENTE quando o substituto declarado existe no HEAD e é
* ele próprio um arquivo de teste — o caso "reescrito em outro path sem rename
* detectável" (conteúdo novo demais para o -M do git). Qualquer entrada cujo
* substituto não exista ou não seja teste continua flagada.
*/
export function evaluateDeletedFiles(
deletedPaths,
deletionAllowlist = {},
fileExists = fs.existsSync
) {
const flags = [];
for (const f of deletedPaths) {
if (!TEST_RE.test(f)) continue;
const entry = deletionAllowlist[f];
if (entry && typeof entry.replacement === "string") {
if (TEST_RE.test(entry.replacement) && fileExists(entry.replacement)) continue;
flags.push(
`${f}: deleção allowlistada mas o substituto declarado (${entry.replacement}) não existe ou não é arquivo de teste`
);
continue;
}
flags.push(
`${f}: arquivo de teste deletado — revisão humana obrigatória (mascaramento alto-sinal)`
);
}
return flags;
}
/**
* Parse `git diff --name-status -M --diff-filter=DR` output, separating TRUE
* test-file deletions ("D\tpath") from RENAMES ("R<score>\told\tnew").
*
* A rename whose destination is still a test file is a *relocation* (the test
* was substituted at a new path, not removed) — per this file's subcheck-1
* contract it must NOT be treated as a deletion; the assert-reduction check
* still runs across the rename to catch gutting-via-rename. A rename that lands
* OUTSIDE test scope (test → non-test) removes the test and is treated as a
* deletion. Returns test-file paths only.
*/
export function partitionDeletedRenamed(nameStatusOutput) {
const deletedTests = [];
const renames = [];
for (const line of (nameStatusOutput || "").split("\n")) {
if (!line.trim()) continue;
const parts = line.split("\t").map((s) => s.trim());
const status = parts[0] || "";
if (status.startsWith("D")) {
if (TEST_RE.test(parts[1] || "")) deletedTests.push(parts[1]);
} else if (status.startsWith("R")) {
const from = parts[1] || "";
const to = parts[2] || "";
if (TEST_RE.test(from)) renames.push({ from, to });
}
}
return { deletedTests, renames };
}
/**
* Avalia por-arquivo: flag em remoção líquida de asserts, nova tautologia,
* aumento líquido de skips, ou nova tautologia extendida.
*
* Cada entrada de perFile deve ter:
* { file, baseAsserts, headAsserts, baseTaut, headTaut,
* baseSkips, headSkips, baseExtTaut, headExtTaut }
*
* Os campos de skip e extTaut são opcionais (default 0) para compatibilidade
* com chamadas legadas que só passam baseAsserts/headAsserts/baseTaut/headTaut.
*/
export function evaluateMasking(perFile, assertReductionAllowlist = new Set()) {
const flags = [];
for (const f of perFile) {
const baseSkips = f.baseSkips ?? 0;
const headSkips = f.headSkips ?? 0;
const baseExtTaut = f.baseExtTaut ?? 0;
const headExtTaut = f.headExtTaut ?? 0;
// The net-assert-REDUCTION signal can be allowlisted per file when the reduction is a
// verified-legitimate refactor/field-removal (config/quality/test-masking-allowlist.json).
// The tautology / skip / deletion signals below are NEVER allowlisted.
if (f.headAsserts < f.baseAsserts && !assertReductionAllowlist.has(f.file))
flags.push(
`${f.file}: asserts ${f.baseAsserts}${f.headAsserts} (REMOÇÃO de ${f.baseAsserts - f.headAsserts} — enfraquecimento?)`
);
if (f.headTaut > f.baseTaut)
flags.push(`${f.file}: nova(s) ${f.headTaut - f.baseTaut} tautologia(s) assert.ok(true)`);
if (headSkips > baseSkips)
flags.push(
`${f.file}: ${headSkips - baseSkips} novo(s) .skip/.todo/.only (asserts silenciados sem remoção)`
);
if (headExtTaut > baseExtTaut)
flags.push(
`${f.file}: nova(s) ${headExtTaut - baseExtTaut} tautologia(s) estendida(s) (expect(true).toBe(true) / assert.equal(1,1))`
);
}
return flags;
}
function git(args) {
try {
return execFileSync("git", args, { encoding: "utf8" });
} catch {
return "";
}
}
function resolveBase() {
if (process.env.GITHUB_BASE_SHA) return process.env.GITHUB_BASE_SHA;
if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`;
return null;
}
function main() {
const base = resolveBase();
if (!base) {
console.log("[test-masking] sem base ref (não é PR) — pulando.");
return;
}
// (6A.10 subcheck 1) Arquivos de teste deletados/renomeados via MDR filter.
// Renames test→test são RELOCAÇÕES (substituição) e passam pela verificação de
// redução de asserts abaixo (gutting-via-rename ainda flaga); só deleções reais
// e renames test→não-teste contam como remoção de teste.
const { deletedTests, renames } = partitionDeletedRenamed(
git(["diff", "--name-status", "-M", "--diff-filter=DR", `${base}...HEAD`])
);
const relocatedOutOfTest = [];
const renamePerFile = [];
for (const { from, to } of renames) {
if (!TEST_RE.test(to)) {
// test → non-test: the test was removed from coverage.
relocatedOutOfTest.push(from);
continue;
}
// test → test: compare the original (base) against the relocated (head) file so
// a clean relocation passes but a rename that drops asserts/adds tautologies fires.
const baseSrc = git(["show", `${base}:${from}`]);
const headSrc = fs.existsSync(to) ? fs.readFileSync(to, "utf8") : "";
renamePerFile.push({
file: to,
baseAsserts: countAssertions(baseSrc),
headAsserts: countAssertions(headSrc),
baseTaut: countTautologies(baseSrc),
headTaut: countTautologies(headSrc),
baseSkips: countSkips(baseSrc),
headSkips: countSkips(headSrc),
baseExtTaut: countExtendedTautologies(baseSrc),
headExtTaut: countExtendedTautologies(headSrc),
});
}
// Arquivos de teste modificados (subcheck original + skips + extTaut)
const changed = git(["diff", "--name-only", "--diff-filter=M", `${base}...HEAD`])
.split("\n")
.map((s) => s.trim())
.filter((f) => TEST_RE.test(f) && fs.existsSync(f));
const perFile = [...renamePerFile];
for (const file of changed) {
const baseSrc = git(["show", `${base}:${file}`]);
const headSrc = fs.readFileSync(file, "utf8");
perFile.push({
file,
baseAsserts: countAssertions(baseSrc),
headAsserts: countAssertions(headSrc),
baseTaut: countTautologies(baseSrc),
headTaut: countTautologies(headSrc),
baseSkips: countSkips(baseSrc),
headSkips: countSkips(headSrc),
baseExtTaut: countExtendedTautologies(baseSrc),
headExtTaut: countExtendedTautologies(headSrc),
});
}
// Per-file allowlist for verified-legitimate net-assert reductions (refactor/field-removal).
// Only exempts the reduction signal; tautology/skip/deletion signals still fire.
let assertReductionAllowlist = new Set();
let deletionAllowlist = {};
let reimplementedAllowlist = new Set();
try {
const raw = JSON.parse(fs.readFileSync("config/quality/test-masking-allowlist.json", "utf8"));
assertReductionAllowlist = new Set(Object.keys(raw).filter((k) => !k.startsWith("_")));
deletionAllowlist = raw._deletedWithReplacement || {};
reimplementedAllowlist = new Set(raw._reimplementedConditions || []);
} catch {
// no allowlist file — treat as empty
}
// (6348 subcheck 4, REPORT-ONLY) Tests that inline-reimplement a prod condition
// instead of importing the symbol that owns it. Prod files changed in this PR
// (added/copied/modified TS sources) are the reference corpus; each changed test
// file is scanned against them. Warns only — it never fails the gate for now.
const prodChanged = git(["diff", "--name-only", "--diff-filter=ACM", `${base}...HEAD`])
.split("\n")
.map((s) => s.trim())
.filter((f) => PROD_SRC_RE.test(f) && !TEST_RE.test(f) && fs.existsSync(f));
const prodSources = prodChanged.map((f) => {
try {
return fs.readFileSync(f, "utf8");
} catch {
return "";
}
});
const changedTests = git(["diff", "--name-only", "--diff-filter=ACM", `${base}...HEAD`])
.split("\n")
.map((s) => s.trim())
.filter((f) => TEST_RE.test(f) && fs.existsSync(f));
const reimplementedFlags = [];
if (prodSources.length) {
for (const tf of changedTests) {
if (reimplementedAllowlist.has(tf)) continue;
const src = fs.readFileSync(tf, "utf8");
for (const hit of findReimplementedConditions(prodSources, src, extractImports(src))) {
reimplementedFlags.push(
`${tf}: re-implementa a condição \`${hit.condition}\`` +
(hit.owner ? ` (dona: ${hit.owner})` : "") +
" — asserte através do import real em vez de copiar a condição"
);
}
}
}
if (reimplementedFlags.length) {
console.warn(
`[test-masking] (report-only) ${reimplementedFlags.length} teste(s) re-implementam ` +
`condição de produção em vez de importar o símbolo dono (classe #6216):\n` +
reimplementedFlags.map((f) => " ⚠ " + f).join("\n") +
`\n → importe o símbolo/função dono e asserte através dele (evita contrato duplicado ` +
`que diverge silenciosamente). Report-only por enquanto — não falha o gate.`
);
}
const deletedFlags = evaluateDeletedFiles(
[...deletedTests, ...relocatedOutOfTest],
deletionAllowlist
);
const maskingFlags = evaluateMasking(perFile, assertReductionAllowlist);
const allFlags = [...deletedFlags, ...maskingFlags];
if (allFlags.length) {
console.error(
`[test-masking] ${allFlags.length} sinal(is) de enfraquecimento de teste:\n` +
allFlags.map((f) => " ✗ " + f).join("\n") +
`\n → se a redução é legítima (refator/consolidação), explique no PR; senão, restaure os asserts.`
);
process.exit(1);
}
console.log(
`[test-masking] OK — ${changed.length} modificado(s), ${renames.length} renomeado(s) (relocação), ` +
`${deletedTests.length} deletado(s) — sem enfraquecimento`
);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+59
View File
@@ -0,0 +1,59 @@
import fs from "node:fs";
import path from "node:path";
// Dirs collected ONLY by vitest (vitest.mcp.config.ts include globs for .ts tests).
// Keep in sync with vitest.mcp.config.ts. A test here MUST import from "vitest".
const VITEST_ONLY_DIRS = [
"tests/unit/autoCombo",
"open-sse/services/autoCombo",
"open-sse/mcp-server",
];
function walk(dir, root, out = []) {
const abs = path.join(root, dir);
if (!fs.existsSync(abs)) return out;
for (const name of fs.readdirSync(abs)) {
const rel = path.join(dir, name);
const s = fs.statSync(path.join(root, rel));
if (s.isDirectory()) {
if (name === "node_modules" || name === ".git") continue;
walk(rel, root, out);
} else if (/\.test\.(ts|tsx|js|mjs)$/.test(name)) {
out.push(rel);
}
}
return out;
}
function isVitestOnly(relFile) {
const norm = relFile.replace(/\\/g, "/");
return VITEST_ONLY_DIRS.some(
(d) => norm.startsWith(d + "/") && (norm.includes("/__tests__/") || d.startsWith("tests/unit/"))
);
}
export function findRunnerMismatches(root) {
const files = VITEST_ONLY_DIRS.flatMap((d) => walk(d, root));
const bad = [];
for (const f of files) {
if (!isVitestOnly(f)) continue;
const txt = fs.readFileSync(path.join(root, f), "utf8");
const importsNodeTest = /from\s+["']node:test["']/.test(txt);
const importsVitest = /from\s+["']vitest["']/.test(txt);
if (importsNodeTest && !importsVitest) {
bad.push({ file: f, reason: "vitest-only dir but imports node:test (use the vitest API)" });
}
}
return bad;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const root = process.cwd();
const bad = findRunnerMismatches(root);
if (bad.length) {
console.error(`[test-runner-api] FAIL — ${bad.length} test(s) use the wrong runner API:`);
for (const b of bad) console.error(` ${b.file}: ${b.reason}`);
process.exit(1);
}
console.log("[test-runner-api] OK — vitest-only dirs use the vitest API.");
}
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env node
// scripts/check/check-tracked-artifacts.mjs
// Gate: falha se o git rastreia artefatos de build/artefatos gerados proibidos.
// Guarda contra `git add -A` acidental em worktrees que include node_modules symlinks.
// Incidente registrado 2× neste repo (v3.8.12 / v3.8.13) — Hard Rule #7 extensão.
//
// Artefatos proibidos:
// - node_modules/ — deps de build nunca devem entrar no repo
// - .next/ — output do build Next.js
// - coverage/ — relatórios de cobertura gerados pelo c8
// - quality-metrics.json — saída do collect-metrics.mjs (gerado, não-versionado)
// - symlinks rastreados (mode 120000) — indício de `git add -A` em worktree
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const FORBIDDEN_PREFIXES = ["node_modules/", ".next/", "coverage/"];
const FORBIDDEN_EXACT = new Set([
"quality-metrics.json", // legacy root location (still forbidden if a stale run writes it)
"config/quality/quality-metrics.json", // current generated location (collect-metrics.mjs)
]);
/**
* Verifica se algum caminho na lista de arquivos rastreados corresponde a um
* artefato proibido. Também aceita uma lista separada de symlinks rastreados.
*
* @param {string[]} trackedFiles - saída de `git ls-files` (caminhos relativos)
* @param {string[]} trackedSymlinks - caminhos com mode 120000 (saída de git ls-files -s)
* @returns {string[]} lista de violações (strings descritivas)
*/
export function checkTrackedArtifacts(trackedFiles, trackedSymlinks = []) {
const violations = [];
for (const file of trackedFiles) {
if (FORBIDDEN_EXACT.has(file)) {
violations.push(`forbidden tracked artifact: ${file}`);
continue;
}
for (const prefix of FORBIDDEN_PREFIXES) {
if (file.startsWith(prefix)) {
violations.push(`forbidden tracked artifact (${prefix}*): ${file}`);
break;
}
}
}
for (const sym of trackedSymlinks) {
violations.push(`forbidden tracked symlink (mode 120000): ${sym}`);
}
return violations;
}
function getTrackedFiles() {
const output = execFileSync("git", ["ls-files"], { encoding: "utf8" });
return output
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
}
function getTrackedSymlinks() {
// git ls-files -s prints: <mode> <hash> <stage>\t<path>
// mode 120000 = symlink
const output = execFileSync("git", ["ls-files", "-s"], { encoding: "utf8" });
const symlinks = [];
for (const line of output.split("\n")) {
if (line.startsWith("120000")) {
const parts = line.split("\t");
if (parts[1]) symlinks.push(parts[1].trim());
}
}
return symlinks;
}
function main() {
const trackedFiles = getTrackedFiles();
const trackedSymlinks = getTrackedSymlinks();
const violations = checkTrackedArtifacts(trackedFiles, trackedSymlinks);
if (violations.length === 0) {
console.log("[tracked-artifacts] OK — no forbidden artifacts tracked by git");
process.exit(0);
}
console.error(
`[tracked-artifacts] FAIL — ${violations.length} forbidden artifact(s) tracked by git:`
);
for (const v of violations) {
console.error(`${v}`);
}
console.error(
"\n → Run: git rm --cached <path> to untrack the artifact." +
"\n → Add the path to .gitignore to prevent re-tracking."
);
process.exit(1);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env node
// scripts/check/check-type-coverage.mjs
// Type-coverage ratchet (Task 6 of Fase 7).
// Fase 7 INT: promovido de ADVISORY para RATCHET bloqueante.
//
// Measures the % of typed symbols across the codebase using the `type-coverage`
// tool and prints `typeCoveragePct=<N>`. Lê o baseline de quality-baseline.json
// (metrics.typeCoveragePct) e falha com exit 1 se a % CAIR além do eps.
//
// tsconfig used: open-sse/tsconfig.json
// - Rationale: the only tsconfig that covers the full open-sse workspace
// (src+open-sse together). `tsconfig.json` excludes open-sse; the
// `tsconfig.typecheck-core.json` only lists 26 explicit files (partial).
// open-sse/tsconfig.json sets `baseUrl: ".."` and path aliases so it
// resolves both workspaces correctly and yields a representative global %.
//
// Direction: up (% can only improve; ratchet blocks drops once wired into INT).
// Eps: 0.05 (float noise tolerance — type-coverage may vary by ~0.01% between runs).
//
// Run:
// node scripts/check/check-type-coverage.mjs
// node scripts/check/check-type-coverage.mjs --update # ratchet baseline up
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const TSCONFIG = path.join(ROOT, "open-sse", "tsconfig.json");
const UPDATE = process.argv.includes("--update");
const BASELINE_PATH = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "config/quality/quality-baseline.json")
);
// Small epsilon to absorb float noise between runs (type-coverage can vary ~0.01%).
const DEFAULT_EPS = 0.05;
/**
* Parse the JSON output produced by `type-coverage --json-output`.
* Returns the coverage percentage as a number (e.g. 91.66).
* Throws if the output cannot be parsed or has unexpected shape.
*
* Exported for unit-testing against synthetic output.
*/
export function parseTypeCoverageOutput(jsonText) {
let parsed;
try {
parsed = JSON.parse(jsonText);
} catch (err) {
throw new Error(`[type-coverage] Failed to parse JSON output: ${err.message}`);
}
if (typeof parsed.percent !== "number") {
throw new Error(
`[type-coverage] Unexpected output shape — missing numeric 'percent' field. Got: ${JSON.stringify(parsed)}`
);
}
return parsed.percent;
}
/**
* Avalia a % de type-coverage atual contra o baseline.
* Direction: up (% só pode SUBIR; queda além de eps é regressão).
*
* Exported for unit testing.
*
* @param {number} current
* @param {number} baseline
* @param {number} [eps=0] - tolerance for float noise
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateTypeCoverage(current, baseline, eps = 0) {
const regressed = current < baseline - eps;
const improved = current > baseline + eps;
return { regressed, improved };
}
function runTypeCoverage() {
const typeCoverageBin = path.join(ROOT, "node_modules", ".bin", "type-coverage");
if (!fs.existsSync(typeCoverageBin)) {
throw new Error(`[type-coverage] Binary not found at ${typeCoverageBin}`);
}
if (!fs.existsSync(TSCONFIG)) {
throw new Error(`[type-coverage] tsconfig not found at ${TSCONFIG}`);
}
let stdout;
try {
stdout = execFileSync(typeCoverageBin, ["--json-output", "-p", TSCONFIG], {
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
cwd: ROOT,
});
} catch (err) {
// type-coverage exits non-zero when --at-least check fails, but we don't use that.
// If there is stdout, try to parse it anyway.
stdout = err.stdout ? String(err.stdout) : "";
if (!stdout.trim()) throw err;
}
return parseTypeCoverageOutput(stdout.trim());
}
function main() {
if (!fs.existsSync(BASELINE_PATH)) {
process.stderr.write(`[type-coverage] FAIL — ${path.basename(BASELINE_PATH)} ausente.\n`);
process.exit(2);
}
const baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
const baselineMetric = baselineJson.metrics && baselineJson.metrics.typeCoveragePct;
if (!baselineMetric || typeof baselineMetric.value !== "number") {
process.stderr.write(
"[type-coverage] FAIL — metrics.typeCoveragePct ausente em quality-baseline.json.\n"
);
process.exit(2);
}
const baselineValue = baselineMetric.value;
const eps = typeof baselineMetric.eps === "number" ? baselineMetric.eps : DEFAULT_EPS;
console.log("[type-coverage] Running type-coverage (this may take ~30-60 s)…");
console.log(`[type-coverage] tsconfig: ${path.relative(ROOT, TSCONFIG)}`);
let pct;
try {
pct = runTypeCoverage();
} catch (err) {
process.stderr.write(`[type-coverage] FAIL — ${err.message}\n`);
process.exit(2);
}
// Canonical output line consumed by collect-metrics.mjs and shell scripts.
console.log(`typeCoveragePct=${pct}`);
const { regressed, improved } = evaluateTypeCoverage(pct, baselineValue, eps);
if (UPDATE && improved) {
baselineJson.metrics.typeCoveragePct.value = pct;
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselineJson, null, 2) + "\n");
console.log(`[type-coverage] baseline ratcheado: ${pct} (era ${baselineValue})`);
}
if (regressed) {
process.stderr.write(
`[type-coverage] REGRESSÃO — ${pct}% < baseline ${baselineValue}% (eps=${eps})\n` +
` → Adicione anotações de tipo ou rode\n` +
` 'node scripts/check/check-type-coverage.mjs --update' se a % subiu legitimamente.\n`
);
process.exit(1);
}
console.log(
`[type-coverage] OK — ${pct}% symbols typed (baseline ${baselineValue}%, eps=${eps})`
);
process.exit(0);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
main();
}
+375
View File
@@ -0,0 +1,375 @@
#!/usr/bin/env node
// scripts/check/check-vuln-ratchet.mjs
// Catraca de vulnerabilidades de dependências via osv-scanner (Task 7.2 — Fase 7).
//
// Saída (stdout):
// vulnCount=N — total de vulnerabilidades encontradas (todos os severities)
// vulnCount=SKIP reason=binary-absent — osv-scanner não está no PATH
//
// Por default é ADVISORY (sai 0 sempre). Passe --ratchet para tornar BLOQUEANTE:
// lê metrics.vulnCount.value de config/quality/quality-baseline.json, compara a
// contagem MEDIDA e SAI 1 SE — E SOMENTE SE — a medida for MAIOR que o baseline
// (regressão real, direction:down). Qualquer SKIP gracioso (binário ausente,
// osv.dev/rede inacessível, erro de parse) SAI 0 mesmo com --ratchet — uma falha
// de MEDIÇÃO nunca bloqueia, só uma regressão medida bloqueia.
//
// NB (variância de CVE): osv mede contra um banco de CVEs que cresce de forma
// contínua. Um PR que NÃO toca dependências pode subitamente medir vulnCount >
// baseline porque um novo CVE foi divulgado nas deps existentes — isso é o
// comportamento ESPERADO de um gate de CVE bloqueante, não uma regressão de
// produto. O remédio é (a) bumpar a dep afetada (preferível) ou, se não houver
// fix, (b) re-baseline metrics.vulnCount com justificativa + issue de tracking.
// Ver docs/security/SUPPLY_CHAIN.md → "Variância de CVE".
//
// Uso:
// node scripts/check/check-vuln-ratchet.mjs
// node scripts/check/check-vuln-ratchet.mjs --json # imprime JSON bruto do osv-scanner
// node scripts/check/check-vuln-ratchet.mjs --quiet # suprime logs de diagnóstico
// node scripts/check/check-vuln-ratchet.mjs --ratchet # falha (exit 1) numa regressão
import fs from "node:fs";
import { execFileSync, spawnSync } from "node:child_process";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const QUIET = process.argv.includes("--quiet");
const PRINT_JSON = process.argv.includes("--json");
const RATCHET = process.argv.includes("--ratchet");
const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json");
// ---------------------------------------------------------------------------
// Pure parsing function (exported for tests)
// ---------------------------------------------------------------------------
/**
* Conta vulnerabilidades no JSON emitido por `osv-scanner --format json`.
*
* Formato do osv-scanner v1+:
* {
* results: [
* {
* packages: [
* {
* package: { name, version, ecosystem },
* vulnerabilities: [ { id, aliases, affected, ... }, ... ],
* groups: [ { ids: [...] }, ... ]
* },
* ...
* ]
* },
* ...
* ]
* }
*
* Contagem: cada entrada em `vulnerabilities[]` de cada package conta como 1 vuln.
* Se `groups` estiver presente e tiver menos entradas que `vulnerabilities`, usamos
* `groups.length` para deduplificar (mesma vuln em múltiplos pacotes conta 1x por
* grupo). Caso contrário, contamos `vulnerabilities.length`.
*
* @param {object|null} osvJson - Objeto JSON parseado do osv-scanner
* @returns {{ vulnCount: number, bySeverity: Record<string, number> }}
*/
export function parseOsvJson(osvJson) {
if (!osvJson || !Array.isArray(osvJson.results)) {
return { vulnCount: 0, bySeverity: {} };
}
let vulnCount = 0;
const bySeverity = {};
for (const result of osvJson.results) {
if (!Array.isArray(result.packages)) continue;
for (const pkg of result.packages) {
if (!Array.isArray(pkg.vulnerabilities)) continue;
// Use groups for deduplication when available (same vuln in multiple paths)
const pkgCount = Array.isArray(pkg.groups) && pkg.groups.length > 0
? pkg.groups.length
: pkg.vulnerabilities.length;
vulnCount += pkgCount;
// Collect severity info from the vulnerability entries
for (const vuln of pkg.vulnerabilities) {
const severity = extractSeverity(vuln);
bySeverity[severity] = (bySeverity[severity] ?? 0) + 1;
}
}
}
return { vulnCount, bySeverity };
}
/**
* Extrai a severidade de uma entrada de vulnerabilidade do osv-scanner.
* Tenta database_specific.severity, depois severity[0].type, depois "UNKNOWN".
*
* @param {object} vuln - Entrada de vulnerabilidade do osv-scanner
* @returns {string}
*/
export function extractSeverity(vuln) {
if (!vuln) return "UNKNOWN";
// osv-scanner v2 field: database_specific.severity (common in OSV schema)
const dbSeverity = vuln.database_specific?.severity;
if (typeof dbSeverity === "string" && dbSeverity.length > 0) {
return dbSeverity.toUpperCase();
}
// CVSS severity array: [{ type: "CVSS_V3", score: "CVSS:3.1/..." }, ...]
if (Array.isArray(vuln.severity) && vuln.severity.length > 0) {
const first = vuln.severity[0];
if (typeof first?.type === "string") {
return first.type;
}
}
return "UNKNOWN";
}
// ---------------------------------------------------------------------------
// Ratchet (direction:down) — exported for tests
// ---------------------------------------------------------------------------
/**
* Avalia a contagem MEDIDA de vulnerabilidades contra o baseline.
* Direction: down (a contagem só pode CAIR — mais vulns = regressão).
*
* @param {number} current - Contagem de vulnerabilidades medida agora.
* @param {number} baseline - Contagem congelada em quality-baseline.json.
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateVulnRatchet(current, baseline) {
return {
regressed: current > baseline,
improved: current < baseline,
};
}
/**
* Lê metrics.vulnCount.value do quality-baseline.json.
* Retorna null se o arquivo ou a métrica estiverem ausentes (sem baseline não há
* ratchet possível — o caller trata como SKIP gracioso, exit 0).
*
* @param {string} baselinePath
* @returns {number|null}
*/
export function readBaselineVulnValue(baselinePath = BASELINE_PATH) {
if (!fs.existsSync(baselinePath)) return null;
let baselineJson;
try {
baselineJson = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
} catch {
return null;
}
const metric = baselineJson?.metrics?.vulnCount;
if (!metric || typeof metric.value !== "number") return null;
return metric.value;
}
// ---------------------------------------------------------------------------
// Binary detection
// ---------------------------------------------------------------------------
/**
* Detecta se o binário `osv-scanner` está disponível no PATH.
* Usa `which` (Unix) sem interpolação de shell — Hard Rule #13.
*
* @returns {string|null} Caminho absoluto para o binário, ou null se ausente.
*/
export function findOsvScanner() {
try {
const result = spawnSync("which", ["osv-scanner"], {
encoding: "utf8",
timeout: 5_000,
});
if (result.status === 0) {
return result.stdout.trim();
}
} catch {
// which não disponível — tentar command -v via sh
}
// Fallback: tentar executar diretamente para verificar ENOENT
try {
const result = spawnSync("osv-scanner", ["--version"], {
encoding: "utf8",
timeout: 5_000,
});
if (result.error?.code === "ENOENT") return null;
if (result.status !== null) return "osv-scanner"; // found in PATH
} catch {
// noop
}
return null;
}
// ---------------------------------------------------------------------------
// Runner
// ---------------------------------------------------------------------------
/**
* Executa o osv-scanner sobre o lockfile/diretório.
* Usa execFileSync sem shell interpolation (Hard Rule #13).
*
* Em uma falha de MEDIÇÃO (osv-scanner não produziu JSON — rede/osv.dev
* inacessível, timeout, JSON inválido) retorna { skip: true, reason } em vez de
* abortar o processo: o caller traduz isso num SKIP gracioso (exit 0 mesmo com
* --ratchet). Uma falha de medição NUNCA bloqueia; só uma regressão MEDIDA bloqueia.
*
* @param {string} osvBin - Caminho para o binário osv-scanner
* @returns {{ json: object } | { skip: true, reason: string }}
*/
function runOsvScanner(osvBin) {
const args = [
"--format", "json",
"--lockfile", path.join(ROOT, "package-lock.json"),
];
if (!QUIET) {
process.stderr.write("[vuln-ratchet] Rodando osv-scanner --format json ...\n");
}
let stdout;
try {
stdout = execFileSync(osvBin, args, {
cwd: ROOT,
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
timeout: 120_000, // 2 min
});
} catch (err) {
// osv-scanner sai com código != 0 quando encontra vulnerabilidades;
// o JSON ainda vai no stdout.
stdout = err.stdout ? String(err.stdout) : "";
if (!stdout.trim()) {
process.stderr.write(`[vuln-ratchet] ERRO ao executar osv-scanner: ${err.message}\n`);
return { skip: true, reason: "osv-error" };
}
}
try {
return { json: JSON.parse(stdout) };
} catch (parseErr) {
process.stderr.write(`[vuln-ratchet] ERRO ao parsear JSON do osv-scanner: ${parseErr.message}\n`);
process.stderr.write(`[vuln-ratchet] stdout (primeiros 500 chars): ${stdout.slice(0, 500)}\n`);
return { skip: true, reason: "parse-error" };
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const osvBin = findOsvScanner();
if (!osvBin) {
// Skip gracioso: binário ausente — esperado em ambientes sem osv-scanner instalado.
// SKIP sai 0 MESMO com --ratchet (binário ausente nunca bloqueia).
console.log("vulnCount=SKIP reason=binary-absent");
if (!QUIET) {
process.stderr.write(
"[vuln-ratchet] SKIP — osv-scanner não encontrado no PATH.\n" +
"[vuln-ratchet] Instale via: https://google.github.io/osv-scanner/\n" +
"[vuln-ratchet] SKIP gracioso — sai 0 mesmo com --ratchet (binário ausente nunca bloqueia).\n"
);
}
process.exitCode = 0;
return;
}
const osvResult = runOsvScanner(osvBin);
// Falha de MEDIÇÃO (rede/osv.dev inacessível, timeout, JSON inválido) → SKIP
// gracioso, sai 0 mesmo com --ratchet (uma falha de medição nunca bloqueia).
if (osvResult.skip) {
console.log(`vulnCount=SKIP reason=${osvResult.reason}`);
if (!QUIET) {
process.stderr.write(
`[vuln-ratchet] SKIP — osv-scanner não produziu uma medição (${osvResult.reason}).\n` +
"[vuln-ratchet] SKIP gracioso — sai 0 mesmo com --ratchet (falha de medição nunca bloqueia).\n"
);
}
process.exitCode = 0;
return;
}
const osvJson = osvResult.json;
if (PRINT_JSON) {
process.stdout.write(JSON.stringify(osvJson, null, 2) + "\n");
return;
}
const { vulnCount, bySeverity } = parseOsvJson(osvJson);
// Emitir em formato KEY=VALUE para o coletor de métricas (collect-metrics.mjs)
console.log(`vulnCount=${vulnCount}`);
if (!QUIET) {
const severitySummary = Object.entries(bySeverity)
.map(([k, v]) => `${k}=${v}`)
.join(", ") || "nenhuma";
process.stderr.write(
`[vuln-ratchet] Total de vulnerabilidades: ${vulnCount} (${severitySummary})\n`
);
}
// Medição bem-sucedida → aplica o ratchet (bloqueante só com --ratchet).
applyRatchet(vulnCount);
}
/**
* Aplica o ratchet (direction:down) sobre a contagem medida vs o baseline.
* Sem --ratchet: advisory (exit 0). Com --ratchet: exit 1 numa regressão real
* (medida > baseline). Baseline ausente → SKIP gracioso (exit 0).
*
* @param {number} vulnCount - Contagem MEDIDA (medição bem-sucedida).
*/
function applyRatchet(vulnCount) {
if (!RATCHET) {
if (!QUIET) {
process.stderr.write(
"[vuln-ratchet] ADVISORY — não falha pela contagem (passe --ratchet para bloquear regressão).\n"
);
}
process.exitCode = 0;
return;
}
const baselineValue = readBaselineVulnValue(BASELINE_PATH);
if (baselineValue === null) {
if (!QUIET) {
process.stderr.write(
"[vuln-ratchet] baseline ausente (metrics.vulnCount) — SKIP gracioso, sai 0.\n"
);
}
process.exitCode = 0;
return;
}
const { regressed } = evaluateVulnRatchet(vulnCount, baselineValue);
if (regressed) {
process.stderr.write(
`[vuln-ratchet] REGRESSÃO — ${vulnCount} vulnerabilidades > baseline ${baselineValue}\n` +
" → Bumpe a(s) dep(s) afetada(s) (preferível). Se não houver fix, re-baseline\n" +
" metrics.vulnCount em config/quality/quality-baseline.json com justificativa\n" +
" + issue de tracking. Ver docs/security/SUPPLY_CHAIN.md → 'Variância de CVE'.\n"
);
process.exitCode = 1;
return;
}
if (!QUIET) {
process.stderr.write(
`[vuln-ratchet] OK — sem regressão (${vulnCount} vulns, baseline ${baselineValue}).\n`
);
}
process.exitCode = 0;
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
+398
View File
@@ -0,0 +1,398 @@
#!/usr/bin/env node
// scripts/check/check-workflows.mjs
// Lint + security audit of GitHub Actions workflow files.
// PLANO-QUALITY-GATES-FASE7.md, Task 19.
//
// Tools:
// actionlint — syntax / correctness / shellcheck of workflow YAML
// zizmor — 24+ security audits (unpinned actions, script injection,
// pull_request_target misuse, cache poisoning, …)
//
// Graceful-SKIP contract:
// If EITHER binary is absent from PATH, the script prints a SKIP notice and
// exits 0. This allows the gate to run in environments that have the tools
// installed (CI with setup steps, developer machines with actionlint/zizmor)
// while being inert elsewhere.
//
// Output (stdout, one line each):
// workflowFindings=<n> — total findings from both tools combined
// actionlintFindings=<n> — findings from actionlint alone
// zizmorFindings=<n> — findings from zizmor alone
//
// Exit codes:
// 0 — SKIP (binary absent) or all tools passed / no ratchet regression
// 1 — gate failure: --strict + any finding, OR --ratchet + zizmorFindings
// regression (measured > baseline)
//
// Ratchet mode (--ratchet): reads metrics.zizmorFindings.value from
// config/quality/quality-baseline.json and exits 1 IF — AND ONLY IF — the MEASURED
// zizmor count is GREATER than the baseline (real regression, direction:down).
// ONLY zizmorFindings is ratcheted; actionlint findings are REPORTED but NOT
// ratcheted (use the separate --strict all-or-nothing flag for those). Any graceful
// SKIP (binary absent, no workflows) exits 0 even with --ratchet — missing infra
// never blocks, only a measured regression does.
//
// Usage:
// node scripts/check/check-workflows.mjs # advisory (exit 0 always)
// node scripts/check/check-workflows.mjs --strict # fail on any finding
// node scripts/check/check-workflows.mjs --ratchet # fail on zizmor regression
// node scripts/check/check-workflows.mjs --quiet # suppress progress logs
import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const WORKFLOWS_DIR = path.join(ROOT, ".github", "workflows");
const ZIZMOR_CONFIG = path.join(ROOT, ".zizmor.yml");
const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json");
const STRICT = process.argv.includes("--strict");
const RATCHET = process.argv.includes("--ratchet");
const QUIET = process.argv.includes("--quiet");
// ---------------------------------------------------------------------------
// Utility: resolve binary from PATH (cross-platform)
// ---------------------------------------------------------------------------
/**
* Checks whether a binary exists in PATH by running `which`/`where`.
* Returns true if found, false otherwise.
*
* @param {string} name - Binary name (e.g. "actionlint")
* @returns {boolean}
*/
export function isBinaryAvailable(name) {
// Use `command -v` on Unix; `where` on Windows (via cmd).
// We shell through `sh -c` because execFileSync needs the actual path
// and we want cross-platform behaviour.
const result = spawnSync("sh", ["-c", `command -v ${name}`], {
encoding: "utf8",
timeout: 5_000,
windowsHide: true,
});
return result.status === 0 && result.stdout.trim().length > 0;
}
// ---------------------------------------------------------------------------
// actionlint result parsing
// ---------------------------------------------------------------------------
/**
* Parses actionlint output (line-based, one finding per line) and counts
* findings. Each non-empty line = one finding.
*
* actionlint emits lines in the format:
* <file>:<line>:<col>: <message> [<rule>]
* or a summary line when all is well (zero findings = empty stdout).
*
* @param {string} stdout - Raw stdout from actionlint
* @returns {{ count: number, lines: string[] }}
*/
export function parseActionlintOutput(stdout) {
const lines = stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
return { count: lines.length, lines };
}
// ---------------------------------------------------------------------------
// zizmor result parsing
// ---------------------------------------------------------------------------
/**
* Parses zizmor JSON output and counts findings.
*
* zizmor --format json emits a JSON object:
* { diagnostics: Array<{ ...finding fields }> }
* or an array directly in older versions.
*
* If JSON parsing fails, falls back to line counting (graceful degradation).
*
* @param {string} stdout - Raw stdout from zizmor --format json (or text)
* @returns {{ count: number, diagnostics: unknown[] }}
*/
export function parseZizmorOutput(stdout) {
const trimmed = stdout.trim();
if (!trimmed) {
return { count: 0, diagnostics: [] };
}
try {
const parsed = JSON.parse(trimmed);
// zizmor ≥0.8 emits { diagnostics: [...] }
if (parsed && typeof parsed === "object" && Array.isArray(parsed.diagnostics)) {
return { count: parsed.diagnostics.length, diagnostics: parsed.diagnostics };
}
// Older versions may emit a bare array
if (Array.isArray(parsed)) {
return { count: parsed.length, diagnostics: parsed };
}
// Unexpected JSON shape — treat whole object as 0 findings if it has no
// obvious error marker; this is a best-effort parse.
return { count: 0, diagnostics: [] };
} catch {
// Not JSON (e.g. text output or error message) — count non-empty lines as
// a conservative fallback.
const lines = trimmed.split("\n").filter(Boolean);
return { count: lines.length, diagnostics: [] };
}
}
// ---------------------------------------------------------------------------
// Ratchet (direction:down, zizmorFindings only) — exported for tests
// ---------------------------------------------------------------------------
/**
* Evaluates the MEASURED zizmor finding count against the baseline.
* Direction: down (the count may only DROP — more findings = regression).
*
* @param {number} current - Measured zizmor finding count.
* @param {number} baseline - Frozen count in quality-baseline.json.
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateZizmorRatchet(current, baseline) {
return {
regressed: current > baseline,
improved: current < baseline,
};
}
/**
* Reads metrics.zizmorFindings.value from quality-baseline.json.
* Returns null when the file or metric is missing (no baseline → no ratchet
* possible; the caller treats this as a graceful SKIP, exit 0).
*
* @param {string} baselinePath
* @returns {number|null}
*/
export function readBaselineZizmorValue(baselinePath = BASELINE_PATH) {
if (!fs.existsSync(baselinePath)) return null;
let baselineJson;
try {
baselineJson = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
} catch {
return null;
}
const metric = baselineJson?.metrics?.zizmorFindings;
if (!metric || typeof metric.value !== "number") return null;
return metric.value;
}
// ---------------------------------------------------------------------------
// Runner helpers
// ---------------------------------------------------------------------------
/**
* Collects all *.yml files from the workflows directory.
*
* @param {string} workflowsDir
* @returns {string[]} Absolute paths
*/
export function collectWorkflowFiles(workflowsDir) {
if (!fs.existsSync(workflowsDir)) {
return [];
}
return fs
.readdirSync(workflowsDir)
.filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"))
.map((f) => path.join(workflowsDir, f));
}
/**
* Runs actionlint over the given workflow files.
* Returns parsed result. Never throws — returns count=0 on any exec error so
* that one broken binary does not abort the whole check.
*
* @param {string[]} files - Absolute paths to workflow YAMLs
* @returns {{ count: number, lines: string[], skipped: boolean }}
*/
export function runActionlint(files) {
if (files.length === 0) {
return { count: 0, lines: [], skipped: false };
}
try {
const stdout = execFileSync("actionlint", files, {
encoding: "utf8",
// actionlint exits non-zero when it finds issues; capture output anyway
// by catching the thrown error.
});
return { ...parseActionlintOutput(stdout), skipped: false };
} catch (err) {
// execFileSync throws when exit code != 0.
// stdout still contains the finding lines.
const stdout = (err && typeof err === "object" && "stdout" in err ? err.stdout : "") || "";
return { ...parseActionlintOutput(String(stdout)), skipped: false };
}
}
/**
* Runs zizmor over the workflows directory.
* Returns parsed result. Never throws.
*
* @param {string} workflowsDir - Path to .github/workflows
* @returns {{ count: number, diagnostics: unknown[], skipped: boolean }}
*/
export function runZizmor(workflowsDir) {
const args = ["--format", "json"];
if (fs.existsSync(ZIZMOR_CONFIG)) {
args.push("--config", ZIZMOR_CONFIG);
}
args.push(workflowsDir);
try {
const stdout = execFileSync("zizmor", args, {
encoding: "utf8",
maxBuffer: 4 * 1024 * 1024,
});
return { ...parseZizmorOutput(stdout), skipped: false };
} catch (err) {
const stdout = (err && typeof err === "object" && "stdout" in err ? err.stdout : "") || "";
return { ...parseZizmorOutput(String(stdout)), skipped: false };
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const hasActionlint = isBinaryAvailable("actionlint");
const hasZizmor = isBinaryAvailable("zizmor");
if (!hasActionlint && !hasZizmor) {
console.log(
"[check-workflows] SKIP — actionlint and zizmor not found in PATH.\n" +
" Install them to enable workflow linting and security audit:\n" +
" • actionlint: https://github.com/rhysd/actionlint\n" +
" • zizmor: https://github.com/woodruffw/zizmor\n" +
" Graceful SKIP — exits 0 even with --ratchet (missing binaries never block)."
);
process.stdout.write("workflowFindings=SKIP\n");
process.exit(0);
}
const workflowFiles = collectWorkflowFiles(WORKFLOWS_DIR);
if (workflowFiles.length === 0) {
if (!QUIET) {
console.log(
`[check-workflows] No workflow files found in ${WORKFLOWS_DIR} — nothing to check.`
);
}
process.stdout.write("workflowFindings=0\nactionlintFindings=0\nzizmorFindings=0\n");
process.exit(0);
}
if (!QUIET) {
console.log(`[check-workflows] Found ${workflowFiles.length} workflow file(s) to check.`);
}
let actionlintCount = 0;
let zizmorCount = 0;
// ── actionlint ────────────────────────────────────────────────────────────
if (hasActionlint) {
if (!QUIET) {
process.stderr.write("[check-workflows] Running actionlint …\n");
}
const result = runActionlint(workflowFiles);
actionlintCount = result.count;
if (result.count > 0 && !QUIET) {
console.error(`[check-workflows] actionlint: ${result.count} finding(s):`);
result.lines.forEach((l) => console.error(` ${l}`));
} else if (!QUIET) {
console.log(`[check-workflows] actionlint: OK (0 findings)`);
}
} else {
if (!QUIET) {
console.log("[check-workflows] actionlint: SKIP (not in PATH)");
}
}
// ── zizmor ────────────────────────────────────────────────────────────────
if (hasZizmor) {
if (!QUIET) {
process.stderr.write("[check-workflows] Running zizmor …\n");
}
const result = runZizmor(WORKFLOWS_DIR);
zizmorCount = result.count;
if (result.count > 0 && !QUIET) {
console.error(`[check-workflows] zizmor: ${result.count} finding(s).`);
console.error(" Run: zizmor --format text .github/workflows/ for human-readable details.");
} else if (!QUIET) {
console.log(`[check-workflows] zizmor: OK (0 findings)`);
}
} else {
if (!QUIET) {
console.log("[check-workflows] zizmor: SKIP (not in PATH)");
}
}
const total = actionlintCount + zizmorCount;
process.stdout.write(`workflowFindings=${total}\n`);
process.stdout.write(`actionlintFindings=${actionlintCount}\n`);
process.stdout.write(`zizmorFindings=${zizmorCount}\n`);
if (STRICT && total > 0) {
console.error(`\n[check-workflows] FAIL — ${total} workflow finding(s) total (--strict mode).`);
process.exit(1);
}
// ── ratchet (zizmorFindings only, direction:down) ──────────────────────────
// We can only ratchet zizmor when zizmor actually RAN (binary present). If
// zizmor is absent we have no comparable measurement → graceful SKIP (exit 0):
// a missing binary must never block, only a measured regression does.
if (RATCHET) {
if (!hasZizmor) {
if (!QUIET) {
process.stderr.write(
"[check-workflows] --ratchet: zizmor absent — SKIP (no measurement, never blocks).\n"
);
}
process.exit(0);
}
const baselineValue = readBaselineZizmorValue(BASELINE_PATH);
if (baselineValue === null) {
if (!QUIET) {
process.stderr.write(
"[check-workflows] --ratchet: baseline absent (metrics.zizmorFindings) — SKIP, exit 0.\n"
);
}
process.exit(0);
}
const { regressed } = evaluateZizmorRatchet(zizmorCount, baselineValue);
if (regressed) {
console.error(
`\n[check-workflows] REGRESSION — ${zizmorCount} zizmor finding(s) > baseline ${baselineValue}.\n` +
" → Fix the new workflow finding(s), or re-baseline metrics.zizmorFindings in\n" +
" config/quality/quality-baseline.json if the rise is a legitimate, justified drift.\n" +
" (actionlint findings are reported, not ratcheted — use --strict for those.)"
);
process.exit(1);
}
if (!QUIET) {
process.stderr.write(
`[check-workflows] --ratchet OK — ${zizmorCount} zizmor finding(s), baseline ${baselineValue} (no regression).\n`
);
}
process.exit(0);
}
if (total > 0 && !QUIET) {
console.log(
`[check-workflows] ADVISORY — ${total} finding(s) detected. ` +
"Pass --strict to block on any finding, or --ratchet to block on a zizmor regression."
);
}
process.exit(0);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
@@ -0,0 +1,58 @@
{
"lite": {
"tasks": {
"prose": 129,
"tool-output": 127,
"json": 160
}
},
"caveman": {
"tasks": {
"prose": 129,
"tool-output": 127,
"json": 160
}
},
"aggressive": {
"tasks": {
"prose": 129,
"tool-output": 127,
"json": 160
}
},
"ultra": {
"tasks": {
"prose": 92,
"tool-output": 116,
"json": 117
}
},
"rtk": {
"tasks": {
"prose": 129,
"tool-output": 127,
"json": 160
}
},
"session-dedup": {
"tasks": {
"prose": 129,
"tool-output": 127,
"json": 160
}
},
"headroom": {
"tasks": {
"prose": 129,
"tool-output": 127,
"json": 160
}
},
"ccr": {
"tasks": {
"prose": 129,
"tool-output": 56,
"json": 14
}
}
}
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env node
// scripts/check/lib/allowlist.mjs
// Shared helper for stale-allowlist enforcement (6A.3).
//
// Purpose: detect allowlist entries that no longer correspond to any live
// violation. When a developer fixes a violation, the entry in KNOWN_* must
// also be removed — otherwise the gate silently allows the violation to
// regress. This pattern is validated practice (ESLint --report-unused-disable-
// directives; Notion suppression hygiene).
/**
* Returns the subset of `allowlist` entries that do NOT appear in
* `liveViolations`. These are "stale" entries — the violation they once
* suppressed has been corrected, so the entry should be removed to prevent
* silent regression.
*
* @param {string[] | Set<string>} allowlist - The known-violations list/set.
* @param {string[] | Set<string>} liveViolations - Violations detected in the
* current run (strings as they appear in the allowlist).
* @param {string} gateName - Gate name used only in future error messages; not
* used internally by this function but kept for API consistency with
* assertNoStale.
* @returns {string[]} Stale entries (present in allowlist, absent in live).
*/
export function reportStaleEntries(allowlist, liveViolations, gateName) {
const liveSet = liveViolations instanceof Set ? liveViolations : new Set(liveViolations);
const stale = [];
for (const entry of allowlist) {
if (!liveSet.has(entry)) {
stale.push(entry);
}
}
return stale;
}
/**
* Calls reportStaleEntries; if any stale entries are found, logs them to
* stderr and sets process.exitCode = 1 so the gate fails without throwing
* (allowing multiple gates to report before the process exits).
*
* @param {string[] | Set<string>} allowlist
* @param {string[] | Set<string>} liveViolations
* @param {string} gateName - Shown in the error message to identify the gate.
* @returns {string[]} The same stale array returned by reportStaleEntries.
*/
export function assertNoStale(allowlist, liveViolations, gateName) {
const stale = reportStaleEntries(allowlist, liveViolations, gateName);
if (stale.length > 0) {
console.error(
`[${gateName}] ${stale.length} entrada(s) obsoleta(s) na allowlist ` +
`— a violação foi corrigida; REMOVA a entrada para travar a correção:\n` +
stale.map((e) => `${e}`).join("\n")
);
process.exitCode = 1;
}
return stale;
}
+110
View File
@@ -0,0 +1,110 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
function getArg(name, fallbackValue = "") {
const index = process.argv.indexOf(name);
if (index === -1 || index === process.argv.length - 1) {
return fallbackValue;
}
return process.argv[index + 1];
}
function formatPercent(value) {
return `${Number(value ?? 0).toFixed(2)}%`;
}
function parseThreshold(name, fallbackValue) {
const rawValue = getArg(name, fallbackValue);
const value = Number(rawValue);
if (!Number.isFinite(value)) {
console.error(`Invalid coverage threshold for ${name}: ${rawValue}`);
process.exit(1);
}
return value;
}
const inputPath = getArg("--input", "coverage/coverage-summary.json");
const outputPath = getArg("--output", "");
const hasGlobalThreshold = process.argv.includes("--threshold");
const defaultThreshold = parseThreshold("--threshold", "75");
if (!existsSync(inputPath)) {
console.error(`Coverage summary file not found: ${inputPath}`);
process.exit(1);
}
const summary = JSON.parse(readFileSync(inputPath, "utf8"));
const cwd = process.cwd();
const metrics = [
["lines", "Lines"],
["statements", "Statements"],
["functions", "Functions"],
["branches", "Branches"],
];
const thresholds = Object.fromEntries(
metrics.map(([metric]) => {
const fallback = metric === "branches" && !hasGlobalThreshold ? "70" : String(defaultThreshold);
return [metric, parseThreshold(`--${metric}`, fallback)];
})
);
const total = summary.total ?? {};
const gatePassed = metrics.every(([metric]) => (total[metric]?.pct ?? 0) >= thresholds[metric]);
const files = Object.entries(summary)
.filter(([name]) => name !== "total" && /\.(?:[cm]?[jt]sx?)$/.test(name))
.map(([name, stats]) => {
const relativeName = path.relative(cwd, name);
const totalLines = stats.lines?.total ?? 0;
const coveredLines = stats.lines?.covered ?? 0;
return {
name: relativeName,
lines: stats.lines?.pct ?? 0,
branches: stats.branches?.pct ?? 0,
functions: stats.functions?.pct ?? 0,
missingLines: Math.max(totalLines - coveredLines, 0),
};
})
.sort((left, right) => {
if (left.lines !== right.lines) return left.lines - right.lines;
if (left.branches !== right.branches) return left.branches - right.branches;
return right.missingLines - left.missingLines;
})
.slice(0, 15);
const report = [
"# Coverage Report",
"",
`Gate: ${gatePassed ? "PASS" : "FAIL"} at configured metric minimums.`,
"",
"## Totals",
"",
"| Metric | Covered | Total | Percent | Threshold | Status |",
"| --- | ---: | ---: | ---: | ---: | --- |",
...metrics.map(([metric, label]) => {
const covered = total[metric]?.covered ?? 0;
const totalCount = total[metric]?.total ?? 0;
const pct = total[metric]?.pct ?? 0;
const threshold = thresholds[metric];
const status = pct >= threshold ? "PASS" : "FAIL";
return `| ${label} | ${covered} | ${totalCount} | ${formatPercent(pct)} | ${threshold}% | ${status} |`;
}),
"",
"## Lowest Coverage Files",
"",
"| File | Lines | Branches | Functions | Missing Lines |",
"| --- | ---: | ---: | ---: | ---: |",
...files.map(
(entry) =>
`| \`${entry.name}\` | ${formatPercent(entry.lines)} | ${formatPercent(entry.branches)} | ${formatPercent(entry.functions)} | ${entry.missingLines} |`
),
];
const reportContent = `${report.join("\n")}\n`;
if (outputPath) {
writeFileSync(outputPath, reportContent);
} else {
process.stdout.write(reportContent);
}
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Decide whether the just-built release VERSION should also move the Docker
# `:latest` / `:latest-web` tags.
#
# Promote ONLY when VERSION is the highest STABLE semver among the union of
# {existing git tags} {VERSION}. Folding VERSION into the candidate set makes
# the decision independent of git-tag sync timing: on a `release: released`
# event the freshly-created tag is often not yet visible to `git fetch --tags`
# when this job runs, so a candidate set built purely from `git tag -l` would
# resolve HIGHEST to the *previous* version and skip the `:latest` promotion —
# leaving `latest` one release behind (#5301).
#
# Usage:
# git tag -l 'v[0-9]*' | sed 's/^v//' | scripts/ci/should-promote-latest.sh "$VERSION"
#
# - $1 : the version being published (no leading `v`, e.g. 3.8.40).
# - stdin : newline-separated candidate tags (a leading `v` is stripped;
# pre-release tags — anything containing `-`, e.g. 3.9.0-rc.1 —
# are ignored). May be empty (first release).
# Prints "true" if VERSION should move :latest, "false" otherwise.
set -euo pipefail
VERSION="${1:?version required}"
# A pre-release VERSION must never grab :latest (callers already short-circuit
# this, but stay safe as a standalone unit).
case "$VERSION" in
*-*) echo "false"; exit 0 ;;
esac
# Build the stable candidate set: incoming tags (v-stripped, pre-releases
# dropped) plus VERSION itself, then pick the numerically highest.
HIGHEST="$(
{
sed 's/^v//' | grep -vE -- '-' || true
printf '%s\n' "$VERSION"
} | sort -V | tail -1
)"
if [ "$VERSION" = "$HIGHEST" ]; then
echo "true"
else
echo "false"
fi
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env node
/**
* Generates bin/cli/api-commands/<tag>.mjs from the OpenAPI spec.
* Run: npm run build:cli-api
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import * as yaml from "js-yaml";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, "..", "..");
const SPEC_PATH = process.env.OPENAPI_SPEC || join(ROOT, "docs/openapi.yaml");
const OUT_DIR = join(ROOT, "bin/cli/api-commands");
// Operations already covered by hand-crafted commands — skip in generated output.
const IGNORED_OP_IDS = new Set([
"createChatCompletion",
"streamChatCompletion",
"listModels",
"getModel",
"createEmbedding",
"createImage",
"createImageEdit",
"createImageVariation",
"createTranscription",
"createSpeech",
"createModeration",
]);
function kebab(s) {
return s
.replace(/([A-Z])/g, (m) => "-" + m.toLowerCase())
.replace(/^-/, "")
.replace(/_/g, "-")
.replace(/--+/g, "-");
}
function camelCase(s) {
return s.replace(/[-_](\w)/g, (_, c) => c.toUpperCase());
}
function escapeStr(s) {
return String(s || "")
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.slice(0, 150);
}
if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true });
const spec = yaml.load(readFileSync(SPEC_PATH, "utf8"));
/** @type {Record<string, Array<{path: string, method: string, opId: string, op: object}>>} */
const byTag = {};
for (const [path, methods] of Object.entries(spec.paths || {})) {
for (const [method, op] of Object.entries(methods)) {
if (["parameters", "summary", "description", "servers"].includes(method)) continue;
if (typeof op !== "object" || op === null) continue;
if (IGNORED_OP_IDS.has(op.operationId)) continue;
const rawTag = op.tags?.[0] || "uncategorized";
const tag = rawTag
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
const opId = op.operationId || `${method}-${path.replace(/[^a-z0-9]/gi, "-")}`;
byTag[tag] = byTag[tag] || [];
byTag[tag].push({ path, method, opId, op });
}
}
const generatedTags = [];
for (const [tag, ops] of Object.entries(byTag)) {
const fnName = `register_${tag.replace(/-/g, "_")}`;
const lines = [
`// AUTO-GENERATED from ${SPEC_PATH.replace(ROOT + "/", "")}. Do not edit.`,
`import { apiFetch } from "../api.mjs";`,
`import { emit } from "../output.mjs";`,
`import { readFileSync } from "node:fs";`,
``,
`export function ${fnName}(parent) {`,
` const tag = parent.command("${tag}").description("${escapeStr(ops[0]?.op?.tags?.[0] || tag)} endpoints");`,
];
for (const { path, method, opId, op } of ops) {
const cmdName = kebab(opId);
const params = op.parameters || [];
const pathParams = params.filter((p) => p.in === "path");
const queryParams = params.filter((p) => p.in === "query");
const hasBody = !!op.requestBody;
const summary = escapeStr(op.summary || op.description || cmdName);
lines.push(` tag.command("${cmdName}")`);
lines.push(` .description("${summary}")`);
for (const p of pathParams) {
lines.push(
` .requiredOption("--${kebab(p.name)} <${p.name}>", "${escapeStr(p.description)}")`
);
}
for (const p of queryParams) {
const flag = p.required ? "requiredOption" : "option";
lines.push(` .${flag}("--${kebab(p.name)} <${p.name}>", "${escapeStr(p.description)}")`);
}
if (hasBody) {
lines.push(` .option("--body <jsonOrPath>", "JSON body or @path/to/file.json")`);
}
lines.push(` .action(async (opts, cmd) => {`);
lines.push(` const gOpts = cmd.optsWithGlobals();`);
// Build URL with path param substitution
lines.push(` let url = "${path}";`);
for (const p of pathParams) {
lines.push(
` url = url.replace("{${p.name}}", encodeURIComponent(opts.${camelCase(kebab(p.name))} ?? ""));`
);
}
// Build query string from query params
if (queryParams.length > 0) {
lines.push(` const qs = new URLSearchParams();`);
for (const p of queryParams) {
const optName = camelCase(kebab(p.name));
lines.push(
` if (opts.${optName} != null) qs.set("${p.name}", String(opts.${optName}));`
);
}
lines.push(` if (qs.toString()) url += "?" + qs.toString();`);
}
// Body handling
if (hasBody) {
lines.push(` let body;`);
lines.push(` if (opts.body) {`);
lines.push(` body = opts.body.startsWith("@")`);
lines.push(` ? JSON.parse(readFileSync(opts.body.slice(1), "utf8"))`);
lines.push(` : JSON.parse(opts.body);`);
lines.push(` }`);
}
const bodyArg = hasBody ? ", body" : "";
lines.push(
` const res = await apiFetch(url, { method: "${method.toUpperCase()}"${hasBody ? ", body" : ""}, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });`
);
lines.push(` const data = res.ok ? await res.json() : await res.text();`);
lines.push(` emit(data, gOpts);`);
lines.push(` });`);
}
lines.push(`}`);
const content = lines.join("\n") + "\n";
writeFileSync(join(OUT_DIR, `${tag}.mjs`), content);
generatedTags.push(tag);
console.log(`[generate] ${tag}.mjs — ${ops.length} operations`);
}
// Generate registry
const registryLines = [
`// AUTO-GENERATED. Do not edit.`,
...generatedTags.map((t) => `import { register_${t.replace(/-/g, "_")} } from "./${t}.mjs";`),
``,
`export const API_TAGS = ${JSON.stringify(generatedTags)};`,
``,
`export function registerApiCommands(program) {`,
` const api = program`,
` .command("api")`,
` .description("Direct REST API access (generated from OpenAPI spec)");`,
` api`,
` .command("tags")`,
` .description("List available API tag groups")`,
` .action(() => { API_TAGS.forEach((t) => console.log(t)); });`,
...generatedTags.map((t) => ` register_${t.replace(/-/g, "_")}(api);`),
`}`,
];
writeFileSync(join(OUT_DIR, "registry.mjs"), registryLines.join("\n") + "\n");
console.log(`[generate] registry.mjs — ${generatedTags.length} tags`);
console.log("[generate] Done.");
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# =============================================================================
# codex-ws — roda a OpenAI Codex CLI contra um OmniRoute LOCAL usando o
# transporte Responses-over-WebSocket (em vez do HTTP da Cloud).
#
# POR QUE ESTE WRAPPER EXISTE
# ---------------------------
# O OmniRoute expõe um proxy WebSocket para a API de Responses do Codex em
# ws(s)://<host>/v1/responses
# A Codex CLI sabe falar esse transporte quando o provider tem
# `supports_websockets = true` + `wire_api = "responses"`. Mas há DOIS detalhes
# que quebram o uso ingênuo:
#
# 1) A Codex CLI valida o NOME do modelo no cliente. Ids com prefixo de provider
# (ex.: "codex/gpt-5.5") são REJEITADOS ("model is not supported ... ChatGPT
# account"). É preciso mandar o id "puro" -> "gpt-5.5". (O OmniRoute, no
# bridge WS, re-resolve "gpt-5.5" -> provider codex internamente.)
#
# 2) A Codex CLI v0.136 carrega TAMBÉM "$CWD/.codex/config.toml" como config
# "project-local". Se você rodar de um diretório que tenha um .codex (ex.:
# /root, onde mora a config da Cloud), o `model` daquele arquivo SOBRESCREVE
# o `model` do seu CODEX_HOME -> você acaba mandando o modelo errado.
# Por isso forçamos model + model_provider via `-c` (precedência máxima),
# que vence qualquer config de arquivo (user-level OU project-local).
#
# Além disso, no modo `exec` (headless) a CLI exige um diretório "confiável" ou
# a flag --skip-git-repo-check; o wrapper adiciona a flag automaticamente.
#
# USO
# ---
# codex-ws "sua pergunta" # abre a TUI interativa (precisa de terminal)
# codex-ws exec "Responda: PONG" # one-shot headless (CI/validação)
# codex-ws --help # repassa flags pra Codex CLI
#
# Variáveis de ambiente (todas com default; sobrescreva exportando antes):
# OMNIROUTE_WS_BASE base URL do OmniRoute local (default abaixo)
# OMNIROUTE_WS_MODEL modelo codex (id puro) (default "gpt-5.5")
# OMNIROUTE_LOCAL_KEY API key (qualquer valor se REQUIRE_API_KEY=false)
# CODEX_WS_HOME dir de config isolado da Codex (default ~/.codex-ws)
# =============================================================================
set -euo pipefail
# ---- Configuração (com defaults seguros) ------------------------------------
OMNIROUTE_WS_BASE="${OMNIROUTE_WS_BASE:-http://127.0.0.1:20128/v1}" # base do OmniRoute local
OMNIROUTE_WS_MODEL="${OMNIROUTE_WS_MODEL:-gpt-5.5}" # id PURO (sem "codex/")
CODEX_WS_HOME="${CODEX_WS_HOME:-$HOME/.codex-ws}" # CODEX_HOME isolado da Cloud
# A Codex CLI lê a key Bearer da env var nomeada em `env_key`. Mantemos um nome
# próprio para não colidir com OPENAI_API_KEY da config da Cloud.
export OMNIROUTE_LOCAL_KEY="${OMNIROUTE_LOCAL_KEY:-local}"
# CODEX_HOME isolado: a Codex CLI usa ESTE diretório como config "user-level",
# deixando a sua ~/.codex (Cloud) totalmente intacta.
export CODEX_HOME="$CODEX_WS_HOME"
# ---- Garante que a config do CODEX_HOME exista (auto-bootstrap) --------------
# Só o bloco [model_providers.*] precisa estar aqui; model/model_provider são
# forçados via -c logo abaixo (por causa do detalhe #2 do cabeçalho).
if [ ! -f "$CODEX_HOME/config.toml" ]; then
mkdir -p "$CODEX_HOME"
cat > "$CODEX_HOME/config.toml" <<EOF
# Gerado por codex-ws.sh — config isolada para o WS local do OmniRoute.
model = "$OMNIROUTE_WS_MODEL"
model_provider = "omniroute-local"
[model_providers.omniroute-local]
name = "OmniRoute Local (WS)"
base_url = "$OMNIROUTE_WS_BASE" # a URL WebSocket é derivada desta base pela CLI
wire_api = "responses" # único valor suportado desde fev/2026
supports_websockets = true # <- liga o transporte Responses-over-WebSocket
env_key = "OMNIROUTE_LOCAL_KEY" # a CLI lê a key Bearer desta env var
# Marca o HOME como diretório confiável para o modo exec.
[projects."$HOME"]
trust_level = "trusted"
EOF
fi
# ---- Overrides de precedência máxima ----------------------------------------
# Vencem qualquer config de arquivo (inclusive a project-local da Cloud em
# $CWD/.codex/config.toml). É o que garante o modelo certo no transporte certo.
overrides=(-c model="$OMNIROUTE_WS_MODEL" -c model_provider="omniroute-local")
# ---- Dispatch ---------------------------------------------------------------
# No modo headless (`exec`) injeta --skip-git-repo-check (senão a CLI recusa
# rodar fora de um repo git "confiável"). O `shift` remove o "exec" duplicado.
if [ "${1:-}" = "exec" ]; then
shift
exec codex exec --skip-git-repo-check "${overrides[@]}" "$@"
fi
# Modo interativo (TUI) ou qualquer outro subcomando/flag: repassa direto.
exec codex "${overrides[@]}" "$@"
+72
View File
@@ -0,0 +1,72 @@
/**
* Compression evaluation harness CLI (D1). Runs the offline corpus eval (full-vs-compressed
* + self-validating judge + gold grading + mechanical savings) and prints a markdown report.
*
* Real model calls cost money — a full run is OPT-IN. Use --sample and --cost-cap-usd.
*
* Usage:
* npm run eval:compression -- --answer-model <id> --judge-model <id> --provider <p> \
* --sample 10 --cost-cap-usd 2 --mode lite
*
* Implementer-config (spec leaves open): the answer/judge model ids, the provider, and the
* default cost cap / sample. There is NO safe default that calls a real model — the CLI errors
* if --answer-model / --judge-model / --provider are missing, so a bare run never spends money.
*/
import { runEval } from "../../open-sse/services/compression/eval/runner.ts";
import { createExecutorModelClient } from "../../open-sse/services/compression/eval/executorModelClient.ts";
import { formatReport } from "../../open-sse/services/compression/eval/report.ts";
import { SEED_CORPUS } from "../../open-sse/services/compression/eval/seedCorpus.ts";
import { getDefaultCompressionConfig } from "../../open-sse/services/compression/stats.ts";
import type { CompressionConfig } from "../../open-sse/services/compression/types.ts";
function flag(name: string): string | undefined {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 ? process.argv[i + 1] : undefined;
}
async function main(): Promise<void> {
const answerModel = flag("answer-model");
const judgeModel = flag("judge-model");
const provider = flag("provider");
if (!answerModel || !judgeModel || !provider) {
console.error("eval:compression requires --answer-model, --judge-model and --provider (no model is called without them).");
process.exitCode = 2;
return;
}
const sample = flag("sample") ? Number(flag("sample")) : undefined;
const costCapUsd = flag("cost-cap-usd") ? Number(flag("cost-cap-usd")) : 0;
const mode = (flag("mode") ?? "lite") as CompressionConfig["defaultMode"];
// Credentials wiring is operator-supplied (env / connection store). Documented in Rule #18:
// the adapter is validated against a real account; this CLI reads the credential the operator
// exports for the chosen provider. Placeholder lookup left to the operator's environment.
const credentials = JSON.parse(process.env.OMNIROUTE_EVAL_CREDENTIALS ?? "{}");
const client = createExecutorModelClient(provider, credentials);
const config: CompressionConfig = { ...getDefaultCompressionConfig(), enabled: true, defaultMode: mode };
const result = await runEval({
corpus: SEED_CORPUS,
client,
config,
comboId: null,
combos: {},
answerModel,
judgeModel,
provider,
costCapUsd,
sample,
});
if (result.aborted) {
console.error(`eval aborted: ${result.abortReason}`);
process.exitCode = 1;
return;
}
console.log(formatReport(result.report!));
}
main().catch((err) => {
console.error("eval:compression failed:", err instanceof Error ? err.message : err);
process.exitCode = 1;
});
+39
View File
@@ -0,0 +1,39 @@
/**
* Compression engine A/B benchmark CLI (F2.4 / L2).
*
* Runs the deterministic compression engines over BENCHMARK_CORPUS through the C1 harness and
* prints a best-first markdown table, so the default engine can be chosen from real numbers
* rather than intuition. Deterministic and API-free (safe in CI / local dev).
*
* Usage:
* npm run bench:compression # the default deterministic engine set
* npm run bench:compression rtk lite # a specific subset
*
* llmlingua is excluded by default — its real compression needs the ONNX model at runtime
* (pass it explicitly once provisioned; the framework is engine-agnostic).
*/
import {
BENCHMARK_CORPUS,
DEFAULT_BENCHMARK_ENGINES,
benchmarkEngines,
compareReports,
formatBenchmarkTable,
} from "../../open-sse/services/compression/harness/benchmark.ts";
async function main(): Promise<void> {
const requested = process.argv.slice(2).filter((arg) => !arg.startsWith("-"));
const engines = requested.length > 0 ? requested : DEFAULT_BENCHMARK_ENGINES;
const reports = await benchmarkEngines(BENCHMARK_CORPUS, engines);
const rows = compareReports(reports);
console.log("# Compression engine A/B benchmark (F2.4)\n");
console.log(`Corpus: ${BENCHMARK_CORPUS.length} cases · engines: ${engines.join(", ")}\n`);
console.log(formatBenchmarkTable(rows));
console.log("");
}
main().catch((err) => {
console.error("benchmark failed:", err instanceof Error ? err.message : err);
process.exitCode = 1;
});
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env node
/**
* OmniRoute — Dev-startup native SQLite ABI guard.
*
* `better-sqlite3` is a native addon compiled for a specific Node.js ABI
* (NODE_MODULE_VERSION). This project supports both Node 22 (ABI 127) and
* Node 24 (ABI 137); switching between them via nvm leaves the previously
* built `better_sqlite3.node` incompatible, so `npm run dev` crashes during
* bootstrap with:
*
* "The module '…/better_sqlite3.node' was compiled against a different
* Node.js version using NODE_MODULE_VERSION 127. This version of Node.js
* requires NODE_MODULE_VERSION 137."
*
* `postinstall.mjs` only fixes the published standalone bundle and only runs
* on `npm install` — it does NOT cover "cloned repo, switched Node, ran dev".
*
* This guard probes the root binary against the *current* Node ABI and, ONLY
* when it detects a genuine ABI mismatch, runs `npm rebuild better-sqlite3`
* once. The healthy path (matching ABI) does no work, so dev startup stays
* fast. Unrelated errors are NOT swallowed — they fall through so the normal
* bootstrap surfaces them.
*/
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, "..", "..");
export const SQLITE_BINARY = join(
ROOT,
"node_modules",
"better-sqlite3",
"build",
"Release",
"better_sqlite3.node"
);
/**
* Whether an error message indicates a native-addon ABI / load mismatch
* (as opposed to an unrelated runtime error such as a missing table).
* Mirrors the detection in src/lib/db/core.ts::isNativeSqliteLoadError.
* @param {unknown} message
* @returns {boolean}
*/
export function isNativeAbiMismatch(message) {
const m = String(message ?? "");
return (
m.includes("NODE_MODULE_VERSION") ||
m.includes("was compiled against a different Node.js version") ||
m.includes("Module did not self-register") ||
m.includes("ERR_DLOPEN_FAILED") ||
m.includes("Could not locate the bindings file")
);
}
/** Probe a native binary against the current Node ABI without polluting the require cache. */
function probeLoad(binaryPath) {
process.dlopen({ exports: {} }, binaryPath);
}
/** Default rebuild: `npm rebuild better-sqlite3` at the repo root (no shell interpolation). */
function defaultRebuild() {
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
const result = spawnSync(npm, ["rebuild", "better-sqlite3"], { cwd: ROOT, stdio: "inherit" });
return result.status === 0;
}
/**
* Ensure better-sqlite3 loads under the current Node. Rebuilds once on ABI
* mismatch. Returns a result object; never throws for the mismatch path.
*
* @param {{ logger?: Pick<Console,"warn"|"error"|"log">, rebuild?: () => boolean, probe?: (p: string) => void, binaryPath?: string }} [opts]
* @returns {{ ok: boolean, rebuilt: boolean, error?: unknown }}
*/
export function ensureNativeSqlite(opts = {}) {
const {
logger = console,
rebuild = defaultRebuild,
probe = probeLoad,
binaryPath = SQLITE_BINARY,
} = opts;
// Nothing built yet (fresh clone before install) — let install/bootstrap handle it.
if (!existsSync(binaryPath)) return { ok: true, rebuilt: false };
try {
probe(binaryPath);
return { ok: true, rebuilt: false };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!isNativeAbiMismatch(message)) {
// Not an ABI problem — do not mask it; bootstrap will surface the real error.
return { ok: false, rebuilt: false, error };
}
logger.warn(
`[dev] better-sqlite3 was built for a different Node ABI than ${process.version}` +
"rebuilding (one-time)…"
);
if (!rebuild()) {
logger.error(
"[dev] Automatic 'npm rebuild better-sqlite3' failed. Run it manually:\n" +
" npm rebuild better-sqlite3"
);
return { ok: false, rebuilt: false };
}
logger.log("[dev] better-sqlite3 rebuilt for the current Node. Continuing startup.");
return { ok: true, rebuilt: true };
}
}
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env node
/**
* Docker healthcheck script for OmniRoute.
* Probes the /api/monitoring/health endpoint on the dashboard port.
* Used by Dockerfile and docker-compose files.
*
* #3151 — in some Docker network setups the server binds to a container IP and
* a probe against `127.0.0.1` is not reachable, while `localhost`/`::1` (or vice
* versa) is. The previous version probed ONLY `127.0.0.1` and swallowed every
* error, so the container was reported `unhealthy` with an empty, undiagnosable
* `State.Health[].Output`. We now try an ordered list of hosts and surface the
* last error on total failure.
*
* Bridge Network Fix: Also probes the container's internal bridge IP (e.g., 172.17.0.2)
* to handle Docker network setups that isolate loopback interfaces.
*/
import { pathToFileURL } from "node:url";
import { networkInterfaces } from "node:os";
const DEFAULT_HOSTS = ["127.0.0.1", "localhost", "::1"];
const DEFAULT_TIMEOUT_MS = 4000;
/**
* Get the primary non-loopback IPv4 address (container internal IP).
* Falls back to null if unable to determine.
*/
function getContainerInternalIP() {
try {
const interfaces = networkInterfaces();
for (const [name, addrs] of Object.entries(interfaces)) {
// Skip loopback and docker0, prioritize eth0/veth interfaces
if (name.startsWith("lo") || name === "docker0") continue;
const ipv4 = addrs?.find((a) => a.family === "IPv4" && !a.internal);
if (ipv4) return ipv4.address;
}
} catch {
// silently ignore if unable to read interfaces
}
return null;
}
/**
* Build the health URL for a host, bracketing IPv6 literals (e.g. `::1`).
* @param {string} host
* @param {string|number} port
*/
function healthUrl(host, port) {
const hostPart = host.includes(":") ? `[${host}]` : host;
return `http://${hostPart}:${port}/api/monitoring/health`;
}
/**
* Probe the health endpoint across an ordered list of hosts. Resolves with the
* first host that returns a 2xx response; rejects with the last error if every
* host fails. Each attempt is bounded by a per-host timeout so one unreachable
* host cannot hang the whole probe.
*
* @param {object} opts
* @param {string|number} opts.port
* @param {string[]} [opts.hosts]
* @param {typeof fetch} [opts.fetchImpl]
* @param {number} [opts.timeoutMs]
* @returns {Promise<string>} the host that succeeded
*/
export async function probeHealth({
port,
hosts = DEFAULT_HOSTS,
fetchImpl = fetch,
timeoutMs = DEFAULT_TIMEOUT_MS,
} = {}) {
let lastError = new Error("no hosts to probe");
for (const host of hosts) {
try {
const res = await fetchImpl(healthUrl(host, port), {
signal: AbortSignal.timeout(timeoutMs),
});
if (res.ok) return host;
lastError = new Error(`${host}: HTTP ${res.status}`);
} catch (err) {
lastError = new Error(`${host}: ${err instanceof Error ? err.message : String(err)}`);
}
}
throw lastError;
}
async function main() {
const port = process.env.DASHBOARD_PORT || process.env.PORT || "20128";
// Build host list: defaults + detected container bridge IP
const hosts = [...DEFAULT_HOSTS];
const containerIP = getContainerInternalIP();
if (containerIP && !hosts.includes(containerIP)) {
hosts.push(containerIP);
}
try {
await probeHealth({ port, hosts });
process.exit(0);
} catch (err) {
// Surface the failure so `docker inspect ... .State.Health[].Output` is
// diagnostic instead of empty (#3151).
process.stderr.write(`healthcheck failed: ${err instanceof Error ? err.message : err}\n`);
process.exit(1);
}
}
// Only auto-run when invoked as the entrypoint (so importing the helper in
// tests does not trigger a real probe + process.exit).
const isEntrypoint =
Boolean(process.argv[1]) && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isEntrypoint) {
main();
}
+104
View File
@@ -0,0 +1,104 @@
"use strict";
const http = require("node:http");
const HIGH_RISK_METHOD_RULES = [
[/^\/api\/auth\/login\/?$/, ["POST"]],
[/^\/api\/auth\/logout\/?$/, ["POST"]],
[/^\/api\/keys\/?$/, ["GET", "POST"]],
[/^\/api\/keys\/[^/]+\/?$/, ["GET", "PATCH", "DELETE"]],
[/^\/api\/keys\/[^/]+\/devices\/?$/, ["GET"]],
];
let installed = false;
function getPathname(req) {
const rawUrl = typeof req?.url === "string" && req.url ? req.url : "/";
try {
return new URL(rawUrl, "http://localhost").pathname;
} catch {
return rawUrl.split("?")[0] || "/";
}
}
function getAllowedMethods(pathname) {
for (const [pattern, methods] of HIGH_RISK_METHOD_RULES) {
if (pattern.test(pathname)) return methods;
}
return null;
}
function getAllowHeader(pathname) {
const methods = getAllowedMethods(pathname);
return methods ? methods.join(", ") : null;
}
// Methods undici/fetch cannot represent: Next's middleware adapter throws
// `TypeError: 'TRACE' HTTP method is unsupported.` while building the Request,
// which surfaces as an unhandled 500 on EVERY route (caught by the dast-smoke
// Schemathesis negative tests). Reject them up-front with a clean 405.
const UNSUPPORTED_METHODS = new Set(["TRACE", "TRACK", "CONNECT"]);
function maybeHandleDisallowedMethod(req, res) {
const method = typeof req?.method === "string" ? req.method.toUpperCase() : "";
const pathname = getPathname(req);
if (UNSUPPORTED_METHODS.has(method)) {
res.statusCode = 405;
res.setHeader("Allow", getAllowHeader(pathname) || "GET, POST, OPTIONS");
res.setHeader("Cache-Control", "no-store");
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(
JSON.stringify({
error: {
code: "METHOD_NOT_ALLOWED",
message: `${method} is not allowed`,
},
})
);
return true;
}
const methods = getAllowedMethods(pathname);
if (!methods || method === "OPTIONS" || methods.includes(method)) return false;
res.statusCode = 405;
res.setHeader("Allow", methods.join(", "));
res.setHeader("Cache-Control", "no-store");
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(
JSON.stringify({
error: {
code: "METHOD_NOT_ALLOWED",
message: `${method || "Method"} is not allowed`,
},
})
);
return true;
}
function wrapRequestListenerWithMethodGuard(listener) {
return function methodGuardRequestHandler(req, res) {
if (maybeHandleDisallowedMethod(req, res)) return;
return listener.call(this, req, res);
};
}
function installHttpMethodGuard() {
if (installed) return;
installed = true;
const originalCreateServer = http.createServer.bind(http);
http.createServer = function createServerWithMethodGuard(...args) {
const lastFnIdx = args.map((arg) => typeof arg === "function").lastIndexOf(true);
if (lastFnIdx >= 0) {
args[lastFnIdx] = wrapRequestListenerWithMethodGuard(args[lastFnIdx]);
}
return originalCreateServer(...args);
};
}
module.exports = {
getAllowHeader,
maybeHandleDisallowedMethod,
wrapRequestListenerWithMethodGuard,
installHttpMethodGuard,
};
+76
View File
@@ -0,0 +1,76 @@
import { randomUUID } from "node:crypto";
/**
* Trusted peer-IP stamping for the custom Node HTTP servers.
*
* The Next.js middleware runtime (proxy.ts → runAuthzPipeline) exposes NO socket
* or peer IP — only request headers, ALL of which are client-controlled. The
* LOCAL_ONLY route guard (spawn-capable routes) must decide locality from the
* real TCP peer, never from the spoofable Host header.
*
* Our custom servers DO have the real `req.socket.remoteAddress`. They stamp it
* into PEER_IP_HEADER as `<token>|<ip>`, where <token> is a per-process secret
* (OMNIROUTE_PEER_STAMP_TOKEN). Any client-supplied value of PEER_IP_HEADER is
* deleted first, so a remote caller cannot pre-populate it. The middleware
* (src/server/authz/policies/management.ts → resolveStampedPeer) trusts the IP
* ONLY when the token matches this process's secret; otherwise it fails closed.
*
* Keep PEER_IP_HEADER in sync with PEER_IP_HEADER in
* src/server/authz/headers.ts (the TS side cannot import this .mjs).
*/
export const PEER_IP_HEADER = "x-omniroute-peer-ip";
/**
* Companion header to PEER_IP_HEADER: `<token>|1` when the inbound TCP request
* carried forwarding headers (`x-forwarded-for` / `x-real-ip`), `<token>|0`
* otherwise. Required so the middleware can tell that a loopback socket is the
* reverse-proxy hop (nginx / Caddy / Cloudflare Tunnel) and NOT trust it as
* local — without this, a leaked JWT over a public tunnel would reach the
* LOCAL_ONLY routes that spawn child processes (Hard Rules #15 + #17;
* port of upstream decolua/9router commit da667836).
*
* Keep VIA_PROXY_HEADER in sync with VIA_PROXY_HEADER in
* src/server/authz/headers.ts (the TS side cannot import this .mjs).
*/
export const VIA_PROXY_HEADER = "x-omniroute-via-proxy";
/** Generate (once) and return the per-process stamp token, persisting it in env
* so the middleware running in the same process reads the identical value. */
export function ensurePeerStampToken() {
process.env.OMNIROUTE_PEER_STAMP_TOKEN ||= randomUUID();
return process.env.OMNIROUTE_PEER_STAMP_TOKEN;
}
/** Strip any client-supplied PEER_IP_HEADER + VIA_PROXY_HEADER and stamp the
* real TCP peer IP plus a token-protected via-proxy marker. Never throws — a
* stamping failure must not block a request (it degrades to "locality
* unknown" → fail closed in the middleware). */
export function stampPeerIp(req) {
try {
if (!req || !req.headers) return;
// Node lowercases incoming header names; delete kills any client value.
delete req.headers[PEER_IP_HEADER];
delete req.headers[VIA_PROXY_HEADER];
const ip = req.socket && req.socket.remoteAddress;
if (ip) {
const token = ensurePeerStampToken();
req.headers[PEER_IP_HEADER] = `${token}|${ip}`;
// Forwarding headers present = request arrived via a reverse proxy; the
// loopback socket is the proxy hop, not the end-user, so it must not be
// trusted as local. Token-prefix the marker so a remote caller cannot
// forge it (or its absence) on a non-proxied request.
const viaProxy = !!(req.headers["x-forwarded-for"] || req.headers["x-real-ip"]);
req.headers[VIA_PROXY_HEADER] = `${token}|${viaProxy ? "1" : "0"}`;
}
} catch {
/* never block a request on peer stamping */
}
}
/** Wrap a Node request listener so every request is peer-stamped first. */
export function wrapRequestListenerWithPeerStamp(listener) {
return function peerStampingRequestHandler(req, res) {
stampPeerIp(req);
return listener.call(this, req, res);
};
}
+907
View File
@@ -0,0 +1,907 @@
import { createHash, randomUUID } from "node:crypto";
import { createRequire } from "node:module";
import { STATUS_CODES } from "node:http";
const _wreqRequire = createRequire(import.meta.url);
let _websocketFn = null;
let _wreqChecked = false;
function getWebSocketTransport() {
if (_wreqChecked) return _websocketFn;
_wreqChecked = true;
try {
const mod = _wreqRequire("wreq-js");
_websocketFn = typeof mod.websocket === "function" ? mod.websocket : null;
} catch {
_websocketFn = null;
}
return _websocketFn;
}
export const RESPONSES_WS_PUBLIC_PATHS = new Set([
"/responses",
"/v1/responses",
"/api/v1/responses",
]);
const INTERNAL_ROUTE = "/api/internal/codex-responses-ws";
const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
const WS_QUERY_TOKEN_KEYS = ["api_key", "token", "access_token"];
const textDecoder = new TextDecoder();
const DEFAULT_MAX_WS_BUFFER_BYTES = 16 * 1024 * 1024;
const DEFAULT_MAX_WS_MESSAGE_BYTES = 16 * 1024 * 1024;
class WebSocketInputTooLargeError extends Error {
constructor(message, reason = "message_too_large") {
super(message);
this.name = "WebSocketInputTooLargeError";
this.closeCode = 1009;
this.reason = reason;
}
}
function normalizePositiveInteger(value, fallback) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function isText(value) {
return typeof value === "string" && value.length > 0;
}
function isRecord(value) {
return value && typeof value === "object" && !Array.isArray(value);
}
function jsonStringifySafe(value) {
try {
return JSON.stringify(value);
} catch {
return JSON.stringify({
type: "response.failed",
response: {
status: "failed",
error: {
code: "serialization_failed",
message: "Failed to serialize WebSocket payload",
},
},
});
}
}
function parseJsonRecord(value) {
try {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function toFiniteNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function toStringOrNull(value) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function buildFailurePayload(code, message) {
return {
type: "response.failed",
response: {
id: null,
status: "failed",
error: {
code,
message,
},
},
};
}
function getResponseErrorStatus(error) {
if (!isRecord(error)) return null;
const candidates = [
error.status,
error.status_code,
error.statusCode,
error.code === "usage_limit_reached" ? 429 : null,
];
for (const candidate of candidates) {
const status = Number(candidate);
if (Number.isInteger(status) && status >= 400 && status <= 599) return status;
}
return null;
}
function getTerminalResponseEvent(rawData) {
const message = parseJsonRecord(rawData);
if (!message) return null;
const type = toStringOrNull(message.type) || "";
const response = isRecord(message.response) ? message.response : {};
const statusText = toStringOrNull(response.status) || "";
if (type === "response.completed" || type === "response.done" || statusText === "completed") {
return {
status: 200,
success: true,
terminalMessage: message,
responseBody: response,
};
}
if (type === "response.failed" || type === "response.error" || statusText === "failed") {
const error = isRecord(response.error)
? response.error
: isRecord(message.error)
? message.error
: null;
return {
status: getResponseErrorStatus(error) || 500,
success: false,
errorCode: toStringOrNull(error?.code) || "upstream_response_failed",
errorMessage:
toStringOrNull(error?.message) ||
toStringOrNull(response.error) ||
"Codex upstream response failed",
terminalMessage: message,
responseBody: response,
};
}
return null;
}
export function isResponsesWsPath(pathname) {
return RESPONSES_WS_PUBLIC_PATHS.has(pathname);
}
export function encodeWsFrame(opcode, payload = Buffer.alloc(0)) {
const payloadBuffer = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
const length = payloadBuffer.length;
let header;
if (length < 126) {
header = Buffer.allocUnsafe(2);
header[1] = length;
} else if (length <= 0xffff) {
header = Buffer.allocUnsafe(4);
header[1] = 126;
header.writeUInt16BE(length, 2);
} else {
header = Buffer.allocUnsafe(10);
header[1] = 127;
header.writeBigUInt64BE(BigInt(length), 2);
}
header[0] = 0x80 | (opcode & 0x0f);
return Buffer.concat([header, payloadBuffer]);
}
export function decodeClientFrames(
buffer,
{ maxPayloadBytes = DEFAULT_MAX_WS_MESSAGE_BYTES } = {}
) {
const frames = [];
let offset = 0;
while (buffer.length - offset >= 2) {
const byte1 = buffer[offset];
const byte2 = buffer[offset + 1];
const fin = (byte1 & 0x80) !== 0;
const opcode = byte1 & 0x0f;
const masked = (byte2 & 0x80) !== 0;
let payloadLength = byte2 & 0x7f;
let headerLength = 2;
if (!masked) {
throw new Error("Client WebSocket frames must be masked");
}
if (payloadLength === 126) {
if (buffer.length - offset < 4) break;
payloadLength = buffer.readUInt16BE(offset + 2);
headerLength = 4;
} else if (payloadLength === 127) {
if (buffer.length - offset < 10) break;
const bigLength = buffer.readBigUInt64BE(offset + 2);
if (bigLength > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new WebSocketInputTooLargeError("WebSocket payload too large");
}
payloadLength = Number(bigLength);
headerLength = 10;
}
if (payloadLength > maxPayloadBytes) {
throw new WebSocketInputTooLargeError("WebSocket payload exceeds configured limit");
}
const totalLength = headerLength + 4 + payloadLength;
if (buffer.length - offset < totalLength) break;
const mask = buffer.subarray(offset + headerLength, offset + headerLength + 4);
const payload = Buffer.from(buffer.subarray(offset + headerLength + 4, offset + totalLength));
for (let index = 0; index < payload.length; index += 1) {
payload[index] ^= mask[index % 4];
}
frames.push({ fin, opcode, payload });
offset += totalLength;
}
return {
frames,
remaining: buffer.subarray(offset),
};
}
const WRITE_ERROR_RESERVED_HEADERS = new Set([
// Framing — must never collide with our Content-Length default.
"transfer-encoding",
"content-length",
"content-type",
"connection",
"keep-alive",
// Next pipeline / security headers are meaningless on a raw JSON error socket
// and must not leak from a forwarded internal-fetch response.
"content-security-policy",
"x-frame-options",
"x-content-type-options",
"referrer-policy",
"permissions-policy",
"strict-transport-security",
"x-omniroute-route-class",
"x-request-id",
"date",
]);
export function writeHttpError(socket, status, body, headers = {}) {
if (!socket.writable || socket.destroyed) return;
const bodyBuffer = Buffer.from(body || "", "utf8");
const statusText = STATUS_CODES[status] || "Error";
// Strip any caller-supplied framing / duplicate-prone headers (case-insensitive)
// so our Content-Length/Connection/Content-Type defaults always win. Forwarding
// an upstream fetch's chunked Transfer-Encoding here would collide with
// Content-Length ("Transfer-Encoding can't be present with Content-Length") and
// break the client's HTTP parser on a raw upgrade socket.
const safeHeaders = {};
for (const [name, value] of Object.entries(headers || {})) {
if (!WRITE_ERROR_RESERVED_HEADERS.has(String(name).toLowerCase())) {
safeHeaders[name] = value;
}
}
const responseHeaders = {
Connection: "close",
"Content-Length": String(bodyBuffer.length),
"Content-Type": "application/json; charset=utf-8",
...safeHeaders,
};
const head = [
`HTTP/1.1 ${status} ${statusText}`,
...Object.entries(responseHeaders).map(([name, value]) => `${name}: ${value}`),
"",
"",
].join("\r\n");
socket.write(head);
socket.end(bodyBuffer);
}
function getAuthHeaders(requestUrl, requestHeaders) {
const headers = {};
if (isText(requestHeaders.authorization)) {
headers.authorization = requestHeaders.authorization;
} else {
const url = new URL(requestUrl, "http://omniroute.local");
for (const key of WS_QUERY_TOKEN_KEYS) {
const value = url.searchParams.get(key);
if (isText(value)) {
headers.authorization = `Bearer ${value.trim()}`;
break;
}
}
}
if (isText(requestHeaders.cookie)) headers.cookie = requestHeaders.cookie;
if (isText(requestHeaders.origin)) headers.origin = requestHeaders.origin;
if (isText(requestHeaders["x-forwarded-for"])) {
headers["x-forwarded-for"] = requestHeaders["x-forwarded-for"];
}
return headers;
}
function getResponseCreatePayload(message) {
if (!message || typeof message !== "object" || Array.isArray(message)) return null;
if (message.type !== "response.create") return null;
if (
message.response &&
typeof message.response === "object" &&
!Array.isArray(message.response)
) {
return message.response;
}
if (message.body && typeof message.body === "object" && !Array.isArray(message.body)) {
return message.body;
}
if (message.payload && typeof message.payload === "object" && !Array.isArray(message.payload)) {
return message.payload;
}
const { type, ...payload } = message;
return payload;
}
function withPreparedResponseCreate(message, preparedBody) {
const next = { ...message };
if (
message.response &&
typeof message.response === "object" &&
!Array.isArray(message.response)
) {
next.response = preparedBody;
} else if (message.body && typeof message.body === "object" && !Array.isArray(message.body)) {
next.body = preparedBody;
} else if (
message.payload &&
typeof message.payload === "object" &&
!Array.isArray(message.payload)
) {
next.payload = preparedBody;
} else {
return { type: "response.create", ...preparedBody };
}
return next;
}
async function callInternal(fetchImpl, baseUrl, bridgeSecret, action, payload) {
const response = await fetchImpl(new URL(INTERNAL_ROUTE, baseUrl), {
method: "POST",
headers: {
"content-type": "application/json",
"x-omniroute-ws-bridge-secret": bridgeSecret,
},
body: JSON.stringify({ action, ...payload }),
});
const text = await response.text();
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
return { ok: response.ok, status: response.status, text, json, headers: response.headers };
}
class ResponsesWsSession {
constructor({
baseUrl,
bridgeSecret,
fetchImpl,
socket,
requestHeaders,
requestUrl,
wsFactory,
pingIntervalMs,
idleTimeoutMs,
maxBufferBytes,
maxMessageBytes,
}) {
this.baseUrl = baseUrl;
this.bridgeSecret = bridgeSecret;
this.fetchImpl = fetchImpl;
this.socket = socket;
this.requestHeaders = requestHeaders;
this.requestUrl = requestUrl;
this.wsFactory = wsFactory;
this.pingIntervalMs = pingIntervalMs;
this.idleTimeoutMs = idleTimeoutMs;
this.maxBufferBytes = normalizePositiveInteger(maxBufferBytes, DEFAULT_MAX_WS_BUFFER_BYTES);
this.maxMessageBytes = normalizePositiveInteger(maxMessageBytes, DEFAULT_MAX_WS_MESSAGE_BYTES);
this.sessionId = randomUUID();
this.startedAt = Date.now();
this.closed = false;
this.buffer = Buffer.alloc(0);
this.fragmentOpcode = null;
this.fragmentParts = [];
this.fragmentBytes = 0;
this.processing = Promise.resolve();
this.upstream = null;
this.upstreamReady = null;
this.firstResponseBody = null;
this.preparedContext = null;
this.historyLogged = false;
this.lastSeenAt = Date.now();
this.pingTimer = setInterval(() => {
if (this.closed) return;
const idleForMs = Date.now() - this.lastSeenAt;
if (idleForMs >= this.idleTimeoutMs) {
this.close(1001, "idle_timeout");
return;
}
this.sendFrame(0x9);
}, this.pingIntervalMs);
this.socket.setNoDelay(true);
this.socket.on("data", (chunk) => this.enqueueData(chunk));
this.socket.on("close", () => this.dispose());
this.socket.on("end", () => this.dispose());
this.socket.on("error", () => this.dispose());
}
enqueueData(chunk) {
this.processing = this.processing
.then(() => this.onData(chunk))
.catch((error) => {
if (this.closed) return;
const isTooLarge =
error instanceof WebSocketInputTooLargeError || error?.closeCode === 1009;
this.sendFailure(
isTooLarge ? "message_too_large" : "frame_decode_failed",
error instanceof Error ? error.message : String(error)
);
this.close(
isTooLarge ? 1009 : 1011,
isTooLarge ? error.reason || "message_too_large" : "frame_decode_failed"
);
});
}
cleanupBuffers() {
this.buffer = Buffer.alloc(0);
this.fragmentOpcode = null;
this.fragmentParts = [];
this.fragmentBytes = 0;
}
sendFrame(opcode, payload) {
if (this.closed || this.socket.destroyed) return;
this.socket.write(encodeWsFrame(opcode, payload));
}
sendJson(payload) {
this.sendFrame(0x1, Buffer.from(jsonStringifySafe(payload), "utf8"));
}
sendFailure(code, message) {
const payload = buildFailurePayload(code, message);
this.sendJson(payload);
return payload;
}
async onData(chunk) {
if (this.closed) return;
this.lastSeenAt = Date.now();
if (this.buffer.length + chunk.length > this.maxBufferBytes) {
throw new WebSocketInputTooLargeError("WebSocket input buffer exceeds configured limit");
}
this.buffer = Buffer.concat([this.buffer, chunk]);
const parsed = decodeClientFrames(this.buffer, { maxPayloadBytes: this.maxMessageBytes });
this.buffer = parsed.remaining;
if (this.buffer.length > this.maxBufferBytes) {
throw new WebSocketInputTooLargeError("WebSocket input buffer exceeds configured limit");
}
for (const frame of parsed.frames) {
if (this.closed) return;
await this.handleFrame(frame);
}
}
async handleFrame(frame) {
switch (frame.opcode) {
case 0x0:
if (this.fragmentOpcode === null) {
this.sendFailure("unexpected_continuation", "Unexpected continuation frame");
return;
}
this.fragmentBytes += frame.payload.length;
if (this.fragmentBytes > this.maxMessageBytes) {
throw new WebSocketInputTooLargeError(
"Fragmented WebSocket message exceeds configured limit"
);
}
this.fragmentParts.push(frame.payload);
if (frame.fin) {
const payload = Buffer.concat(this.fragmentParts);
const opcode = this.fragmentOpcode;
this.fragmentOpcode = null;
this.fragmentParts = [];
this.fragmentBytes = 0;
await this.handleDataFrame(opcode, payload);
}
return;
case 0x1:
case 0x2:
if (!frame.fin) {
this.fragmentOpcode = frame.opcode;
this.fragmentParts = [frame.payload];
this.fragmentBytes = frame.payload.length;
if (this.fragmentBytes > this.maxMessageBytes) {
throw new WebSocketInputTooLargeError(
"Fragmented WebSocket message exceeds configured limit"
);
}
return;
}
await this.handleDataFrame(frame.opcode, frame.payload);
return;
case 0x8:
this.close();
return;
case 0x9:
this.sendFrame(0xa, frame.payload);
return;
case 0xa:
this.lastSeenAt = Date.now();
return;
default:
this.sendFailure("unsupported_opcode", `Unsupported opcode ${frame.opcode}`);
}
}
async handleDataFrame(opcode, payload) {
if (payload.length > this.maxMessageBytes) {
throw new WebSocketInputTooLargeError("WebSocket message exceeds configured limit");
}
if (opcode !== 0x1) {
this.sendFailure("unsupported_payload", "Only UTF-8 text messages are supported");
return;
}
const raw = textDecoder.decode(payload);
let message;
try {
message = JSON.parse(raw);
} catch {
this.sendFailure("invalid_json", "WebSocket message must be valid JSON");
return;
}
await this.forwardClientMessage(message);
}
async ensureUpstream(firstMessage) {
if (this.upstreamReady) return this.upstreamReady;
this.upstreamReady = (async () => {
const responseBody = getResponseCreatePayload(firstMessage);
if (responseBody === null) {
throw new Error("First Responses WebSocket message must be response.create");
}
this.firstResponseBody ||= responseBody;
const prepared = await callInternal(
this.fetchImpl,
this.baseUrl,
this.bridgeSecret,
"prepare",
{
requestUrl: this.requestUrl,
headers: getAuthHeaders(this.requestUrl, this.requestHeaders),
message: firstMessage,
response: responseBody,
}
);
if (!prepared.ok) {
const message =
prepared.json?.error?.message ||
prepared.json?.message ||
prepared.text ||
"Codex WS prepare failed";
const code = prepared.json?.error?.code || "codex_ws_prepare_failed";
const error = new Error(message);
error.code = code;
error.status = prepared.status;
throw error;
}
this.preparedContext = {
upstreamUrl: toStringOrNull(prepared.json?.upstreamUrl),
connectionId: toStringOrNull(prepared.json?.connectionId),
account: toStringOrNull(prepared.json?.account),
provider: toStringOrNull(prepared.json?.provider) || "codex",
model: toStringOrNull(prepared.json?.model) || toStringOrNull(responseBody.model),
requestedModel: toStringOrNull(responseBody.model),
serviceTier:
toStringOrNull(responseBody.service_tier) || toStringOrNull(responseBody.serviceTier),
};
const wsOptions = {
// #5591: chrome_149 is not a wreq-js 2.3.1 profile (max chrome_147); the
// prepare route now sends chrome_142, this fallback matches it.
browser: prepared.json.browser || "chrome_142",
os: prepared.json.os || "windows",
headers: prepared.json.headers || {},
};
// #5611: forward the configured proxy so the upstream WS connect honors the
// Proxy Registry in no-direct-egress deployments.
if (prepared.json.proxy) wsOptions.proxy = prepared.json.proxy;
const upstream = await this.wsFactory(prepared.json.upstreamUrl, wsOptions);
upstream.onmessage = (event) => {
if (this.closed) return;
const data =
typeof event.data === "string" ? event.data : Buffer.from(event.data).toString("utf8");
const terminalEvent = getTerminalResponseEvent(data);
if (terminalEvent) {
void this.persistHistory(terminalEvent);
}
this.sendFrame(0x1, Buffer.from(data, "utf8"));
};
upstream.onerror = (event) => {
if (this.closed) return;
const errorMessage = event.message || "Codex upstream WebSocket error";
const failurePayload = this.sendFailure("upstream_websocket_error", errorMessage);
void this.persistHistory({
status: 502,
success: false,
errorCode: "upstream_websocket_error",
errorMessage,
terminalMessage: failurePayload,
});
};
upstream.onclose = (event) => {
if (this.closed) return;
void this.persistHistory({
status: event.code === 1000 ? 499 : 502,
success: false,
errorCode: "upstream_websocket_closed",
errorMessage: event.reason || "Codex upstream WebSocket closed before completion",
terminalMessage: buildFailurePayload(
"upstream_websocket_closed",
event.reason || "Codex upstream WebSocket closed before completion"
),
});
this.close(event.code || 1000, event.reason || "upstream_closed");
};
this.upstream = upstream;
return {
upstream,
firstMessage: withPreparedResponseCreate(firstMessage, prepared.json.response),
};
})();
return this.upstreamReady;
}
async forwardClientMessage(message) {
try {
if (!this.upstream) {
const { upstream, firstMessage } = await this.ensureUpstream(message);
upstream.send(jsonStringifySafe(firstMessage));
return;
}
this.upstream.send(jsonStringifySafe(message));
} catch (error) {
const code = error?.code || "upstream_websocket_connect_failed";
const messageText = error instanceof Error ? error.message : String(error);
const failurePayload = this.sendFailure(code, messageText);
void this.persistHistory({
status: Number.isInteger(error?.status) ? error.status : 502,
success: false,
errorCode: code,
errorMessage: messageText,
terminalMessage: failurePayload,
});
this.close(1011, "upstream_connect_failed");
}
}
async persistHistory({
status = 200,
success = true,
errorCode = null,
errorMessage = null,
terminalMessage = null,
responseBody = null,
} = {}) {
if (this.historyLogged || !this.firstResponseBody) return;
this.historyLogged = true;
const finishedAt = Date.now();
try {
await callInternal(this.fetchImpl, this.baseUrl, this.bridgeSecret, "log", {
sessionId: this.sessionId,
transport: "responses_websocket",
requestUrl: this.requestUrl,
headers: getAuthHeaders(this.requestUrl, this.requestHeaders),
path: new URL(this.requestUrl || "/v1/responses", "http://omniroute.local").pathname,
startedAt: new Date(this.startedAt).toISOString(),
completedAt: new Date(finishedAt).toISOString(),
durationMs: Math.max(0, finishedAt - this.startedAt),
status: toFiniteNumber(status),
success,
errorCode,
errorMessage,
clientRequest: this.firstResponseBody,
terminalMessage,
responseBody,
sourceFormat: "openai-responses",
targetFormat: "openai-responses",
...this.preparedContext,
});
} catch {
// History logging must never break an already-established WebSocket session.
}
}
close(code = 1000, reason = "normal_closure") {
if (this.closed) return;
this.closed = true;
clearInterval(this.pingTimer);
this.cleanupBuffers();
try {
this.upstream?.close?.(code, reason);
} catch {
// ignore close races
}
const reasonBuffer = Buffer.from(reason, "utf8");
const payload = Buffer.allocUnsafe(2 + reasonBuffer.length);
payload.writeUInt16BE(code, 0);
reasonBuffer.copy(payload, 2);
if (!this.socket.destroyed && this.socket.writable) {
this.socket.write(encodeWsFrame(0x8, payload));
}
this.socket.end();
setTimeout(() => {
if (!this.socket.destroyed) {
this.socket.destroy();
}
}, 50).unref?.();
}
dispose() {
if (this.closed) return;
this.closed = true;
clearInterval(this.pingTimer);
this.cleanupBuffers();
try {
this.upstream?.close?.(1000, "downstream_closed");
} catch {
// ignore close races
}
}
}
export function createResponsesWsProxy({
baseUrl,
bridgeSecret,
fetchImpl = fetch,
wsFactory = getWebSocketTransport(),
pingIntervalMs = 25000,
idleTimeoutMs = 90000,
maxBufferBytes = DEFAULT_MAX_WS_BUFFER_BYTES,
maxMessageBytes = DEFAULT_MAX_WS_MESSAGE_BYTES,
} = {}) {
if (!isText(baseUrl)) {
throw new Error("createResponsesWsProxy requires a baseUrl");
}
if (!isText(bridgeSecret)) {
throw new Error("createResponsesWsProxy requires a bridgeSecret");
}
return {
isResponsesWsPath,
async handleUpgrade(req, socket, head) {
const pathname = new URL(req.url || "/", baseUrl).pathname;
if (!isResponsesWsPath(pathname)) {
return false;
}
if (!wsFactory) {
writeHttpError(
socket,
503,
JSON.stringify({
error: {
message:
"Responses WebSocket proxy unavailable: wreq-js is not installed on this platform",
code: "wreq_js_unavailable",
},
})
);
return true;
}
const upgradeHeader = String(req.headers.upgrade || "").toLowerCase();
if (upgradeHeader !== "websocket") {
writeHttpError(
socket,
426,
JSON.stringify({
error: {
message: "Upgrade Required",
code: "upgrade_required",
},
}),
{ Upgrade: "websocket" }
);
return true;
}
try {
const auth = await callInternal(fetchImpl, baseUrl, bridgeSecret, "authenticate", {
requestUrl: req.url || pathname,
headers: getAuthHeaders(req.url || pathname, req.headers),
});
if (!auth.ok) {
// Do NOT forward the internal fetch's response headers onto the raw
// upgrade socket — they carry chunked transfer-encoding + Next security
// headers that collide with writeHttpError's Content-Length framing.
// The sanitized JSON body alone is enough for the client.
writeHttpError(socket, auth.status, auth.text || "{}");
return true;
}
const wsKey = req.headers["sec-websocket-key"];
if (!isText(wsKey)) {
writeHttpError(
socket,
400,
JSON.stringify({
error: {
message: "Missing sec-websocket-key header",
code: "bad_websocket_handshake",
},
})
);
return true;
}
const acceptKey = createHash("sha1").update(`${wsKey}${WS_GUID}`).digest("base64");
const headers = [
"HTTP/1.1 101 Switching Protocols",
"Upgrade: websocket",
"Connection: Upgrade",
`Sec-WebSocket-Accept: ${acceptKey}`,
"",
"",
].join("\r\n");
socket.write(headers);
if (head && head.length > 0) {
socket.unshift(head);
}
new ResponsesWsSession({
baseUrl,
bridgeSecret,
fetchImpl,
socket,
requestUrl: req.url || pathname,
requestHeaders: req.headers,
wsFactory,
pingIntervalMs,
idleTimeoutMs,
maxBufferBytes,
maxMessageBytes,
});
return true;
} catch (error) {
writeHttpError(
socket,
500,
JSON.stringify({
error: {
message: error instanceof Error ? error.message : String(error),
code: "responses_websocket_proxy_failed",
},
})
);
return true;
}
},
};
}
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { sanitizeColorEnv } from "../build/runtime-env.mjs";
function parsePort(value, fallback) {
const parsed = Number.parseInt(String(value), 10);
return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback;
}
const explicitBaseUrl = process.env.OMNIROUTE_BASE_URL || "";
const isolatedPort = parsePort(
process.env.DASHBOARD_PORT || process.env.PORT,
22000 + (process.pid % 1000)
);
const isolatedDataDir =
process.env.DATA_DIR || join(process.cwd(), ".tmp", "ecosystem-data", String(process.pid));
const port = explicitBaseUrl ? null : isolatedPort;
const baseUrl = explicitBaseUrl || `http://127.0.0.1:${isolatedPort}`;
const healthUrl = `${baseUrl}/api/monitoring/health`;
const maxWaitMs = Number(process.env.ECOSYSTEM_SERVER_WAIT_MS || 180000);
const pollMs = 2000;
async function isServerReady() {
const timeout = AbortSignal.timeout(2000);
try {
const res = await fetch(healthUrl, { signal: timeout });
return res.ok;
} catch {
return false;
}
}
async function waitForServerReady() {
const maxAttempts = Math.ceil(maxWaitMs / pollMs);
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
if (await isServerReady()) {
return;
}
await delay(pollMs);
}
throw new Error(`Timed out waiting for ${healthUrl} after ${maxWaitMs}ms`);
}
async function main() {
let serverProcess = null;
let startedHere = false;
const testEnv = {
...sanitizeColorEnv(process.env),
DATA_DIR: isolatedDataDir,
...(explicitBaseUrl
? {}
: {
PORT: String(port),
DASHBOARD_PORT: String(port),
API_PORT: String(port),
OMNIROUTE_BASE_URL: baseUrl,
}),
OMNIROUTE_E2E_BOOTSTRAP_MODE: process.env.OMNIROUTE_E2E_BOOTSTRAP_MODE || "open",
REQUIRE_API_KEY: explicitBaseUrl ? process.env.REQUIRE_API_KEY : "false",
ENABLE_CLI_TOOLS: "true",
};
if (!(await isServerReady())) {
serverProcess = spawn(process.execPath, ["scripts/dev/run-next-playwright.mjs", "dev"], {
stdio: "inherit",
env: testEnv,
});
startedHere = true;
await waitForServerReady();
}
const vitestProcess = spawn(
process.execPath,
["./node_modules/vitest/vitest.mjs", "run", "tests/e2e/ecosystem.test.ts"],
{
stdio: "inherit",
env: testEnv,
}
);
const exitCode = await new Promise((resolve) => {
vitestProcess.on("exit", (code, signal) => {
if (signal) {
resolve(1);
return;
}
resolve(code ?? 1);
});
});
if (startedHere && serverProcess) {
serverProcess.kill("SIGTERM");
await delay(1000);
if (!serverProcess.killed) {
serverProcess.kill("SIGKILL");
}
}
process.exit(exitCode);
}
main().catch((error) => {
console.error("[test:ecosystem] Failed:", error?.message || error);
process.exit(1);
});
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { cpSync, existsSync, mkdirSync, readdirSync, renameSync } from "node:fs";
import { dirname, join } from "node:path";
import { pathToFileURL } from "node:url";
import {
resolveRuntimePorts,
sanitizeColorEnv,
spawnWithForwardedSignals,
withRuntimePortEnv,
} from "../build/runtime-env.mjs";
import { bootstrapEnv } from "../build/bootstrap-env.mjs";
const mode = process.argv[2] === "start" ? "start" : "dev";
const cwd = process.cwd();
const appDir = join(cwd, "app");
const srcAppDir = join(cwd, "src", "app");
const appPage = join(appDir, "page.tsx");
const defaultBackupDir = join(cwd, "app.__qa_backup");
const backupDir = resolvePlaywrightAppBackupDir({
cwd,
baseBackupExists: existsSync(defaultBackupDir),
appDirExists: existsSync(appDir),
});
const usingAlternativeBackupDir = backupDir !== defaultBackupDir;
const buildScript = join(cwd, "scripts", "build", "build-next-isolated.mjs");
const standaloneServer = join(cwd, testDistDir(), "standalone", "server.js");
const rootStaticDir = join(cwd, testDistDir(), "static");
const rootPublicDir = join(cwd, "public");
const standaloneStaticDir = join(cwd, testDistDir(), "standalone", ".next", "static");
const standalonePublicDir = join(cwd, testDistDir(), "standalone", "public");
let appDirMoved = false;
function testDistDir() {
// Layer 1 moved the Next distDir default to .build/next; the Playwright
// `start` runner must resolve the standalone server under the same dir.
return process.env.NEXT_DIST_DIR || ".build/next";
}
function resolvePlaywrightDataDir({ cwd, env, pid = process.pid }) {
if (typeof env.DATA_DIR === "string" && env.DATA_DIR.trim().length > 0) {
return env.DATA_DIR;
}
return join(cwd, ".tmp", "playwright-data", String(pid));
}
export function resolvePlaywrightAppBackupDir({
cwd,
baseBackupExists,
appDirExists,
pid = process.pid,
now = Date.now(),
}) {
const baseBackupDir = join(cwd, "app.__qa_backup");
if (!baseBackupExists || !appDirExists) {
return baseBackupDir;
}
return join(cwd, `app.__qa_backup.${pid}.${now}`);
}
function shouldMoveAppDir() {
return existsSync(appDir) && !existsSync(appPage) && existsSync(srcAppDir);
}
export function directoryHasEntries(dirPath) {
try {
return readdirSync(dirPath).length > 0;
} catch {
return false;
}
}
export function standaloneAssetsNeedSync({
standaloneServerPath,
rootStaticDirPath,
standaloneStaticDirPath,
}) {
return (
existsSync(standaloneServerPath) &&
existsSync(rootStaticDirPath) &&
!directoryHasEntries(standaloneStaticDirPath)
);
}
export function syncStandaloneRuntimeAssets({
standaloneServerPath,
rootStaticDirPath,
standaloneStaticDirPath,
rootPublicDirPath,
standalonePublicDirPath,
log = console,
}) {
if (!existsSync(standaloneServerPath)) return false;
let changed = false;
if (existsSync(rootPublicDirPath) && !directoryHasEntries(standalonePublicDirPath)) {
cpSync(rootPublicDirPath, standalonePublicDirPath, {
recursive: true,
force: true,
});
changed = true;
}
if (existsSync(rootStaticDirPath) && !directoryHasEntries(standaloneStaticDirPath)) {
mkdirSync(dirname(standaloneStaticDirPath), {
recursive: true,
});
cpSync(rootStaticDirPath, standaloneStaticDirPath, {
recursive: true,
force: true,
});
changed = true;
}
if (changed) {
log.log("[Playwright WebServer] Rehydrated standalone static/public assets");
}
return changed;
}
function prepareAppDir() {
if (!shouldMoveAppDir()) return;
if (usingAlternativeBackupDir) {
console.warn(
"[Playwright WebServer] Existing app.__qa_backup detected; using a per-run backup dir instead."
);
}
renameSync(appDir, backupDir);
appDirMoved = true;
console.log("[Playwright WebServer] Temporarily moved app/ to app.__qa_backup");
}
function restoreAppDir() {
if (!appDirMoved) return;
if (!existsSync(backupDir) || existsSync(appDir)) return;
renameSync(backupDir, appDir);
console.log("[Playwright WebServer] Restored app/ directory");
}
const playwrightDataDir = resolvePlaywrightDataDir({
cwd,
env: process.env,
});
const bootstrapEnvVars = bootstrapEnv({
quiet: true,
dataDirOverride: playwrightDataDir,
});
const runtimePorts = resolveRuntimePorts(bootstrapEnvVars);
const bootstrapMode = process.env.OMNIROUTE_E2E_BOOTSTRAP_MODE || "auth";
const playwrightPassword =
process.env.OMNIROUTE_E2E_PASSWORD || process.env.INITIAL_PASSWORD || "omniroute-e2e-password";
const testServerEnv = {
...sanitizeColorEnv(bootstrapEnvVars),
...sanitizeColorEnv(process.env),
NODE_ENV: mode === "start" ? "production" : "development",
DATA_DIR: playwrightDataDir,
NEXT_PUBLIC_OMNIROUTE_E2E_MODE: process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE || "1",
OMNIROUTE_DISABLE_BACKGROUND_SERVICES:
process.env.OMNIROUTE_DISABLE_BACKGROUND_SERVICES || "true",
OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK: process.env.OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK || "true",
OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK: process.env.OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK || "true",
OMNIROUTE_HIDE_HEALTHCHECK_LOGS: process.env.OMNIROUTE_HIDE_HEALTHCHECK_LOGS || "true",
...(bootstrapMode === "open"
? {
INITIAL_PASSWORD: "",
OMNIROUTE_E2E_PASSWORD: "",
OMNIROUTE_API_KEY: "",
}
: {
INITIAL_PASSWORD: playwrightPassword,
OMNIROUTE_E2E_PASSWORD: playwrightPassword,
}),
...(process.env.OMNIROUTE_USE_TURBOPACK
? {
OMNIROUTE_USE_TURBOPACK: process.env.OMNIROUTE_USE_TURBOPACK,
}
: {}),
};
export function shouldUseWebpackForPlaywrightDev({ mode, env }) {
// Webpack only on the explicit escape hatch (=0) — turbopack is the default.
return mode === "dev" && env.OMNIROUTE_USE_TURBOPACK === "0";
}
function runChild(command, args, env) {
return new Promise((resolve) => {
const child = spawn(command, args, {
stdio: "inherit",
env,
});
const forward = (signal) => {
if (!child.killed) child.kill(signal);
};
process.on("SIGINT", forward);
process.on("SIGTERM", forward);
child.on("exit", (code, signal) => {
process.off("SIGINT", forward);
process.off("SIGTERM", forward);
resolve({ code: code ?? 1, signal: signal ?? null });
});
});
}
async function runBuildForStart() {
if (mode !== "start") return;
if (process.env.OMNIROUTE_PLAYWRIGHT_SKIP_BUILD === "1") return;
console.log("[Playwright WebServer] Building fresh standalone app for this run...");
const buildEnv = withRuntimePortEnv(testServerEnv, runtimePorts);
const result = await runChild(process.execPath, [buildScript], buildEnv);
if (result.signal) {
process.kill(process.pid, result.signal);
return;
}
if (result.code !== 0) {
process.exit(result.code);
}
}
export async function main() {
process.on("exit", restoreAppDir);
process.on("uncaughtException", (error) => {
restoreAppDir();
throw error;
});
prepareAppDir();
await runBuildForStart();
if (mode === "start") {
if (existsSync(standaloneServer)) {
syncStandaloneRuntimeAssets({
standaloneServerPath: standaloneServer,
rootStaticDirPath: rootStaticDir,
standaloneStaticDirPath: standaloneStaticDir,
rootPublicDirPath: rootPublicDir,
standalonePublicDirPath: standalonePublicDir,
});
spawnWithForwardedSignals(process.execPath, [standaloneServer], {
stdio: "inherit",
env: {
...withRuntimePortEnv(testServerEnv, runtimePorts),
PORT: String(runtimePorts.dashboardPort),
HOSTNAME: process.env.HOSTNAME || "127.0.0.1",
},
});
return;
}
const args = [
"./node_modules/next/dist/bin/next",
"start",
"--port",
String(runtimePorts.dashboardPort),
];
spawnWithForwardedSignals(process.execPath, args, {
stdio: "inherit",
env: withRuntimePortEnv(testServerEnv, runtimePorts),
});
return;
}
const args = [
"./node_modules/next/dist/bin/next",
mode,
"--port",
String(runtimePorts.dashboardPort),
];
if (shouldUseWebpackForPlaywrightDev({ mode, env: testServerEnv })) {
args.splice(2, 0, "--webpack");
}
spawnWithForwardedSignals(process.execPath, args, {
stdio: "inherit",
env: withRuntimePortEnv(testServerEnv, runtimePorts),
});
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env node
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import next from "next";
import { bootstrapEnv } from "../build/bootstrap-env.mjs";
import { resolveRuntimePorts, withRuntimePortEnv } from "../build/runtime-env.mjs";
import { createOmnirouteWsBridge } from "./v1-ws-bridge.mjs";
import { createResponsesWsProxy } from "./responses-ws-proxy.mjs";
import { ensurePeerStampToken, stampPeerIp } from "./peer-stamp.mjs";
import methodGuard from "./http-method-guard.cjs";
import { ensureNativeSqlite } from "./ensure-native-sqlite.mjs";
import {
isTurbopackCacheCorruption,
purgeAllTurbopackCaches,
} from "./turbopackCacheHeal.mjs";
import { randomUUID } from "node:crypto";
const { maybeHandleDisallowedMethod } = methodGuard;
// Pre-read DATA_DIR from local .env before bootstrap resolves paths
if (!process.env.DATA_DIR) {
try {
const raw = fs.readFileSync(path.join(process.cwd(), ".env"), "utf8");
const match = raw.match(/^DATA_DIR=(.+)$/m);
if (match?.[1]?.trim()) process.env.DATA_DIR = match[1].trim();
} catch {
/* .env ausente ou ilegível — ok, bootstrap usa o padrão */
}
}
// Add check for conflicting app/ directory (Issue #1206)
const rootAppDir = path.join(process.cwd(), "app");
if (fs.existsSync(rootAppDir) && fs.statSync(rootAppDir).isDirectory()) {
console.error("\x1b[31m[FATAL ERROR]\x1b[0m Next.js App Router conflict detected!");
console.error(`A root-level 'app/' directory was found at: ${rootAppDir}`);
console.error("This conflicts with the 'src/app/' directory on Windows environments.");
console.error("Next.js will serve 404s for all pages because it prefers the root 'app/' folder.");
console.error("Please rename or delete the root 'app/' directory before starting OmniRoute.\n");
process.exit(1);
}
const mode = process.argv[2] === "start" ? "start" : "dev";
const dev = mode === "dev";
// Self-heal a stale better-sqlite3 native binary after a Node version switch
// (nvm 22 <-> 24) before bootstrap touches the DB. No-op when the ABI matches.
if (dev) {
ensureNativeSqlite();
}
const bootstrappedEnv = bootstrapEnv();
const runtimePorts = resolveRuntimePorts(bootstrappedEnv);
const mergedEnv = withRuntimePortEnv(bootstrappedEnv, runtimePorts);
for (const [key, value] of Object.entries(mergedEnv)) {
if (value !== undefined) {
process.env[key] = value;
}
}
// The mergedEnv copy above pulls NODE_ENV straight from `.env` — and the shipped
// `.env.example` default is `NODE_ENV=production`. Next's programmatic `next()`
// entry (unlike the `next` CLI) trusts that value verbatim, so `npm run dev`
// would boot the dev bundler with NODE_ENV=production. That desyncs Next's
// webpack CSS pipeline and `src/app/globals.css` reaches webpack's JS parser
// instead of PostCSS — failing as `Module parse failed: Unexpected character
// '@'` on the `@import "tailwindcss"` line. Force NODE_ENV to track the run
// mode, exactly like the `next` CLI does.
process.env.NODE_ENV = dev ? "development" : "production";
const { dashboardPort } = runtimePorts;
const hostname = process.env.HOST || "0.0.0.0";
// Turbopack by default in dev (matches the Next 16 CLI default and the production
// build default in build-next-isolated.mjs); OMNIROUTE_USE_TURBOPACK=0 is the
// webpack escape hatch.
const useTurbopack = dev && mergedEnv.OMNIROUTE_USE_TURBOPACK !== "0";
process.env.OMNIROUTE_WS_BRIDGE_SECRET ||= randomUUID();
// Per-process secret used to prove the trusted peer-IP stamp came from this
// server (read by the authz middleware in the same process). See peer-stamp.mjs.
ensurePeerStampToken();
// Next 16 picks Turbopack by default in dev. Passing `turbopack: false` to the
// programmatic next() entry is *not* enough on its own:
// - parseBundlerArgs (node_modules/next/dist/lib/bundler.js) sees no positive
// bundler flag and falls back to `process.env.TURBOPACK = 'auto'`.
// - next-dev-server.js then reads `process.env.TURBOPACK` directly and
// starts Turbopack regardless of the option we passed.
// Force webpack by both passing `webpack: true` and clearing the env var.
// Mirrors the workaround PR #4052 applied for the production Docker build.
if (!useTurbopack) {
delete process.env.TURBOPACK;
}
function createNextApp() {
return next({
dev,
dir: process.cwd(),
hostname,
port: dashboardPort,
turbopack: useTurbopack,
webpack: !useTurbopack,
});
}
let nextApp = createNextApp();
// Best-effort self-heal for a corrupted Turbopack persistent dev cache (#6289):
// on Windows an mmap of an SST cache file can fail ("os error 1455" / paging
// file too small), which Turbopack surfaces as a misleading module-resolve
// error. This is an UPSTREAM Turbopack cache-corruption bug — not our code.
// When `prepare()` rejects with that signature we purge the cache and retry
// ONCE. Caveat: the failure often surfaces as a runtime overlay rather than a
// `prepare()` rejection, so this cannot always intercept it — the reliable
// remedy remains manually deleting `.build/next/**/cache/turbopack`.
async function prepareWithHeal() {
try {
await nextApp.prepare();
} catch (error) {
const detail = error instanceof Error ? `${error.message}\n${error.stack ?? ""}` : String(error);
if (!useTurbopack || !isTurbopackCacheCorruption(detail)) throw error;
console.warn(
"[Next] Turbopack dev cache looks corrupted (Windows mmap / os error 1455 — known upstream bug). Purging and retrying once…"
);
const removed = purgeAllTurbopackCaches();
for (const dir of removed) console.warn(`[Next] purged Turbopack cache: ${dir}`);
nextApp = createNextApp();
await nextApp.prepare();
console.warn("[Next] Turbopack dev cache purged; startup retry succeeded.");
}
}
async function start() {
await prepareWithHeal();
const requestHandler = nextApp.getRequestHandler();
const upgradeHandler = nextApp.getUpgradeHandler();
const responsesWsProxy = createResponsesWsProxy({
baseUrl: `http://127.0.0.1:${dashboardPort}`,
bridgeSecret: process.env.OMNIROUTE_WS_BRIDGE_SECRET,
});
const wsBridge = createOmnirouteWsBridge({
baseUrl: `http://127.0.0.1:${dashboardPort}`,
});
const server = http.createServer((req, res) => {
if (maybeHandleDisallowedMethod(req, res)) return;
// Stamp the real TCP peer IP before Next sees the request, so the authz
// middleware can decide LOCAL_ONLY locality without trusting the Host header.
stampPeerIp(req);
return requestHandler(req, res);
});
server.on("upgrade", async (req, socket, head) => {
try {
const responsesWsHandled = await responsesWsProxy.handleUpgrade(req, socket, head);
if (responsesWsHandled) return;
const handled = await wsBridge.handleUpgrade(req, socket, head);
if (handled) return;
await upgradeHandler(req, socket, head);
} catch (error) {
if (!socket.destroyed) {
socket.destroy(error instanceof Error ? error : undefined);
}
console.error("[WS] Upgrade handling failed:", error);
}
});
server.on("error", (error) => {
console.error("[FATAL] Next custom server failed:", error);
process.exit(1);
});
const shutdown = async (signal) => {
try {
await new Promise((resolve) => server.close(resolve));
await nextApp.close();
} catch (error) {
console.error("[SHUTDOWN] Failed during signal:", signal, error);
} finally {
process.exit(0);
}
};
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));
server.listen(dashboardPort, hostname, () => {
const bundler = dev ? (useTurbopack ? "turbopack" : "webpack") : "production";
console.log(
`[Next] ${mode} server listening on http://${hostname}:${dashboardPort} (${bundler})`
);
});
}
start().catch((error) => {
console.error("[FATAL] Failed to start Next custom server:", error);
process.exit(1);
});
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { sanitizeColorEnv } from "../build/runtime-env.mjs";
const defaultArgs = ["test", "tests/e2e/*.spec.ts"];
const forwardedArgs = process.argv.slice(2);
const args = forwardedArgs.length > 0 ? forwardedArgs : defaultArgs;
const playwrightEnv = sanitizeColorEnv(process.env);
delete playwrightEnv.NO_COLOR;
delete playwrightEnv.FORCE_COLOR;
const child = spawn(process.execPath, ["./node_modules/playwright/cli.js", ...args], {
stdio: "inherit",
env: playwrightEnv,
});
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
});
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { sanitizeColorEnv } from "../build/runtime-env.mjs";
function parsePort(value, fallback) {
const parsed = Number.parseInt(String(value), 10);
return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback;
}
const explicitBaseUrl = process.env.OMNIROUTE_BASE_URL || "";
const isolatedPort = parsePort(
process.env.DASHBOARD_PORT || process.env.PORT,
23000 + (process.pid % 1000)
);
const isolatedDataDir =
process.env.DATA_DIR || join(process.cwd(), ".tmp", "protocol-clients-data", String(process.pid));
const port = explicitBaseUrl ? null : isolatedPort;
const baseUrl = explicitBaseUrl || `http://127.0.0.1:${isolatedPort}`;
const healthUrl = `${baseUrl}/api/monitoring/health`;
const maxWaitMs = Number(process.env.ECOSYSTEM_SERVER_WAIT_MS || 180000);
const pollMs = 2000;
async function isServerReady() {
const timeout = AbortSignal.timeout(2000);
try {
const res = await fetch(healthUrl, { signal: timeout });
return res.ok;
} catch {
return false;
}
}
async function waitForServerReady() {
const maxAttempts = Math.ceil(maxWaitMs / pollMs);
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
if (await isServerReady()) return;
await delay(pollMs);
}
throw new Error(`Timed out waiting for ${healthUrl} after ${maxWaitMs}ms`);
}
async function main() {
let serverProcess = null;
let startedHere = false;
const testEnv = {
...sanitizeColorEnv(process.env),
DATA_DIR: isolatedDataDir,
...(explicitBaseUrl
? {}
: {
PORT: String(port),
DASHBOARD_PORT: String(port),
API_PORT: String(port),
OMNIROUTE_BASE_URL: baseUrl,
}),
OMNIROUTE_E2E_BOOTSTRAP_MODE: process.env.OMNIROUTE_E2E_BOOTSTRAP_MODE || "open",
};
if (!(await isServerReady())) {
serverProcess = spawn(process.execPath, ["scripts/dev/run-next-playwright.mjs", "dev"], {
stdio: "inherit",
env: testEnv,
});
startedHere = true;
await waitForServerReady();
}
const vitestProcess = spawn(
process.execPath,
[
"./node_modules/vitest/vitest.mjs",
"run",
"--environment",
"node",
"tests/e2e/protocol-clients.test.ts",
],
{
stdio: "inherit",
env: testEnv,
}
);
const exitCode = await new Promise((resolve) => {
vitestProcess.on("exit", (code, signal) => {
if (signal) {
resolve(1);
return;
}
resolve(code ?? 1);
});
});
if (startedHere && serverProcess) {
serverProcess.kill("SIGTERM");
await delay(1000);
if (!serverProcess.killed) {
serverProcess.kill("SIGKILL");
}
}
process.exit(exitCode);
}
main().catch((error) => {
console.error("[test:protocols:e2e] Failed:", error?.message || error);
process.exit(1);
});
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env node
import { existsSync } from "node:fs";
import {
resolveRuntimePorts,
withRuntimePortEnv,
resolveMaxOldSpaceMb,
spawnWithForwardedSignals,
} from "../build/runtime-env.mjs";
import { bootstrapEnv } from "../build/bootstrap-env.mjs";
const env = bootstrapEnv();
const runtimePorts = resolveRuntimePorts(env);
const childEnv = withRuntimePortEnv(env, runtimePorts);
// #2939: honor OMNIROUTE_MEMORY_MB (default 512), the same knob
// `omniroute serve` uses, so Docker users can control the server heap under
// load / large SQLite DBs. A trailing --max-old-space-size wins, so this
// overrides the image fallback without clobbering any other NODE_OPTIONS flags.
const maxOldSpaceMb = resolveMaxOldSpaceMb(childEnv.OMNIROUTE_MEMORY_MB);
childEnv.NODE_OPTIONS =
`${childEnv.NODE_OPTIONS || ""} --max-old-space-size=${maxOldSpaceMb}`.trim();
// Prefer the WS-aware wrapper (server-ws.mjs) over the bare Next standalone
// server.js: it installs the trusted peer-IP stamp (scripts/dev/peer-stamp.mjs)
// that the authz middleware needs to allow loopback/LAN access to LOCAL_ONLY
// routes. Falling back to server.js fails CLOSED (every LOCAL_ONLY request 403s)
// rather than trusting the spoofable Host header.
const entry = existsSync("server-ws.mjs") ? "server-ws.mjs" : "server.js";
spawnWithForwardedSignals("node", [entry], {
stdio: "inherit",
env: childEnv,
});

Some files were not shown because too many files have changed in this diff Show More