#!/usr/bin/env node /* eslint-disable no-console */ import { readdir, stat } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { scanApiRoutesFromFile, type ApiRouteKey } from "../tools/api-inventory/scan-api-routes.js"; export const DEPRECATED_API_ROUTE_KEYS = ["GET /v1/auth/verify-token", "GET /v1/auth/me"] as const satisfies readonly ApiRouteKey[]; export type DeprecatedApiRouteKey = (typeof DEPRECATED_API_ROUTE_KEYS)[number]; export type ApiInventoryGuardOptions = { appSourcePath?: string; removedWebApiRoutesRoot?: string; legacyRoutesRoot?: string; }; export type ApiInventoryViolation = { routeKey: DeprecatedApiRouteKey | "REMOVED /api web route" | "UNRESOLVED mounted router"; surface: "hono" | "removed-react-router-api" | "hono-route-mount"; sourcePath: string; location?: { line: number; column: number; }; detail?: string; }; export type ApiInventoryGuardResult = { ok: boolean; violations: ApiInventoryViolation[]; missingRoots: string[]; }; const DEFAULT_APP_SOURCE_PATH = "apps/api/src/app.ts"; const DEFAULT_REMOVED_WEB_API_ROUTES_ROOT = "apps/web/app/routes/api"; const deprecatedRouteKeys = new Set(DEPRECATED_API_ROUTE_KEYS); const legacyRouteFileNamesByRouteKey = new Map([ ["GET /v1/auth/verify-token", [path.join("auth", "verify-token.ts"), path.join("auth", "verify-token.tsx")]], ["GET /v1/auth/me", [path.join("auth", "me.ts"), path.join("auth", "me.tsx")]], ]); export async function validateApiInventory(options: ApiInventoryGuardOptions = {}): Promise { const appSourcePath = options.appSourcePath ?? DEFAULT_APP_SOURCE_PATH; const removedWebApiRoutesRoot = options.removedWebApiRoutesRoot ?? options.legacyRoutesRoot ?? DEFAULT_REMOVED_WEB_API_ROUTES_ROOT; const violations: ApiInventoryViolation[] = []; const missingRoots: string[] = []; const scanResult = await scanApiRoutesFromFile(appSourcePath); for (const mountedRouter of scanResult.unresolvedMountedRouters) { violations.push({ routeKey: "UNRESOLVED mounted router", surface: "hono-route-mount", sourcePath: appSourcePath, location: mountedRouter.location, detail: `${mountedRouter.prefix} -> ${mountedRouter.receiver}`, }); } for (const route of scanResult.routes) { if (!deprecatedRouteKeys.has(route.key)) continue; violations.push({ routeKey: route.key as DeprecatedApiRouteKey, surface: "hono", sourcePath: appSourcePath, location: route.location, }); } const removedWebApiRootExists = await pathExists(removedWebApiRoutesRoot); const removedWebApiRouteFiles = removedWebApiRootExists ? await listFiles(removedWebApiRoutesRoot) : []; const normalizedRemovedWebApiRouteFiles = new Set(removedWebApiRouteFiles.map((filePath) => normalizeRouteFilePath(filePath))); for (const fileName of removedWebApiRouteFiles) { violations.push({ routeKey: "REMOVED /api web route", surface: "removed-react-router-api", sourcePath: path.join(removedWebApiRoutesRoot, fileName), }); } for (const [routeKey, fileNames] of legacyRouteFileNamesByRouteKey.entries()) { for (const fileName of fileNames) { if (!normalizedRemovedWebApiRouteFiles.has(normalizeRouteFilePath(fileName))) continue; violations.push({ routeKey, surface: "removed-react-router-api", sourcePath: path.join(removedWebApiRoutesRoot, fileName), }); } } return { ok: violations.length === 0 && missingRoots.length === 0, violations, missingRoots }; } export function renderApiInventoryGuardResult(result: ApiInventoryGuardResult): string { if (result.ok) return "PASS api-inventory: no deprecated route keys found"; return [ "FAIL api-inventory: deprecated route keys or removed web API routes found", ...result.missingRoots.map((root) => `- missing scan root: ${root}`), ...result.violations.map(formatViolation), ].join("\n"); } async function pathExists(targetPath: string): Promise { try { await stat(targetPath); return true; } catch (error) { if (isNodeError(error) && error.code === "ENOENT") return false; throw error; } } async function listFiles(root: string, relativeDir = ""): Promise { const absoluteDir = path.join(root, relativeDir); const entries = await readdir(absoluteDir, { withFileTypes: true }); const files: string[] = []; for (const entry of entries) { const relativePath = path.join(relativeDir, entry.name); if (entry.isDirectory()) { files.push(...(await listFiles(root, relativePath))); } else if (entry.isFile()) { files.push(relativePath); } } return files; } function formatViolation(violation: ApiInventoryViolation): string { const location = violation.location ? `:${violation.location.line}:${violation.location.column}` : ""; const detail = violation.detail ? ` (${violation.detail})` : ""; return `- ${violation.routeKey} [${violation.surface}] ${violation.sourcePath}${location}${detail}`; } function normalizeRouteFilePath(filePath: string): string { return filePath.split(path.sep).join("/"); } function isNodeError(error: unknown): error is NodeJS.ErrnoException { return typeof error === "object" && error !== null && "code" in error; } async function main(args = process.argv.slice(2)): Promise { if (args.length > 0) { console.error("FAIL api-inventory: unknown arguments"); for (const arg of args) console.error(`- ${arg}`); process.exitCode = 1; return; } const result = await validateApiInventory(); const output = renderApiInventoryGuardResult(result); if (result.ok) { console.log(output); return; } console.error(output); process.exitCode = 1; } if (process.argv[1] === fileURLToPath(import.meta.url)) { await main(); }