Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions scanner/astgrep.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
6 changes: 5 additions & 1 deletion scanner/filegraph.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 27 additions & 1 deletion scanner/jsworkspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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{
Expand Down
4 changes: 3 additions & 1 deletion scanner/unresolved.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
64 changes: 64 additions & 0 deletions scanner/unresolved_false_positive_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading