mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
feat: add MCP server, A2A protocol, auto-combo engine & VS Code extension
Introduce full AI orchestration ecosystem: - MCP Server with 16 tools, scoped auth, and audit logging - A2A v0.3 server with JSON-RPC 2.0, SSE streaming, and task manager - Auto-Combo engine with 6-factor scoring and self-healing - VS Code extension with smart dispatch and budget tracking - Harden CI pipeline: add static checks, remove continue-on-error - Add translator schema validation tests - Update .gitignore and CHANGELOG for release checklist
This commit is contained in:
177
scripts/check-cycles.mjs
Normal file
177
scripts/check-cycles.mjs
Normal file
@@ -0,0 +1,177 @@
|
||||
#!/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", "open-sse/translator"];
|
||||
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);
|
||||
111
scripts/check-docs-sync.mjs
Normal file
111
scripts/check-docs-sync.mjs
Normal file
@@ -0,0 +1,111 @@
|
||||
#!/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");
|
||||
|
||||
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+—\s+.*)?$/gm)];
|
||||
return headings.map((match) => match[1]);
|
||||
}
|
||||
|
||||
function isSemver(value) {
|
||||
return /^\d+\.\d+\.\d+$/.test(value);
|
||||
}
|
||||
|
||||
let hasFailure = false;
|
||||
|
||||
function fail(message) {
|
||||
hasFailure = true;
|
||||
console.error(`[docs-sync] FAIL - ${message}`);
|
||||
}
|
||||
|
||||
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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} 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.");
|
||||
61
scripts/check-route-validation.mjs
Normal file
61
scripts/check-route-validation.mjs
Normal file
@@ -0,0 +1,61 @@
|
||||
#!/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*\(/;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
if (!VALIDATE_BODY_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():");
|
||||
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.`
|
||||
);
|
||||
56
scripts/check-t11-any-budget.mjs
Normal file
56
scripts/check-t11-any-budget.mjs
Normal file
@@ -0,0 +1,56 @@
|
||||
#!/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/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/server.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");
|
||||
const matches = content.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.");
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { sanitizeColorEnv } from "./runtime-env.mjs";
|
||||
|
||||
const port = process.env.DASHBOARD_PORT || process.env.PORT || "20128";
|
||||
const baseUrl = process.env.OMNIROUTE_BASE_URL || `http://localhost:${port}`;
|
||||
@@ -33,11 +34,12 @@ async function waitForServerReady() {
|
||||
async function main() {
|
||||
let serverProcess = null;
|
||||
let startedHere = false;
|
||||
const testEnv = sanitizeColorEnv(process.env);
|
||||
|
||||
if (!(await isServerReady())) {
|
||||
serverProcess = spawn(process.execPath, ["scripts/run-next-playwright.mjs", "dev"], {
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
env: testEnv,
|
||||
});
|
||||
startedHere = true;
|
||||
await waitForServerReady();
|
||||
@@ -48,7 +50,7 @@ async function main() {
|
||||
["./node_modules/vitest/vitest.mjs", "run", "tests/e2e/ecosystem.test.ts"],
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
env: testEnv,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { existsSync, renameSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
resolveRuntimePorts,
|
||||
sanitizeColorEnv,
|
||||
spawnWithForwardedSignals,
|
||||
withRuntimePortEnv,
|
||||
} from "./runtime-env.mjs";
|
||||
@@ -54,6 +55,11 @@ process.on("uncaughtException", (error) => {
|
||||
prepareAppDir();
|
||||
|
||||
const runtimePorts = resolveRuntimePorts();
|
||||
const testServerEnv = {
|
||||
...sanitizeColorEnv(process.env),
|
||||
OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK: process.env.OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK || "1",
|
||||
OMNIROUTE_HIDE_HEALTHCHECK_LOGS: process.env.OMNIROUTE_HIDE_HEALTHCHECK_LOGS || "1",
|
||||
};
|
||||
const args = [
|
||||
"./node_modules/next/dist/bin/next",
|
||||
mode,
|
||||
@@ -66,5 +72,5 @@ if (mode === "dev") {
|
||||
|
||||
spawnWithForwardedSignals(process.execPath, args, {
|
||||
stdio: "inherit",
|
||||
env: withRuntimePortEnv(process.env, runtimePorts),
|
||||
env: withRuntimePortEnv(testServerEnv, runtimePorts),
|
||||
});
|
||||
|
||||
25
scripts/run-playwright-tests.mjs
Normal file
25
scripts/run-playwright-tests.mjs
Normal file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { sanitizeColorEnv } from "./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);
|
||||
});
|
||||
@@ -25,6 +25,18 @@ export function withRuntimePortEnv(env, runtimePorts) {
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user