Skip to content

Commit 976aa3c

Browse files
committed
Enhance TS marker detection to support plain JS frameworks and improve test coverage
1 parent 778bf3b commit 976aa3c

2 files changed

Lines changed: 48 additions & 62 deletions

File tree

internal/extractors/tsextractor/ts.go

Lines changed: 26 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@ func searchTSRoot(dir string, depth, maxDepth int) (string, bool) {
9090
return "", false
9191
}
9292

93-
// hasTSMarkers returns true if the directory looks like a TypeScript project root.
93+
// hasTSMarkers returns true if the directory looks like a project root this
94+
// extractor should handle (TypeScript, or a JS framework it also parses).
9495
func hasTSMarkers(dir string) bool {
9596
// tsconfig.json (standard) or tsconfig.base.json (Nx monorepo)
9697
for _, name := range []string{"tsconfig.json", "tsconfig.base.json"} {
@@ -99,20 +100,9 @@ func hasTSMarkers(dir string) bool {
99100
}
100101
}
101102

102-
// package.json with a typescript dependency
103-
data, err := os.ReadFile(filepath.Join(dir, "package.json"))
104-
if err != nil {
105-
return false
106-
}
107-
var pkg map[string]any
108-
if err := json.Unmarshal(data, &pkg); err != nil {
109-
return false
110-
}
111-
for _, key := range []string{"dependencies", "devDependencies"} {
112-
if deps, ok := pkg[key].(map[string]any); ok {
113-
if _, ok := deps["typescript"]; ok {
114-
return true
115-
}
103+
for _, pkg := range []string{"typescript", "vue", "react", "svelte", "next", "nuxt"} {
104+
if hasPkgDependency(dir, pkg) {
105+
return true
116106
}
117107
}
118108
return false
@@ -128,8 +118,7 @@ func (e *TSExtractor) Extract(ctx context.Context, repoPath string, files []stri
128118
isNuxt := detectNuxt(repoPath)
129119
isSvelteKit := detectSvelteKit(repoPath)
130120

131-
// Parse tsconfig.json for path alias mappings (e.g., "@/*" → "src/*"),
132-
// one alias root per package for monorepos.
121+
// Parse tsconfig.json path aliases, one root per package for monorepos.
133122
aliasRoots := collectTSAliasRoots(repoPath)
134123

135124
// SvelteKit maps $lib → src/lib by convention.
@@ -292,13 +281,8 @@ func (e *TSExtractor) extractImports(root *sitter.Node, src []byte, relFile stri
292281
for i := range root.ChildCount() {
293282
child := root.Child(i)
294283

295-
// import_statement always carries its source as a "string" child.
296-
// export_statement only carries a "source" field when it has a `from`
297-
// clause — i.e. it's a re-export (`export * from "x"`, `export { A }
298-
// from "x"`, `export type { A } from "x"`) rather than a local
299-
// declaration/value export. Barrel files consist entirely of the
300-
// latter and previously emitted zero facts, making anything only
301-
// reachable through them look unused.
284+
// export_statement only has a "source" field for re-exports
285+
// (export * from / export { X } from), not local declarations.
302286
var source *sitter.Node
303287
isReexport := false
304288
switch child.Kind() {
@@ -333,10 +317,10 @@ func (e *TSExtractor) extractImports(root *sitter.Node, src []byte, relFile stri
333317
}
334318

335319
result = append(result, facts.Fact{
336-
Kind: facts.KindDependency,
337-
Name: dir + " -> " + resolved,
338-
File: relFile,
339-
Line: int(child.StartPosition().Row) + 1,
320+
Kind: facts.KindDependency,
321+
Name: dir + " -> " + resolved,
322+
File: relFile,
323+
Line: int(child.StartPosition().Row) + 1,
340324
Props: props,
341325
Relations: []facts.Relation{
342326
{Kind: facts.RelImports, Target: resolved},
@@ -992,24 +976,16 @@ func nodeText(node *sitter.Node, src []byte) string {
992976
return string(src[node.StartByte():node.EndByte()])
993977
}
994978

995-
// tsAliasRoot pairs a directory (relative to repoPath, forward-slash form,
996-
// "" = repo root) with the path-alias map declared by the tsconfig.json /
997-
// tsconfig.base.json that governs that directory. Replacement values in
998-
// `aliases` are already qualified with `dir` as a prefix, so resolveImportPath
999-
// can use them exactly as it always has — it never needs to know about roots.
979+
// tsAliasRoot is a directory (repoPath-relative, "" = root) and the alias
980+
// map its tsconfig declares, already qualified with dir as a prefix.
1000981
type tsAliasRoot struct {
1001982
dir string
1002983
aliases map[string]string
1003984
}
1004985

1005-
// collectTSAliasRoots finds every directory in the repo whose tsconfig.json
1006-
// (or tsconfig.base.json) declares at least one compilerOptions.paths alias.
1007-
// Monorepos (Nx, pnpm workspaces, Turborepo, ...) commonly have one tsconfig
1008-
// per package, each with its own aliases, plus a paths-less root/base config
1009-
// shared for compiler settings only — so unlike findTSRoot/searchTSRoot
1010-
// (which stop at the first TS-marker directory, for project *detection*),
1011-
// this walks the full subtree and collects every alias-bearing directory, so
1012-
// sibling packages are all discovered rather than just the first one found.
986+
// collectTSAliasRoots finds every directory whose tsconfig.json (or
987+
// tsconfig.base.json) declares path aliases — unlike findTSRoot, which stops
988+
// at the first match, this covers monorepos with one tsconfig per package.
1013989
func collectTSAliasRoots(repoPath string) []tsAliasRoot {
1014990
maxDepth := 2
1015991
if isDeepNestedProject(repoPath) {
@@ -1028,12 +1004,8 @@ func walkTSAliasRoots(repoPath, dir string, depth, maxDepth int, out *[]tsAliasR
10281004
}
10291005
rel = filepath.ToSlash(rel)
10301006

1031-
// Qualify each replacement with this root's directory so the resolved
1032-
// import path ends up relative to repoPath, not relative to the
1033-
// tsconfig's own directory. Plain string concatenation (not
1034-
// filepath.Join/Clean) is deliberate: replacement values carry a
1035-
// trailing slash that resolveImportPath's `replacement + rest`
1036-
// concatenation depends on, and Join would strip it.
1007+
// Concatenation, not filepath.Join, to preserve the trailing slash
1008+
// resolveImportPath's `replacement + rest` depends on.
10371009
qualified := make(map[string]string, len(aliases))
10381010
for prefix, replacement := range aliases {
10391011
if rel != "" {
@@ -1069,11 +1041,8 @@ func aliasesAtDir(dir string) (map[string]string, bool) {
10691041
return nil, false
10701042
}
10711043

1072-
// aliasesForDir returns the alias map of whichever tsAliasRoot is the
1073-
// longest matching ancestor-or-equal prefix of `dir` (a repoPath-relative,
1074-
// forward-slash directory) — i.e. "which package's tsconfig governs this
1075-
// file". Returns nil if no root matches; ranging over a nil map is a no-op,
1076-
// so callers need no separate nil-check.
1044+
// aliasesForDir returns the alias map of the root whose dir is the longest
1045+
// matching ancestor-or-equal prefix of dir, or nil if none match.
10771046
func aliasesForDir(roots []tsAliasRoot, dir string) map[string]string {
10781047
dir = filepath.ToSlash(dir)
10791048
var best *tsAliasRoot
@@ -1094,11 +1063,8 @@ func aliasesForDir(roots []tsAliasRoot, dir string) map[string]string {
10941063
return best.aliases
10951064
}
10961065

1097-
// withSvelteKitLibDefault ensures every alias root defines the SvelteKit
1098-
// "$lib/" convention (→ "<root>/src/lib/") unless that root's own tsconfig
1099-
// already defines "$lib/". If no alias roots were found at all (the common
1100-
// case: a paths-less tsconfig.json), synthesizes a repo-root entry so the
1101-
// convention still applies.
1066+
// withSvelteKitLibDefault adds the "$lib/" -> "<root>/src/lib/" convention
1067+
// to every root that doesn't already define it.
11021068
func withSvelteKitLibDefault(roots []tsAliasRoot) []tsAliasRoot {
11031069
if len(roots) == 0 {
11041070
roots = []tsAliasRoot{{dir: "", aliases: map[string]string{}}}
@@ -1116,11 +1082,9 @@ func withSvelteKitLibDefault(roots []tsAliasRoot) []tsAliasRoot {
11161082
return roots
11171083
}
11181084

1119-
// tryParseTSConfigAliases reads a single tsconfig.json and extracts path
1120-
// alias mappings. For example "@/*": ["./src/*"] maps prefix "@/" to
1121-
// replacement "src/". ok is false both when the file doesn't exist/parse and
1122-
// when it parses but declares no usable paths — either way, callers should
1123-
// keep searching rather than treating this as "found, but empty".
1085+
// tryParseTSConfigAliases reads path alias mappings from a tsconfig.json,
1086+
// e.g. "@/*": ["./src/*"] maps prefix "@/" to replacement "src/". ok is
1087+
// false if the file is missing/invalid or declares no usable paths.
11241088
func tryParseTSConfigAliases(tsconfigPath string) (map[string]string, bool) {
11251089
data, err := os.ReadFile(tsconfigPath)
11261090
if err != nil {

internal/extractors/tsextractor/ts_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,28 @@ func TestDetectRoute_NonRoute(t *testing.T) {
173173
}
174174
}
175175

176+
func TestHasTSMarkers_PlainJSFramework(t *testing.T) {
177+
dir := t.TempDir()
178+
if err := os.WriteFile(filepath.Join(dir, "package.json"),
179+
[]byte(`{"dependencies":{"vue":"^2.0.0"}}`), 0o644); err != nil {
180+
t.Fatal(err)
181+
}
182+
if !hasTSMarkers(dir) {
183+
t.Error("expected a plain-JS Vue project (no tsconfig, no typescript dep) to be detected")
184+
}
185+
}
186+
187+
func TestHasTSMarkers_PlainJSNoFramework(t *testing.T) {
188+
dir := t.TempDir()
189+
if err := os.WriteFile(filepath.Join(dir, "package.json"),
190+
[]byte(`{"dependencies":{"express":"^4.0.0"}}`), 0o644); err != nil {
191+
t.Fatal(err)
192+
}
193+
if hasTSMarkers(dir) {
194+
t.Error("expected a plain JS project with no recognized framework to stay undetected")
195+
}
196+
}
197+
176198
// --- Full extraction tests ---
177199

178200
func TestExtract_FunctionDeclaration(t *testing.T) {

0 commit comments

Comments
 (0)