Skip to content
Open
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixes

- In a Go project with several modules placed side by side under one directory (a common monorepo layout), a call from one module into another now resolves to the correct definition instead of being dropped or wired to the wrong symbol (#388).
- Exported Go methods are now correctly marked as exported, so cross-package method calls resolve.
- In a Go project, variables declared in a grouped `var (...)` block are now indexed, and exported package-level constants and variables are now correctly marked as exported.


## [1.5.0] - 2026-07-21

Expand Down
67 changes: 67 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,73 @@ in
});
});

describe('Go const/var extraction (grouped var + isExported)', () => {
it('extracts every entry in a grouped var (…) block', () => {
// Defect B: a grouped `var ( … )` wraps its specs in a `var_spec_list`
// node (tree-sitter-go asymmetry vs grouped const), so they were skipped.
const code = `package main

var (
A = 1
b = 2
)`;
const result = extractFromSource('main.go', code);
const names = result.nodes.filter((n) => n.kind === 'variable').map((n) => n.name);
expect(names).toContain('A');
expect(names).toContain('b');
});

it('does not regress grouped const (…) extraction', () => {
const code = `package main

const (
C = 1
D = 2
)`;
const result = extractFromSource('main.go', code);
const names = result.nodes.filter((n) => n.kind === 'constant').map((n) => n.name).sort();
expect(names).toEqual(['C', 'D']);
});

it('sets isExported by leading-case for all Go var/const forms', () => {
// Defect A: Go const/variable isExported was always 0. All four forms here.
const code = `package main

var SingleVar = 1
var (
GroupedA = 1
groupedB = 2
)
const SingleConst = 1
const (
ConstA = 1
constB = 2
)`;
const result = extractFromSource('main.go', code);
const node = (kind: string, name: string) =>
result.nodes.find((n) => n.kind === kind && n.name === name);
expect(node('variable', 'SingleVar')?.isExported).toBe(true);
expect(node('variable', 'GroupedA')?.isExported).toBe(true);
expect(node('variable', 'groupedB')?.isExported).toBe(false);
expect(node('constant', 'SingleConst')?.isExported).toBe(true);
expect(node('constant', 'ConstA')?.isExported).toBe(true);
expect(node('constant', 'constB')?.isExported).toBe(false);
});

it('does not change isExported behavior for TypeScript or Python (Go-only fix)', () => {
// TypeScript: export-marked const is exported, plain const is not.
const ts = extractFromSource('a.ts', 'export const Exp = 1;\nconst priv = 2;\n');
expect(ts.nodes.find((n) => n.kind === 'constant' && n.name === 'Exp')?.isExported).toBe(true);
expect(ts.nodes.find((n) => n.kind === 'constant' && n.name === 'priv')?.isExported).toBeFalsy();

// Python has no isExported predicate — symbols stay not-exported (unchanged).
const py = extractFromSource('a.py', 'MAX = 100\ncounter = 0\n');
const pyMax = py.nodes.find((n) => (n.kind === 'constant' || n.kind === 'variable') && n.name === 'MAX');
expect(pyMax).toBeDefined();
expect(pyMax?.isExported).toBeFalsy();
});
});

