UNPKG

socraticode

Version:

SocratiCode — MCP server for local codebase indexing, semantic search, and code dependency graphs. All private, all local via Docker.

175 lines • 7.12 kB
// SPDX-License-Identifier: AGPL-3.0-only // Copyright (C) 2026 Giancarlo Erra - Altaire Limited import fs from "node:fs/promises"; import path from "node:path"; import { logger } from "./logger.js"; /** Empty aliases constant for when no config is found */ const EMPTY_ALIASES = { entries: new Map() }; /** * Load path aliases from tsconfig.json or jsconfig.json. * Parses `compilerOptions.paths` and `compilerOptions.baseUrl` to build * a prefix → directory mapping used during import resolution. * Follows `extends` chains to find paths in parent configs. * * Returns empty aliases if no config is found (graceful degradation). */ export async function loadPathAliases(projectPath, recorder) { const configNames = ["tsconfig.json", "jsconfig.json"]; for (const name of configNames) { const configPath = path.join(projectPath, name); try { const raw = await fs.readFile(configPath, "utf-8"); recorder?.read(configPath, raw); const aliases = parsePathAliases(raw, projectPath); if (aliases.entries.size > 0) { logger.info("Loaded path aliases", { config: name, aliases: Array.from(aliases.entries.keys()), }); return aliases; } // Config exists but has no paths — follow extends chain const extended = await followExtendsChain(configPath, projectPath, recorder); if (extended.entries.size > 0) { logger.info("Loaded path aliases via extends", { config: name, aliases: Array.from(extended.entries.keys()), }); return extended; } // No paths in entire chain — try next config file } catch { // Config not found, or found and unreadable — try next. Either way the // build looked here and got nothing, and a config appearing (or becoming // readable) changes the aliases, so it is an input. `read` above takes // precedence, so this only lands when nothing was read. recorder?.unreadable(configPath); } } return EMPTY_ALIASES; } /** Maximum depth for tsconfig extends chains to prevent circular references */ const MAX_EXTENDS_DEPTH = 10; /** * Follow the `extends` chain of a tsconfig/jsconfig looking for `compilerOptions.paths`. * Resolves relative paths and package references. Caps at MAX_EXTENDS_DEPTH. */ async function followExtendsChain(configPath, _projectPath, recorder) { const visited = new Set(); let currentPath = configPath; for (let depth = 0; depth < MAX_EXTENDS_DEPTH; depth++) { const resolved = path.resolve(currentPath); if (visited.has(resolved)) break; // circular visited.add(resolved); let raw; try { raw = await fs.readFile(resolved, "utf-8"); } catch { recorder?.unreadable(resolved); break; // file not found } // Every config the chain reaches shapes the aliases, including one outside // the project — a base config in a parent directory is recorded relative // to the root and read back through the same relative path. recorder?.read(resolved, raw); const config = parseTsconfigJson(raw); if (!config) break; // Check if this config has paths const co = config.compilerOptions; if (co?.paths) { const configDir = path.dirname(resolved); return parsePathAliases(raw, configDir); } // Follow extends const extendsValue = config.extends; if (!extendsValue || typeof extendsValue !== "string") break; const configDir = path.dirname(resolved); if (extendsValue.startsWith(".")) { // Relative path: "./tsconfig.base.json" currentPath = path.resolve(configDir, extendsValue); // Add .json if missing if (!currentPath.endsWith(".json")) currentPath += ".json"; } else { // Package reference: "@tsconfig/node20/tsconfig.json" // Try to resolve from node_modules try { currentPath = path.resolve(configDir, "node_modules", extendsValue); if (!currentPath.endsWith(".json")) currentPath += ".json"; } catch { break; } } } return EMPTY_ALIASES; } /** * Parse tsconfig/jsconfig JSON with comment stripping. * Returns null if parsing fails. */ export function parseTsconfigJson(jsonContent) { try { const stripped = jsonContent.replace(/"(?:[^"\\]|\\.)*"|\/\/.*$|\/\*[\s\S]*?\*\//gm, (match) => match.startsWith('"') ? match : ""); return JSON.parse(stripped); } catch { return null; } } /** * Parse path aliases from tsconfig/jsconfig JSON content. * Handles JSON with comments (// and /* ... *\/) which tsconfig allows. */ export function parsePathAliases(jsonContent, projectPath) { const entries = new Map(); try { const config = parseTsconfigJson(jsonContent); if (!config) return EMPTY_ALIASES; const compilerOptions = config.compilerOptions; if (!compilerOptions?.paths) return EMPTY_ALIASES; const baseUrl = compilerOptions.baseUrl ?? "."; const baseDir = path.resolve(projectPath, baseUrl); const paths = compilerOptions.paths; for (const [pattern, targets] of Object.entries(paths)) { if (!Array.isArray(targets) || targets.length === 0) continue; // Convert wildcard pattern: "$lib/*" → prefix "$lib/" // Convert exact pattern: "~" → prefix "~" const prefix = pattern.endsWith("/*") ? pattern.slice(0, -1) // "$lib/*" → "$lib/" : pattern; const resolvedTargets = []; for (const target of targets) { if (typeof target !== "string") continue; // Convert wildcard target: "src/lib/*" → "src/lib/" // Convert exact target: "./src" → "src" const targetPath = target.endsWith("/*") ? target.slice(0, -1) // "src/lib/*" → "src/lib/" : target; // Resolve relative to baseUrl, then make relative to project root const absolute = path.resolve(baseDir, targetPath); const relative = path.relative(projectPath, absolute); resolvedTargets.push(relative); } if (resolvedTargets.length > 0) { entries.set(prefix, resolvedTargets); } } } catch (err) { logger.warn("Failed to parse path aliases from config", { error: String(err) }); } return { entries }; } //# sourceMappingURL=graph-aliases.js.map