Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

169 changes: 169 additions & 0 deletions src/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
7 changes: 6 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ program
.option("--max-nodes <n>", "Node limit", "500")
.option("--root <dir>", "Repo root directory")
.option("-o, --output <file>", "Write output to file (default: stdout)")
.option("--tsconfig <path>", "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)
Expand All @@ -122,6 +123,7 @@ program
maxNodes: string;
root?: string;
output?: string;
tsconfig?: string;
verbose: boolean;
json: boolean;
html: boolean;
Expand Down Expand Up @@ -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);
Expand Down
88 changes: 55 additions & 33 deletions src/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ParsedFile>();
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,
};
}

/**
Expand Down Expand Up @@ -65,39 +84,42 @@ 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"];
for (const ext of extensions) {
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;
}

Expand Down
Loading