diff --git a/scanner/aliastarget_test.go b/scanner/aliastarget_test.go new file mode 100644 index 0000000..930265f --- /dev/null +++ b/scanner/aliastarget_test.go @@ -0,0 +1,83 @@ +package scanner + +import ( + "context" + "reflect" + "sort" + "testing" +) + +// create-next-app has shipped "@/*": ["./*"] for years, so this is the most +// common TypeScript layout in the wild. The substituted target was "./lib/a1" +// while the file index holds "lib/a1", so nothing matched and a file imported +// everywhere reported no importers at all. +func TestTsconfigDotSlashAliasResolvesImporters(t *testing.T) { + graph, err := BuildFileGraph(context.Background(), "../testdata/tsconfig-alias-dotslash", Filters{}) + if err != nil { + t.Fatalf("build alias fixture graph: %v", err) + } + // Compare as a set: the graph appends importers in analysis order, which + // is not deterministic across runs (see the note in the PR for #173). The + // exactness this asserts is membership, not sequence. + got := append([]string(nil), graph.Importers["lib/a1.ts"]...) + sort.Strings(got) + want := []string{"app/layout.tsx", "app/page.tsx"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("lib/a1.ts importers = %v, want exactly %v", got, want) + } +} + +func TestNormalizeAliasTarget(t *testing.T) { + for _, tc := range []struct { + name string + target string + want string + }{ + {"create-next-app default", "./lib/a1", "lib/a1"}, + {"bare wildcard target", "lib/a1", "lib/a1"}, + {"nested with dot slash", "./src/lib/s1", "src/lib/s1"}, + {"redundant separators", "./src//lib/../lib/s1", "src/lib/s1"}, + {"bare dot collapses to root", ".", ""}, + {"dot slash only", "./", ""}, + {"empty stays empty", "", ""}, + // A target escaping the project must not be silently rewritten into a + // path that could match an unrelated file. + {"parent traversal preserved", "../shared/x", "../shared/x"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := normalizeAliasTarget(tc.target); got != tc.want { + t.Fatalf("normalizeAliasTarget(%q) = %q, want %q", tc.target, got, tc.want) + } + }) + } +} + +// Every alias target shape has to keep resolving, so the "./" fix cannot +// regress the forms that already worked. +func TestPathAliasTargetShapes(t *testing.T) { + files := []FileInfo{{Path: "lib/a1.ts"}, {Path: "src/lib/s1.ts"}, {Path: "app/page.tsx"}} + idx := buildFileIndex(files, "") + + for _, tc := range []struct { + name string + imp string + aliases map[string][]string + baseURL string + want []string + }{ + {"dot slash wildcard", "@/lib/a1", map[string][]string{"@/*": {"./*"}}, "", []string{"lib/a1.ts"}}, + {"bare wildcard", "@/lib/a1", map[string][]string{"@/*": {"*"}}, "", []string{"lib/a1.ts"}}, + {"nested dot slash", "@/lib/s1", map[string][]string{"@/*": {"./src/*"}}, "", []string{"src/lib/s1.ts"}}, + {"nested bare", "@/lib/s1", map[string][]string{"@/*": {"src/*"}}, "", []string{"src/lib/s1.ts"}}, + {"base url with dot slash", "@/lib/a1", map[string][]string{"@/*": {"./*"}}, ".", []string{"lib/a1.ts"}}, + {"exact alias with dot slash", "@app", map[string][]string{"@app": {"./lib/a1"}}, "", []string{"lib/a1.ts"}}, + {"unmatched alias resolves to nothing", "@/nope", map[string][]string{"@/*": {"./*"}}, "", nil}, + } { + t.Run(tc.name, func(t *testing.T) { + got := resolvePathAlias(tc.imp, tc.aliases, tc.baseURL, idx, "typescript") + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("resolvePathAlias(%q, %v, baseURL=%q) = %v, want %v", tc.imp, tc.aliases, tc.baseURL, got, tc.want) + } + }) + } +} diff --git a/scanner/filegraph.go b/scanner/filegraph.go index bb1acf0..73227c1 100644 --- a/scanner/filegraph.go +++ b/scanner/filegraph.go @@ -811,6 +811,23 @@ func readTSConfig(configPath, root string) (map[string][]string, string) { return paths, baseURL } +// normalizeAliasTarget puts a substituted tsconfig alias target into the same +// shape as the file index, which stores repository-relative paths with no "./" +// prefix. create-next-app has shipped "@/*": ["./*"] for years, so the +// substituted target is "./lib/a1" while the index holds "lib/a1" and nothing +// matches. filepath.Join already cleaned the target when a baseUrl was set, +// which is why this only ever bit projects without one. +func normalizeAliasTarget(target string) string { + if target == "" { + return target + } + cleaned := filepath.ToSlash(filepath.Clean(target)) + if cleaned == "." { + return "" + } + return cleaned +} + // resolvePathAlias attempts to resolve an import using TypeScript path aliases // e.g., "@modules/auth" with alias "@modules/*" -> ["src/modules/*"] becomes "src/modules/auth" func resolvePathAlias(imp string, pathAliases map[string][]string, baseURL string, idx *fileIndex, sourceLanguage string) []string { @@ -828,6 +845,7 @@ func resolvePathAlias(imp string, pathAliases map[string][]string, baseURL strin if baseURL != "" && !filepath.IsAbs(resolved) { resolved = filepath.Join(baseURL, resolved) } + resolved = normalizeAliasTarget(resolved) if files := tryExactMatch(resolved, idx, sourceLanguage); len(files) > 0 { return files } @@ -863,6 +881,8 @@ func resolvePathAlias(imp string, pathAliases map[string][]string, baseURL strin resolved = filepath.Join(baseURL, resolved) } + resolved = normalizeAliasTarget(resolved) + // Try to find matching files if files := tryExactMatch(resolved, idx, sourceLanguage); len(files) > 0 { return files diff --git a/testdata/tsconfig-alias-dotslash/app/layout.tsx b/testdata/tsconfig-alias-dotslash/app/layout.tsx new file mode 100644 index 0000000..65440b3 --- /dev/null +++ b/testdata/tsconfig-alias-dotslash/app/layout.tsx @@ -0,0 +1,5 @@ +import { a1 } from "@/lib/a1"; + +export function Layout() { + return a1(); +} diff --git a/testdata/tsconfig-alias-dotslash/app/page.tsx b/testdata/tsconfig-alias-dotslash/app/page.tsx new file mode 100644 index 0000000..a9b6e04 --- /dev/null +++ b/testdata/tsconfig-alias-dotslash/app/page.tsx @@ -0,0 +1,5 @@ +import { a1 } from "@/lib/a1"; + +export default function Page() { + return a1(); +} diff --git a/testdata/tsconfig-alias-dotslash/lib/a1.ts b/testdata/tsconfig-alias-dotslash/lib/a1.ts new file mode 100644 index 0000000..2783dfc --- /dev/null +++ b/testdata/tsconfig-alias-dotslash/lib/a1.ts @@ -0,0 +1,3 @@ +export function a1(): string { + return "a1"; +} diff --git a/testdata/tsconfig-alias-dotslash/tsconfig.json b/testdata/tsconfig-alias-dotslash/tsconfig.json new file mode 100644 index 0000000..2a2e4b3 --- /dev/null +++ b/testdata/tsconfig-alias-dotslash/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": { + "@/*": ["./*"] + } + } +}