From 30311c7e18a377585efc6d48fc1e3a43075f1c5b Mon Sep 17 00:00:00 2001 From: Daniel Rivas Date: Mon, 9 Feb 2026 17:18:17 +0000 Subject: [PATCH] Remove hardcoded workspace resolution --- .../results.json | 2 +- src/__tests__/integration.test.ts | 169 ++++++++++++++++++ src/cli.ts | 7 +- src/resolver.ts | 88 +++++---- src/workspaceResolver.ts | 89 --------- 5 files changed, 231 insertions(+), 124 deletions(-) delete mode 100644 src/workspaceResolver.ts diff --git a/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json index 66223a3..32d2646 100644 --- a/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +++ b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json @@ -1 +1 @@ -{"version":"4.0.18","results":[["browser (chromium):visualiser/src/__tests__/render.browser.test.ts",{"duration":14.5,"failed":false}],["unit:src/__tests__/codeowners.test.ts",{"duration":3.9469580000000235,"failed":false}],["unit:src/__tests__/parser.test.ts",{"duration":20.337208000000032,"failed":false}],["unit:src/__tests__/wrapperUnwrap.test.ts",{"duration":8.235083999999972,"failed":false}],["unit:visualiser/src/__tests__/layout-utils.unit.test.ts",{"duration":13.240666999999974,"failed":false}],["unit:src/__tests__/graph.test.ts",{"duration":4.617541000000017,"failed":false}],["unit:src/__tests__/types.test.ts",{"duration":3.9865839999999935,"failed":false}],["unit:visualiser/src/__tests__/render-utils.unit.test.ts",{"duration":5.513040999999987,"failed":false}],["unit:src/__tests__/integration.test.ts",{"duration":22.036999999999978,"failed":false}],["unit:visualiser/src/__tests__/layout-integration.unit.test.ts",{"duration":8.336332999999968,"failed":false}],["unit:src/__tests__/dotRenderer.test.ts",{"duration":4.096458000000013,"failed":false}]]} \ No newline at end of file +{"version":"4.0.18","results":[["browser (chromium):visualiser/src/__tests__/render.browser.test.ts",{"duration":14.5,"failed":false}],["unit:src/__tests__/codeowners.test.ts",{"duration":3.6234579999999994,"failed":false}],["unit:src/__tests__/parser.test.ts",{"duration":19.78804199999996,"failed":false}],["unit:src/__tests__/wrapperUnwrap.test.ts",{"duration":7.992000000000019,"failed":false}],["unit:visualiser/src/__tests__/layout-utils.unit.test.ts",{"duration":13.72233399999999,"failed":false}],["unit:src/__tests__/graph.test.ts",{"duration":3.7744169999999997,"failed":false}],["unit:src/__tests__/types.test.ts",{"duration":3.3089999999999975,"failed":false}],["unit:visualiser/src/__tests__/render-utils.unit.test.ts",{"duration":5.356040999999976,"failed":false}],["unit:src/__tests__/integration.test.ts",{"duration":29.360500000000002,"failed":false}],["unit:visualiser/src/__tests__/layout-integration.unit.test.ts",{"duration":8.220791999999989,"failed":false}],["unit:src/__tests__/dotRenderer.test.ts",{"duration":4.067958000000004,"failed":false}]]} \ No newline at end of file diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts index 3278da8..a838634 100644 --- a/src/__tests__/integration.test.ts +++ b/src/__tests__/integration.test.ts @@ -278,3 +278,172 @@ export function main() { expect(sliced.edges.length).toBeGreaterThanOrEqual(2); }); }); + +describe("integration: tsconfig paths resolution", () => { + let tmpDir: string; + + beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ts-callpath-paths-test-")); + + // tsconfig.json with paths alias + fs.writeFileSync( + path.join(tmpDir, "tsconfig.json"), + JSON.stringify( + { + compilerOptions: { + baseUrl: ".", + paths: { + "@mylib/*": ["lib/*"], + }, + target: "ESNext", + moduleResolution: "Node10", + esModuleInterop: true, + }, + }, + null, + 2, + ), + ); + + // lib/utils.ts: the aliased module + fs.mkdirSync(path.join(tmpDir, "lib"), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, "lib", "utils.ts"), + `export function doWork(input: string) { + return input.toUpperCase(); +} +`, + ); + + // app.ts: imports via @mylib/utils alias + fs.writeFileSync( + path.join(tmpDir, "app.ts"), + `import { doWork } from '@mylib/utils'; + +export function main() { + return doWork("hello"); +} +`, + ); + }); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("resolves @mylib/utils via tsconfig paths", () => { + const resolver = new Resolver(tmpDir); + const sourceId = makeFunctionId(path.join(tmpDir, "app.ts"), "main"); + + const graph = forwardBfs(sourceId, resolver, { + maxDepth: 10, + maxNodes: 100, + verbose: false, + }); + + const nodeNames = Array.from(graph.nodes.values()).map((n) => n.qualifiedName); + expect(nodeNames).toContain("main"); + expect(nodeNames).toContain("doWork"); + }); + + it("slices path from main to doWork via alias", () => { + const resolver = new Resolver(tmpDir); + const sourceId = makeFunctionId(path.join(tmpDir, "app.ts"), "main"); + const targetId = makeFunctionId(path.join(tmpDir, "lib", "utils.ts"), "doWork"); + + const fullGraph = forwardBfs(sourceId, resolver, { + maxDepth: 10, + maxNodes: 100, + verbose: false, + }); + + const sliced = sliceGraph(fullGraph, [sourceId], [targetId]); + const nodeNames = Array.from(sliced.nodes.values()).map((n) => n.qualifiedName); + expect(nodeNames).toContain("main"); + expect(nodeNames).toContain("doWork"); + expect(sliced.edges.length).toBeGreaterThanOrEqual(1); + }); +}); + +describe("integration: tsconfig baseUrl resolution", () => { + let tmpDir: string; + + beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ts-callpath-baseurl-test-")); + + // tsconfig.json with baseUrl + fs.writeFileSync( + path.join(tmpDir, "tsconfig.json"), + JSON.stringify( + { + compilerOptions: { + baseUrl: "./src", + target: "ESNext", + moduleResolution: "Node10", + esModuleInterop: true, + }, + }, + null, + 2, + ), + ); + + // src/utils/helper.ts + fs.mkdirSync(path.join(tmpDir, "src", "utils"), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, "src", "utils", "helper.ts"), + `export function helper(x: number) { + return x * 2; +} +`, + ); + + // src/app.ts: imports via baseUrl-relative path + fs.writeFileSync( + path.join(tmpDir, "src", "app.ts"), + `import { helper } from 'utils/helper'; + +export function main() { + return helper(42); +} +`, + ); + }); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("resolves utils/helper via tsconfig baseUrl", () => { + const resolver = new Resolver(tmpDir); + const sourceId = makeFunctionId(path.join(tmpDir, "src", "app.ts"), "main"); + + const graph = forwardBfs(sourceId, resolver, { + maxDepth: 10, + maxNodes: 100, + verbose: false, + }); + + const nodeNames = Array.from(graph.nodes.values()).map((n) => n.qualifiedName); + expect(nodeNames).toContain("main"); + expect(nodeNames).toContain("helper"); + }); + + it("slices path from main to helper via baseUrl", () => { + const resolver = new Resolver(tmpDir); + const sourceId = makeFunctionId(path.join(tmpDir, "src", "app.ts"), "main"); + const targetId = makeFunctionId(path.join(tmpDir, "src", "utils", "helper.ts"), "helper"); + + const fullGraph = forwardBfs(sourceId, resolver, { + maxDepth: 10, + maxNodes: 100, + verbose: false, + }); + + const sliced = sliceGraph(fullGraph, [sourceId], [targetId]); + const nodeNames = Array.from(sliced.nodes.values()).map((n) => n.qualifiedName); + expect(nodeNames).toContain("main"); + expect(nodeNames).toContain("helper"); + expect(sliced.edges.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index f985fc0..55dd1b9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -103,6 +103,7 @@ program .option("--max-nodes ", "Node limit", "500") .option("--root ", "Repo root directory") .option("-o, --output ", "Write output to file (default: stdout)") + .option("--tsconfig ", "Path to tsconfig.json (auto-detected if omitted)") .option("--verbose", "Show progress on stderr", false) .option("--json", "Output JSON instead of DOT", false) .option("--html", "Output self-contained HTML visualizer", false) @@ -122,6 +123,7 @@ program maxNodes: string; root?: string; output?: string; + tsconfig?: string; verbose: boolean; json: boolean; html: boolean; @@ -155,7 +157,10 @@ program // Parse source spec const sourceSpecs = parseUserFunctionSpecs(sourceSpec, repoRoot); - const resolver = new Resolver(repoRoot, opts.verbose); + const resolver = new Resolver(repoRoot, { + verbose: opts.verbose, + tsconfigPath: opts.tsconfig ? path.resolve(opts.tsconfig) : undefined, + }); // Resolve source specs const sourceIds = resolveSpecs(sourceSpecs, resolver); diff --git a/src/resolver.ts b/src/resolver.ts index c303269..0c391fc 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -11,30 +11,49 @@ import type { } from "./types.js"; import { makeFunctionId } from "./types.js"; import { parseFile } from "./parser.js"; -import { WorkspaceResolver } from "./workspaceResolver.js"; -const tsCompilerOptions: ts.CompilerOptions = { - moduleResolution: ts.ModuleResolutionKind.Node10, - target: ts.ScriptTarget.ESNext, - esModuleInterop: true, -}; - -const tsModuleResolutionHost: ts.ModuleResolutionHost = { - fileExists: (p) => fs.existsSync(p), - readFile: (p) => fs.readFileSync(p, "utf-8"), -}; +export interface ResolverOptions { + verbose?: boolean; + tsconfigPath?: string; +} export class Resolver { private fileCache = new Map(); - private workspaceResolver: WorkspaceResolver; private verbose: boolean; + private compilerOptions: ts.CompilerOptions; constructor( private repoRoot: string, - verbose: boolean = false, + opts?: ResolverOptions, ) { - this.workspaceResolver = new WorkspaceResolver(repoRoot); - this.verbose = verbose; + this.verbose = opts?.verbose ?? false; + this.compilerOptions = this.loadCompilerOptions(opts?.tsconfigPath); + } + + private loadCompilerOptions(tsconfigPath?: string): ts.CompilerOptions { + const configPath = tsconfigPath ?? ts.findConfigFile(this.repoRoot, ts.sys.fileExists); + + if (configPath) { + const configFile = ts.readConfigFile(configPath, ts.sys.readFile); + if (!configFile.error) { + const parsed = ts.parseJsonConfigFileContent( + configFile.config, + ts.sys, + path.dirname(configPath), + ); + if (this.verbose) { + process.stderr.write(` using tsconfig: ${configPath}\n`); + } + return parsed.options; + } + } + + // Fallback defaults when no tsconfig is found + return { + moduleResolution: ts.ModuleResolutionKind.Node10, + target: ts.ScriptTarget.ESNext, + esModuleInterop: true, + }; } /** @@ -65,24 +84,30 @@ export class Resolver { * Resolve a module specifier from a given source file to an absolute file path. */ resolveModule(specifier: string, fromFile: string): string | null { - // 1. Package specifiers (e.g. @watershed/*) - if (specifier.startsWith("@watershed/") || specifier.startsWith("@watershed-")) { - const resolved = this.workspaceResolver.resolve(specifier); - if (resolved) return resolved; + // 1. Unified resolution via ts.resolveModuleName (handles paths, baseUrl, relative, etc.) + const result = ts.resolveModuleName(specifier, fromFile, this.compilerOptions, ts.sys); + + if (result.resolvedModule) { + const resolved = result.resolvedModule; + + // Accept in-project (non-external) modules directly + if (!resolved.isExternalLibraryImport) { + return resolved.resolvedFileName; + } + + // For external hits, check if they're actually workspace symlinks + const realPath = ts.sys.realpath?.(resolved.resolvedFileName); + if ( + realPath && + realPath.startsWith(this.repoRoot) && + !realPath.slice(this.repoRoot.length).includes("node_modules") + ) { + return realPath; + } } - // 2. Relative paths + // 2. Fallback: manual resolution for relative paths as safety net if (specifier.startsWith(".")) { - const result = ts.resolveModuleName( - specifier, - fromFile, - tsCompilerOptions, - tsModuleResolutionHost, - ); - if (result.resolvedModule) { - return result.resolvedModule.resolvedFileName; - } - // Fallback: manual resolution for .ts/.tsx files const dir = path.dirname(fromFile); const basePath = path.resolve(dir, specifier); const extensions = [".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx"]; @@ -90,14 +115,11 @@ export class Resolver { const candidate = basePath + ext; if (fs.existsSync(candidate)) return candidate; } - // Check if basePath itself exists (has extension already) if (fs.existsSync(basePath) && fs.statSync(basePath).isFile()) { return basePath; } - return null; } - // 3. Other absolute or node_modules — skip return null; } diff --git a/src/workspaceResolver.ts b/src/workspaceResolver.ts deleted file mode 100644 index a42a5c7..0000000 --- a/src/workspaceResolver.ts +++ /dev/null @@ -1,89 +0,0 @@ -import * as fs from "fs"; -import * as path from "path"; - -/** - * Maps @watershed/* package specifiers to filesystem paths. - * Built once at startup by scanning workspaces/STAR/package.json. - */ -export class WorkspaceResolver { - /** package name (e.g. "@watershed/domain") → directory (absolute) */ - private packageDirs: Map = new Map(); - - constructor(private repoRoot: string) { - this.buildPackageMap(); - } - - private buildPackageMap(): void { - const workspacesDir = path.join(this.repoRoot, "workspaces"); - if (!fs.existsSync(workspacesDir)) return; - - const entries = fs.readdirSync(workspacesDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const pkgJsonPath = path.join(workspacesDir, entry.name, "package.json"); - if (!fs.existsSync(pkgJsonPath)) continue; - try { - const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8")); - if (pkgJson.name) { - this.packageDirs.set(pkgJson.name, path.join(workspacesDir, entry.name)); - } - } catch { - // skip malformed package.json - } - } - } - - /** - * Resolve a package specifier like "@watershed/domain/service/BartService" - * to an absolute file path. - * Returns null if not resolvable. - */ - resolve(specifier: string): string | null { - // Find the matching package prefix - // Try progressively shorter prefixes: @watershed/domain/service → @watershed/domain - const parts = specifier.split("/"); - // @watershed packages always have scope + name, so minimum 2 parts - if (parts.length < 2) return null; - - // Try @scope/name first (most common) - const pkgName = `${parts[0]}/${parts[1]}`; - const pkgDir = this.packageDirs.get(pkgName); - if (!pkgDir) return null; - - const subpath = parts.slice(2).join("/"); - if (!subpath) { - // Importing the package root — look for index file - return this.resolveFile(path.join(pkgDir, "index")); - } - - return this.resolveFile(path.join(pkgDir, subpath)); - } - - /** - * Try to resolve a base path to an actual file by appending common extensions. - */ - private resolveFile(basePath: string): string | null { - // If already has extension and exists - if (fs.existsSync(basePath) && fs.statSync(basePath).isFile()) { - return basePath; - } - - const extensions = [".ts", ".tsx", ".js", ".jsx"]; - for (const ext of extensions) { - const candidate = basePath + ext; - if (fs.existsSync(candidate)) return candidate; - } - - // Try index files in directory - for (const ext of extensions) { - const candidate = path.join(basePath, "index" + ext); - if (fs.existsSync(candidate)) return candidate; - } - - return null; - } - - getPackageDirs(): Map { - return this.packageDirs; - } -}