From f75ca214c8706ed5282910e66ee85a31bef3c16b Mon Sep 17 00:00:00 2001 From: Jordan Coin Jackson Date: Sun, 11 Oct 2026 23:50:26 +0200 Subject: [PATCH] fix(scanner): aliased Python imports resolve; declared npm packages and "*" paths keys are not unresolved project imports Two wrong answers in the unresolved-import tally from #204, found in the pre-release smoke on real repositories. openclaw's tsconfig has a "*" paths key, so every bare specifier looked alias-shaped and 3,732 declared npm packages were reported as missing edges (3,732 of 14,595). A paths key with no literal prefix is no longer an alias, and a package the nearest package.json declares is external regardless of aliases: 2 of 10,615 remain, both genuine. "import pkg.mod as alias" captured the alias inside $PATH, so the resolver looked up "pkg.mod as alias": the edge was lost on every aliased Python import and the tally reported it with the alias text. The alias is stripped at extraction, so resolver and tally see the same module: docling goes from 3 false misses and partial to 0 and complete, and gains the edge. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NRaBgKRAZMX9hjbkkefA7T --- scanner/astgrep.go | 6 +++ scanner/filegraph.go | 6 ++- scanner/jsworkspace.go | 28 +++++++++- scanner/unresolved.go | 4 +- scanner/unresolved_false_positive_test.go | 64 +++++++++++++++++++++++ 5 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 scanner/unresolved_false_positive_test.go 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) + } +}