UNPKG

socraticode

Version:

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

293 lines • 14.1 kB
// SPDX-License-Identifier: AGPL-3.0-only // Copyright (C) 2026 Giancarlo Erra - Altaire Limited import fs from "node:fs"; import path from "node:path"; import { globSync } from "glob"; import ts from "typescript"; import { parseDocument } from "yaml"; import { toForwardSlash } from "../constants.js"; import { parsePathAliases } from "./graph-aliases.js"; import { resolveImport } from "./graph-resolution.js"; const inside = (root, file) => { const relative = path.relative(root, file); return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); }; /** Emit targets and standard-library types do not participate in this resolver. */ function isCompilerOnlyDiagnostic(diagnostic) { if (diagnostic.code !== 6046 || !diagnostic.file || diagnostic.start === undefined) return false; const start = diagnostic.start; let option; const visit = (node) => { if (node.pos > start || node.end <= start) return; if (ts.isPropertyAssignment(node) && ts.isStringLiteralLike(node.name)) option = node.name.text; ts.forEachChild(node, visit); }; visit(diagnostic.file); return option === "target" || option === "lib"; } /** * One build's importer contexts. The compiler owns config inheritance and * package resolution; the host supplies declared workspace links without * installing packages or changing the indexed tree. */ export function createTypeScriptResolver(projectPath, fileSet, recorder) { const root = path.resolve(projectPath); const reads = new Map(); const bindings = new Map(); const realpath = (file) => { const absolute = path.resolve(file); if (bindings.has(absolute)) return bindings.get(absolute) ?? null; let canonical = null; try { // Match the native canonicalization used by the async freshness check. canonical = fs.realpathSync.native(absolute); } catch (error) { const code = error.code; if (code !== "ENOENT" && code !== "ENOTDIR") throw error; } recorder.realpath?.(absolute, canonical); bindings.set(absolute, canonical); return canonical; }; const read = (file) => { const absolute = path.resolve(file); if (reads.has(absolute)) return reads.get(absolute); let raw; try { raw = fs.readFileSync(absolute, "utf8"); recorder.read(absolute, raw); } catch (error) { recorder.unreadable(absolute); const code = error.code; if (code !== "ENOENT" && code !== "ENOTDIR") throw error; } reads.set(absolute, raw); return raw; }; // Only ancestors of graphable files can supply a workspace or a package // with a graph target. Ignored trees and node_modules never become members. const directories = new Set([root]); for (const file of fileSet) { let dir = path.dirname(path.resolve(root, file)); while (inside(root, dir) && !directories.has(dir)) { directories.add(dir); dir = path.dirname(dir); } } const manifests = new Map(); for (const dir of directories) { const file = path.join(dir, "package.json"); const raw = read(file); if (raw !== undefined) { let manifest; try { manifest = JSON.parse(raw); } catch (error) { throw new Error(`Invalid package manifest ${file}: ${String(error)}`, { cause: error }); } if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) { throw new Error(`Package manifest must contain an object: ${file}`); } manifests.set(dir, manifest); } } const workspaces = []; for (const dir of directories) { const manifest = manifests.get(dir); const declared = manifest?.workspaces; let patterns = Array.isArray(declared) ? declared : declared && typeof declared === "object" ? declared.packages : undefined; const yamlPath = path.join(dir, "pnpm-workspace.yaml"); const yaml = read(yamlPath); if (yaml !== undefined) { const document = parseDocument(yaml); if (document.errors.length > 0) { throw new Error(`Invalid workspace manifest ${yamlPath}: ${document.errors[0].message}`); } patterns = document.toJS()?.packages ?? []; } if (patterns === undefined) continue; if (!Array.isArray(patterns)) throw new Error(`Workspace packages must be an array of strings: ${dir}`); if (!patterns.every((pattern) => typeof pattern === "string")) { throw new Error(`Workspace packages must be an array of strings: ${dir}`); } const positive = patterns.filter((pattern) => !pattern.startsWith("!")); const negative = patterns.filter((pattern) => pattern.startsWith("!")).map((pattern) => pattern.slice(1)); const members = new Set([dir, ...globSync(positive, { cwd: dir, absolute: true, ignore: [...negative, "**/node_modules/**", "**/.git/**"], }).map((member) => path.resolve(member))]); const packages = new Map(); for (const member of members) { if (!inside(root, member)) continue; const name = manifests.get(member)?.name; if (typeof name !== "string") continue; if (packages.has(name)) throw new Error(`Duplicate workspace package ${name} in ${dir}`); packages.set(name, member); } workspaces.push({ root: dir, packages }); } workspaces.sort((a, b) => b.root.length - a.root.length); const configByDir = new Map(); const parsedByConfig = new Map(); const configHost = { useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, getCurrentDirectory: () => root, readDirectory: () => [], // Only compiler options are needed, not its input file list. fileExists: (file) => { if (file.endsWith(".json")) return read(file) !== undefined; return ts.sys.fileExists(file); }, readFile: read, onUnRecoverableConfigFileDiagnostic: (diagnostic) => { throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); }, }; const parsedConfig = (file) => { let parsed = parsedByConfig.get(file); if (!parsed) { parsed = ts.getParsedCommandLineOfConfigFile(toForwardSlash(file), undefined, configHost); if (!parsed) throw new Error(`Cannot read TypeScript configuration: ${file}`); // Empty inputs are immaterial to a graph resolver. All actual config // diagnostics retain their compiler-provided cause. // The original alias loader ignored compiler options it did not use. // Keep known paths usable when a project uses another compiler version's // options (5023/5025) or emit/type-library values. Missing extends bases // (6053/5083) also retain local aliases, as the original loader did. // Syntax, circular inheritance and invalid resolution values still fail. const error = parsed.errors.find((diagnostic) => ![18003, 18002, 5023, 5025, 6053, 5083].includes(diagnostic.code) && !isCompilerOnlyDiagnostic(diagnostic)); if (error) throw new Error(`Invalid TypeScript configuration ${file}: ${ts.flattenDiagnosticMessageText(error.messageText, "\n")}`); parsedByConfig.set(file, parsed); } return parsed; }; const configFor = (dir) => { if (configByDir.has(dir)) return configByDir.get(dir) ?? null; let found = null; for (const name of ["tsconfig.json", "jsconfig.json"]) { const file = path.join(dir, name); if (read(file) === undefined) continue; // Preserve the existing root convention: a tsconfig with no paths // permits jsconfig aliases. A nested config remains its own boundary. if (dir === root && name === "tsconfig.json" && !parsedConfig(file).options.paths && read(path.join(dir, "jsconfig.json")) !== undefined) continue; found = file; break; } if (!found && dir !== root) found = configFor(path.dirname(dir)); configByDir.set(dir, found); return found; }; return (sourceFile) => { const config = configFor(path.dirname(sourceFile)); const options = config ? parsedConfig(config).options : {}; const effective = { ...options }; if (effective.moduleResolution === undefined && effective.module === undefined) { effective.moduleResolution = ts.ModuleResolutionKind.Bundler; effective.module = ts.ModuleKind.ESNext; } const base = options.baseUrl ?? options.pathsBasePath; const aliases = parsePathAliases(JSON.stringify({ compilerOptions: { paths: options.paths, baseUrl: typeof base === "string" ? base : root, } }), root); const workspace = workspaces.find((candidate) => inside(candidate.root, sourceFile)); const virtualRoot = workspace && path.join(workspace.root, "node_modules"); const translate = (file) => { const absolute = path.resolve(file); if (!workspace || !inside(workspace.root, absolute)) return absolute; const parts = toForwardSlash(path.relative(workspace.root, absolute)).split("/"); const at = parts.lastIndexOf("node_modules"); if (at < 0) return absolute; const rest = parts.slice(at + 1); const length = rest[0]?.startsWith("@") ? 2 : 1; const member = workspace.packages.get(rest.slice(0, length).join("/")); if (!member) return absolute; const entry = path.join(workspace.root, ...parts.slice(0, at + 1 + length)); const installed = realpath(entry); // An existing package may be an independent version or npm alias. Only // actual workspace links and absent (virtual) entries map to a member. if (installed !== null && installed !== realpath(member)) return absolute; return path.join(member, ...rest.slice(length)); }; const host = { fileExists: (file) => configHost.fileExists(translate(file)), readFile: (file) => read(translate(file)), directoryExists: (dir) => { const absolute = path.resolve(dir); if (workspace && inside(workspace.root, absolute) && (path.basename(absolute) === "node_modules" || (path.basename(path.dirname(absolute)) === "node_modules" && path.basename(absolute).startsWith("@")))) realpath(absolute); if (virtualRoot && path.resolve(dir) === virtualRoot) return true; if (virtualRoot && path.dirname(path.resolve(dir)) === virtualRoot && path.basename(dir).startsWith("@")) { const scope = `${path.basename(dir)}/`; if ([...workspace.packages.keys()].some((name) => name.startsWith(scope))) return true; } return ts.sys.directoryExists(translate(dir)); }, realpath: translate, getCurrentDirectory: () => root, useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, }; return { aliases, resolve(imp) { const mode = imp.isRequire ? ts.ModuleKind.CommonJS : imp.isDynamic ? ts.ModuleKind.ESNext : ts.getImpliedNodeFormatForFile(sourceFile, undefined, host, effective) ?? (effective.module === ts.ModuleKind.CommonJS ? ts.ModuleKind.CommonJS : ts.ModuleKind.ESNext); const result = ts.resolveModuleName(imp.moduleSpecifier, sourceFile, effective, host, undefined, undefined, mode).resolvedModule; if (result) { const relative = toForwardSlash(path.relative(root, translate(result.resolvedFileName))); return fileSet.has(relative) ? relative : null; } // Keep existing aliases to components, styles and other graphable // files the compiler does not load. Use only its selected paths // pattern, never a broader mapping after a failed match. const spec = imp.moduleSpecifier; const patterns = Object.keys(options.paths ?? {}).filter((pattern) => { const star = pattern.indexOf("*"); return star < 0 ? pattern === spec : spec.startsWith(pattern.slice(0, star)) && spec.endsWith(pattern.slice(star + 1)); }).sort((a, b) => a === spec ? -1 : b === spec ? 1 : b.indexOf("*") - a.indexOf("*")); const selected = patterns[0]; if (!selected) return null; const legacyAliases = parsePathAliases(JSON.stringify({ compilerOptions: { baseUrl: typeof base === "string" ? base : root, paths: { [selected]: options.paths?.[selected] }, } }), root); return resolveImport(spec, sourceFile, root, fileSet, "typescript", legacyAliases); }, }; }; } //# sourceMappingURL=graph-typescript.js.map