Skip to content

Add cross-file code graph with Go and Python resolvers - #34

Merged
andrew merged 6 commits into
mainfrom
graph
Sep 22, 2026
Merged

andrew merged 6 commits into
mainfrom
graph

Conversation

@andrew

@andrew andrew commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Adds Build(root, opts) (*Graph, error) and a cmd/outline binary with graph, def, callers, callees, affected, and path subcommands.

Build reuses the Pack walk and parser pool. One parse per file yields nested declarations with byte spans, parent links, and parameter names; imports; and call sites. Nodes are files, modules, declarations, and unresolved external targets; edges are contains, imports, and calls with extracted (same-AST fact) or inferred (cross-file name match) confidence. Node IDs are file:<path>, sym:<path>:<start>, mod:<lang>:<name>, ext:<lang>:<module>:<name>; JSON output is sorted so repeated builds of unchanged input are byte-identical.

Cross-file call resolution covers Go (go.mod prefix with path-boundary check, same-package by directory, /vN major-version suffix stripped from the default alias) and Python (dotted and relative module paths, from x import name, wildcard). Bare calls are resolved by walking outward through enclosing declarations, skipping class bodies, before the file-level table; a name matching an enclosing function's parameter is treated as shadowed and left external. Other supported languages get file, symbol, module, and import structure with call edges omitted. Options.Resolution accepts caller-supplied source roots and package hints; Pack ignores it so existing callers are unchanged.

Traversal: Def looks up by ID, name, or qualified name; Callers/Callees are one-hop; Affected returns reverse-reachable evidence paths sorted by distance; Path is forward BFS shortest path. TraverseOptions.Allow exposes the relation and confidence filter. Text renders a budgeted, sanitised NODE/EDGE line format with file:line locations, following only edges the caller's filter admits; every field including file paths and edge kinds is passed through sanitise so a repository filename or a hand-edited saved graph cannot break the line protocol.

The changes to existing files are: extracting the per-language dispatch from Imports into an internal importsFor; adding the Resolution field to Options; and splitting the export-list lookup out of applyExplicitExports into explicitExports so the graph extractor shares it.

Known gaps: method calls on non-identifier receivers (g.sortStable(), foo().Bar()) resolve to ext:; local-variable assignments (not parameters) that shadow a top-level or imported name are not detected; Python from pkg import submodule and __init__.py re-exports resolve to ext:; Go external-test-package files are grouped with the main package; defaultAlias uses the last import-path segment (after /vN stripping) rather than the target's package clause.

Build() walks the tree, runs one parse per file to extract nested
declarations, imports, and call sites, then joins them into a
Node/Edge graph with contains/imports/calls relations. Go resolves via
go.mod prefix and same-package directory grouping; Python via dotted
and relative module paths. Unresolved targets get ext: nodes.

