Spaces:
Runtime error
Runtime error
| // 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(); | |