diff --git a/scanner/filegraph.go b/scanner/filegraph.go index 498d4b8..accdcd7 100644 --- a/scanner/filegraph.go +++ b/scanner/filegraph.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "os" + pathpkg "path" "path/filepath" "sort" "strings" @@ -706,6 +707,15 @@ func tryExactMatch(path string, idx *fileIndex, sourceLanguage string) []string if idx.byExact[path] == 1 && languagesCompatible(sourceLanguage, DetectLanguage(path)) { return []string{path} } + // Under ESM and NodeNext, TypeScript requires the specifier to name the + // emitted JavaScript file, so "./helper.js" is written in a file whose + // only on-disk counterpart is helper.ts. The specifier already carries an + // extension, so appending one below never matches and the edge was lost. + for _, candidate := range typescriptSourceCandidates(path, sourceLanguage) { + if idx.byExact[candidate] == 1 && languagesCompatible(sourceLanguage, DetectLanguage(candidate)) { + return []string{candidate} + } + } for _, ext := range resolverExtensions[:len(resolverExtensions)-1] { candidate := path + ext if idx.byExact[candidate] == 1 && languagesCompatible(sourceLanguage, DetectLanguage(candidate)) { @@ -716,6 +726,38 @@ func tryExactMatch(path string, idx *fileIndex, sourceLanguage string) []string return nil } +// typescriptEmitExtensions maps an emitted JavaScript extension to the +// TypeScript sources that produce it, in the order the compiler prefers. +// +// ".mjs" and ".cjs" are deliberately absent: they emit from ".mts" and +// ".cts", which are not recognized source extensions here, so the scanner +// never indexes such a file and a mapping for them would be unreachable code +// that looks like support. Adding those extensions is a separate change. +var typescriptEmitExtensions = map[string][]string{ + ".js": {".ts", ".tsx", ".d.ts"}, + ".jsx": {".tsx", ".d.ts"}, +} + +// typescriptSourceCandidates returns the TypeScript files a JavaScript +// specifier may name. It applies only to JS-family importers, and only when +// the specifier already carries an emitted extension: for anything else the +// ordinary extension-appending search is correct and this returns nothing. +func typescriptSourceCandidates(path, sourceLanguage string) []string { + if !isJavaScriptLanguage(sourceLanguage) { + return nil + } + sources, emitted := typescriptEmitExtensions[strings.ToLower(pathpkg.Ext(path))] + if !emitted { + return nil + } + stem := strings.TrimSuffix(path, pathpkg.Ext(path)) + candidates := make([]string, 0, len(sources)) + for _, ext := range sources { + candidates = append(candidates, stem+ext) + } + return candidates +} + // trySuffixMatch finds files where the path ends with the normalized import func trySuffixMatch(normalized string, idx *fileIndex, sourceLanguage string) []string { for _, ext := range resolverExtensions { diff --git a/scanner/tsesm_test.go b/scanner/tsesm_test.go new file mode 100644 index 0000000..4e29f93 --- /dev/null +++ b/scanner/tsesm_test.go @@ -0,0 +1,87 @@ +package scanner + +import ( + "context" + "reflect" + "sort" + "testing" +) + +// Under ESM and NodeNext, TypeScript requires the specifier to name the +// emitted JavaScript file, so "./helper.js" appears in a project whose only +// on-disk counterpart is helper.ts. The specifier already carries an +// extension, so the extension-appending search never matched and most imports +// in such a project were lost. +func TestTypeScriptESMSpecifiersResolve(t *testing.T) { + graph, err := BuildFileGraph(context.Background(), "../testdata/typescript-esm-specifiers", Filters{}) + if err != nil { + t.Fatalf("build typescript fixture graph: %v", err) + } + + for _, tc := range []struct { + file string + want []string + why string + }{ + {"src/helper.ts", []string{"src/a_js_to_ts.ts", "src/d_extensionless.ts"}, "./helper.js and ./helper both reach helper.ts"}, + {"src/widget.tsx", []string{"src/b_jsx_to_tsx.ts"}, "./widget.jsx reaches widget.tsx"}, + // A real .js file must win over a same-named .ts: the specifier names + // a file that exists, so rewriting it to TypeScript would be a wrong + // edge rather than a missing one. + {"src/real.js", []string{"src/c_real_js_wins.ts"}, "an existing .js beats the .ts counterpart"}, + {"src/real.ts", nil, "the .ts counterpart must not steal the edge"}, + } { + got := append([]string(nil), graph.Importers[tc.file]...) + sort.Strings(got) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("%s importers = %v, want exactly %v (%s)", tc.file, got, tc.want, tc.why) + } + } +} + +// A specifier naming a file that exists in neither form resolves to nothing. +func TestTypeScriptESMMissingSpecifierResolvesToNothing(t *testing.T) { + graph, err := BuildFileGraph(context.Background(), "../testdata/typescript-esm-specifiers", Filters{}) + if err != nil { + t.Fatalf("build typescript fixture graph: %v", err) + } + if got := graph.Imports["src/e_missing.ts"]; len(got) != 0 { + t.Fatalf("src/e_missing.ts imports = %v, want none", got) + } +} + +func TestTypeScriptSourceCandidates(t *testing.T) { + for _, tc := range []struct { + name string + path string + language string + want []string + }{ + {"js maps to ts, tsx and a declaration", "src/helper.js", "typescript", + []string{"src/helper.ts", "src/helper.tsx", "src/helper.d.ts"}}, + {"jsx maps to tsx", "src/widget.jsx", "typescript", []string{"src/widget.tsx", "src/widget.d.ts"}}, + {"javascript importers get the same mapping", "src/helper.js", "javascript", + []string{"src/helper.ts", "src/helper.tsx", "src/helper.d.ts"}}, + {"uppercase extension still maps", "src/helper.JS", "typescript", + []string{"src/helper.ts", "src/helper.tsx", "src/helper.d.ts"}}, + // Anything without an emitted extension is the ordinary + // extension-appending search's job, not this one's. + {"extensionless is left alone", "src/helper", "typescript", nil}, + {"a ts specifier is left alone", "src/helper.ts", "typescript", nil}, + // mts and cts are not recognized source extensions, so mjs and cjs + // deliberately map to nothing rather than to files that can never be + // indexed. + {"mjs has no reachable counterpart", "src/mod.mjs", "typescript", nil}, + {"cjs has no reachable counterpart", "src/mod.cjs", "typescript", nil}, + // Only JS-family importers use TypeScript emit semantics. + {"go importers are left alone", "src/helper.js", "go", nil}, + {"python importers are left alone", "src/helper.js", "python", nil}, + } { + t.Run(tc.name, func(t *testing.T) { + got := typescriptSourceCandidates(tc.path, tc.language) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("typescriptSourceCandidates(%q, %q) = %v, want %v", tc.path, tc.language, got, tc.want) + } + }) + } +} diff --git a/testdata/typescript-esm-specifiers/src/a_js_to_ts.ts b/testdata/typescript-esm-specifiers/src/a_js_to_ts.ts new file mode 100644 index 0000000..3319b14 --- /dev/null +++ b/testdata/typescript-esm-specifiers/src/a_js_to_ts.ts @@ -0,0 +1,2 @@ +import { helper } from "./helper.js"; +export function a(): string { return helper(); } diff --git a/testdata/typescript-esm-specifiers/src/b_jsx_to_tsx.ts b/testdata/typescript-esm-specifiers/src/b_jsx_to_tsx.ts new file mode 100644 index 0000000..a8b666d --- /dev/null +++ b/testdata/typescript-esm-specifiers/src/b_jsx_to_tsx.ts @@ -0,0 +1,2 @@ +import { widget } from "./widget.jsx"; +export function b(): string { return widget(); } diff --git a/testdata/typescript-esm-specifiers/src/c_real_js_wins.ts b/testdata/typescript-esm-specifiers/src/c_real_js_wins.ts new file mode 100644 index 0000000..b37fc84 --- /dev/null +++ b/testdata/typescript-esm-specifiers/src/c_real_js_wins.ts @@ -0,0 +1,2 @@ +import { real } from "./real.js"; +export function c(): string { return real(); } diff --git a/testdata/typescript-esm-specifiers/src/d_extensionless.ts b/testdata/typescript-esm-specifiers/src/d_extensionless.ts new file mode 100644 index 0000000..75c96bc --- /dev/null +++ b/testdata/typescript-esm-specifiers/src/d_extensionless.ts @@ -0,0 +1,2 @@ +import { helper } from "./helper"; +export function d(): string { return helper(); } diff --git a/testdata/typescript-esm-specifiers/src/e_missing.ts b/testdata/typescript-esm-specifiers/src/e_missing.ts new file mode 100644 index 0000000..50520a4 --- /dev/null +++ b/testdata/typescript-esm-specifiers/src/e_missing.ts @@ -0,0 +1,2 @@ +import { nothing } from "./absent.js"; +export function e(): string { return nothing; } diff --git a/testdata/typescript-esm-specifiers/src/helper.ts b/testdata/typescript-esm-specifiers/src/helper.ts new file mode 100644 index 0000000..5365dc9 --- /dev/null +++ b/testdata/typescript-esm-specifiers/src/helper.ts @@ -0,0 +1 @@ +export function helper(): string { return "h"; } diff --git a/testdata/typescript-esm-specifiers/src/real.js b/testdata/typescript-esm-specifiers/src/real.js new file mode 100644 index 0000000..14e58e1 --- /dev/null +++ b/testdata/typescript-esm-specifiers/src/real.js @@ -0,0 +1 @@ +export function real() { return "js"; } diff --git a/testdata/typescript-esm-specifiers/src/real.ts b/testdata/typescript-esm-specifiers/src/real.ts new file mode 100644 index 0000000..6d1b73c --- /dev/null +++ b/testdata/typescript-esm-specifiers/src/real.ts @@ -0,0 +1 @@ +export function real(): string { return "ts"; } diff --git a/testdata/typescript-esm-specifiers/src/widget.tsx b/testdata/typescript-esm-specifiers/src/widget.tsx new file mode 100644 index 0000000..6a703a4 --- /dev/null +++ b/testdata/typescript-esm-specifiers/src/widget.tsx @@ -0,0 +1 @@ +export function widget(): string { return "w"; } diff --git a/testdata/typescript-esm-specifiers/tsconfig.json b/testdata/typescript-esm-specifiers/tsconfig.json new file mode 100644 index 0000000..d3d6864 --- /dev/null +++ b/testdata/typescript-esm-specifiers/tsconfig.json @@ -0,0 +1 @@ +{"compilerOptions":{"module":"NodeNext","moduleResolution":"NodeNext"}}