mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-24 06:53:52 +08:00
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:
co-authored by
Claude Opus 5.5
parent
fc293f0081
commit
f5dfa21fef
@@ -198,6 +198,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- In JavaScript, an import of a TypeScript module written without an extension, like `import AudioModal from './audio_modal'` in a `.jsx` file next to `audio_modal.tsx`, now links to that module, as it already did from a TypeScript file. Before, a `.js` or `.jsx` file's imports of TypeScript modules were never followed, so in a codebase moving to TypeScript they were matched by name instead: an import under another name linked nothing, and a name that repeats could link to the wrong file, so mastodon's `<IconButton>` and `<Status>` tags rendered other components of those names than the ones each file imports. A JavaScript module beside a TypeScript one of the same name still comes first. Re-index JavaScript projects after upgrading.
|
||||
- `codegraph_explore`'s Flow now goes through the implementation your query names when an interface method has several that lead to the same place. Before, it took whichever one came first in the index: asking about prometheus's `Engine.execEvalStmt Queryable.Querier fanout.Querier NewMergeQuerier` traced the call through the TSDB's `DB.Querier` and left the `fanout.Querier` you named off the Flow. Any two routes of the same length are now settled this way, in favor of the one that passes through more of the symbols you named.
|
||||
- `codegraph_explore`'s "Dynamic-dispatch links among your symbols" list now shows every link on its own line. Before, the links from an interface method to its implementations all read alike, like `TrySet → TrySet`, so only one was listed, chosen by the order of the index rather than by your query: asking about gin's `tryToSetValue setter.TrySet multipartRequest.TrySet setByForm` showed `headerSource`'s implementation and hid `formSource`'s. Both ends of such a link are now named with their type, like `setter.TrySet → formSource.TrySet`. When there are more links than the list shows, the implementations your query names come first and those in tests or examples last, and a hop the Flow already shows is not repeated.
|
||||
- `codegraph_explore`, `codegraph_node`, `codegraph_callers`, `codegraph_callees`, `codegraph_impact` and the `codegraph callers`, `callees` and `impact` commands now find a symbol written with its package, the way Go code names it: `refresh.Discovery.refresh`, `storage.Queryable.Querier`, or `gin.Context.Next` in gin's root package. Before, such a name found nothing, so a question written that way lost the symbol and explore's Flow had nothing to connect. A name the search ranked below other results, like gin's `binding.Default`, is found as well, and a Python or Rust module followed by a class and method works the same way.
|
||||
- In C and C++, a struct, class, union or enum defined in a variable declaration, like `struct Foo { … } foo;`, is now indexed with its methods and enum values, next to the variable. Before, at most the variable was, so the type was missing from search and `codegraph_explore`, a class deriving from it had no base, and calls to its methods linked to nothing: in redis, the Sentinel's `sentinelState` and `redis-cli`'s `config` struct were missing. A struct, union or enum with no name of its own now takes the name of the variable it declares, so redis's `static struct { … } SPT;` is found as `SPT`, and one declared inside a function is named after its variable instead of `<anonymous>`. The comment above the declaration becomes the type's description. Re-index C and C++ projects after upgrading.
|
||||
- In Objective-C, a struct, union or enum defined in a variable declaration, like `static struct { Class isa; } placeholder;` or `enum { stateType, stateParamName } state;`, is now indexed with its enum values, next to the variable, as in C and C++. Before, only the variable was, so the type was missing from search and `codegraph_explore`. The same went for a `typedef enum { … } Name;` or `typedef struct … Name;` written after a line holding only a macro, like `OF_ASSUME_NONNULL_BEGIN` or `RCT_EXTERN_C_BEGIN`: ObjFW's `OFZIPArchiveEntryCompressionMethod` and every value in it were missing, and so was React Native's `RCTMethodInfo`. A struct, union or enum with no name of its own takes the name of the variable it declares, so ObjFW's class-cluster placeholders are found as `placeholder`, one declared inside a function or method is named after its variable instead of `<anonymous>`, and the comment above the declaration becomes the type's description. As in C, a type that a `.m` file defines links only from that file, so a call elsewhere that just shares its name, like ObjFW's calls of AmigaOS's `Lock(path, mode)`, doesn't link to the runtime's private `struct Lock`. Re-index Objective-C projects after upgrading.
|
||||
- In Objective-C, a macro written alone on its line, like `NS_ASSUME_NONNULL_BEGIN`, `OF_SUBCLASSING_RESTRICTED` or React Native's `RCT_EXTERN_C_BEGIN`, no longer hides the declaration below it. Before, the parser read such a macro as the start of a declaration, so an `@interface` under it could come out as a function and a `typedef` under it as two variables: many of ObjFW's classes, with their properties and methods and the classes they extend and protocols they adopt, were missing, and so were classes and protocols in React Native and KSCrash headers. A message to `super` from a class whose `@interface` follows an attribute macro like `NS_SWIFT_NAME(…)` still reaches the superclass. Re-index Objective-C projects after upgrading.
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Regression: a symbol written with its package resolves.
|
||||
*
|
||||
* Explore's token resolution (and codegraph_node's lookup) 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, each pinned below:
|
||||
* - a qualified name was matched either against the qualifiedName (which holds
|
||||
* the type, not the package) or entirely against the path (which holds the
|
||||
* package, not the type), never as a package followed by a type;
|
||||
* - a module's root package has no directory, so its name is only in the
|
||||
* files' `package` clause (gin's `context.go` is `package gin`);
|
||||
* - a qualified token was looked up in the full-text search's top hits only,
|
||||
* and for `binding.Default` those were three `binding/default_validator*.go`
|
||||
* files, not the function; the exact-name index is complete.
|
||||
*/
|
||||
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import CodeGraph from '../src/index';
|
||||
import type { Node } from '../src/types';
|
||||
import { matchesSymbol, lookupSymbolNodes } from '../src/graph/symbol-lookup';
|
||||
import { goPackageClause, goPackageReaderFor } from '../src/graph/go-package';
|
||||
import { findAllSymbols, resolveNamedTokens } from '../src/graph/named-symbol-flow';
|
||||
import { ToolHandler } from '../src/mcp/tools';
|
||||
|
||||
function node(over: Partial<Node>): Node {
|
||||
return {
|
||||
id: `${over.kind ?? 'method'}:${over.filePath}:${over.qualifiedName}`,
|
||||
kind: 'method',
|
||||
name: 'x',
|
||||
qualifiedName: 'x',
|
||||
filePath: 'x.go',
|
||||
language: 'go',
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
startColumn: 0,
|
||||
endColumn: 0,
|
||||
updatedAt: 0,
|
||||
...over,
|
||||
} as Node;
|
||||
}
|
||||
|
||||
describe('matchesSymbol — a package, then the scope inside it', () => {
|
||||
const refresh = node({ name: 'refresh', qualifiedName: 'Discovery::refresh', filePath: 'discovery/refresh/refresh.go' });
|
||||
const uyuni = node({ name: 'refresh', qualifiedName: 'Discovery::refresh', filePath: 'discovery/uyuni/uyuni.go' });
|
||||
|
||||
it('reads `pkg.Type.method` as the package in the path and the type in the scope', () => {
|
||||
expect(matchesSymbol(refresh, 'refresh.Discovery.refresh')).toBe(true);
|
||||
expect(matchesSymbol(uyuni, 'uyuni.Discovery.refresh')).toBe(true);
|
||||
// Every leading qualifier names the package — `discovery/refresh`.
|
||||
expect(matchesSymbol(refresh, 'discovery.refresh.Discovery.refresh')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the package: the same type in another package is not it', () => {
|
||||
expect(matchesSymbol(uyuni, 'refresh.Discovery.refresh')).toBe(false);
|
||||
expect(matchesSymbol(refresh, 'uyuni.Discovery.refresh')).toBe(false);
|
||||
expect(matchesSymbol(refresh, 'nope.Discovery.refresh')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the type: the scope must end with what the query writes', () => {
|
||||
expect(matchesSymbol(refresh, 'refresh.Other.refresh')).toBe(false);
|
||||
// A package can't stand in for the type.
|
||||
expect(matchesSymbol(refresh, 'Discovery.refresh.refresh')).toBe(false);
|
||||
});
|
||||
|
||||
it("names a root package by the file's `package` clause", () => {
|
||||
const next = node({ name: 'Next', qualifiedName: 'Context::Next', filePath: 'context.go' });
|
||||
const fn = node({ kind: 'function', name: 'Default', qualifiedName: 'Default', filePath: 'utils.go' });
|
||||
const gin = (p: string) => (p === 'context.go' || p === 'utils.go' ? 'gin' : null);
|
||||
expect(matchesSymbol(next, 'gin.Context.Next', gin)).toBe(true);
|
||||
expect(matchesSymbol(fn, 'gin.Default', gin)).toBe(true);
|
||||
// Without the clause the path has no `gin`; another package name is not it.
|
||||
expect(matchesSymbol(next, 'gin.Context.Next')).toBe(false);
|
||||
expect(matchesSymbol(next, 'echo.Context.Next', gin)).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the package clause for Go files only', () => {
|
||||
const ts = node({ name: 'Next', qualifiedName: 'Context::Next', filePath: 'context.ts', language: 'typescript' });
|
||||
expect(matchesSymbol(ts, 'gin.Context.Next', () => 'gin')).toBe(false);
|
||||
});
|
||||
|
||||
it('reads a Python `pkg.mod.Class.method` the same way', () => {
|
||||
const save = node({ name: 'save', qualifiedName: 'Model::save', filePath: 'app/models.py', language: 'python' });
|
||||
expect(matchesSymbol(save, 'app.models.Model.save')).toBe(true);
|
||||
expect(matchesSymbol(save, 'other.models.Model.save')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('goPackageClause', () => {
|
||||
it('reads the clause past comments and build constraints', () => {
|
||||
expect(goPackageClause('// Copyright\n\n//go:build !nomsgpack\n\npackage binding\n\nimport "x"\n')).toBe('binding');
|
||||
expect(goPackageClause('/* license\n package notthis */\npackage gin // the root\n')).toBe('gin');
|
||||
expect(goPackageClause('package main\n')).toBe('main');
|
||||
expect(goPackageClause('// no clause\nfunc x() {}\n')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('package-qualified Go names resolve on every surface', () => {
|
||||
let dir = '';
|
||||
let cg: CodeGraph;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-package-names-'));
|
||||
const write = (rel: string, content: string) => {
|
||||
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, rel), content);
|
||||
};
|
||||
write('go.mod', 'module example.com/gin\n\ngo 1.22\n');
|
||||
// The root package: its name is in the clause only.
|
||||
write('context.go', '// Package gin is the root.\npackage gin\n\ntype Context struct{}\n\nfunc (c *Context) Next() {}\n');
|
||||
write(
|
||||
'binding/binding.go',
|
||||
'package binding\n\ntype Binding interface {\n\tBind(req string) error\n}\n\nfunc Default(method string) Binding {\n\treturn nil\n}\n'
|
||||
);
|
||||
// Gives the full-text search `binding` + `default` hits that are not the function.
|
||||
write(
|
||||
'binding/default_validator.go',
|
||||
'package binding\n\ntype defaultValidator struct{}\n\nfunc (v *defaultValidator) ValidateStruct(obj any) error {\n\treturn nil\n}\n'
|
||||
);
|
||||
write(
|
||||
'discovery/refresh/refresh.go',
|
||||
'package refresh\n\ntype Discovery struct {\n\trefreshf func() error\n}\n\nfunc (d *Discovery) refresh() error {\n\treturn d.refreshf()\n}\n'
|
||||
);
|
||||
write(
|
||||
'discovery/uyuni/uyuni.go',
|
||||
'package uyuni\n\ntype Discovery struct{}\n\nfunc (d *Discovery) refresh() error {\n\treturn nil\n}\n'
|
||||
);
|
||||
cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } });
|
||||
await cg.indexAll();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
cg?.destroy();
|
||||
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const where = (nodes: Node[]) => nodes.map((n) => `${n.qualifiedName}@${n.filePath}`).sort();
|
||||
|
||||
it("explore's resolution finds each package's own symbol", () => {
|
||||
expect(where(findAllSymbols(cg, 'refresh.Discovery.refresh').nodes)).toEqual(['Discovery::refresh@discovery/refresh/refresh.go']);
|
||||
expect(where(findAllSymbols(cg, 'uyuni.Discovery.refresh').nodes)).toEqual(['Discovery::refresh@discovery/uyuni/uyuni.go']);
|
||||
expect(where(findAllSymbols(cg, 'gin.Context.Next').nodes)).toEqual(['Context::Next@context.go']);
|
||||
expect(findAllSymbols(cg, 'nope.Discovery.refresh').nodes).toEqual([]);
|
||||
});
|
||||
|
||||
it('finds a definition the full-text search ranks below its cut', () => {
|
||||
// Precondition: the search answers `binding.Default`, with other nodes.
|
||||
const hits = cg.searchNodes('binding.Default', { limit: 50 });
|
||||
expect(hits.length).toBeGreaterThan(0);
|
||||
expect(hits.some((h) => h.node.name === 'Default')).toBe(false);
|
||||
expect(where(findAllSymbols(cg, 'binding.Default').nodes)).toEqual(['Default@binding/binding.go']);
|
||||
});
|
||||
|
||||
it('feeds explore its named symbols', () => {
|
||||
const flow = resolveNamedTokens(cg, 'refresh.Discovery.refresh gin.Context.Next');
|
||||
const named = [...flow.named.values()];
|
||||
expect(where(named)).toEqual(['Context::Next@context.go', 'Discovery::refresh@discovery/refresh/refresh.go']);
|
||||
});
|
||||
|
||||
it('codegraph_node and the CLI lookup agree', async () => {
|
||||
const res = await new ToolHandler(cg).execute('codegraph_node', { symbol: 'uyuni.Discovery.refresh' });
|
||||
const text = res.content[0].text as string;
|
||||
expect(text).toContain('discovery/uyuni/uyuni.go');
|
||||
expect(text).not.toContain('discovery/refresh/refresh.go');
|
||||
expect(where(lookupSymbolNodes(cg, 'gin.Context.Next', goPackageReaderFor(cg)).nodes)).toEqual(['Context::Next@context.go']);
|
||||
});
|
||||
});
|
||||
@@ -72,6 +72,7 @@ import { getTelemetry, TELEMETRY_DOCS, recordIndexEvent } from '../telemetry';
|
||||
import { BROWSER_ENV, DEFAULT_UI_PORT } from '../ui-server/constants';
|
||||
import type { UiServerHandle } from '../ui-server';
|
||||
import { lookupSymbolNodes, describeSymbolNode, groupDefinitions } from '../graph/symbol-lookup';
|
||||
import { goPackageReaderFor } from '../graph/go-package';
|
||||
import type { Node, Edge } from '../types';
|
||||
import { isTestPath } from '../search/query-utils';
|
||||
|
||||
@@ -2332,7 +2333,7 @@ for (const direction of ['callers', 'callees'] as const) {
|
||||
const cg = await CodeGraph.open(projectPath);
|
||||
try {
|
||||
const limit = parseInt(options.limit || '20', 10);
|
||||
const { nodes: targets } = lookupSymbolNodes(cg, symbol);
|
||||
const { nodes: targets } = lookupSymbolNodes(cg, symbol, goPackageReaderFor(cg));
|
||||
if (targets.length === 0) {
|
||||
info(formatSymbolNotFound(symbol, cg.searchNodes(symbol, { limit: 5 }).map((m) => m.node.name)));
|
||||
return;
|
||||
@@ -2456,7 +2457,7 @@ program
|
||||
const cg = await CodeGraph.open(projectPath);
|
||||
try {
|
||||
const depth = Math.min(Math.max(parseInt(options.depth || '2', 10), 1), 10);
|
||||
const { nodes: targets } = lookupSymbolNodes(cg, symbol);
|
||||
const { nodes: targets } = lookupSymbolNodes(cg, symbol, goPackageReaderFor(cg));
|
||||
if (targets.length === 0) {
|
||||
info(formatSymbolNotFound(symbol, cg.searchNodes(symbol, { limit: 5 }).map((m) => m.node.name)));
|
||||
return;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+12
-38
@@ -35,7 +35,8 @@ import {
|
||||
import type { PendingFile } from '../sync';
|
||||
import type { Node, Edge, SearchResult, Subgraph, NodeKind } from '../types';
|
||||
import { isTestFile, normalizeNameToken } from '../search/query-utils';
|
||||
import { groupDefinitions, isQualifiedSymbol, lastQualifierPart, matchesSymbol } from '../graph/symbol-lookup';
|
||||
import { groupDefinitions, isQualifiedSymbol, lastQualifierPart, matchesSymbol, qualifiedSymbolMatches } from '../graph/symbol-lookup';
|
||||
import { goPackageReaderFor } from '../graph/go-package';
|
||||
import {
|
||||
extractQueryPaths,
|
||||
queryMightContainPaths,
|
||||
@@ -221,8 +222,9 @@ const NOT_A_DEFINITION = new Set(['file', 'import', 'export', 'parameter']);
|
||||
*/
|
||||
function filesDefiningSymbol(cg: CodeGraph, symbol: string): string[] {
|
||||
try {
|
||||
const goPackageOf = goPackageReaderFor(cg);
|
||||
const nodes = isQualifiedSymbol(symbol)
|
||||
? cg.getNodesByName(lastQualifierPart(symbol)).filter((n) => matchesSymbol(n, symbol))
|
||||
? cg.getNodesByName(lastQualifierPart(symbol)).filter((n) => matchesSymbol(n, symbol, goPackageOf))
|
||||
: cg.getNodesByName(symbol);
|
||||
return nodes.filter((n) => !NOT_A_DEFINITION.has(n.kind)).map((n) => n.filePath);
|
||||
} catch {
|
||||
@@ -8342,18 +8344,6 @@ export class ToolHandler {
|
||||
// Symbol resolution helpers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Find a symbol by name, handling disambiguation when multiple matches exist.
|
||||
* Returns the best match and a note about alternatives if any.
|
||||
*/
|
||||
/**
|
||||
* Check if a node matches a symbol query — see `matchesSymbol` in
|
||||
* `../graph/symbol-lookup`, which owns the rules.
|
||||
*/
|
||||
private matchesSymbol(node: Node, symbol: string): boolean {
|
||||
return matchesSymbol(node, symbol);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ALL definitions matching a name, ranked, so codegraph_node can return
|
||||
* every overload instead of guessing one (the wrong guess → a Read). Keepers
|
||||
@@ -8382,34 +8372,18 @@ export class ToolHandler {
|
||||
return fuzzy[0] ? [fuzzy[0].node] : [];
|
||||
}
|
||||
|
||||
// Qualified lookup (`Session.request`, `stage_apply::run`): FTS + matchesSymbol.
|
||||
const limit = 50;
|
||||
let results = cg.searchNodes(symbol, { limit });
|
||||
|
||||
// FTS strips colons, so `stage_apply::run` searches the literal
|
||||
// `stage_applyrun` and finds nothing. Re-search by the bare last part and
|
||||
// let `matchesSymbol` filter by qualifier.
|
||||
if (isQualified && results.length === 0) {
|
||||
const tail = lastQualifierPart(symbol);
|
||||
if (tail && tail !== symbol) results = cg.searchNodes(tail, { limit });
|
||||
}
|
||||
|
||||
if (results.length === 0) return [];
|
||||
|
||||
const exactMatches = results.filter((r) => this.matchesSymbol(r.node, symbol));
|
||||
if (exactMatches.length === 0) {
|
||||
// No exact match — a qualified lookup must not fall back to a fuzzy file
|
||||
// hit (#173); a bare name may use the single top fuzzy result.
|
||||
return isQualified ? [] : results[0] ? [results[0].node] : [];
|
||||
}
|
||||
// Qualified lookup (`Session.request`, `stage_apply::run`,
|
||||
// `refresh.Discovery.refresh`): the search's matching hits, else the
|
||||
// exact-name index's — shared with explore's resolution. No match is no
|
||||
// match: a qualified lookup must not fall back to a fuzzy file hit (#173).
|
||||
const exactMatches = qualifiedSymbolMatches(cg, symbol, goPackageReaderFor(cg));
|
||||
if (exactMatches.length === 0) return [];
|
||||
|
||||
// Down-rank generated files (.pb.go, .pulsar.go, _grpc.pb.go, and anything
|
||||
// whose header declares it generated) so a flow query prefers the keeper
|
||||
// implementation over the generated stub.
|
||||
const isGen = cg.generatedFilePredicate(exactMatches.map((r) => r.node.filePath));
|
||||
return [...exactMatches]
|
||||
.sort((a, b) => (isGen(a.node.filePath) ? 1 : 0) - (isGen(b.node.filePath) ? 1 : 0))
|
||||
.map((r) => r.node);
|
||||
const isGen = cg.generatedFilePredicate(exactMatches.map((n) => n.filePath));
|
||||
return [...exactMatches].sort((a, b) => (isGen(a.filePath) ? 1 : 0) - (isGen(b.filePath) ? 1 : 0));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user