a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
376 lines
13 KiB
Go
376 lines
13 KiB
Go
package resolver
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// resolveRelativeImports rewrites Python and Dart relative-import edges
|
|
// onto the internal `KindFile` node they actually reference. The Go
|
|
// resolver's resolveImport / dep-module bridge target language-agnostic
|
|
// directory paths, which never line up with Python file stems or Dart
|
|
// `..`-walking URIs; without this pass, every relative import landed as
|
|
// an `external::*` stub and the subsequent module-attribution sweep
|
|
// either left them alone (Dart) or mis-attributed them to a phantom
|
|
// pypi package called after the first path segment (Python).
|
|
//
|
|
// Runs serially after the main resolve loop and BEFORE
|
|
// attributeNonGoModuleImports so that any edge resolved to an internal
|
|
// file no longer participates in pypi/pub attribution. Edges whose
|
|
// target file is not in the graph stay as `external::*` so the
|
|
// module-attribution pass can decide what to do with them.
|
|
func (r *Resolver) resolveRelativeImports() {
|
|
// Relative-import resolution for Python / Dart relative imports, C-family
|
|
// quoted includes, and PHP literal require/include paths; skip the
|
|
// File-node + edge walk when the graph has none of those languages.
|
|
if !r.graphHasLanguage("python") && !r.graphHasLanguage("dart") &&
|
|
!r.graphHasLanguage("c") && !r.graphHasLanguage("cpp") && !r.graphHasLanguage("objc") &&
|
|
!r.graphHasLanguage("php") {
|
|
return
|
|
}
|
|
fileLang := r.collectFileLanguages()
|
|
var reindexBatch []graph.EdgeReindex
|
|
|
|
// Pre-build a map of every KindFile node's ID. The relative-
|
|
// import resolvers below check 1-2 candidate IDs per edge to
|
|
// decide whether a target file exists; doing that as a per-edge
|
|
// GetNode (a per-edge round-trip on a disk backend) is what made
|
|
// this pass dominate disk-backed resolve time. One NodesByKind scan
|
|
// materialises the set once at indexed cost; lookups become
|
|
// O(1) map hits.
|
|
fileIDs := make(map[string]struct{}, 1024)
|
|
// filesByBase indexes every KindFile by its basename so a non-relative
|
|
// C-family include (`foo/bar.h`) can be resolved against an include root
|
|
// without enumerating the whole file set per include (the `-I` search).
|
|
filesByBase := make(map[string][]string, 1024)
|
|
for n := range r.graph.NodesByKind(graph.KindFile) {
|
|
if n != nil && n.ID != "" {
|
|
fileIDs[n.ID] = struct{}{}
|
|
base := n.ID
|
|
if i := strings.LastIndex(n.ID, "/"); i >= 0 {
|
|
base = n.ID[i+1:]
|
|
}
|
|
filesByBase[base] = append(filesByBase[base], n.ID)
|
|
}
|
|
}
|
|
resolvePython := func(stem string) string {
|
|
if !strings.Contains(stem, "/") {
|
|
return ""
|
|
}
|
|
for _, cand := range []string{stem + ".py", stem + "/__init__.py"} {
|
|
if _, ok := fileIDs[cand]; ok {
|
|
return cand
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
// cppDirsUnion is the deterministic union of every compile-DB include dir —
|
|
// the fallback search path for an importing file (typically a header) that
|
|
// has no translation-unit entry of its own.
|
|
var cppDirsUnion []string
|
|
if len(r.cppIncludeDirs) > 0 {
|
|
seen := map[string]bool{}
|
|
for _, dirs := range r.cppIncludeDirs {
|
|
for _, d := range dirs {
|
|
if !seen[d] {
|
|
seen[d] = true
|
|
cppDirsUnion = append(cppDirsUnion, d)
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(cppDirsUnion)
|
|
} else {
|
|
// No compile_commands.json: fall back to the heuristic include roots
|
|
// (conventional dirs + top-level header dirs, in their intrinsic
|
|
// priority order) so the ordered probe still runs and breaks
|
|
// collisions deterministically even without a compile DB.
|
|
cppDirsUnion = r.cppFallbackDirs
|
|
}
|
|
// resolveCInclude resolves a C-family include to an indexed file, returning
|
|
// the resolved file ID and the `-I` dir it was found under ("" for the
|
|
// same-dir or suffix-fallback paths).
|
|
resolveCInclude := func(importingFile, rel string) (string, string) {
|
|
if rel == "" {
|
|
return "", ""
|
|
}
|
|
dir := ""
|
|
if i := strings.LastIndex(importingFile, "/"); i >= 0 {
|
|
dir = importingFile[:i]
|
|
}
|
|
// (1) Same-dir relative join.
|
|
for _, cand := range []string{joinRelativePath(dir, rel), joinRelativePath(dir, "./"+rel)} {
|
|
if cand != "" {
|
|
if _, ok := fileIDs[cand]; ok {
|
|
return cand, ""
|
|
}
|
|
}
|
|
}
|
|
// (2) compile_commands.json `-I` dir-ordered probe: for each include dir
|
|
// in order, probe dir/rel against the indexed files — first existing wins.
|
|
// This breaks basename collisions deterministically by the TU's `-I`
|
|
// order (authoritative), where the suffix search below would refuse.
|
|
incDirs := r.cppIncludeDirs[importingFile]
|
|
if len(incDirs) == 0 {
|
|
incDirs = cppDirsUnion
|
|
}
|
|
for _, idir := range incDirs {
|
|
if id := cppProbeIncludeDir(fileIDs, idir, rel); id != "" {
|
|
return id, idir
|
|
}
|
|
}
|
|
// (3) Suffix-unique fallback: a multi-segment include that is not
|
|
// relative to the including file binds to the uniquely-matching indexed
|
|
// header whose path ends with that suffix. The recall net for headers
|
|
// with no `-I` dir; refuses on ambiguity so no false edge lands.
|
|
if strings.Contains(rel, "/") {
|
|
base := rel
|
|
if i := strings.LastIndex(rel, "/"); i >= 0 {
|
|
base = rel[i+1:]
|
|
}
|
|
suffix := "/" + rel
|
|
match := ""
|
|
for _, cand := range filesByBase[base] {
|
|
if cand == rel || strings.HasSuffix(cand, suffix) {
|
|
if match != "" && match != cand {
|
|
return "", "" // ambiguous across include roots — refuse
|
|
}
|
|
match = cand
|
|
}
|
|
}
|
|
if match != "" {
|
|
return match, ""
|
|
}
|
|
}
|
|
return "", ""
|
|
}
|
|
resolveDart := func(importingFile, uri string) string {
|
|
if uri == "" || strings.HasPrefix(uri, "dart:") || strings.HasPrefix(uri, "package:") {
|
|
return ""
|
|
}
|
|
dir := ""
|
|
if i := strings.LastIndex(importingFile, "/"); i >= 0 {
|
|
dir = importingFile[:i]
|
|
}
|
|
target := joinRelativePath(dir, uri)
|
|
if target == "" {
|
|
return ""
|
|
}
|
|
if _, ok := fileIDs[target]; ok {
|
|
return target
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// EdgesByKind pushes the "kind = imports" filter into the store;
|
|
// disk backends only enumerate import edges instead of every
|
|
// edge in the graph.
|
|
for e := range r.graph.EdgesByKind(graph.EdgeImports) {
|
|
// Scoped warm pass: an unchanged repo's imports were already resolved (or
|
|
// left permanently external) by a prior full pass, so reconsidering them
|
|
// is a no-op. Skip them to keep the pass proportional to the changed set.
|
|
if !r.edgeFromInScope(e.From) {
|
|
continue
|
|
}
|
|
lang, ok := fileLang[e.From]
|
|
if !ok {
|
|
continue
|
|
}
|
|
var path string
|
|
var resolved string
|
|
switch {
|
|
case strings.HasPrefix(e.To, "unresolved::pyrel::"):
|
|
// Python parser-emitted relative-import placeholder.
|
|
// Always resolvable via internal-file lookup.
|
|
path = strings.TrimPrefix(e.To, "unresolved::pyrel::")
|
|
if lang == "python" {
|
|
resolved = resolvePython(path)
|
|
}
|
|
case strings.HasPrefix(e.To, "external::"):
|
|
// Fallthrough path for Dart relative URIs the main
|
|
// resolveImport sweep landed at `external::*`, plus a
|
|
// safety net for any Python relative stem that arrived
|
|
// here without the `pyrel::` marker.
|
|
path = strings.TrimPrefix(e.To, "external::")
|
|
switch lang {
|
|
case "python":
|
|
resolved = resolvePython(path)
|
|
case "dart":
|
|
resolved = resolveDart(e.From, path)
|
|
}
|
|
default:
|
|
// C-family include: a quoted `#include "foo.h"` / `#import "Foo.h"`
|
|
// resolves relative to the including file's directory; an angle
|
|
// `#include <foo/bar.h>` resolves through the `-I` search path —
|
|
// except standard-library headers, which stay external.
|
|
if lang == "c" || lang == "cpp" || lang == "objc" {
|
|
k, _ := e.Meta["include_kind"].(string)
|
|
if (k == "quoted" || k == "system") &&
|
|
strings.HasPrefix(e.To, "unresolved::import::") {
|
|
path = strings.TrimPrefix(e.To, "unresolved::import::")
|
|
// A standard-library angle include (<vector>, <stdio.h>, …)
|
|
// is external by construction: never probe `-I` dirs for it,
|
|
// so it can never bind to an in-tree file sharing its
|
|
// basename. The basename-collision guard.
|
|
if k == "system" && IsCppStdlibHeader(path) {
|
|
continue
|
|
}
|
|
var incDir string
|
|
resolved, incDir = resolveCInclude(e.From, path)
|
|
if resolved != "" && incDir != "" {
|
|
if e.Meta == nil {
|
|
e.Meta = map[string]any{}
|
|
}
|
|
// A compile-DB dir is authoritative; the heuristic
|
|
// fallback set is consulted only when no DB exists.
|
|
via := "compile_db"
|
|
if len(r.cppIncludeDirs) == 0 {
|
|
via = "heuristic"
|
|
}
|
|
e.Meta["include_dir"] = incDir
|
|
e.Meta["resolved_via"] = via
|
|
}
|
|
}
|
|
}
|
|
// PHP literal `require`/`include` of a file path. The `use`-namespace
|
|
// imports the extractor also lowers to `unresolved::import::` are NOT
|
|
// path-shaped (no `.php` / leading `./`,`../`,`/`) and resolve through
|
|
// the main import sweep, so isPhpPathInclude keeps them untouched.
|
|
if lang == "php" && strings.HasPrefix(e.To, "unresolved::import::") {
|
|
raw := strings.TrimPrefix(e.To, "unresolved::import::")
|
|
if isPhpPathInclude(raw) {
|
|
path = raw
|
|
resolved = resolvePhpInclude(e.From, raw, fileIDs, filesByBase)
|
|
}
|
|
}
|
|
if resolved == "" {
|
|
continue
|
|
}
|
|
}
|
|
if resolved == "" {
|
|
// pyrel:: edges that don't find an internal target are
|
|
// downgraded to `external::` so the module-attribution
|
|
// pass + audits don't see the internal marker prefix.
|
|
if strings.HasPrefix(e.To, "unresolved::pyrel::") {
|
|
oldTo := e.To
|
|
e.To = "external::" + path
|
|
reindexBatch = append(reindexBatch, graph.EdgeReindex{Edge: e, OldTo: oldTo})
|
|
}
|
|
continue
|
|
}
|
|
oldTo := e.To
|
|
e.To = resolved
|
|
e.Origin = graph.OriginASTResolved
|
|
reindexBatch = append(reindexBatch, graph.EdgeReindex{Edge: e, OldTo: oldTo})
|
|
}
|
|
if len(reindexBatch) > 0 {
|
|
r.graph.ReindexEdges(reindexBatch)
|
|
}
|
|
}
|
|
|
|
// cppProbeIncludeDir probes a single include directory for `dir/rel` against
|
|
// the indexed file set, trying common header extensions when the include omits
|
|
// one. Returns the matching file ID, or "".
|
|
func cppProbeIncludeDir(fileIDs map[string]struct{}, dir, rel string) string {
|
|
cand := joinRelativePath(dir, rel)
|
|
if cand == "" {
|
|
return ""
|
|
}
|
|
if _, ok := fileIDs[cand]; ok {
|
|
return cand
|
|
}
|
|
base := rel
|
|
if i := strings.LastIndex(rel, "/"); i >= 0 {
|
|
base = rel[i+1:]
|
|
}
|
|
if !strings.Contains(base, ".") {
|
|
for _, ext := range []string{".h", ".hpp", ".hh", ".hxx"} {
|
|
if _, ok := fileIDs[cand+ext]; ok {
|
|
return cand + ext
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// isPhpPathInclude reports whether a PHP `unresolved::import::` target is a
|
|
// file-path include (`require 'lib/x.php'`, `require __DIR__ . '/lib/x.php'`)
|
|
// rather than a `use`-namespace import. Namespaces are lowered with `\`→`/` but
|
|
// never carry a `.php` extension or a leading `./`,`../`,`/`, so the extension
|
|
// and path-prefix shape distinguishes the two unambiguously.
|
|
func isPhpPathInclude(target string) bool {
|
|
if target == "" {
|
|
return false
|
|
}
|
|
return strings.HasSuffix(strings.ToLower(target), ".php") ||
|
|
strings.HasPrefix(target, "/") ||
|
|
strings.HasPrefix(target, "./") ||
|
|
strings.HasPrefix(target, "../")
|
|
}
|
|
|
|
// resolvePhpInclude resolves a PHP literal include path to an indexed file:
|
|
// relative to the including file's directory first (the `__DIR__` convention,
|
|
// leading `/` being relative to that dir), then repo-root-relative, then a
|
|
// unique path-suffix match. A `.php` extension is appended when the include
|
|
// omits one. Returns "" when no unique indexed file matches.
|
|
func resolvePhpInclude(importingFile, rel string, fileIDs map[string]struct{}, filesByBase map[string][]string) string {
|
|
withExt := rel
|
|
if !strings.HasSuffix(strings.ToLower(rel), ".php") {
|
|
withExt += ".php"
|
|
}
|
|
dir := ""
|
|
if i := strings.LastIndex(importingFile, "/"); i >= 0 {
|
|
dir = importingFile[:i]
|
|
}
|
|
for _, cand := range []string{joinRelativePath(dir, withExt), joinRelativePath("", withExt)} {
|
|
if cand != "" {
|
|
if _, ok := fileIDs[cand]; ok {
|
|
return cand
|
|
}
|
|
}
|
|
}
|
|
// Project-root-relative suffix net: a unique indexed file ending with the
|
|
// include path. Refuses on ambiguity so no false edge lands.
|
|
base := withExt
|
|
if i := strings.LastIndex(withExt, "/"); i >= 0 {
|
|
base = withExt[i+1:]
|
|
}
|
|
suffix := "/" + withExt
|
|
match := ""
|
|
for _, cand := range filesByBase[base] {
|
|
if cand == withExt || strings.HasSuffix(cand, suffix) {
|
|
if match != "" && match != cand {
|
|
return "" // ambiguous across roots
|
|
}
|
|
match = cand
|
|
}
|
|
}
|
|
return match
|
|
}
|
|
|
|
// joinRelativePath joins a relative URI onto a directory and collapses
|
|
// `.`/`..` segments. Returns "" when the path walks above the repo root
|
|
// (which we never want to silently silently fall through to an
|
|
// arbitrary file).
|
|
func joinRelativePath(dir, rel string) string {
|
|
var parts []string
|
|
if dir != "" {
|
|
parts = strings.Split(dir, "/")
|
|
}
|
|
for _, seg := range strings.Split(rel, "/") {
|
|
switch seg {
|
|
case "", ".":
|
|
// noop
|
|
case "..":
|
|
if len(parts) == 0 {
|
|
return ""
|
|
}
|
|
parts = parts[:len(parts)-1]
|
|
default:
|
|
parts = append(parts, seg)
|
|
}
|
|
}
|
|
return strings.Join(parts, "/")
|
|
}
|