fix(explore): a symbol written with its package resolves, like refresh.Discovery.refresh (#2750)

Explore's token resolution, codegraph_node's lookup and the callers,
callees and impact lookups dropped every Go symbol written the way Go
code names it: `refresh.Discovery.refresh`, `storage.Queryable.Querier`
and `gin.Context.Next` resolved to nothing, and so did `binding.Default`.
Three gaps:

- A qualified name was matched against the qualifiedName, which holds
  the type and not the package, or entirely against the path, which
  holds the package and not the type, never as a package followed by a
  type. A new matcher stage reads `pkg.Type.method` as leading
  qualifiers that name the package by path segment and trailing ones
  that end the node's scope. A Python `pkg.mod.Class.method` and a Rust
  `module::Type::method` read the same way.
- A module's root package has no directory: gin's `context.go` is
  `package gin`. A Go package qualifier now also matches the package the
  file declares, read from its `package` clause (src/graph/go-package.ts).
  That covers versioned `v2/` directories and generated packages like
  prometheus's `io_prometheus_client` as well. The clause is read last
  and only where it can decide, a top-level function or a node whose
  scope already ends with the written type, so a common name's hundreds
  of definitions cost no file reads.
- Explore and codegraph_node took a qualified token's matches from the
  full-text search's top 50 only, and for gin's `binding.Default` those
  were three `binding/default_validator*.go` files. Through one shared
  helper they now fall back to the exact-name index when the search has
  no match; the CLI's lookup already started there.

The new stage runs only after the existing ones fail, and the search
keeps its order, so a name that resolved before keeps its result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-21 14:50:38 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent fc293f0081
commit f5dfa21fef
7 changed files with 324 additions and 58 deletions
+67
View File
@@ -0,0 +1,67 @@
/**
* The package a Go file declares — the name a `pkg.Type.method` query writes.
*
* It is usually the name of the file's directory, which is why a package
* qualifier is first compared with the path. Not always: a module's root
* package has no directory of its own (gin's `context.go` is `package gin` at
* the top of the repository), and a versioned directory (`v2/`) keeps its
* parent's name. The `package` clause is the authority, and it is not in the
* index, so it is read from the file when a lookup needs it.
*/
import { closeSync, openSync, readSync } from 'fs';
import { validatePathWithinRoot } from '../utils';
/** The package a Go file declares, by its project-relative path; null when unknown. */
export type GoPackageOf = (filePath: string) => string | null;
/** Enough of a file to get past any license header and build tags. */
const HEAD_BYTES = 8192;
/**
* The name in a Go source's `package` clause: the first token after its
* comments and build constraints. Null when there is none.
*/
export function goPackageClause(source: string): string | null {
const code = source.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\/\/[^\n]*/g, '');
return /^\s*package\s+([A-Za-z_]\w*)/.exec(code)?.[1] ?? null;
}
/** `goPackageReader` for a graph's project; none when the root can't be had. */
export function goPackageReaderFor(cg: { getProjectRoot(): string }): GoPackageOf | undefined {
try {
return goPackageReader(cg.getProjectRoot());
} catch {
return undefined;
}
}
/**
* A `GoPackageOf` for files under `projectRoot`, reading each file's head once.
* A file it cannot read declares no package, so the qualifier does not match.
*/
export function goPackageReader(projectRoot: string): GoPackageOf {
const cache = new Map<string, string | null>();
return (filePath) => {
const hit = cache.get(filePath);
if (hit !== undefined) return hit;
let pkg: string | null = null;
const absPath = validatePathWithinRoot(projectRoot, filePath);
if (absPath) {
try {
const fd = openSync(absPath, 'r');
try {
const buf = Buffer.alloc(HEAD_BYTES);
const n = readSync(fd, buf, 0, HEAD_BYTES, 0);
pkg = goPackageClause(buf.toString('utf8', 0, n));
} finally {
closeSync(fd);
}
} catch {
pkg = null;
}
}
cache.set(filePath, pkg);
return pkg;
};
}
+5 -10
View File
@@ -36,7 +36,8 @@ import type CodeGraph from '../index';
import type { Node, Edge } from '../types';
import { isTestFile } from '../search/query-utils';
import { lastQualifierPart, matchesSymbol } from './symbol-lookup';
import { qualifiedSymbolMatches } from './symbol-lookup';
import { goPackageReaderFor } from './go-package';
// Preserve the existing imports while sharing the matcher with the CLI and MCP.
export { RUST_PATH_PREFIXES, lastQualifierPart, matchesSymbol } from './symbol-lookup';
@@ -79,15 +80,9 @@ export function findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[];
// ranking a differently-cased sibling above the real node (#1473 Fetch).
exactNodes = cg.getNodesByName(symbol);
} else {
let results = cg.searchNodes(symbol, { limit: 50 });
// Mirror findSymbolMatches — FTS strips colons, so re-search by bare tail.
if (results.length === 0) {
const tail = lastQualifierPart(symbol);
if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit: 50 });
}
exactNodes = results
.filter((r) => matchesSymbol(r.node, symbol))
.map((r) => r.node);
// Shared with codegraph_node's lookup: search rank first, the complete
// exact-name index when the search's cut misses the definition.
exactNodes = qualifiedSymbolMatches(cg, symbol, goPackageReaderFor(cg));
}
if (exactNodes.length === 0) {
+67 -8
View File
@@ -25,6 +25,7 @@
*/
import type { Node } from '../types';
import type { GoPackageOf } from './go-package';
/** Rust path prefixes that name no directory (`crate::x`, `super::y`). */
export const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']);
@@ -57,9 +58,11 @@ function canonicalScope(text: string): string {
* Bare queries match the name. Qualified queries are checked against the
* qualifiedName under both separator conventions, then — for languages whose
* hierarchy lives in the file path rather than the name (Rust modules, Python
* packages) — against the path.
* packages, Go packages) — against the path, and last as a package followed by
* the scope inside it. `goPackageOf` gives a Go file's declared package, for a
* package qualifier that names no directory.
*/
export function matchesSymbol(node: Node, symbol: string): boolean {
export function matchesSymbol(node: Node, symbol: string, goPackageOf?: GoPackageOf): boolean {
// Erlang arity spelling (`fn/3`, `mod:fn/3`): when the node's qualifiedName
// carries an arity (#1610) the written arity must match exactly, and the rest
// of the comparison runs on the arity-less spelling. A node with no arity
@@ -109,9 +112,61 @@ export function matchesSymbol(node: Node, symbol: string): boolean {
const containerHints = parts.slice(0, -1).filter((p) => !RUST_PATH_PREFIXES.has(p));
if (containerHints.length === 0) return false;
const segments = node.filePath.split('/').filter((s) => s.length > 0);
return containerHints.every((hint) =>
segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint)
);
const inPath = (hint: string): boolean =>
segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint);
if (containerHints.every(inPath)) return true;
// Stage 3: a package, then the scope inside it. `refresh.Discovery.refresh` is
// method `refresh` of type `Discovery` in Go package `refresh` (a Python
// `pkg.mod.Class.method` reads the same way): the type is in the
// qualifiedName and the package in the path, so neither stage above reads the
// whole name. The leading qualifiers must name the package as stage 2 does,
// the rest must end the node's scope.
const scope = canonicalScope(node.qualifiedName).split('.').slice(0, -1);
const splits: number[] = [];
for (let k = 1; k < containerHints.length; k++) {
const inner = containerHints.slice(k);
const offset = scope.length - inner.length;
if (offset < 0 || !inner.every((hint, i) => scope[offset + i] === hint)) continue;
if (containerHints.slice(0, k).every(inPath)) return true;
splits.push(k);
}
// Last, a Go qualifier names the package the file DECLARES: its directory as a
// rule, but a module's root package has no directory (gin's `context.go` is
// `package gin`), and a `v2/` directory keeps its parent's name. The clause
// is read only where it can decide — a top-level function (`gin.New`), or a
// node whose scope already ends with the written type — so a common name's
// hundreds of definitions cost no file reads.
if (node.language !== 'go' || !goPackageOf || (scope.length > 0 && splits.length === 0)) return false;
const declared = goPackageOf(node.filePath);
if (!declared) return false;
const namesPackage = (hint: string): boolean => hint === declared || inPath(hint);
if (scope.length === 0 && containerHints.every(namesPackage)) return true;
return splits.some((k) => containerHints.slice(0, k).every(namesPackage));
}
/**
* The nodes a QUALIFIED query names, in search-rank order: the full-text
* search's top hits that satisfy the matcher, else every exact-name definition
* of the last part that does. The search ranks and truncates, so a definition
* can sit below its cut: for gin's `binding.Default` it returned three
* `binding/default_validator*.go` files and not the function, and the query
* named nothing. The exact-name index is complete.
*/
export function qualifiedSymbolMatches(
cg: SymbolLookupHost,
symbol: string,
goPackageOf?: GoPackageOf
): Node[] {
const tail = lastQualifierPart(symbol);
let hits = cg.searchNodes(symbol, { limit: 50 });
// FTS strips colons, so `stage_apply::run` searches the literal
// `stage_applyrun` and finds nothing: search the bare last part instead.
if (hits.length === 0 && tail && tail !== symbol) hits = cg.searchNodes(tail, { limit: 50 });
const ranked = hits.map((h) => h.node).filter((n) => matchesSymbol(n, symbol, goPackageOf));
if (ranked.length > 0 || !tail) return ranked;
return cg.getNodesByName(tail).filter((n) => matchesSymbol(n, symbol, goPackageOf));
}
/** The slice of CodeGraph a symbol lookup needs — keeps this module testable. */
@@ -170,17 +225,21 @@ export function groupDefinitions(
* happened to rank first. FTS candidates still have to satisfy the matcher;
* partial or mistyped names must never select the top fuzzy hit (#1473).
*/
export function lookupSymbolNodes(cg: SymbolLookupHost, symbol: string): SymbolLookupResult {
export function lookupSymbolNodes(
cg: SymbolLookupHost,
symbol: string,
goPackageOf?: GoPackageOf
): SymbolLookupResult {
const qualified = isQualifiedSymbol(symbol);
// Exact-name index, then filter by the qualifier the user actually wrote.
const tail = qualified ? lastQualifierPart(symbol) : symbol;
let nodes = tail ? cg.getNodesByName(tail) : [];
if (qualified) nodes = nodes.filter((n) => matchesSymbol(n, symbol));
if (qualified) nodes = nodes.filter((n) => matchesSymbol(n, symbol, goPackageOf));
if (nodes.length === 0) {
const hits = cg.searchNodes(symbol, { limit: 50 }).map((h) => h.node);
const exact = hits.filter((n) => matchesSymbol(n, symbol));
const exact = hits.filter((n) => matchesSymbol(n, symbol, goPackageOf));
if (exact.length > 0) {
nodes = exact;
}