New cmd/outline exposes graph/def/callers/callees/affected/path over
the built or -g loaded graph.
modName now unescapes the module segment before it is passed back to
ExtID. Affected sorts reached nodes by (distance, id) so path order is
stable across runs, and the CLI drops its now-redundant sort. signature
guards d.Start against a truncated buffer.
decl gains NameAt (the name node's byte offset) and symID(); sym: IDs
use NameAt so multi-name declarations like `var a, b int` produce
distinct nodes. assignParents treats identical-span decls as siblings.

resolver.moduleID qualifies Python relative imports by the importing
file's directory so `from .util import x` in two packages produces
two module nodes and resolves each to its own util.py. emitModules and
emitCalls iterate the sorted path slice instead of the files map.

extQualified derives from the ext: ID rather than the call site's
local receiver, so aliased imports of the same module share one
canonical Qualified and Def("subprocess.run") resolves.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There are confirmed correctness/UX issues in Go module import matching and text rendering output that should be fixed before approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR adds a new cross-file code graph builder (Build) plus a cmd/outline CLI for querying definitions and call relationships, with cross-file call resolution implemented for Go and Python.

Changes:

  • Introduces a Graph schema (nodes/edges, deterministic JSON) and traversal/query APIs (Def, Callers, Callees, Affected, Path, Text).
  • Adds cross-file module + call resolution (Go module/package heuristics; Python module path + import forms) and supporting extraction for calls/imports.
  • Adds a new outline CLI with subcommands and fixtures/tests covering Go and Python scenarios.
File summaries
File Description
testdata/cli-py/pkg/loader.py Python fixture: module function used for CLI graph traversal.
testdata/cli-py/pkg/init.py Python fixture: package init import for module layout coverage.
testdata/cli-py/app.py Python fixture: imports + calls for resolver/CLI coverage.
testdata/cli-go/store/store.go Go fixture: exported function for cross-package call resolution.
testdata/cli-go/main.go Go fixture: imports + calls to internal and external targets.
testdata/cli-go/go.mod Go fixture: module path used by Go import resolution heuristics.
resolve.go Cross-file resolver joining per-file facts into module/call edges.
README.md Documents Build API and the new outline CLI usage.
query.go Graph query/traversal + text rendering for CLI output.
query_test.go Tests for traversal and text rendering behavior.
pack.go Adds Options.Resolution field (Pack ignores it).
import.go Refactors language dispatch into internal importsFor.
graph.go Defines graph schema, ID helpers, indexing, and deterministic JSON sorting.
graph_test.go Tests ID escaping, adjacency, and deterministic JSON output.
cmd/outline/main.go New CLI entrypoint and subcommand implementations.
cmd/outline/main_test.go End-to-end CLI tests building/running the binary against fixtures.
call.go Extracts call sites (Go/Python) for cross-file resolution.
build.go Implements Build, analysis pipeline, node/edge emission, sanitization, digesting.
build_test.go Tests Go/Python resolution behavior, determinism, and edge/node properties.
analyse.go Single-parse extraction of decls/imports/calls feeding the resolver.
analyse_test.go Tests decl nesting, call extraction, and enclosing-decl mapping.
Review details
  • Files reviewed: 21/21 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread resolve.go
Comment thread query.go
Comment thread query.go
@andrew
andrew marked this pull request as draft September 4, 2026 11:19
@nikolai-vysotskyi

Copy link
Copy Markdown

Disclosure: I maintain trace-mcp, an open-source code-graph MCP server. We build cross-file call and import graphs across Go, Python, and other languages. Reading resolve.go in this PR, there are three edge cases we hit in our own resolvers that may save you some debugging:

  1. Go /v2+ major version module suffixes in defaultAlias:
    In defaultAlias (resolve.go:275), extracting module[strings.LastIndexByte(module, '/')+1:] yields "v2" or "v3" for versioned module paths like github.com/stretchr/testify/v2 or github.com/foo/bar/v3. When client code calls testify.Equal(...) or bar.Do(...), sc.mods only holds "v2", so the call receiver fails to match and falls through to ext:. Trimming a trailing /v[0-9]+ segment before taking the last path segment fixes the vast majority of versioned Go packages without needing to parse the remote package clause.

  2. Go external test packages (package foo_test) in indexGoPackages:
    indexGoPackages (resolve.go:13) groups declarations by directory (path.Dir(f.path)). In Go, test files often declare package foo_test alongside package foo in the same directory. Grouping by directory alone means symbols from foo_test.go leak into foo.go's package scope. Keying the package table by dir + ":" + f.a.Package (or separating test files from production files) keeps the test package boundary intact.

  3. Python __init__.py re-exports in moduleExports:
    In pyRelative, module resolution finds __init__.py. In Python libraries where __init__.py re-exports symbols imported from private submodules (from ._impl import Helper), callers doing from . import Helper resolve to mod:python:pkg, but moduleExports only inspects declarations directly in __init__.py. Propagating ImportNamed targets from __init__.py into the module's export table resolves re-exported symbols to their internal definitions instead of marking them as external.

@andrew

andrew commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @nikolai-vysotskyi, out of interest how did you find this pr?

@nikolai-vysotskyi

Copy link
Copy Markdown

Fair question. Cross-file import and call resolution is what I build (I maintain trace-mcp, a code-graph MCP server), so your PR surfaced while I was searching GitHub for open work along those lines. Go plus Python resolvers with a real test surface are rare enough that I read resolve.go end to end.

Text takes an edge filter and every CLI subcommand except def passes
its TraverseOptions.Allow, so callers/callees honour -inferred and
affected/path stop leaking contains/imports edges. resolveCall walks
the enclosing decl chain (skipping class bodies) and checks captured
parameter names before the file table, so nested functions resolve
extracted and parameter-shadowed names go to ext. declsFromMatch
handles the symbol.exported capture and extractDecls applies the
shared explicitExports list. goModuleFiles requires a / after the
module prefix; defaultAlias strips a trailing /vN segment; unresolved
named relative imports use the directory-qualified module in their
ext ID. nodeLine/edgeLine fall back to Name/ID, omit empty locations,
and sanitise every field.
@andrew
andrew marked this pull request as ready for review September 16, 2026 08:04
@andrew
andrew added this pull request to stack #39 September 16, 2026 17:53
goModuleFiles iterates the sorted path slice so duplicate top-level
names resolve to the same declaration on every build. analyseOne now
detects language before reading, signature truncates at a rune
boundary, and the unused resolver.warnings field is gone.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants