diff --git a/scanner/astgrep.go b/scanner/astgrep.go index ccd94f4..d5534fb 100644 --- a/scanner/astgrep.go +++ b/scanner/astgrep.go @@ -541,6 +541,12 @@ func (s *AstGrepScanner) scanDirectory(parent context.Context, root string) ([]F fileMap[relPath].Imports = append(fileMap[relPath].Imports, names...) continue } + // "import pkg.mod as alias" captures the alias inside $PATH, so + // the resolver looked for "pkg.mod as alias" and found nothing: + // every aliased Python import was a missing edge. + if fileMap[relPath].Language == "python" { + mod, _, _ = strings.Cut(mod, " as ") + } if mod != "" { fileMap[relPath].Imports = append(fileMap[relPath].Imports, mod) } diff --git a/scanner/filegraph.go b/scanner/filegraph.go index ef1badd..d704934 100644 --- a/scanner/filegraph.go +++ b/scanner/filegraph.go @@ -320,7 +320,11 @@ func buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx context.Context, resolved := fuzzyResolveWithWorkspace( imp, a.Path, idx, fg.Module, fg.PathAliases, fg.BaseURL, jsResolver, dartResolver, ) - unresolved.record(sourceLanguage, a.Path, imp, fg.PathAliases, len(resolved) > 0) + // A package the nearest package.json declares is external even + // when a tsconfig alias would also match it. + if !jsResolver.declaresDependency(a.Path, strings.Trim(imp, "\"'`")) { + unresolved.record(sourceLanguage, a.Path, imp, fg.PathAliases, len(resolved) > 0) + } // A Go import names a package, and a package is every non-test // .go file in its directory, so one import fans out to one edge // per file. Dropping the multi-file case (as this once did, to diff --git a/scanner/jsworkspace.go b/scanner/jsworkspace.go index 21f17ca..9e94d7b 100644 --- a/scanner/jsworkspace.go +++ b/scanner/jsworkspace.go @@ -27,6 +27,7 @@ type jsPackageWorkspace struct { type jsWorkspacePackage struct { root string name string + dependencies map[string]bool // every package.json dependency field, by name exports jsSpecifierMap hasExports bool entryTargets []string @@ -377,6 +378,24 @@ func (r *jsWorkspaceResolver) nearestPackageScope(fromFile string) *jsPackageSco return nil } +// declaresDependency reports whether the package.json nearest to fromFile +// lists the package a bare specifier names ("@scope/name/sub" -> "@scope/name"). +func (r *jsWorkspaceResolver) declaresDependency(fromFile, imp string) bool { + if r == nil { + return false + } + scope := r.nearestPackageScope(fromFile) + if scope == nil || scope.pkg == nil { + return false + } + parts := strings.SplitN(imp, "/", 3) + name := parts[0] + if strings.HasPrefix(imp, "@") && len(parts) > 1 { + name = parts[0] + "/" + parts[1] + } + return scope.pkg.dependencies[name] +} + func (r *jsWorkspaceResolver) nearestDenoScope(fromFile string) *jsImportScope { for i := range r.denoScopes { if pathContains(r.denoScopes[i].root, fromFile) { @@ -505,7 +524,14 @@ func (m jsSpecifierMap) resolve(specifier string) (jsSpecifierTarget, bool) { func parsePackageManifest(root string, doc map[string]any) *jsWorkspaceManifest { name, _ := doc["name"].(string) - pkg := &jsWorkspacePackage{root: root, name: name} + pkg := &jsWorkspacePackage{root: root, name: name, dependencies: map[string]bool{}} + for _, field := range []string{"dependencies", "devDependencies", "peerDependencies", "optionalDependencies"} { + if deps, ok := doc[field].(map[string]any); ok { + for dep := range deps { + pkg.dependencies[dep] = true + } + } + } pkg.exports, pkg.hasExports = parseExports(doc) pkg.entryTargets = unambiguousEntryTargets(doc) return &jsWorkspaceManifest{ diff --git a/scanner/unresolved.go b/scanner/unresolved.go index 50b7492..97c1f21 100644 --- a/scanner/unresolved.go +++ b/scanner/unresolved.go @@ -92,8 +92,10 @@ func (t *unresolvedImportTally) projectLooking(language, imp string, aliases map if imp == "." || imp == ".." || strings.HasPrefix(imp, "./") || strings.HasPrefix(imp, "../") { return true } + // A "*" catch-all (openclaw's tsconfig) would make every npm + // package alias-shaped; only a key with a literal prefix is an alias. for pattern := range aliases { - if prefix, wildcard := strings.CutSuffix(pattern, "*"); (wildcard && strings.HasPrefix(imp, prefix)) || imp == pattern { + if prefix, wildcard := strings.CutSuffix(pattern, "*"); (wildcard && prefix != "" && strings.HasPrefix(imp, prefix)) || imp == pattern { return true } } diff --git a/scanner/unresolved_false_positive_test.go b/scanner/unresolved_false_positive_test.go new file mode 100644 index 0000000..e0a43b0 --- /dev/null +++ b/scanner/unresolved_false_positive_test.go @@ -0,0 +1,64 @@ +package scanner + +import ( + "context" + "slices" + "testing" + + "codemap/analysis" +) + +// A tsconfig "*" catch-all makes every bare specifier alias-shaped, and a +// package declared in package.json is external by definition: openclaw +// reported 3,732 missing edges that were all npm dependencies. +func TestDeclaredPackagesAreNotUnresolvedProjectImports(t *testing.T) { + dir := writeRepo(t, map[string]string{ + "tsconfig.json": `{"compilerOptions":{"paths":{"*":["./src/*"]}}}`, + "package.json": `{"name":"app","dependencies":{"@scope/pkg":"1.0.0"},"devDependencies":{"lodash":"4"}}`, + "src/a.ts": "import { p } from '@scope/pkg';\nimport _ from 'lodash';\nimport { m } from './missing';\nimport { b } from './b';\nexport const z = p + _ + m + b;\n", + "src/b.ts": "export const b = 1;\n", + }) + g, err := BuildFileGraph(context.Background(), dir, Filters{}) + if err != nil { + t.Fatal(err) + } + if n := len(g.Coverage.Issues); n != 1 || g.Coverage.Issues[0].Message != "import ./missing resolved to no file in the project" { + t.Fatalf("issues = %+v, want exactly the ./missing one", g.Coverage.Issues) + } + if !hasNotePrefix(g.Coverage.Notes, "TypeScript: 1 of 2 project-relative imports did not resolve") { + t.Fatalf("notes = %v", g.Coverage.Notes) + } +} + +// "import pkg.mod as alias" reached the resolver and the tally as +// "pkg.mod as alias": the edge was lost and the miss was reported with the +// alias text (docling, 3 false misses on a clean repo). +func TestPythonImportAsAliasResolves(t *testing.T) { + dir := writeRepo(t, map[string]string{ + "pkg/__init__.py": "", + "pkg/mod.py": "x = 1\n", + "pkg/a.py": "import pkg.mod as alias\n", + }) + g, err := BuildFileGraph(context.Background(), dir, Filters{}) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(g.Imports["pkg/a.py"], "pkg/mod.py") { + t.Fatalf("imports = %v, want pkg/mod.py", g.Imports["pkg/a.py"]) + } + if got := g.Coverage.EffectiveStatus(); got != analysis.CoverageComplete || len(g.Coverage.Issues) != 0 { + t.Fatalf("coverage = %v issues = %+v, want complete and none", got, g.Coverage.Issues) + } + + dir = writeRepo(t, map[string]string{ + "pkg/__init__.py": "", + "pkg/a.py": "import pkg.gone as g\n", + }) + g, err = BuildFileGraph(context.Background(), dir, Filters{}) + if err != nil { + t.Fatal(err) + } + if n := len(g.Coverage.Issues); n != 1 || g.Coverage.Issues[0].Message != "import pkg.gone resolved to no file in the project" { + t.Fatalf("issues = %+v, want one for pkg.gone without alias text", g.Coverage.Issues) + } +}