#!/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 = {}; /** Extrai todas as strings literais de `strategy === "..."` da fonte do combo. */ export function extractHandledStrategies(comboSource: string): Set { const handled = new Set(); 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, implicitDefaults: Record ): StrategyMismatch { const canonicalSet = new Set(canonical); const effectivelyHandled = new Set(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[] { 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[] { 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[] { 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[] { 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, agentCard: Set): 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, agentFiles: Set ): 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 { // Match the AGENTS object: find start, extract keys const start = registrySource.indexOf("const AGENTS: Record = {"); 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(); 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 = { 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 { 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; 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(); 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), ...Object.values(skillTools as Record), ...(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)); // 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(); 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/.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); }); }