describe('TypeScript Extraction', () => {
it('should extract function declarations', () => {
const code = `
Expand Down
285 changes: 285 additions & 0 deletions __tests__/resolution.test.ts

Large diffs are not rendered by default.

219 changes: 219 additions & 0 deletions docs/design/go-multi-module-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
# Go multi-module (monorepo-of-modules) import resolution

**Status**: implemented
**Refs**: #388 — this is the follow-up its own code comment asked for
("Limitation: only the project-root `go.mod` is read. Nested `go.mod` files
… are not yet resolved — a follow-up if a real repro shows up.")

---

## 1. The problem

A layout where several **independent Go modules sit side by side under one
root**, and the root itself has **no `go.mod`**:

```
repo/ ← the directory codegraph indexes
├── (no go.mod here)
├── service-a/go.mod module example.com/org/service-a
├── commons/go.mod module example.com/org/commons
├── commons/basic/go.mod module example.com/org/commons/basic
└── sdk/go.mod module example.com/org/platform/sdk
```

Note `sdk`: its module path (`example.com/org/platform/sdk`) is **not** a
prefix-extension of its directory name, and several module paths share
ancestor segments. Prefix matching therefore has to be longest-first.

In this layout essentially every cross-module call failed to resolve and
degraded to global name matching, producing **wrong** edges — not merely
missing ones. Typical mis-wiring: a call to a `Init` helper in one module
resolved to an unrelated same-named `Init` in a different module.

For an impact-analysis tool, a wrong edge is worse than a missing one.

## 2. Root cause

### 2.1 `loadGoModule` only reads `<projectRoot>/go.mod`

`src/resolution/go-module.ts` read a single hard-coded path, so a root
without its own `go.mod` yielded `null`. That `null` propagated to two
decision points:

**`isExternalImport`** (`src/resolution/import-resolver.ts`, Go branch) —
without a module path to compare against, every in-repo import
(`example.com/org/commons/...`) was classified as a **third-party package**,
so import-based resolution was skipped entirely.

**`resolveGoCrossPackageReference`** (same file) — bailed on the first line
(`if (!mod) return null`) and let the reference fall through to
name-matching with path proximity.

The existing comment at the top of `go-module.ts` had already predicted the
exact symptom: *"resolution falls through to name-matching with path
proximity and returns a tiny fraction of the real call sites."*

### 2.2 Two independent extraction-layer defects with the same symptom

Both block Go package-level symbols from being usable cross-module, and both
reproduce in a **single-module** repo too.

**(a) `isExported` never set on Go `const`/`var` (and on `method`).**
`resolveGoCrossPackageReference` guards on `if (!node.isExported) continue`.
`extractMethod` never called the `isExported` hook at all (unlike
`extractFunction` / `extractClass` / `extractInterface`), and the Go
`const`/`var` branch of `extractVariable` both omitted the property *and*
would have fed the hook the wrong node — the hook reads
`getChildByField(node, 'name')`, but a `const_declaration` / `var_declaration`
has no `name` field; the identifier lives on the `const_spec` / `var_spec`
child.

**(b) Grouped `var ( … )` produced zero nodes.** tree-sitter-go is
asymmetric here:

```
var ( A = 1 B = 2 ) → var_declaration [ var_spec_list ] ← wrapped
const ( C = 1 D = 2 ) → const_declaration [ const_spec, const_spec ] ← direct
var A = 1 → var_declaration [ var_spec ]
const C = 1 → const_declaration [ const_spec ]
```

The extractor filtered **direct** children for `var_spec` / `const_spec`, so a
grouped `var` block matched nothing. Knock-on effect: a call inside a
package-level grouped-var initializer (`var ( log = pkg.NewLogger("x") )`)
had **no source node**, so the edge could not be built at all.

## 3. Design

### 3.1 Data structures

Added to `go-module.ts`. The existing `GoModule` / `loadGoModule` are left
untouched — they remain the single-module fast path and the backward-compat
shim.

```ts
export interface GoModuleEntry {
modulePath: string; // the `module` directive
relDir: string; // dir holding this go.mod, relative to projectRoot, '/'-separated; '' for root
}

export interface GoModuleIndex {
entries: GoModuleEntry[]; // sorted by modulePath length DESCENDING
resolve(importPath: string): { entry: GoModuleEntry; subPath: string } | null;
packageDir(importPath: string): string | null; // → project-relative package dir
}
```

`packageDir` is the core. Its contract:

| relDir | modulePath | importPath | → packageDir |
|---|---|---|---|
| `commons` | `example.com/org/commons` | `example.com/org/commons/basic/errs` | `commons/basic/errs` |
| `commons` | `example.com/org/commons` | `example.com/org/commons` | `commons` |
| `''` (root module) | `example.com/app` | `example.com/app/pkga` | `pkga` |
| `''` | `example.com/app` | `example.com/app` | `''` |

The last two rows reproduce the previous single-module algorithm exactly —
that is the backward-compatibility guarantee. Note `packageDir` can legally
return `''` (root module, root package), so callers must test `=== null`,
never falsiness.

### 3.2 Scanning

`loadGoModules(projectRoot)` walks the tree collecting every `go.mod`:

- skips `node_modules` `.git` `vendor` `testdata` `dist` `build` `target`
`.venv` `.codegraph`
- **keeps descending after finding one** — Go permits nested modules
- caps at depth 8 / 1000 modules, and returns what it collected rather than
throwing
- directory names are sorted before traversal, so results do not depend on
filesystem ordering
- when one module path appears in several `go.mod` (a vendored or templated
copy), the shortest `relDir` wins, deterministically
- returns `null` — not an empty index — when the project has no `go.mod`, so
every downstream `if (!idx)` branch behaves exactly as before

`resolve()` walks the length-descending entries and takes the first
`importPath === modulePath || importPath.startsWith(modulePath + '/')`.
Descending order is load-bearing: without it `example.com/org/platform`
would swallow imports belonging to `example.com/org/platform/sdk`.

### 3.3 Consumption

Three call sites prefer the multi-module index and fall back to the
single-module path verbatim:

- `isExternalImport` (Go branch) — an import belonging to any local module is
in-project
- `resolveGoCrossPackageReference` — uses `packageDir()`; keeps the existing
exact-parent match (`fileDir === pkgDir`, never `startsWith`) so
`pkga.FuncX` cannot land on a `FuncX` in `pkga/subpkg/`
- the name-matcher's Go field-type guard — an anti-fabrication guard (#1276);
widened only to genuine local modules, not relaxed

The index is loaded lazily and memoised per resolver, matching the existing
convention of `path-aliases.ts` and `workspace-packages.ts`. Like those two,
it is **resolver metadata only** — it produces no nodes and no edges.

## 4. Compatibility

Single-module repos with no nested modules are byte-identical to before.

Single-module repos that *do* contain a nested module (e.g. a `tools/go.mod`)
change behaviour: references into that nested module now resolve through the
import path instead of falling back to name matching. That is a correctness
improvement, but it is a change, so it is called out here rather than
described as "identical".

## 5. Verification

**Reproducible** — 14 tests, all building real multi-module layouts in temp
directories and indexing them for real (no mocks):

```
npx vitest run __tests__/resolution.test.ts -t "Go multi-module"
npx vitest run __tests__/extraction.test.ts -t "Go const/var extraction"
```

Coverage: cross-module resolution; same-name symbol in a non-imported module
must not be picked; longest-prefix precedence; single-root-module
no-regress; no-`go.mod` no-op; sub-package exclusion; scan-depth cap;
grouped-var extraction; grouped-const no-regress; Go `isExported` across all
four const/var declaration forms; TypeScript/Python/Rust no-regress.

**Scale check** — measured on a private 11.5k-file Go monorepo with 61
side-by-side modules. Not reproducible outside that environment; reported for
magnitude only. The golden set is derived automatically from the source, with
no manual labelling: an import alias binds to exactly one module path, which
maps to exactly one local directory; a target defined exactly once in that
directory is an unambiguous ground truth. Anything ambiguous is discarded, so
the measurement is conservative.

| | before | after |
|---|---|---|
| cross-module call recall | 4.03% | 99.67% |
| cross-module target precision | 6.83% | 100% |
| file coverage | 99.94% | 99.94% |
| call-site line precision | 99.20% | 99.73% |
| node count | 302,900 | 311,347 (+2.8%, grouped vars now indexed) |
| index time | ~65 s | ~67 s |

## 6. Known gap (deliberately out of scope)

Cross-module references to package-level **constants and variables**
(`alias.SomeConst` in value position) still do not resolve. The cause is
unrelated to anything above: value-position `alias.Symbol` references are
never *extracted* as references in the first place — `flushValueRefs` is
same-file only by design — so they never reach the resolver, regardless of
`isExported`. Fixing `isExported` (§2.2a) is still correct and necessary, but
it removes a guard nothing currently reaches.

The real fix is an extraction-layer feature: emit an unresolved `references`
ref for a package-qualified selector in value position when the alias maps to
an imported package. That is tracked separately.

Also unaddressed: module-level `imports` edges for Go. The module dependency
graph (which module requires which) is not materialised — consistent with
`path-aliases.ts` / `workspace-packages.ts`, module identity stays resolver
metadata rather than becoming graph nodes.
30 changes: 25 additions & 5 deletions src/extraction/tree-sitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1776,13 +1776,15 @@ export class TreeSitterExtractor {
const docstring = getPrecedingDocstring(node, this.source);
const signature = this.extractor.getSignature?.(node, this.source);
const visibility = this.extractor.getVisibility?.(node);
const isExported = this.extractor.isExported?.(node, this.source);
const isAsync = this.extractor.isAsync?.(node);
const isStatic = this.extractor.isStatic?.(node);
const returnType = this.extractor.getReturnType?.(node, this.source);
const extraProps: Partial<Node> = {
docstring,
signature,
visibility,
isExported,
isAsync,
isStatic,
returnType,
Expand Down Expand Up @@ -2726,11 +2728,22 @@ export class TreeSitterExtractor {
});
}
} else if (this.language === 'go') {
// Go: var_declaration, short_var_declaration, const_declaration
// These can have multiple identifiers on the left
const specs = node.namedChildren.filter(c =>
c.type === 'var_spec' || c.type === 'const_spec'
);
// Go: var_declaration, short_var_declaration, const_declaration.
// Collect every var_spec/const_spec. A grouped `var ( … )` wraps its
// specs in a `var_spec_list` node, while a grouped `const ( … )` does
// NOT (its specs are direct children) — a tree-sitter-go grammar
// asymmetry. Filter only direct children and grouped var produces zero
// nodes, so flatten the *_spec_list wrapper too.
const specs: SyntaxNode[] = [];
for (const child of node.namedChildren) {
if (child.type === 'var_spec' || child.type === 'const_spec') {
specs.push(child);
} else if (child.type === 'var_spec_list' || child.type === 'const_spec_list') {
for (const inner of child.namedChildren) {
if (inner.type === 'var_spec' || inner.type === 'const_spec') specs.push(inner);
}
}
}

for (const spec of specs) {
const nameNode = spec.namedChild(0);
Expand All @@ -2740,10 +2753,17 @@ export class TreeSitterExtractor {
const valueNode = spec.namedChildCount > 1 ? spec.namedChild(spec.namedChildCount - 1) : null;
const initValue = valueNode ? getNodeText(valueNode, this.source).slice(0, 100) : undefined;
const initSignature = initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined;
// Go export rule = leading uppercase. The isExported hook reads
// `getChildByField(node, 'name')`, so feed it the SPEC (whose `name`
// field IS the identifier) — NOT the declaration node (no `name`
// field → always false). Recompute per-spec here; do NOT touch the
// shared declaration-level isExported above (other languages use it).
const isExported = this.extractor.isExported?.(spec, this.source) ?? false;

varNode = this.createNode(node.type === 'const_declaration' ? 'constant' : 'variable', name, spec, {
docstring,
signature: initSignature,
isExported,
});
}
// Walk the initializer so composite literals and calls in a
Expand Down
Loading