mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-27 07:23:43 +08:00
Only cfscript's `include "x.cfm";`, written outside any function, linked
the including code to the template. CfmlExtractor never read
`<cfinclude template="...">`, which the grammar parses as a
cf_selfclose_tag whose name is only in the text after `<cf`, so the tag
form made nothing anywhere. cfscript's include hook read only the string
form, `cfinclude(template="x.cfm")` went through the call extractor as a
call to `cfinclude`, and the function-body walker never extracts import
statements, so an `include` in a function was dropped. Resolution went
through the generic pipeline: the file-name matcher links a relative path
like `../shared/helpers.cfm` only when no other file shares its name, and
past it the symbol strategies could take another template's import node
of the same path, or the including file itself.
Fix:
- `<cfinclude template="...">` is extracted like the script form: an
import node in the scope it sits in (function, component, or the
template's file) and an imports reference from that scope.
`cfinclude(...)` and `include template="...";` make the same import and
no call, and an `include` in a function body counts, from the function.
- A path built at runtime (`#dir#/x.cfm`) is no import, in every form.
- A CFML include of a template path resolves as CFML does: relative to the
including file's directory, or from the project root for a leading `/`
(less sure, 0.85), to the file at that path, ignoring case when one file
matches. A path that names no file there goes to the file-name matcher,
as before, but only ever to a file, and never to the including file.
Validation:
- New cfml-include.test.ts: the tag form at component level (same folder,
../, upper case, unquoted), in a function body inside <cfif>, in a
template and from the web root; the script forms; runtime-built paths,
cfinclude(template=variables.x) and <cfmodule> (no import); a relative
path with a namesake elsewhere; an unmatched path that another template
also includes, and a path whose only namesake is the includer. 5 of 7
fail on main, all 7 pass.
- A/B vs main 51c4124d (CFML has no kernel path) on FW/1, BlogCFC,
ColdBox, ContentBox, Preside, CFWheels and its vendor/wheels core as a
project of its own: 127 new imports edges and 152 import nodes (36
more resolved again to the same file, more surely); 9 import nodes of
runtime-built paths gone; 1 edge gone, CFWheels' starter-app
views/wheels/wheels.cfm including itself. No edge moved to another
target. Every CFML imports edge in the fix graphs (225) targets a file
named like the include path, and every relative path that stays inside
the project reaches exactly that file but one: Wheels' Public.cfc
including views/routetesterprocess.cfm, no file beside it, matched by
name to public/views/routetesterprocess.cfm as before. express and flask
controls byte-identical.
- Index time, median of 4 interleaved runs, CPU with workers, main vs
fix: Preside 52.6 / 50.8 s, Wheels core 28.2 / 29.5 s. Within noise.
Refs #2651 (part 1; part 2 is a feature request and stays open)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4357 lines
209 KiB
TypeScript
4357 lines
209 KiB
TypeScript
/**
|
||
* Reference Resolution Orchestrator
|
||
*
|
||
* Coordinates all reference resolution strategies.
|
||
*/
|
||
|
||
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
import { Language, Node, UnresolvedReference, Edge } from '../types';
|
||
import { QueryBuilder } from '../db/queries';
|
||
import { SynthesisStage } from '../db/synthesis-stage';
|
||
import {
|
||
UnresolvedRef,
|
||
ResolvedRef,
|
||
ResolutionResult,
|
||
ResolutionContext,
|
||
FrameworkResolver,
|
||
ImportMapping,
|
||
isSupertypeTarget,
|
||
isInheritanceRef,
|
||
isImportableKind,
|
||
CPP_DEFINE_SIGNATURE,
|
||
} from './types';
|
||
import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, isRustPathCallReachable, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, goDotImportedSupertype, GO_TYPE_KINDS, matchGoAssertedCall, matchGoIndexedCall, matchGoChainedCall, preferGoBuildTwin, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves, matchPythonTypedLocalCall, isImportShadowedByJsBinding, isManualMockPath, isTestSuitePath } from './name-matcher';
|
||
import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility';
|
||
import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor';
|
||
import { isCppSupertypeRef, matchCppSupertype, clearCppSupertypeMemos } from './cpp-supertypes';
|
||
import { gateSwiftTypeTarget, clearSwiftTypeVisibility, swiftExtendedConformances } from './swift-type-visibility';
|
||
import { clearDartLibraryMemos } from './dart-libraries';
|
||
import { clearVbnetReceiverMemos, isVbMemberRead, isVbPathCall, matchVbMemberRead, matchVbPathCall } from './vbnet-receivers';
|
||
import {
|
||
clearPascalScopeMemos, gatePascalCall, isPascalParenlessRef, isPascalSystemCall, matchPascalNestedCall,
|
||
matchPascalParenlessRef, matchPascalSystemCall,
|
||
} from './pascal-scope';
|
||
import { gateTypeParameter, clearTypeParameterMemos } from './type-parameters';
|
||
import { gateDartLocal, clearDartLocalScopeMemos } from './dart-local-scope';
|
||
import { clearCppTypeAliasMemos } from './cpp-type-aliases';
|
||
import { clearCppIncluderMemos } from './cpp-includers';
|
||
import { clearCFeatureVariantMemos } from './c-feature-variants';
|
||
import { clearAmbientModuleMemos, ambientModuleDeclarations } from './ambient-modules';
|
||
import { matchShopifyThemeFile } from './shopify-themes';
|
||
import { isJsSuperCallLanguage, isJsSuperCallSite, matchJsSuperCall } from './js-super-calls';
|
||
import { CLASS_EXPRESSION_REF_LANGUAGES, isCommonJsExportedClass, isJsClassExpression, writtenIn, type ClassExpressionIndex } from './js-class-expressions';
|
||
import { matchRubyConstantPathRef } from './ruby-constant-paths';
|
||
import { resolveViaImport, resolvePhpImportedStaticCall, resolvePhpQualifiedClassRef, resolveTsQualifiedTypeRef, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isDartImportRef, isLuaRequireRef, isJsPathImportRef, isBoundToOutOfRepoImport, reachesAmbientModule, importBindingTail, clearImportResolverMemos, resolveImportPath, isExternalImport, ESM_IMPORT_LANGUAGES, jsModuleFile, pythonRootFingerprint, pythonReopenScope, pythonPackageImporters, moduleDefaultExport, isJsFamilyFile, isModuleBindingName } from './import-resolver';
|
||
import { ResolverPool, minRefsForPool, shouldEngageAdaptively } from './resolver-pool';
|
||
import { resolveAliasBinding } from './alias-binding';
|
||
import { detectFrameworks, getResolvingFrameworks } from './frameworks';
|
||
import { synthesizeCallbackEdges } from './callback-synthesizer';
|
||
import { createYielder, type MaybeYield } from './cooperative-yield';
|
||
import { MAX_SOURCE_FILE_SIZE_BYTES } from '../file-limits';
|
||
import { loadProjectAliases, type AliasMap } from './path-aliases';
|
||
import { findGoModuleForImport, goModulePackageDir, loadGoModule, type GoModule } from './go-module';
|
||
import { loadWorkspacePackages, workspaceFingerprint, workspacePackageDirOf, type WorkspacePackages } from './workspace-packages';
|
||
import { logDebug } from '../errors';
|
||
import { stripCommentsForRegex } from './strip-comments';
|
||
import { lexicalPathWithinRoot } from '../utils';
|
||
import type { CrossFileNodes, ReExport } from './types';
|
||
import { resurrectRefFromDroppedEdge } from './resurrect-ref';
|
||
import { LRUCache } from './lru-cache';
|
||
import { isTestPath } from '../search/query-utils';
|
||
import { JS_BUILT_INS } from './js-builtins';
|
||
import { builtinModules } from 'module';
|
||
import { parse as parseJsonc } from 'jsonc-parser';
|
||
|
||
const NODE_BUILTINS = new Set(builtinModules);
|
||
|
||
/** Node kinds that can declare supertypes (extends/implements). */
|
||
const SUPERTYPE_BEARING_KINDS = new Set<Node['kind']>([
|
||
'class', 'struct', 'interface', 'trait', 'protocol', 'enum',
|
||
]);
|
||
|
||
/**
|
||
* The only edges resolving a reference reads (getSupertypes' walk). The batch
|
||
* loop commits a batch that made any of these before the next batch fans out;
|
||
* its other edges land while that batch resolves. A resolution strategy that
|
||
* reads another edge kind must be added here.
|
||
*/
|
||
const RESOLUTION_READ_EDGE_KINDS: Edge['kind'][] = ['implements', 'extends'];
|
||
|
||
// SUPERTYPE_TARGET_KINDS (the kinds an extends/implements edge may TARGET)
|
||
// lives in ./types — the name-matcher needs the same set to restrict its
|
||
// candidate pool before ranking. It is deliberately wider than
|
||
// SUPERTYPE_BEARING_KINDS above, which is about the DECLARING side.
|
||
|
||
/**
|
||
* Languages whose chained static-factory/fluent calls defer to the conformance
|
||
* second pass. Dotted-receiver languages resolve via matchDottedCallChain; the
|
||
* `::`-receiver ones (Rust) via matchScopedCallChain.
|
||
*/
|
||
const CHAIN_LANGUAGES = new Set(['java', 'kotlin', 'csharp', 'swift', 'rust', 'go', 'scala', 'dart', 'objc', 'pascal']);
|
||
const SCOPED_CHAIN_LANGUAGES = new Set(['rust']);
|
||
|
||
/** The extractor's chained-receiver encoding: `<inner>().<method>`. */
|
||
const CHAIN_SHAPE = /^(.+)\(\)\.(\w+)$/;
|
||
|
||
/** PHP `$this->prop->method()` encoded as `this->prop.method` — no `()`, so CHAIN_SHAPE misses it. */
|
||
const PHP_PROP_SHAPE = /^this->\w+\.\w+$/;
|
||
|
||
/**
|
||
* How many files that import a changed module by default a sync reads the
|
||
* edges of, when the module forwards its default from another file, to see
|
||
* whether they still land on it (changedDefaultExports).
|
||
*/
|
||
const DEFAULT_READER_SAMPLE_FILES = 8;
|
||
|
||
/**
|
||
* Cache size limits. Each per-resolver cache is bounded so memory
|
||
* stays flat on large codebases (20k+ files). Sizes were chosen to
|
||
* cover the working set for typical resolution batches without
|
||
* exceeding a few hundred MB worst-case. Override via the env var
|
||
* `CODEGRAPH_RESOLVER_CACHE_SIZE` (single integer applied to all
|
||
* caches) when tuning for very large or very small projects.
|
||
*/
|
||
const DEFAULT_CACHE_LIMIT = 5_000;
|
||
function resolveCacheLimit(): number {
|
||
const raw = process.env.CODEGRAPH_RESOLVER_CACHE_SIZE;
|
||
if (!raw) return DEFAULT_CACHE_LIMIT;
|
||
const parsed = Number.parseInt(raw, 10);
|
||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||
return DEFAULT_CACHE_LIMIT;
|
||
}
|
||
|
||
// Re-export types
|
||
export * from './types';
|
||
|
||
// Pre-built Sets for O(1) built-in lookups (allocated once, shared across all instances)
|
||
const REACT_HOOKS = new Set([
|
||
'useState', 'useEffect', 'useContext', 'useReducer', 'useCallback',
|
||
'useMemo', 'useRef', 'useLayoutEffect', 'useImperativeHandle', 'useDebugValue',
|
||
]);
|
||
|
||
const PYTHON_BUILT_INS = new Set([
|
||
'print', 'len', 'range', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple',
|
||
'open', 'input', 'type', 'isinstance', 'hasattr', 'getattr', 'setattr',
|
||
'super', 'self', 'cls', 'None', 'True', 'False',
|
||
]);
|
||
|
||
const PYTHON_BUILT_IN_TYPES = new Set([
|
||
'list', 'dict', 'set', 'tuple', 'str', 'int', 'float', 'bool',
|
||
'bytes', 'bytearray', 'frozenset', 'object', 'super',
|
||
]);
|
||
|
||
const PYTHON_BUILT_IN_METHODS = new Set([
|
||
'append', 'extend', 'insert', 'remove', 'pop', 'clear', 'sort', 'reverse', 'copy',
|
||
'update', 'keys', 'values', 'items', 'get',
|
||
'add', 'discard', 'union', 'intersection', 'difference',
|
||
'split', 'join', 'strip', 'lstrip', 'rstrip', 'replace', 'lower', 'upper',
|
||
'startswith', 'endswith', 'find', 'index', 'count', 'encode', 'decode',
|
||
'format', 'isdigit', 'isalpha', 'isalnum',
|
||
'read', 'write', 'readline', 'readlines', 'close', 'flush', 'seek',
|
||
]);
|
||
|
||
const GO_BUILT_INS = new Set([
|
||
'make', 'new', 'len', 'cap', 'append', 'copy', 'delete', 'close',
|
||
'panic', 'recover', 'print', 'println', 'complex', 'real', 'imag',
|
||
'error', 'nil', 'true', 'false', 'iota',
|
||
'int', 'int8', 'int16', 'int32', 'int64',
|
||
'uint', 'uint8', 'uint16', 'uint32', 'uint64', 'uintptr',
|
||
'float32', 'float64', 'complex64', 'complex128',
|
||
'string', 'bool', 'byte', 'rune', 'any',
|
||
]);
|
||
|
||
/** What a Pascal typecast's name resolves to: `TFoo(x)` casts, it doesn't call (see createEdges). */
|
||
const PASCAL_CAST_TARGET_KINDS: ReadonlySet<string> = new Set(['class', 'struct', 'interface', 'enum', 'type_alias']);
|
||
|
||
/** The values a TypeScript type may share its name with in its file, which a call or `new` of the name means. */
|
||
const TYPE_COMPANION_VALUE_KINDS: ReadonlySet<string> = new Set(['function', 'class', 'component', 'constant', 'variable']);
|
||
|
||
/**
|
||
* The RTL's unit namespaces, as it spells them. Spelled otherwise, a
|
||
* receiver is most likely a variable that hides one (`data.GetObjectFrom`,
|
||
* `rest.EngineAdd`); only `system.` is the System unit's in any case.
|
||
*/
|
||
const PASCAL_UNIT_PREFIXES = [
|
||
'System.', 'Winapi.', 'Vcl.', 'Fmx.', 'Data.', 'Datasnap.',
|
||
'Soap.', 'Xml.', 'Web.', 'REST.', 'FireDAC.', 'IBX.',
|
||
'IdHTTP', 'IdTCP', 'IdSSL',
|
||
];
|
||
|
||
/** Lowercased: Pascal names ignore case. */
|
||
const PASCAL_BUILT_INS = new Set([
|
||
'System', 'SysUtils', 'Classes', 'Types', 'Variants', 'StrUtils',
|
||
'Math', 'DateUtils', 'IOUtils', 'Generics.Collections', 'Generics.Defaults',
|
||
'Rtti', 'TypInfo', 'SyncObjs', 'RegularExpressions',
|
||
'SysInit', 'Windows', 'Messages', 'Graphics', 'Controls', 'Forms',
|
||
'Dialogs', 'StdCtrls', 'ExtCtrls', 'ComCtrls', 'Menus', 'ActnList',
|
||
'WriteLn', 'Write', 'ReadLn', 'Read', 'Inc', 'Dec', 'Ord', 'Chr',
|
||
'Length', 'SetLength', 'High', 'Low', 'Assigned', 'FreeAndNil',
|
||
'Format', 'IntToStr', 'StrToInt', 'FloatToStr', 'StrToFloat',
|
||
'Trim', 'UpperCase', 'LowerCase', 'Pos', 'Copy', 'Delete', 'Insert',
|
||
'Now', 'Date', 'Time', 'DateToStr', 'StrToDate',
|
||
'Raise', 'Exit', 'Break', 'Continue', 'Abort',
|
||
'True', 'False', 'nil', 'Self', 'Result',
|
||
'Create', 'Destroy', 'Free',
|
||
'TObject', 'TComponent', 'TPersistent', 'TInterfacedObject',
|
||
'TList', 'TStringList', 'TStrings', 'TStream', 'TMemoryStream', 'TFileStream',
|
||
'Exception', 'EAbort', 'EConvertError', 'EAccessViolation',
|
||
'IInterface', 'IUnknown',
|
||
].map((name) => name.toLowerCase()));
|
||
|
||
const C_BUILT_INS = new Set([
|
||
// Standard C library functions
|
||
'printf', 'fprintf', 'sprintf', 'snprintf', 'scanf', 'fscanf', 'sscanf',
|
||
'malloc', 'calloc', 'realloc', 'free',
|
||
'memcpy', 'memmove', 'memset', 'memcmp', 'memchr',
|
||
'strlen', 'strcpy', 'strncpy', 'strcat', 'strncat', 'strcmp', 'strncmp',
|
||
'strstr', 'strchr', 'strrchr', 'strtok', 'strdup',
|
||
'fopen', 'fclose', 'fread', 'fwrite', 'fgets', 'fputs', 'fputc', 'fgetc',
|
||
'feof', 'ferror', 'fflush', 'fseek', 'ftell', 'rewind',
|
||
'exit', 'abort', 'atexit', 'atoi', 'atol', 'atof', 'strtol', 'strtoul', 'strtod',
|
||
'qsort', 'bsearch',
|
||
'abs', 'labs', 'rand', 'srand',
|
||
'sin', 'cos', 'tan', 'sqrt', 'pow', 'log', 'log10', 'exp', 'ceil', 'floor', 'fabs',
|
||
'time', 'clock', 'difftime', 'mktime', 'localtime', 'gmtime', 'strftime', 'asctime',
|
||
'assert', 'errno',
|
||
'perror', 'remove', 'rename', 'tmpfile', 'tmpnam',
|
||
'getenv', 'system',
|
||
'signal', 'raise',
|
||
'setjmp', 'longjmp',
|
||
'va_start', 'va_end', 'va_arg', 'va_copy',
|
||
'NULL', 'EOF', 'BUFSIZ', 'FILENAME_MAX', 'RAND_MAX', 'EXIT_SUCCESS', 'EXIT_FAILURE',
|
||
'size_t', 'ptrdiff_t', 'wchar_t', 'intptr_t', 'uintptr_t',
|
||
'int8_t', 'int16_t', 'int32_t', 'int64_t',
|
||
'uint8_t', 'uint16_t', 'uint32_t', 'uint64_t',
|
||
'FILE',
|
||
// POSIX additions commonly seen
|
||
'stat', 'lstat', 'fstat', 'open', 'close', 'read', 'write', 'pipe',
|
||
'fork', 'exec', 'waitpid', 'getpid', 'getppid', 'kill', 'sleep', 'usleep',
|
||
'pthread_create', 'pthread_join', 'pthread_mutex_lock', 'pthread_mutex_unlock',
|
||
'dlopen', 'dlsym', 'dlclose',
|
||
]);
|
||
|
||
const CPP_BUILT_INS = new Set([
|
||
// iostream objects (often used without std:: prefix via using)
|
||
'cout', 'cin', 'cerr', 'clog', 'endl', 'flush', 'ws',
|
||
'std', // the namespace itself when used as std::something
|
||
// Common C++ keywords that leak as references
|
||
'nullptr', 'true', 'false', 'this', 'sizeof', 'alignof', 'typeid',
|
||
'static_cast', 'dynamic_cast', 'reinterpret_cast', 'const_cast',
|
||
'make_unique', 'make_shared', 'make_pair',
|
||
'move', 'forward', 'swap',
|
||
]);
|
||
|
||
/**
|
||
* Reference Resolver
|
||
*
|
||
* Orchestrates reference resolution using multiple strategies.
|
||
*/
|
||
export class ReferenceResolver {
|
||
private projectRoot: string;
|
||
private queries: QueryBuilder;
|
||
private context: ResolutionContext;
|
||
private frameworks: FrameworkResolver[] = [];
|
||
/** `frameworks` narrowed to those that resolve each reference language (see getResolvingFrameworks). */
|
||
private frameworksByLanguage = new Map<Language, FrameworkResolver[]>();
|
||
// Chained static-factory/fluent call refs the first pass couldn't resolve,
|
||
// collected in-memory and left pending in the DB until the post-pass
|
||
// finishes, so a restart can recover the queue (#1577). Drained by
|
||
// resolveChainedCallsViaConformance
|
||
// once implements/extends edges exist, to resolve methods on a supertype the
|
||
// receiver conforms to (#750).
|
||
private deferredChainRefs: UnresolvedRef[] = [];
|
||
// `this.<member>` function-as-value refs whose member is NOT on the
|
||
// enclosing class itself — possibly inherited. Collected in-memory for the
|
||
// same reason as deferredChainRefs and drained by
|
||
// resolveDeferredThisMemberRefs once implements/extends edges exist (#808).
|
||
private deferredThisMemberRefs: UnresolvedRef[] = [];
|
||
private deferredRowIds = new Set<number>();
|
||
// Per-`.razor`/`.cshtml`-file `@using` namespace set (own directives + folder
|
||
// `_Imports.razor`, cascading to the project root). Used to disambiguate a
|
||
// markup type ref to the right C# namespace.
|
||
private razorUsingsCache = new Map<string, string[]>();
|
||
// All per-resolver caches are LRU-bounded. Previously these were
|
||
// unbounded Maps that grew with every distinct lookup and OOM'd on
|
||
// codebases with 20k+ files (see issue: unbounded cache growth).
|
||
private nodeCache: LRUCache<string, Node[]>; // per-file node cache
|
||
private fileCache: LRUCache<string, string | null>; // per-file content cache
|
||
private importMappingCache: LRUCache<string, ImportMapping[]>;
|
||
private reExportCache: LRUCache<string, ReExport[]>;
|
||
private nameCache: LRUCache<string, Node[]>; // name → nodes cache
|
||
private lowerNameCache: LRUCache<string, Node[]>; // lower(name) → nodes cache
|
||
private qualifiedNameCache: LRUCache<string, Node[]>; // qualified_name → nodes cache
|
||
private fileLinesCache: LRUCache<string, string[] | null>; // file → split lines cache
|
||
// id → node for the resolver's own point reads. Every resolved reference
|
||
// re-reads its target a few times (kind gate, language gate, alias
|
||
// following, edge creation) and targets recur ~5× on large repos; the query
|
||
// layer's cache is too small for that working set. Same stable window as the
|
||
// name caches above; absent ids are not cached.
|
||
private nodeByIdCache: LRUCache<string, Node>;
|
||
private methodMatchCache: LRUCache<string, Node[]>; // lang\0Type::method → matching method nodes
|
||
// Per-(language, methodName) owner index for getMethodMatches: buckets a
|
||
// method name's candidates by their qualifiedName's last two segments so a
|
||
// (type, method) query is a lookup instead of an O(candidates) filter per
|
||
// methodMatchCache miss. Derived purely from node rows (stable through the
|
||
// resolution loop, same window nameCache relies on); dropped in clearCaches.
|
||
private methodOwnerIndexCache = new Map<string, Map<string, Node[]>>();
|
||
// Generation-tagged memo for getSupertypes. Supertype edges GROW during the
|
||
// resolution loop (batch k persists its implements/extends edges BEFORE
|
||
// batch k+1 fans out — the #1320 ordering), so a plain cache would freeze an
|
||
// early batch's emptier answer and change later batches' outcomes. Within
|
||
// one batch the edge state is fixed by that same ordering, so entries are
|
||
// tagged with a generation that advances at every batch entry point
|
||
// (resolveBatchYielding / resolveListForAdmission) — a stale-gen entry is
|
||
// recomputed, making the memo behavior-identical to no memo at every point
|
||
// in time. On the Swift compiler the unmemoized walk ran 971k times for
|
||
// 565s of combined worker time (~581µs each, recursion-multiplied).
|
||
private supertypeGen = 0;
|
||
private supertypeMemo = new Map<string, { gen: number; supers: string[] }>();
|
||
// getSupertypeNodes' memo, by node id, under the same generation tag.
|
||
private supertypeNodeMemo = new Map<string, { gen: number; nodes: Node[] }>();
|
||
// getSuperclassNodes' memo (`extends` edges alone), the same way.
|
||
private superclassNodeMemo = new Map<string, { gen: number; nodes: Node[] }>();
|
||
|
||
/** Invalidate the getSupertypes memo — call when resolved edges may have advanced. */
|
||
private advanceSupertypeGeneration(): void {
|
||
this.supertypeGen++;
|
||
// Lazy invalidation via the gen tag; bound the map so a long run over many
|
||
// batches doesn't accrete dead entries.
|
||
if (this.supertypeMemo.size > 50_000) this.supertypeMemo.clear();
|
||
if (this.supertypeNodeMemo.size > 50_000) this.supertypeNodeMemo.clear();
|
||
if (this.superclassNodeMemo.size > 50_000) this.superclassNodeMemo.clear();
|
||
}
|
||
// Node kinds are a small fixed set (~24), so this is a plain Map, not an LRU.
|
||
// getNodesByKind returns the FULL node list for a kind; it was previously
|
||
// uncached — a per-ref `SELECT * FROM nodes WHERE kind=?` + row-mapping. Called
|
||
// for every dotted call ref by the Spring resolver (constants) and every
|
||
// `hook_` ref by the Drupal resolver (functions), that scan dominated
|
||
// resolution on large repos (#1180). The node set is stable within a
|
||
// resolution pass (same lifetime assumption as nameCache); clearCaches() resets
|
||
// it between passes. Callers must treat the returned array as read-only.
|
||
private nodesByKindCache = new Map<Node['kind'], Node[]>();
|
||
// Filesystem existence probes behind context.fileExists (paths not in knownFiles).
|
||
private fileExistsMemo = new Map<string, boolean>();
|
||
private knownNames: Set<string> | null = null; // all known symbol names for fast pre-filtering
|
||
private knownFiles: Set<string> | null = null;
|
||
private cachesWarmed = false;
|
||
// tsconfig/jsconfig path-alias map. `undefined` = not yet computed,
|
||
// `null` = computed and absent. Treated as immutable for the
|
||
// resolver's lifetime; callers re-create the resolver if config changes.
|
||
private projectAliases: AliasMap | null | undefined = undefined;
|
||
// Per directory: the aliases of the nearest non-root tsconfig declaring `paths` or `baseUrl`.
|
||
private dirAliases = new Map<string, AliasMap | null>();
|
||
// Per project-relative directory ('' = the root): the module of the nearest
|
||
// go.mod at or above it, up to the project root. Same lazy/immutable
|
||
// convention as dirAliases.
|
||
private goModuleByDir = new Map<string, GoModule | null>();
|
||
// The project's Go modules: the root one plus every module that owns an
|
||
// indexed .go file. Depends on the file set, like knownFiles, so
|
||
// clearCaches drops it.
|
||
private goModules: GoModule[] | null = null;
|
||
// getGoPackageDir answers, keyed by import path and the importing file's
|
||
// module. Derived from goModules, so dropped with it.
|
||
private goPackageDirs = new Map<string, string | null>();
|
||
// Monorepo workspace member packages. Same lazy/immutable convention.
|
||
private workspacePackages: WorkspacePackages | null | undefined = undefined;
|
||
|
||
constructor(projectRoot: string, queries: QueryBuilder) {
|
||
this.projectRoot = projectRoot;
|
||
this.queries = queries;
|
||
|
||
const limit = resolveCacheLimit();
|
||
// The content cache is heavier (full file text), so we give it a
|
||
// smaller budget than the metadata caches.
|
||
const contentLimit = Math.max(64, Math.floor(limit / 5));
|
||
this.nodeCache = new LRUCache(limit);
|
||
this.fileCache = new LRUCache(contentLimit);
|
||
this.importMappingCache = new LRUCache(limit);
|
||
this.reExportCache = new LRUCache(limit);
|
||
this.nameCache = new LRUCache(limit);
|
||
this.lowerNameCache = new LRUCache(limit);
|
||
this.qualifiedNameCache = new LRUCache(limit);
|
||
// Split-lines arrays are heavier than content strings; refs arrive
|
||
// file-ordered, so a small cache still hits nearly always.
|
||
this.fileLinesCache = new LRUCache(contentLimit);
|
||
this.nodeByIdCache = new LRUCache(Math.max(limit * 4, 20_000));
|
||
this.methodMatchCache = new LRUCache(limit);
|
||
|
||
this.context = this.createContext();
|
||
}
|
||
|
||
/**
|
||
* Initialize the resolver (detect frameworks, etc.)
|
||
*/
|
||
initialize(): void {
|
||
// A full index resolves by the package.json files on disk now, as the
|
||
// fingerprint it stamps says, even in a process that read them earlier.
|
||
this.workspacePackages = undefined;
|
||
this.frameworks = detectFrameworks(this.context);
|
||
this.frameworksByLanguage.clear();
|
||
this.clearCaches();
|
||
}
|
||
|
||
/** The detected frameworks whose `resolve()` reads a reference written in `language`. */
|
||
private frameworksFor(language: Language): FrameworkResolver[] {
|
||
let frameworks = this.frameworksByLanguage.get(language);
|
||
if (!frameworks) {
|
||
frameworks = getResolvingFrameworks(this.frameworks, language);
|
||
this.frameworksByLanguage.set(language, frameworks);
|
||
}
|
||
return frameworks;
|
||
}
|
||
|
||
/**
|
||
* Run each framework resolver's cross-file finalization pass and persist
|
||
* the returned node updates. Idempotent — safe to call after every indexAll
|
||
* and every incremental sync. Returns the number of nodes updated.
|
||
*
|
||
* Caches are cleared before/after so the post-extract pass sees fresh DB
|
||
* state and downstream queries see the updated names.
|
||
*/
|
||
runPostExtract(): number {
|
||
let updated = 0;
|
||
this.clearCaches();
|
||
for (const fw of this.frameworks) {
|
||
if (!fw.postExtract) continue;
|
||
try {
|
||
const nodes = fw.postExtract(this.context);
|
||
for (const node of nodes) {
|
||
this.queries.updateNode(node);
|
||
updated++;
|
||
}
|
||
} catch (err) {
|
||
logDebug(`Framework '${fw.name}' postExtract failed`, {
|
||
error: err instanceof Error ? err.message : String(err),
|
||
});
|
||
}
|
||
}
|
||
for (const fw of this.frameworks) {
|
||
if (!fw.crossFileNodes) continue;
|
||
try {
|
||
updated += this.reconcileCrossFileNodes(fw.crossFileNodes(this.context));
|
||
} catch (err) {
|
||
logDebug(`Framework '${fw.name}' crossFileNodes failed`, {
|
||
error: err instanceof Error ? err.message : String(err),
|
||
});
|
||
}
|
||
}
|
||
if (updated > 0) this.clearCaches();
|
||
return updated;
|
||
}
|
||
|
||
/**
|
||
* Bring a framework's cross-file nodes in line with what it wants now:
|
||
* insert the new ones with their references (pending, for the resolution
|
||
* that follows), rename the ones whose name changed, and remove the ones it
|
||
* no longer wants. A removed node's incoming resolution edges go back to
|
||
* being the references that made them, exactly as when a file is deleted,
|
||
* so they rebind elsewhere or park as failed. Unchanged nodes are not
|
||
* touched, so their edges survive. Returns the number of nodes changed.
|
||
*/
|
||
private reconcileCrossFileNodes(result: CrossFileNodes): number {
|
||
const wanted = new Map<string, Node>();
|
||
for (const node of result.nodes) if (result.owns(node)) wanted.set(node.id, node);
|
||
const existing = new Map<string, Node>();
|
||
for (const node of this.queries.getNodesByKind(result.kind)) if (result.owns(node)) existing.set(node.id, node);
|
||
let changed = 0;
|
||
for (const [id, old] of existing) {
|
||
if (wanted.has(id)) continue;
|
||
const refs = this.queries.getCrossFileIncomingEdgesWithTarget(old.filePath)
|
||
.filter((e) => e.target === id)
|
||
.map((e) => resurrectRefFromDroppedEdge(e))
|
||
.filter((r): r is UnresolvedReference => r !== null);
|
||
if (refs.length > 0) this.queries.insertUnresolvedRefsBatch(refs);
|
||
this.queries.deleteNode(id);
|
||
changed++;
|
||
}
|
||
const added: Node[] = [];
|
||
for (const [id, node] of wanted) {
|
||
const old = existing.get(id);
|
||
if (!old) added.push(node);
|
||
else if (old.name !== node.name || old.qualifiedName !== node.qualifiedName) {
|
||
this.queries.updateNode(node);
|
||
changed++;
|
||
}
|
||
}
|
||
if (added.length > 0) {
|
||
this.queries.insertNodes(added);
|
||
const ids = new Set(added.map((n) => n.id));
|
||
this.queries.insertUnresolvedRefsBatch(result.references.filter((r) => ids.has(r.fromNodeId)));
|
||
changed += added.length;
|
||
}
|
||
return changed;
|
||
}
|
||
|
||
/**
|
||
* Put back in the pending set the route references whose answer reads a
|
||
* module in `changedFilePaths` (`FrameworkResolver.lazyModules`), for a
|
||
* sync's resolution sweep. Returns the number put back.
|
||
*
|
||
* A route that renders a same-file `const Docs = lazy(() =>
|
||
* import('./pages/Docs'))` binds to the declaration while the module is
|
||
* missing, and nothing revisited it once the module was added: the
|
||
* reference had resolved, so it was never parked for the failed-ref retry,
|
||
* and the rebind of the names a sync defines (CG-33) reaches it only when
|
||
* the module's component is named like the declaration. An edit that gives
|
||
* the module its default export, or moves it to another component, changes
|
||
* the answer the same way: a route's edge follows its old target through
|
||
* the module's re-index. So does a page behind a barrel the module
|
||
* forwards. References written in a changed file were resolved against the
|
||
* new files already.
|
||
*/
|
||
reopenRouteModuleReaders(changedFilePaths: readonly string[]): number {
|
||
const readers = this.frameworks.filter((f) => f.lazyModules);
|
||
if (readers.length === 0 || changedFilePaths.length === 0) return 0;
|
||
const changed = new Set(changedFilePaths);
|
||
const edgeIds: number[] = [];
|
||
const refs: UnresolvedReference[] = [];
|
||
for (const edge of this.queries.getRouteEdgesMovedBy(changedFilePaths)) {
|
||
const ref = resurrectRefFromDroppedEdge(edge);
|
||
if (!ref) continue;
|
||
const asked: UnresolvedRef = { ...ref, filePath: edge.sourceFilePath, language: edge.sourceLanguage };
|
||
const reads = readers.some((f) => f.lazyModules!(asked, this.context).some((file) => changed.has(file)));
|
||
if (!reads) continue;
|
||
edgeIds.push(edge.edgeId);
|
||
refs.push(ref);
|
||
}
|
||
if (refs.length > 0) this.queries.replaceResolutionEdgesWithUnresolvedRefs(edgeIds, refs);
|
||
return refs.length;
|
||
}
|
||
|
||
/**
|
||
* Pre-build lightweight caches for resolution.
|
||
* Node lookups are now handled by indexed SQLite queries instead of
|
||
* loading all nodes into memory (which caused OOM on large codebases).
|
||
* We cache the set of known symbol names for fast pre-filtering.
|
||
*/
|
||
warmCaches(): void {
|
||
if (this.cachesWarmed) return;
|
||
|
||
// Only cache the set of known file paths (lightweight string set)
|
||
this.knownFiles = new Set(this.queries.getAllFilePaths());
|
||
|
||
// Cache all distinct symbol names for fast pre-filtering (just strings, not full nodes)
|
||
this.knownNames = new Set(this.queries.getAllNodeNames());
|
||
|
||
this.cachesWarmed = true;
|
||
}
|
||
|
||
/**
|
||
* warmCaches for the async resolution entry points: streams the distinct
|
||
* name set with periodic yields instead of one synchronous `.all()`. On a
|
||
* multi-million-node index the DISTINCT scan is a solid multi-second block
|
||
* (measured up to 28s inside `codegraph sync` on the Linux kernel index),
|
||
* long enough to matter to the #850 watchdog on slower hardware. Same
|
||
* result, same memory — only the event loop keeps turning.
|
||
*/
|
||
async warmCachesYielding(onYield: MaybeYield): Promise<void> {
|
||
if (this.cachesWarmed) return;
|
||
|
||
this.knownFiles = new Set(this.queries.getAllFilePaths());
|
||
|
||
const names = new Set<string>();
|
||
let scanned = 0;
|
||
for (const name of this.queries.iterateNodeNames()) {
|
||
names.add(name);
|
||
if ((++scanned & 8191) === 0) await onYield();
|
||
}
|
||
this.knownNames = names;
|
||
|
||
this.cachesWarmed = true;
|
||
}
|
||
|
||
/** `queries.getNodeById` through the resolver's own id → node cache. */
|
||
private nodeById(id: string): Node | null {
|
||
const cached = this.nodeByIdCache.get(id);
|
||
if (cached !== undefined) return cached;
|
||
const node = this.queries.getNodeById(id);
|
||
if (node) this.nodeByIdCache.set(id, node);
|
||
return node;
|
||
}
|
||
|
||
/**
|
||
* Clear internal caches
|
||
*/
|
||
clearCaches(): void {
|
||
this.mocklessContext = null;
|
||
this.mocklessBase = null;
|
||
this.manualMocks = undefined;
|
||
this.classExpressionFreeViews = new WeakMap();
|
||
this.classExpressionFreeBases = new WeakMap();
|
||
this.classExpressionIndex = undefined;
|
||
this.classExpressionOnlyNameSet = null;
|
||
this.nodeCache.clear();
|
||
this.fileCache.clear();
|
||
this.importMappingCache.clear();
|
||
this.reExportCache.clear();
|
||
this.nameCache.clear();
|
||
this.lowerNameCache.clear();
|
||
this.qualifiedNameCache.clear();
|
||
this.fileLinesCache.clear();
|
||
this.nodeByIdCache.clear();
|
||
this.methodMatchCache.clear();
|
||
this.methodOwnerIndexCache.clear();
|
||
this.supertypeMemo.clear();
|
||
this.supertypeNodeMemo.clear();
|
||
this.superclassNodeMemo.clear();
|
||
this.supertypeGen++;
|
||
this.nodesByKindCache.clear();
|
||
this.fileExistsMemo.clear();
|
||
this.manifestScopes.clear();
|
||
this.knownNames = null;
|
||
this.knownLowerNames = null;
|
||
this.knownFiles = null;
|
||
this.goModules = null;
|
||
this.goPackageDirs.clear();
|
||
this.cachesWarmed = false;
|
||
// The import-resolver's and name-matcher's per-context memos assume the
|
||
// same stable window as the caches above — drop them together.
|
||
if (this.context) {
|
||
clearImportResolverMemos(this.context);
|
||
clearNameMatcherMemos(this.context);
|
||
clearCppMacroVisibility(this.context);
|
||
clearSwiftTypeVisibility(this.context);
|
||
clearDartLibraryMemos(this.context);
|
||
clearVbnetReceiverMemos(this.context);
|
||
clearPascalScopeMemos(this.context);
|
||
clearTypeParameterMemos(this.context);
|
||
clearDartLocalScopeMemos(this.context);
|
||
clearCppTypeAliasMemos(this.context);
|
||
clearCppSupertypeMemos(this.context);
|
||
clearCppIncluderMemos(this.context);
|
||
clearCFeatureVariantMemos(this.context);
|
||
clearAmbientModuleMemos(this.context);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The module of the nearest `go.mod` at or above project-relative `dir`
|
||
* ('' = the root), never above the project root (#2322). Each directory
|
||
* is read at most once: every directory the walk passes is memoized.
|
||
*/
|
||
private nearestGoModule(dir: string): GoModule | null {
|
||
const walked: string[] = [];
|
||
let found: GoModule | null | undefined;
|
||
for (;;) {
|
||
found = this.goModuleByDir.get(dir);
|
||
if (found !== undefined) break;
|
||
walked.push(dir);
|
||
found = loadGoModule(path.join(this.projectRoot, dir));
|
||
if (found || dir === '') break;
|
||
dir = goDirOf(dir);
|
||
}
|
||
for (const d of walked) this.goModuleByDir.set(d, found);
|
||
return found;
|
||
}
|
||
|
||
/**
|
||
* The project's Go modules: the root `go.mod`'s, and the nearest `go.mod`
|
||
* of every directory holding an indexed `.go` file — so an ignored or
|
||
* vendored tree, which holds no indexed file, adds none, and nothing walks
|
||
* the disk. The go tool ignores `testdata/` and `_`/`.`-prefixed
|
||
* directories, so a module there serves only its own files (#2322).
|
||
*/
|
||
private getGoModules(): GoModule[] {
|
||
if (this.goModules) return this.goModules;
|
||
const dirs = new Set<string>(['']);
|
||
for (const file of this.knownFiles ?? this.queries.getAllFilePaths()) {
|
||
if (file.endsWith('.go')) dirs.add(goDirOf(file));
|
||
}
|
||
const modules = new Set<GoModule>();
|
||
for (const dir of dirs) {
|
||
const mod = this.nearestGoModule(dir);
|
||
const modDir = mod ? path.relative(this.projectRoot, mod.rootDir).split(path.sep).join('/') : '';
|
||
if (mod && !/(?:^|\/)(?:testdata|[_.][^/]*)(?:\/|$)/.test(modDir)) modules.add(mod);
|
||
}
|
||
this.goModules = [...modules];
|
||
return this.goModules;
|
||
}
|
||
|
||
/** `readFile` through the LRU content cache (null = read failed, also cached). */
|
||
private readFileCached(filePath: string): string | null {
|
||
if (this.fileCache.has(filePath)) {
|
||
return this.fileCache.get(filePath)!;
|
||
}
|
||
const fullPath = path.join(this.projectRoot, filePath);
|
||
try {
|
||
// Import resolvers may follow package metadata to an archive (`file:*.har`,
|
||
// for example). Reject anything extraction would not accept before UTF-8
|
||
// decoding can multiply a large binary blob into gigabytes of V8 heap.
|
||
const stats = fs.statSync(fullPath);
|
||
if (!stats.isFile() || stats.size > MAX_SOURCE_FILE_SIZE_BYTES) {
|
||
this.fileCache.set(filePath, null);
|
||
return null;
|
||
}
|
||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||
this.fileCache.set(filePath, content);
|
||
return content;
|
||
} catch (error) {
|
||
logDebug('Failed to read file for resolution', { filePath, error: String(error) });
|
||
this.fileCache.set(filePath, null);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* `readFileCached(filePath)?.includes(needle)` for an ASCII needle, searched
|
||
* in the raw bytes: an ASCII byte sequence survives UTF-8 decoding
|
||
* unchanged, and decoding is most of reading a file nobody keeps.
|
||
*/
|
||
private fileContains(filePath: string, needle: string): boolean {
|
||
if (this.fileCache.has(filePath)) return this.fileCache.get(filePath)?.includes(needle) ?? false;
|
||
const fullPath = path.join(this.projectRoot, filePath);
|
||
try {
|
||
const stats = fs.statSync(fullPath);
|
||
if (!stats.isFile() || stats.size > MAX_SOURCE_FILE_SIZE_BYTES) return false;
|
||
return fs.readFileSync(fullPath).includes(needle);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Create the resolution context
|
||
*/
|
||
private createContext(): ResolutionContext {
|
||
return {
|
||
// A C or C++ `#include` the import resolver can't place is linked by the
|
||
// end of its path, as resolveOneInner's name matching does: protobuf's
|
||
// `google/protobuf/descriptor.upb.h`, which two generated copies end in.
|
||
// cppIncluders reads includes through this, so it sees the include edges
|
||
// a reader sees. A standard header is never the project's: `<math.h>`
|
||
// is not jemalloc's test/math.h, which only its includers may see.
|
||
resolveImport: (ref) => resolveViaImport(ref, this.context) ??
|
||
((ref.language === 'c' || ref.language === 'cpp') && ref.referenceKind === 'imports' &&
|
||
!isExternalImport(ref.referenceName, ref.language, this.context) ? matchByFilePath(ref, this.context) : null),
|
||
// A path alias makes an import the project's only when it maps the
|
||
// import to a file the index holds. Matching its prefix is not enough:
|
||
// cord-field's `"*": ["./typings/*"]` matches every package, and
|
||
// counting the match bound 169 imports of `@mui/material`'s Typography
|
||
// to its own. Nor is a path that exists on disk: home-assistant's
|
||
// `"lit/decorators": ["./node_modules/lit/decorators.js"]` lands in
|
||
// `node_modules`, topcoder's `config` package on its `config/` folder.
|
||
isOutOfRepoImport: (source, fromFile, language) => {
|
||
if (!isExternalImport(source, language, this.context, { aliasPrefixes: false, fromFile })) return false;
|
||
const resolved = resolveImportPath(source, fromFile, language, this.context);
|
||
if (resolved !== null && this.isIndexedFile(resolved)) return false;
|
||
return this.isDeclaredOutsidePackage(source, fromFile);
|
||
},
|
||
resolveModuleFile: (source, fromFile, language) =>
|
||
resolveImportPath(source, fromFile, language, this.context),
|
||
isDeclaredPackage: (source, fromFile) => this.isDeclaredOutsidePackage(source, fromFile),
|
||
isIndexedFile: (filePath) => this.isIndexedFile(filePath),
|
||
getNodesInFile: (filePath: string) => {
|
||
if (!this.nodeCache.has(filePath)) {
|
||
this.nodeCache.set(filePath, this.queries.getNodesByFile(filePath));
|
||
}
|
||
return this.nodeCache.get(filePath)!;
|
||
},
|
||
|
||
fileHasExportedNode: (filePath: string) => {
|
||
const cached = this.nodeCache.get(filePath);
|
||
return cached !== undefined ? cached.some((n) => n.isExported) : this.queries.fileHasExportedNode(filePath);
|
||
},
|
||
|
||
getExportedNodesInFile: (filePath: string) => {
|
||
const cached = this.nodeCache.get(filePath);
|
||
return cached !== undefined ? cached.filter((n) => n.isExported) : this.queries.getExportedNodesByFile(filePath);
|
||
},
|
||
|
||
getNodesInFileNamed: (filePath: string, name: string) => {
|
||
const cached = this.nodeCache.get(filePath);
|
||
return cached !== undefined ? cached.filter((n) => n.name === name) : this.queries.getNodesByFileAndName(filePath, name);
|
||
},
|
||
|
||
getNodesByName: (name: string) => {
|
||
const cached = this.nameCache.get(name);
|
||
if (cached !== undefined) return cached;
|
||
const result = this.queries.getNodesByName(name);
|
||
this.nameCache.set(name, result);
|
||
return result;
|
||
},
|
||
|
||
getMethodMatches: (typeName: string, methodName: string, language: Language) => {
|
||
const key = `${language} ${typeName}::${methodName}`;
|
||
const cached = this.methodMatchCache.get(key);
|
||
if (cached !== undefined) return cached;
|
||
let candidates = this.nameCache.get(methodName);
|
||
if (candidates === undefined) {
|
||
candidates = this.queries.getNodesByName(methodName);
|
||
this.nameCache.set(methodName, candidates);
|
||
}
|
||
const want = `${typeName}::${methodName}`;
|
||
let matches: Node[];
|
||
if (typeName.includes('::') || methodName.includes(':')) {
|
||
// Legacy linear filter for the shapes the owner index below can't
|
||
// key exactly: a multi-segment typeName (the endsWith test then
|
||
// spans more than two `::` segments) and ObjC selectors (whose
|
||
// single/empty-keyword colons defeat the segment split). Tiny
|
||
// populations; the per-key memo above still amortizes them.
|
||
matches = [];
|
||
for (const m of candidates) {
|
||
if (m.kind !== 'method') continue;
|
||
if (!sameLanguageFamily(m.language, language)) continue;
|
||
const qn = m.qualifiedName;
|
||
if (qn === want || qn.endsWith(`::${want}`)) matches.push(m);
|
||
}
|
||
} else {
|
||
// Owner index: the linear filter above is O(all same-named methods)
|
||
// per CACHE MISS, and on overload-heavy landscapes the distinct
|
||
// (type, method) key space is so large the per-key memo never
|
||
// amortizes — Swift's `init` has tens of thousands of candidates
|
||
// and the compiler repo measured 732µs per failing call, most of it
|
||
// this scan (re-entered once per supertype recursion level, too).
|
||
// Bucket each (language, methodName)'s candidates ONCE by the
|
||
// qualifiedName's last two `::` segments — exactly the span the
|
||
// `qn === want || qn.endsWith('::' + want)` predicate tests for a
|
||
// segment-clean typeName — then every query is a map lookup.
|
||
// Bucket insertion follows candidate order, so each bucket is
|
||
// byte-identical to what the linear filter produced.
|
||
const idxKey = `${language} ${methodName}`;
|
||
let ownerIndex = this.methodOwnerIndexCache.get(idxKey);
|
||
if (!ownerIndex) {
|
||
ownerIndex = new Map<string, Node[]>();
|
||
for (const m of candidates) {
|
||
if (m.kind !== 'method') continue;
|
||
if (!sameLanguageFamily(m.language, language)) continue;
|
||
const qn = m.qualifiedName;
|
||
const i2 = qn.lastIndexOf('::');
|
||
if (i2 < 0) continue; // single-segment qn can never match `T::m`
|
||
const i1 = qn.lastIndexOf('::', i2 - 1);
|
||
const bucketKey = i1 < 0 ? qn : qn.slice(i1 + 2);
|
||
const bucket = ownerIndex.get(bucketKey);
|
||
if (bucket) bucket.push(m);
|
||
else ownerIndex.set(bucketKey, [m]);
|
||
}
|
||
this.methodOwnerIndexCache.set(idxKey, ownerIndex);
|
||
}
|
||
matches = ownerIndex.get(want) ?? [];
|
||
}
|
||
this.methodMatchCache.set(key, matches);
|
||
return matches;
|
||
},
|
||
|
||
getNodesByQualifiedName: (qualifiedName: string) => {
|
||
const cached = this.qualifiedNameCache.get(qualifiedName);
|
||
if (cached !== undefined) return cached;
|
||
const result = this.queries.getNodesByQualifiedNameExact(qualifiedName);
|
||
this.qualifiedNameCache.set(qualifiedName, result);
|
||
return result;
|
||
},
|
||
|
||
getNodesByKind: (kind: Node['kind']) => {
|
||
const cached = this.nodesByKindCache.get(kind);
|
||
if (cached !== undefined) return cached;
|
||
const result = this.queries.getNodesByKind(kind);
|
||
this.nodesByKindCache.set(kind, result);
|
||
return result;
|
||
},
|
||
|
||
// Streamed, uncached — synthesizers scan-and-filter whole kinds, and
|
||
// both the materialized array AND the per-kind cache retention are
|
||
// O(nodes) memory (#1212). Per-ref resolvers keep the cached array
|
||
// variant above.
|
||
iterateNodesByKind: (kind: Node['kind']) => this.queries.iterateNodesByKind(kind),
|
||
|
||
fileExists: (filePath: string) => {
|
||
// Check pre-built known files set first (O(1))
|
||
if (this.knownFiles) {
|
||
const normalized = filePath.replace(/\\/g, '/');
|
||
if (this.knownFiles.has(filePath) || this.knownFiles.has(normalized)) {
|
||
return true;
|
||
}
|
||
}
|
||
// Fall back to filesystem for files not yet indexed. `path.join` does
|
||
// not clamp, and relative-import resolution hands us paths carrying
|
||
// `../` segments, so the probe has to be contained (#1631): a path
|
||
// outside the root can never be an indexed project file, and the
|
||
// `knownFiles` check above already answered for everything that is.
|
||
// Lexical containment only: this is a per-candidate hot path, and the
|
||
// symlink half of `validatePathWithinRoot` costs two `realpathSync`
|
||
// calls per probe (~70x slower here). It would also be wrong to apply
|
||
// — indexing deliberately follows in-root symlinks whose targets live
|
||
// outside the root (#935), so only the `../` escape is refused.
|
||
// Memoized: import resolution probes the same candidate paths (every
|
||
// extension of a specifier) from every file that imports it, and the
|
||
// tree does not change within a resolution pass (same window as the
|
||
// file-content cache; dropped by clearCaches).
|
||
const probed = this.fileExistsMemo.get(filePath);
|
||
if (probed !== undefined) return probed;
|
||
const fullPath = lexicalPathWithinRoot(this.projectRoot, filePath);
|
||
let exists = false;
|
||
if (fullPath !== null) {
|
||
try {
|
||
exists = fs.existsSync(fullPath);
|
||
} catch (error) {
|
||
logDebug('Error checking file existence', { filePath, error: String(error) });
|
||
}
|
||
}
|
||
if (this.fileExistsMemo.size >= 200_000) this.fileExistsMemo.clear();
|
||
this.fileExistsMemo.set(filePath, exists);
|
||
return exists;
|
||
},
|
||
|
||
readFile: (filePath: string) => this.readFileCached(filePath),
|
||
|
||
fileContains: (filePath: string, needle: string) => this.fileContains(filePath, needle),
|
||
|
||
getFileLines: (filePath: string) => {
|
||
const cached = this.fileLinesCache.get(filePath);
|
||
if (cached !== undefined) return cached;
|
||
const source = this.readFileCached(filePath);
|
||
const lines = source === null ? null : source.split(/\r?\n/);
|
||
this.fileLinesCache.set(filePath, lines);
|
||
return lines;
|
||
},
|
||
|
||
getProjectRoot: () => this.projectRoot,
|
||
|
||
getAllFiles: () => {
|
||
return this.queries.getAllFilePaths();
|
||
},
|
||
|
||
listDirectories: (relativePath: string) => {
|
||
const target = relativePath === '.' || relativePath === ''
|
||
? this.projectRoot
|
||
: path.join(this.projectRoot, relativePath);
|
||
try {
|
||
return fs
|
||
.readdirSync(target, { withFileTypes: true })
|
||
.filter((entry) => entry.isDirectory())
|
||
.map((entry) => entry.name);
|
||
} catch (error) {
|
||
logDebug('Failed to list directory for resolution', {
|
||
relativePath,
|
||
error: String(error),
|
||
});
|
||
return [];
|
||
}
|
||
},
|
||
|
||
getNodesByLowerName: (lowerName: string) => {
|
||
const cached = this.lowerNameCache.get(lowerName);
|
||
if (cached !== undefined) return cached;
|
||
const result = this.queries.getNodesByLowerName(lowerName);
|
||
this.lowerNameCache.set(lowerName, result);
|
||
return result;
|
||
},
|
||
|
||
getNodeNamesOfKinds: (language, kinds) => this.queries.getNodeNamesOfKinds(language, kinds),
|
||
|
||
rememberNode: (node: Node) => {
|
||
if (!this.nodeByIdCache.has(node.id)) this.nodeByIdCache.set(node.id, node);
|
||
},
|
||
|
||
getNodeById: (id: string) => {
|
||
return this.nodeById(id);
|
||
},
|
||
|
||
getSupertypes: (typeName: string, language) => {
|
||
// Union the `implements`/`extends` targets of every same-named type node.
|
||
// Matching by simple name (not id) reconciles a type declared in one node
|
||
// (`KF::Builder`) with conformance declared in a separate extension node
|
||
// (`KF.Builder: KFOptionSetter`) — both have name `Builder`.
|
||
// Memoized per batch generation (see supertypeMemo): within a batch the
|
||
// edge state is fixed, and the conformance walk re-queries the same
|
||
// popular supertypes (Swift stdlib protocols especially) thousands of
|
||
// times per batch.
|
||
const memoKey = `${language} ${typeName}`;
|
||
const hit = this.supertypeMemo.get(memoKey);
|
||
if (hit && hit.gen === this.supertypeGen) return hit.supers;
|
||
const typeNodes = this.context
|
||
.getNodesByName(typeName)
|
||
// Scala singletons can inherit members even though they cannot be parents.
|
||
.filter((n) => n.language === language && (SUPERTYPE_BEARING_KINDS.has(n.kind) ||
|
||
(n.language === 'scala' && n.kind === 'module')));
|
||
let supers: string[];
|
||
if (typeNodes.length === 0) {
|
||
supers = [];
|
||
} else {
|
||
const supertypes = new Set<string>();
|
||
for (const tn of typeNodes) {
|
||
for (const edge of this.queries.getOutgoingEdges(tn.id, RESOLUTION_READ_EDGE_KINDS)) {
|
||
const target = this.nodeById(edge.target);
|
||
if (target?.name && target.name !== typeName) supertypes.add(target.name);
|
||
}
|
||
// A Swift conformance to a type the project only extends (SwiftUI's
|
||
// `View`) resolves to nothing — the extension is not the type — yet
|
||
// the members its extensions add are still the conformer's.
|
||
if (language === 'swift') {
|
||
for (const name of swiftExtendedConformances(tn, this.context)) {
|
||
if (name !== typeName) supertypes.add(name);
|
||
}
|
||
}
|
||
}
|
||
supers = [...supertypes];
|
||
}
|
||
this.supertypeMemo.set(memoKey, { gen: this.supertypeGen, supers });
|
||
return supers;
|
||
},
|
||
|
||
getSupertypeNodes: (id: string) => {
|
||
const hit = this.supertypeNodeMemo.get(id);
|
||
if (hit && hit.gen === this.supertypeGen) return hit.nodes;
|
||
const nodes: Node[] = [];
|
||
for (const edge of this.queries.getOutgoingEdges(id, RESOLUTION_READ_EDGE_KINDS)) {
|
||
const target = this.nodeById(edge.target);
|
||
if (target) nodes.push(target);
|
||
}
|
||
this.supertypeNodeMemo.set(id, { gen: this.supertypeGen, nodes });
|
||
return nodes;
|
||
},
|
||
|
||
getSuperclassNodes: (id: string) => {
|
||
const hit = this.superclassNodeMemo.get(id);
|
||
if (hit && hit.gen === this.supertypeGen) return hit.nodes;
|
||
const nodes: Node[] = [];
|
||
for (const edge of this.queries.getOutgoingEdges(id, ['extends'])) {
|
||
const target = this.nodeById(edge.target);
|
||
if (target) nodes.push(target);
|
||
}
|
||
this.superclassNodeMemo.set(id, { gen: this.supertypeGen, nodes });
|
||
return nodes;
|
||
},
|
||
|
||
getImportMappings: (filePath: string, language) => {
|
||
const cacheKey = filePath;
|
||
const cached = this.importMappingCache.get(cacheKey);
|
||
if (cached) return cached;
|
||
|
||
const content = this.context.readFile(filePath);
|
||
if (!content) {
|
||
this.importMappingCache.set(cacheKey, []);
|
||
return [];
|
||
}
|
||
|
||
const mappings = extractImportMappings(filePath, content, language);
|
||
this.importMappingCache.set(cacheKey, mappings);
|
||
return mappings;
|
||
},
|
||
|
||
getProjectAliases: () => {
|
||
if (this.projectAliases === undefined) {
|
||
this.projectAliases = loadProjectAliases(this.projectRoot);
|
||
}
|
||
return this.projectAliases;
|
||
},
|
||
|
||
getNearestAliases: (fromFile: string) => {
|
||
let dir = path.posix.dirname(fromFile.replace(/\\/g, '/'));
|
||
const walked: string[] = [];
|
||
let found: AliasMap | null = null;
|
||
while (dir && dir !== '.' && dir !== '/') {
|
||
const hit = this.dirAliases.get(dir);
|
||
if (hit !== undefined) {
|
||
found = hit;
|
||
break;
|
||
}
|
||
walked.push(dir);
|
||
const abs = path.join(this.projectRoot, dir);
|
||
if (['tsconfig.json', 'jsconfig.json'].some((name) => fs.existsSync(path.join(abs, name)))) {
|
||
const aliases = loadProjectAliases(abs);
|
||
if (aliases) {
|
||
found = aliases;
|
||
break;
|
||
}
|
||
}
|
||
dir = path.posix.dirname(dir);
|
||
}
|
||
for (const d of walked) this.dirAliases.set(d, found);
|
||
return found;
|
||
},
|
||
|
||
getGoPackageDir: (importPath: string, fromFile?: string) => {
|
||
const own = fromFile === undefined ? null : this.nearestGoModule(goDirOf(fromFile));
|
||
const key = `${importPath}\0${own?.rootDir ?? ''}`;
|
||
const hit = this.goPackageDirs.get(key);
|
||
if (hit !== undefined) return hit;
|
||
const mod = findGoModuleForImport(importPath, this.getGoModules(), own);
|
||
const dir = mod ? goModulePackageDir(importPath, mod, this.projectRoot) : null;
|
||
this.goPackageDirs.set(key, dir);
|
||
return dir;
|
||
},
|
||
|
||
getWorkspacePackages: () => {
|
||
if (this.workspacePackages === undefined) {
|
||
this.workspacePackages = loadWorkspacePackages(this.projectRoot);
|
||
}
|
||
return this.workspacePackages;
|
||
},
|
||
|
||
getReExports: (filePath: string, language) => {
|
||
const cached = this.reExportCache.get(filePath);
|
||
if (cached) return cached;
|
||
const content = this.context.readFile(filePath);
|
||
if (!content) {
|
||
this.reExportCache.set(filePath, []);
|
||
return [];
|
||
}
|
||
// What matters is the BARREL file's own language — not the consuming
|
||
// reference's. A `.svelte`/`.vue` consumer threads its own language
|
||
// down the re-export chase, which would make extractReExports() bail
|
||
// on a `.ts` index barrel and silently break the chain (#629). Re-key
|
||
// the parse on the barrel's extension so the chase works no matter
|
||
// what kind of file imports through it. Python re-exports through its
|
||
// imports (a package `__init__.py`), so a `.py` barrel parses as Python.
|
||
const isJsFamily = /\.(?:d\.ts|[cm]?tsx?|[cm]?jsx?|ets)$/i.test(filePath);
|
||
const barrelLanguage = isJsFamily ? 'typescript' : /\.py$/i.test(filePath) ? 'python' : language;
|
||
const reExports = extractReExports(content, barrelLanguage);
|
||
this.reExportCache.set(filePath, reExports);
|
||
return reExports;
|
||
},
|
||
|
||
getCppIncludeDirs: () => {
|
||
return loadCppIncludeDirs(this.projectRoot);
|
||
},
|
||
|
||
getCppIncludeNodes: () => this.queries.getCppIncludeNodes(),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Resolve all unresolved references
|
||
*/
|
||
resolveAll(
|
||
unresolvedRefs: UnresolvedReference[],
|
||
onProgress?: (current: number, total: number) => void
|
||
): ResolutionResult {
|
||
// Pre-load all nodes into memory for fast lookups
|
||
this.warmCaches();
|
||
this.advanceSupertypeGeneration();
|
||
|
||
const resolved: ResolvedRef[] = [];
|
||
const unresolved: UnresolvedRef[] = [];
|
||
const byMethod: Record<string, number> = {};
|
||
|
||
// Convert to our internal format, using denormalized fields when available
|
||
const refs: UnresolvedRef[] = unresolvedRefs.map((ref) => ({
|
||
fromNodeId: ref.fromNodeId,
|
||
referenceName: ref.referenceName,
|
||
referenceKind: ref.referenceKind,
|
||
line: ref.line,
|
||
column: ref.column,
|
||
filePath: ref.filePath || this.getFilePathFromNodeId(ref.fromNodeId),
|
||
language: ref.language || this.getLanguageFromNodeId(ref.fromNodeId),
|
||
rowId: ref.rowId,
|
||
}));
|
||
|
||
const total = refs.length;
|
||
let lastReportedPercent = -1;
|
||
|
||
for (let i = 0; i < refs.length; i++) {
|
||
const ref = refs[i]!; // Array index is guaranteed to be in bounds
|
||
const result = this.resolveOneTimed(ref);
|
||
|
||
if (result) {
|
||
resolved.push(result);
|
||
byMethod[result.resolvedBy] = (byMethod[result.resolvedBy] || 0) + 1;
|
||
} else {
|
||
unresolved.push(this.parkable(ref));
|
||
}
|
||
|
||
// Report progress every 1% to avoid too many updates
|
||
if (onProgress) {
|
||
const currentPercent = Math.floor((i / total) * 100);
|
||
if (currentPercent > lastReportedPercent) {
|
||
lastReportedPercent = currentPercent;
|
||
onProgress(i + 1, total);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Final progress report
|
||
if (onProgress && total > 0) {
|
||
onProgress(total, total);
|
||
}
|
||
|
||
return {
|
||
resolved,
|
||
unresolved,
|
||
stats: {
|
||
total: refs.length,
|
||
resolved: resolved.length,
|
||
unresolved: unresolved.length,
|
||
byMethod,
|
||
},
|
||
};
|
||
}
|
||
|
||
/** Lowercased `knownNames`, built the first time a case-insensitive language asks. */
|
||
private knownLowerNames: Set<string> | null = null;
|
||
|
||
/** `hasAnyPossibleMatch` for a language whose names ignore case: the name, or any `.`/`::`/`->` part of it. */
|
||
private hasAnyPossibleMatchIgnoringCase(name: string): boolean {
|
||
if (!this.knownNames) return true;
|
||
if (!this.knownLowerNames || this.knownLowerNames.size === 0) {
|
||
this.knownLowerNames = new Set();
|
||
for (const known of this.knownNames) this.knownLowerNames.add(known.toLowerCase());
|
||
}
|
||
const lower = name.toLowerCase();
|
||
if (this.knownLowerNames.has(lower)) return true;
|
||
return lower.split(/::|->|\./).some((part) => part.length > 0 && this.knownLowerNames!.has(part));
|
||
}
|
||
|
||
/**
|
||
* Check if a reference name has any possible match in the codebase.
|
||
* Uses the pre-built knownNames set to skip expensive resolution
|
||
* for names that definitely don't exist as symbols.
|
||
*/
|
||
private hasAnyPossibleMatch(name: string): boolean {
|
||
if (!this.knownNames) return true; // no pre-filter available
|
||
// In the view outside the class expressions, names only their classes
|
||
// and members carry name nothing (classExpressionFreeContextFor).
|
||
const only = this.classExpressionFreeBases.has(this.context) ? this.classExpressionOnlyNames() : null;
|
||
const known = (n: string): boolean => this.knownNames!.has(n) && !(only?.has(n) ?? false);
|
||
|
||
// Direct name match
|
||
if (known(name)) return true;
|
||
|
||
// For qualified names like "obj.method" or "Class::method", check the parts
|
||
const dotIdx = name.indexOf('.');
|
||
if (dotIdx > 0) {
|
||
const receiver = name.substring(0, dotIdx);
|
||
const member = name.substring(dotIdx + 1);
|
||
if (known(receiver) || known(member)) return true;
|
||
// Also check capitalized receiver (instance-method resolution)
|
||
const capitalized = receiver.charAt(0).toUpperCase() + receiver.slice(1);
|
||
if (known(capitalized)) return true;
|
||
// JVM FQN: `com.example.foo.Bar` — the only useful segment is the
|
||
// last one (`Bar`); the earlier check finds `example.foo.Bar` which
|
||
// never matches a node name.
|
||
const lastDot = name.lastIndexOf('.');
|
||
if (lastDot > dotIdx) {
|
||
const tail = name.substring(lastDot + 1);
|
||
if (tail && known(tail)) return true;
|
||
}
|
||
}
|
||
const colonIdx = name.indexOf('::');
|
||
if (colonIdx > 0) {
|
||
const receiver = name.substring(0, colonIdx);
|
||
const member = name.substring(colonIdx + 2);
|
||
if (known(receiver) || known(member)) return true;
|
||
// Multi-segment path `a::b::c` (a Rust/C++ module call like
|
||
// `database::profiles::find`) — the only segment that names a symbol is
|
||
// the last (`c`); `member` above is `b::c`, which never matches a node
|
||
// name, so without this the pre-filter drops the ref before the Rust path
|
||
// resolver ever sees it. Mirror the dotted-name leaf check above.
|
||
const lastColon = name.lastIndexOf('::');
|
||
if (lastColon > colonIdx) {
|
||
const tail = name.substring(lastColon + 2);
|
||
if (tail && known(tail)) return true;
|
||
}
|
||
}
|
||
|
||
// Lua/Luau method calls use a single `:` (`lg:log`); R uses `$` (`lg$log`).
|
||
// Check the member (and receiver) around these separators too, so the ref
|
||
// isn't dropped here before the method-call resolver ever sees it. The `:`
|
||
// case is skipped when the name actually contains `::` (handled above).
|
||
for (const sep of [':', '$']) {
|
||
if (sep === ':' && name.includes('::')) continue;
|
||
const sepIdx = name.indexOf(sep);
|
||
if (sepIdx > 0) {
|
||
const receiver = name.substring(0, sepIdx);
|
||
const member = name.substring(sepIdx + 1);
|
||
if (known(member) || known(receiver)) return true;
|
||
const capitalized = receiver.charAt(0).toUpperCase() + receiver.slice(1);
|
||
if (known(capitalized)) return true;
|
||
}
|
||
}
|
||
|
||
// For path-like references (e.g., "snippets/drawer-menu.liquid"), check the filename
|
||
const slashIdx = name.lastIndexOf('/');
|
||
if (slashIdx > 0) {
|
||
const fileName = name.substring(slashIdx + 1);
|
||
if (known(fileName)) return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Does `ref.referenceName` match an import declared in its containing
|
||
* file? Used as a pre-filter escape so re-export chain resolution
|
||
* still gets a chance when the name has no project-wide declaration.
|
||
*/
|
||
private matchesAnyImport(ref: UnresolvedRef): boolean {
|
||
const imports = this.context.getImportMappings(ref.filePath, ref.language);
|
||
if (imports.length === 0) return false;
|
||
for (const imp of imports) {
|
||
if (
|
||
imp.localName === ref.referenceName ||
|
||
ref.referenceName.startsWith(imp.localName + '.')
|
||
) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Resolve a single reference.
|
||
*
|
||
* Thin decorator over `resolveOneInner` so every strategy — framework,
|
||
* import, name-match, chain, CFML component path — passes through the
|
||
* inheritance target-kind gate at ONE seam. Filtering inside the
|
||
* name-matcher would have covered `matchByExactName` only.
|
||
* Calls that land on an alias binding then forward once to the callable
|
||
* the alias names (see ./alias-binding), regardless of the strategy.
|
||
*/
|
||
/**
|
||
* The failed python attribute-chain refs that an edit to `touched` makes
|
||
* resolvable now — what a sync retries after a factory or a base class
|
||
* changed in another file (`pythonAttrRefResolves` answers each one).
|
||
*
|
||
* The candidates are the refs in the files that import a touched file
|
||
* within a few hops, counted once per (calling symbol, name). When there
|
||
* are more than `ceiling` of them — an edit to a module most of the project
|
||
* imports — only those the edit can plausibly reach are kept: an attribute
|
||
* a touched file assigns, a call receiver it defines, a class that
|
||
* subclasses one of its classes, or an attribute assigned from a call to
|
||
* something it defines. Null when even that is over the ceiling; those refs
|
||
* wait for a full index.
|
||
*
|
||
* Past the ceiling, then, these become resolvable only at the next full
|
||
* index: a factory two hops from the edit, a subclass two hops away, and an
|
||
* attribute typed by an annotation — a parameter `c: Client`, or a
|
||
* class-level `c: Client` in a class that only gains the base or import that
|
||
* resolves `Client` in the touched file.
|
||
*/
|
||
pythonAttrRetryCandidates(touched: string[], ceiling: number = 2000): UnresolvedReference[] | null {
|
||
const touchedSet = new Set(touched);
|
||
const closure = this.queries.getPythonImporterClosure(touched).filter((f) => !touchedSet.has(f));
|
||
// One answer per (calling symbol, name): the same call written twice in
|
||
// one function resolves the same way, so it is asked — and counted — once.
|
||
const keyOf = (ref: { fromNodeId: string; referenceName: string }) => `${ref.fromNodeId}\0${ref.referenceName}`;
|
||
const resolvable = (refs: UnresolvedReference[]) => {
|
||
const answers = new Map<string, boolean>();
|
||
return refs.filter((ref) => {
|
||
if (!ref.filePath || !ref.language) return false;
|
||
const key = keyOf(ref);
|
||
let ok = answers.get(key);
|
||
if (ok === undefined) answers.set(key, (ok = pythonAttrRefResolves(ref as UnresolvedRef, this.context)));
|
||
return ok;
|
||
});
|
||
};
|
||
const candidates = this.queries.getFailedPythonAttrRefKeys(closure);
|
||
const load = (keys: typeof candidates) => this.queries.getUnresolvedReferencesByIds(keys.map((k) => k.id));
|
||
if (new Set(candidates.map(keyOf)).size <= ceiling) return resolvable(load(candidates));
|
||
|
||
const assigned = new Set<string>();
|
||
const defined = new Set<string>();
|
||
const subclassing = new Set<string>();
|
||
const calling = new Set<string>();
|
||
for (const file of touched) {
|
||
const source = this.context.readFile(file);
|
||
if (source) {
|
||
const text = stripCommentsForRegex(source, 'python');
|
||
for (const m of text.matchAll(/\b(?:self|cls)\.(\w+)\s*(?::[^=\n]*)?=(?!=)|^[ \t]+(\w+)\s*(?::[^=\n]*)?=(?!=)/gm)) {
|
||
assigned.add((m[1] ?? m[2])!);
|
||
}
|
||
}
|
||
for (const n of this.context.getNodesInFile(file)) {
|
||
if (n.kind === 'class' || n.kind === 'function' || n.kind === 'method') defined.add(n.name);
|
||
}
|
||
for (const f of this.queries.getPythonFilesWithEdgesInto(file, ['extends'], ['class'])) subclassing.add(f);
|
||
for (const f of this.queries.getPythonFilesWithEdgesInto(file, ['calls', 'instantiates'], ['function', 'method', 'class'])) {
|
||
calling.add(f);
|
||
}
|
||
}
|
||
|
||
const callees = new Map<string, Map<string, Set<string>>>(); // file → attr → callee names
|
||
const assignedCallees = (file: string, attr: string): Set<string> => {
|
||
let byAttr = callees.get(file);
|
||
if (!byAttr) {
|
||
byAttr = new Map();
|
||
const source = this.context.readFile(file);
|
||
const text = source ? stripCommentsForRegex(source, 'python') : '';
|
||
for (const m of text.matchAll(/\b(\w+)\s*(?::[^=\n]*)?=(?!=)\s*(?:await\s+)?(?:[\w.]*\.)?(\w+)\s*\(/g)) {
|
||
let set = byAttr.get(m[1]!);
|
||
if (!set) byAttr.set(m[1]!, (set = new Set()));
|
||
set.add(m[2]!);
|
||
}
|
||
callees.set(file, byAttr);
|
||
}
|
||
return byAttr.get(attr) ?? new Set();
|
||
};
|
||
|
||
const kept = candidates.filter((ref) => {
|
||
const name = ref.referenceName;
|
||
if (name.includes('().')) return defined.has(name.split('().')[0]!.split('.').pop()!);
|
||
const segs = name.split('.');
|
||
if (segs.length !== 3 || !segs.every((seg) => /^[A-Za-z_]\w*$/.test(seg))) return false;
|
||
const attr = segs[1]!;
|
||
if (assigned.has(attr)) return true;
|
||
if (subclassing.has(ref.filePath) && (segs[0] === 'self' || segs[0] === 'cls')) return true;
|
||
return calling.has(ref.filePath) && [...assignedCallees(ref.filePath, attr)].some((c) => defined.has(c));
|
||
});
|
||
return new Set(kept.map(keyOf)).size <= ceiling ? resolvable(load(kept)) : null;
|
||
}
|
||
|
||
resolveOne(ref: UnresolvedRef): ResolvedRef | null {
|
||
// Production code is resolved as if the project's manual mocks were not
|
||
// indexed (see mocklessContextFor), and code outside the named class
|
||
// expressions as if their classes were not (classExpressionFreeContextFor);
|
||
// every strategy below reads the graph through this.context, so it
|
||
// answers for that view.
|
||
const outer = this.context;
|
||
const unviewed = this.classExpressionFreeBases.get(outer) ?? outer;
|
||
const base = unviewed === this.mocklessContext && this.mocklessBase ? this.mocklessBase : unviewed;
|
||
const mockless = this.mocklessContextFor(ref, base) ?? base;
|
||
return this.resolveOneWith(this.classExpressionFreeContextFor(ref, mockless) ?? mockless, ref);
|
||
}
|
||
|
||
/** resolveOneIn with every strategy reading the graph through `context`. */
|
||
private resolveOneWith(context: ResolutionContext, ref: UnresolvedRef): ResolvedRef | null {
|
||
const outer = this.context;
|
||
if (context === outer) return this.resolveOneIn(ref);
|
||
this.context = context;
|
||
try {
|
||
return this.resolveOneIn(ref);
|
||
} finally {
|
||
this.context = outer;
|
||
}
|
||
}
|
||
|
||
private resolveOneIn(ref: UnresolvedRef): ResolvedRef | null {
|
||
// A C/C++ "call" whose name is a function-like macro visible in this
|
||
// translation unit is a macro expansion, not a call — it must never bind
|
||
// to a same-named function in another file (#1838).
|
||
if (isVisibleCppMacro(ref, this.context)) return null;
|
||
// A Swift type reference never lands on an `extension X {}` node, nor on a
|
||
// nested type it cannot name bare (see ./swift-type-visibility).
|
||
// A name a declaration around the reference declares as a type parameter
|
||
// (`def f[A]`, `class Foo<T>`) is that parameter (see ./type-parameters),
|
||
// and a Dart call to a parameter or local calls that (./dart-local-scope).
|
||
const candidate = gateDartLocal(
|
||
gateTypeParameter(
|
||
gateSwiftTypeTarget(this.gateTargetKind(this.resolveOneInner(ref), ref), ref, this.context),
|
||
ref,
|
||
this.context,
|
||
),
|
||
ref,
|
||
this.context,
|
||
);
|
||
const scoped = this.gateRustScope(candidate, ref);
|
||
// A Go name its package defines once per build target is the definition
|
||
// the reference's file builds with (see preferGoBuildTwin).
|
||
const resolved = preferGoBuildTwin(this.gateSuperSelfCall(
|
||
scoped?.resolvedBy === 'framework' ? this.gateFrameworkLanguage(scoped, ref) : this.gateLanguage(scoped, ref),
|
||
ref,
|
||
), ref, this.context);
|
||
// A Dart class whose field holds its own type (`class Node { Node? next; }`)
|
||
// names itself: a self-edge that says nothing (#2327).
|
||
if (resolved && ref.language === 'dart' && ref.referenceKind === 'references' &&
|
||
resolved.targetNodeId === ref.fromNodeId) return null;
|
||
if (resolved && isPythonFromImportModule(ref, this.context)) {
|
||
const module = this.nodeById(resolved.targetNodeId);
|
||
if (module && module.kind !== 'file' && module.kind !== 'module') return null;
|
||
}
|
||
// A name the calling JS/TS function binds itself (a parameter, a local) in
|
||
// scope at the reference is that binding even when the file imports the
|
||
// name, which the import resolver answers before the matcher's own check.
|
||
if (resolved && resolved.resolvedBy === 'import' && isImportShadowedByJsBinding(resolved, ref, this.context)) return null;
|
||
// A declaration of a declaration file's `declare module 'x'` is reached
|
||
// through an import of `x`, never by its bare name from a file that
|
||
// reaches no `x` (see reachesAmbientModule).
|
||
if (resolved && resolved.resolvedBy !== 'import') {
|
||
const specifier = ambientModuleDeclarations(this.context).get(resolved.targetNodeId);
|
||
const declaringFile = specifier === undefined ? undefined : this.nodeById(resolved.targetNodeId)?.filePath;
|
||
if (declaringFile !== undefined && declaringFile !== ref.filePath &&
|
||
!reachesAmbientModule(ref, specifier!, declaringFile, this.context)) return null;
|
||
}
|
||
if (!resolved || ref.referenceKind !== 'calls') return resolved;
|
||
// A Pascal call to data runs only a routine the data holds, in scope, and
|
||
// one to a routine nested in another comes only from inside that one (see
|
||
// gatePascalCall).
|
||
const call = ref.language === 'pascal' ? gatePascalCall(resolved, ref, this.context) : resolved;
|
||
if (!call) return null;
|
||
|
||
const target = this.nodeById(call.targetNodeId);
|
||
if (!target) return call;
|
||
|
||
const dot = ref.referenceName.lastIndexOf('.');
|
||
const memberName = dot >= 0 ? ref.referenceName.slice(dot + 1) : null;
|
||
const forwarded = resolveAliasBinding(target, memberName, this.context);
|
||
if (!forwarded || forwarded.id === call.targetNodeId) return call;
|
||
|
||
return this.gateLanguage({
|
||
...call,
|
||
targetNodeId: forwarded.id,
|
||
confidence: Math.min(call.confidence, 0.85),
|
||
}, ref);
|
||
}
|
||
|
||
private resolveOneInner(ref: UnresolvedRef): ResolvedRef | null {
|
||
// A local C++ object construction (`T obj(args)`, ref `ns::T::T/1`)
|
||
// resolves ONLY to a constructor of the lexically nearest `T` (#1839).
|
||
if (isCppConstructorRef(ref)) return matchCppConstructor(ref, this.context);
|
||
// A C or C++ base class is the one C++ name lookup finds from where the
|
||
// class is declared — never a namesake of another namespace or language,
|
||
// which a framework's heuristics or name matching would take (see
|
||
// ./cpp-supertypes).
|
||
if (isCppSupertypeRef(ref)) return matchCppSupertype(ref, this.context);
|
||
|
||
// A Pascal name written without parentheses (#2514) links only to a
|
||
// routine its scope reaches, or through a receiver to a method of the
|
||
// receiver's declared type; data of that name links nothing.
|
||
if (isPascalParenlessRef(ref)) return matchPascalParenlessRef(ref, this.context);
|
||
// So does a Pascal call to a name the compiler or the System unit gives
|
||
// meaning to (`SizeOf`, `TypeInfo`, `length(S)`, `Pointer(P)`), whatever
|
||
// its case: only a declaration in the call's scope shadows it.
|
||
if (isPascalSystemCall(ref, this.context)) return matchPascalSystemCall(ref, this.context);
|
||
// A bare Pascal call to a routine nested where it is made (`Inner(1)` in
|
||
// the routine that declares `procedure Inner(N: Integer);`) is that one,
|
||
// and one made in a nested routine is what its scope declares first.
|
||
const nested = matchPascalNestedCall(ref, this.context);
|
||
if (nested) return nested;
|
||
// A Dart member read (`x.area`) links the getter the receiver's type
|
||
// reaches, as a call, or nothing — never a guess by name (#2338).
|
||
if (isDartMemberRead(ref)) return matchDartMemberRead(ref, this.context);
|
||
// So does a later link of a Dart call chain (`X.autoDispose.family(…)`,
|
||
// `events.map(f).transform(…)`), which arrives by its bare name: a member
|
||
// of what the chain's head and links are declared to be, or nothing (#750).
|
||
if (isDartChainLink(ref, this.context)) return matchDartChainLink(ref, this.context);
|
||
// A Dart annotation (`@riverpod`, `@Riverpod(…)`) is a constant or a
|
||
// constructor call, as written — never a method or function by its name.
|
||
if (isDartAnnotation(ref)) return matchDartAnnotation(ref, this.context);
|
||
// A Go call through a type assertion (`srv.(KVServer).Range(ctx, in)`),
|
||
// which arrives by its bare name, is to a method of the asserted type, or
|
||
// to nothing the project declares.
|
||
const asserted = matchGoAssertedCall(ref, this.context);
|
||
if (asserted !== undefined) return asserted;
|
||
// So is one on an indexed element (`schemas[ix].ValidateBytes(data)`,
|
||
// deepcopy's `(*in)[i].DeepCopyInto(…)`): to a method of the element type.
|
||
const indexed = matchGoIndexedCall(ref, this.context);
|
||
if (indexed !== undefined) return indexed;
|
||
// And one on any other receiver the extractor keeps no name for: a
|
||
// fluent chain's later link, a call's result, a composite literal
|
||
// (`model.WithContext(0).Where(1)`, `GitOracle{}.Worktree()`, #2543).
|
||
const chained = matchGoChainedCall(ref, this.context);
|
||
if (chained !== undefined) return chained;
|
||
|
||
// A section or snippet a Shopify theme's file names is that theme's own,
|
||
// or nothing: Shopify never looks in another theme (see ./shopify-themes).
|
||
const themeFile = matchShopifyThemeFile(ref, this.context);
|
||
if (themeFile !== undefined) return themeFile;
|
||
|
||
// A TS/JS `super.run(…)` runs the method of the class's parent or a
|
||
// nearer ancestor, or one outside the project: never a method found by
|
||
// its name alone (see ./js-super-calls).
|
||
const superCall = matchJsSuperCall(ref, this.context);
|
||
if (superCall !== undefined) return superCall;
|
||
|
||
// Skip built-in/external references — except a python call of a
|
||
// builtin-named method through a local or a parameter its def types as a
|
||
// project class (`reg = Registry(); reg.get(k)`, #2583): that reading,
|
||
// by evidence only, is the one way back, and nothing guesses past it.
|
||
if (this.isBuiltInOrExternal(ref)) {
|
||
return this.isPythonBuiltinNamedMethodCall(ref)
|
||
? this.gateLanguage(matchPythonTypedLocalCall(ref, this.context), ref)
|
||
: null;
|
||
}
|
||
|
||
// CFML component paths in inheritance (#1152): `extends="coldbox.system.web.
|
||
// Controller"` names the supertype by its dot-separated path (or `extends=
|
||
// "../base"` by relative file path) — the graph indexes the class under its
|
||
// final segment only, so these die at the fast pre-filter below and never
|
||
// resolved. Handled by a dedicated path-corroborated matcher, gated to
|
||
// inheritance refs only (a dotted `calls` ref is a member-access chain, not
|
||
// a component path). No fallthrough on miss: the full path string can only
|
||
// ever mis-match downstream, and an unresolvable supertype usually lives in
|
||
// an out-of-repo library (mxunit, testbox) — silent beats wrong.
|
||
if (
|
||
(ref.language === 'cfml' || ref.language === 'cfscript') &&
|
||
(ref.referenceKind === 'extends' || ref.referenceKind === 'implements') &&
|
||
(ref.referenceName.includes('.') || ref.referenceName.includes('/'))
|
||
) {
|
||
return this.resolveCfmlComponentPath(ref);
|
||
}
|
||
|
||
// A CFML include names a template by its path from the including file's
|
||
// directory, or from the web root (#2651): `<cfinclude template="../shared/
|
||
// helpers.cfm">` is that file, not whichever `helpers.cfm` file-name
|
||
// matching would settle on. A path that names no file here (one through a
|
||
// server mapping, `/contenscms/…`) is matched by its file name, as before,
|
||
// but only to a file: the symbol strategies below took a path no file
|
||
// matched for another template's import statement of the same path. Nor
|
||
// is it the including file, which a file-name match can land on (Wheels'
|
||
// `views/wheels/wheels.cfm` including `../../wheels/public/wheels.cfm`).
|
||
if ((ref.language === 'cfml' || ref.language === 'cfscript') && ref.referenceKind === 'imports' && isCfmlTemplatePath(ref.referenceName)) {
|
||
const included = this.resolveCfmlIncludePath(ref) ?? matchByFilePath(ref, this.context);
|
||
if (included && this.nodeById(included.targetNodeId)?.filePath === ref.filePath) return null;
|
||
return this.gateLanguage(included, ref);
|
||
}
|
||
|
||
// A PHP class written with a namespace in it — `new Alias\X()` through a
|
||
// `use Ns as Alias;` namespace alias, `extends Sub\Base`, `\Ns\X::make()`
|
||
// (#2256). The pre-filter below would drop most of these before any import
|
||
// strategy ran, and PHP gives each one exactly one meaning; resolve it first.
|
||
const phpQualified = resolvePhpQualifiedClassRef(ref, this.context);
|
||
if (phpQualified !== undefined) return this.gateLanguage(phpQualified, ref);
|
||
|
||
// A Ruby constant written with its namespace (`Billing::ChargeCard`,
|
||
// `::Foo`) and a call on one (`Billing::ChargeCard.call`) are what Ruby's
|
||
// constant lookup finds from where they are written, or nothing the
|
||
// project declares (see ./ruby-constant-paths). The pre-filter below
|
||
// drops a `::`-rooted name, and the strategies past it would take a
|
||
// namesake by its last name.
|
||
const rubyPath = matchRubyConstantPathRef(ref, this.context);
|
||
if (rubyPath !== undefined) return this.gateLanguage(rubyPath, ref);
|
||
|
||
// Fast pre-filter: skip if no symbol with this name exists anywhere
|
||
// AND the name doesn't match a local import. The import escape is
|
||
// necessary because re-export rename chains (`import { login }
|
||
// from './barrel'` where the barrel has `export { signIn as login }
|
||
// from './auth'`) intentionally call a name that has no
|
||
// declaration anywhere — only the renamed upstream symbol does.
|
||
// ArkTS chained-attribute refs carry a leading dot (`.titleStyle`) that
|
||
// routes them to the decorator-gated matcher; the symbol itself is
|
||
// indexed under the bare name, so the existence check strips the dot.
|
||
// Nix static path imports (`import ./x.nix`) and JS/TS module paths
|
||
// (`require('./x')`) name a FILE, not a symbol — they bypass the
|
||
// symbol-existence check and resolve via resolveViaImport.
|
||
let existenceName =
|
||
ref.language === 'arkts' && ref.referenceName.startsWith('.')
|
||
? ref.referenceName.slice(1)
|
||
: ref.referenceName;
|
||
// Erlang refs carry the call-site arity (`f/1`, `mod::f/2` — #1610); the
|
||
// name index stores bare names, so existence is checked arity-less.
|
||
if (ref.language === 'erlang') existenceName = existenceName.replace(/\/\d{1,3}$/, '');
|
||
// A C or C++ name written from the global scope (`::_pbi::PrivateAccess::
|
||
// GenerateParseTable`, `::memset`) exists when the name under the `::` does.
|
||
if ((ref.language === 'cpp' || ref.language === 'c') && existenceName.startsWith('::')) {
|
||
existenceName = existenceName.slice(2);
|
||
}
|
||
const tPre = this.profileStages ? process.hrtime.bigint() : 0n;
|
||
const preFilterPass =
|
||
isNixPathImportRef(ref) ||
|
||
isJsPathImportRef(ref) ||
|
||
this.hasAnyPossibleMatch(existenceName) ||
|
||
// PHP, Pascal, CFML, COBOL and VB.NET names ignore case: `formatprice()`
|
||
// calls `FormatPrice`, which the exact-name set never lists.
|
||
(CASE_INSENSITIVE_LANGUAGES.has(ref.language) && this.hasAnyPossibleMatchIgnoringCase(existenceName)) ||
|
||
this.matchesAnyImport(ref) ||
|
||
this.frameworksFor(ref.language).some((f) => f.claimsReference?.(ref.referenceName));
|
||
if (this.profileStages) this.stageAdd('preFilter', ref, preFilterPass, tPre);
|
||
if (!preFilterPass) {
|
||
return this.gateLanguage(matchJsStoreBindingCall(ref, this.context), ref);
|
||
}
|
||
|
||
// A VB.NET member read (`AppSession.SessionId`, #2305; `x.Normal`,
|
||
// `Me._h.Title`) means what VB.NET's scoping says its receiver is — a
|
||
// project type, whose member and the type itself it links, or a value,
|
||
// whose declared type's member it links — and no framework, import or
|
||
// name strategy guesses past that. Nor past a `With` block's call through
|
||
// a receiver path (`.Run()` in `With Me._h`).
|
||
if (isVbMemberRead(ref)) return this.gateLanguage(matchVbMemberRead(ref, this.context), ref);
|
||
if (isVbPathCall(ref)) {
|
||
return this.gateLanguage(matchVbPathCall(ref, this.context, (name) => isStdMethodName('vbnet', name)), ref);
|
||
}
|
||
|
||
// The last name of a TypeScript type written qualified (`React.ReactNode`,
|
||
// `TChat.ChatMessage`) is a member of what its qualifier names, which no
|
||
// framework, import or name strategy may guess past by the name alone.
|
||
const tsQualified = resolveTsQualifiedTypeRef(ref, this.context);
|
||
if (tsQualified !== undefined) return this.gateLanguage(tsQualified, ref);
|
||
|
||
// Function-as-value refs (#756) get a dedicated, strictly-gated path:
|
||
// import-based resolution first (an imported callback resolves through its
|
||
// import, the most precise cross-file signal), then matchFunctionRef
|
||
// (same-file first, unique-only cross-file, function/method targets only).
|
||
// They never reach the framework or fuzzy strategies below.
|
||
if (ref.referenceKind === 'function_ref') {
|
||
// `this.<member>` values (TS/JS) resolve ONLY against the enclosing
|
||
// class's own members — never a same-named symbol elsewhere.
|
||
if (ref.referenceName.startsWith('this.')) {
|
||
return this.gateLanguage(this.resolveThisMemberFnRef(ref), ref);
|
||
}
|
||
if ((ref.language === 'python' || ref.language === 'go') && ref.referenceName.includes('.')) {
|
||
return this.gateLanguage(matchFunctionRef(ref, this.context), ref);
|
||
}
|
||
const viaImport = this.gateLanguage(resolveViaImport(ref, this.context), ref);
|
||
if (viaImport) {
|
||
const target = this.nodeById(viaImport.targetNodeId);
|
||
if (
|
||
target &&
|
||
(target.kind === 'function' ||
|
||
target.kind === 'method' ||
|
||
// Python (#1478): an imported class used as a value (`return
|
||
// OrgSerializerFull`) resolves through its import like any
|
||
// callback — mirrors matchFunctionRef's bareClassOk.
|
||
(ref.language === 'python' && target.kind === 'class'))
|
||
) {
|
||
return viaImport;
|
||
}
|
||
}
|
||
return this.gateLanguage(matchFunctionRef(ref, this.context), ref);
|
||
}
|
||
|
||
// JVM FQN imports skip framework/name-matcher: `import com.example.Bar`
|
||
// resolves directly through the qualifiedName index, which is unambiguous
|
||
// even when several `Bar` classes exist in different packages.
|
||
const tJvm = this.profileStages ? process.hrtime.bigint() : 0n;
|
||
const jvmImport = resolveJvmImport(ref, this.context);
|
||
if (this.profileStages) this.stageAdd('jvmImport', ref, !!jvmImport, tJvm);
|
||
if (jvmImport) return jvmImport;
|
||
|
||
// Razor/Blazor: a markup or `@code` type ref resolves through the file's
|
||
// `@using` namespaces (incl. folder `_Imports.razor`). This precisely
|
||
// disambiguates a simple name that exists in several namespaces — e.g.
|
||
// `CatalogBrand` resolving to `BlazorShared.Models::CatalogBrand` (the DTO,
|
||
// which the `.razor` `@using`s) rather than the same-named domain entity.
|
||
if (ref.language === 'razor') {
|
||
const razorResult = this.resolveRazorUsing(ref);
|
||
if (razorResult) return razorResult;
|
||
}
|
||
|
||
// An explicit PHP class import owns its static calls, including an
|
||
// unavailable method. Do not let same-name fallbacks change the receiver
|
||
// to an unrelated Service/Repository type (#1545).
|
||
const phpStaticImport = resolvePhpImportedStaticCall(ref, this.context);
|
||
if (phpStaticImport !== undefined) return this.gateLanguage(phpStaticImport, ref);
|
||
|
||
const candidates: ResolvedRef[] = [];
|
||
|
||
// Strategy 1: Try framework-specific resolution, by the frameworks that
|
||
// read this reference's language: Express's `logger` middleware rule took
|
||
// a Go method's `*zap.Logger` result for the method itself (etcd).
|
||
// Cross-language bridges are deliberately preserved (Drupal `routing.yml`
|
||
// → PHP controller, RN JS → native `calls`); each lists the language its
|
||
// references are written in. Other code references obey the shared
|
||
// family gate.
|
||
const tFw = this.profileStages ? process.hrtime.bigint() : 0n;
|
||
let fwEarly: ResolvedRef | null = null;
|
||
for (const framework of this.frameworksFor(ref.language)) {
|
||
const resolved = this.gateFrameworkLanguage(framework.resolve(ref, this.context), ref);
|
||
// Name the resolver on the edge (`metadata.framework`): a Swift→ObjC or
|
||
// React Native bridge hop says how it got into the graph, as a
|
||
// synthesized edge's `synthesizedBy` does.
|
||
const result = resolved ? { ...resolved, metadata: { ...resolved.metadata, framework: framework.name } } : null;
|
||
if (result) {
|
||
if (result.confidence >= 0.9) {
|
||
fwEarly = result; // High confidence, return immediately (below)
|
||
break;
|
||
}
|
||
candidates.push(result);
|
||
}
|
||
}
|
||
if (this.profileStages) this.stageAdd('frameworks', ref, fwEarly !== null, tFw);
|
||
if (fwEarly) return fwEarly;
|
||
// A reference a framework answers alone ends with the frameworks' answer
|
||
// (see FrameworkResolver.ownsReference): Nuxt's `navigateTo(x)` with a
|
||
// destination no route serves is no namesake's call.
|
||
if (this.frameworksFor(ref.language).some((f) => f.ownsReference?.(ref, this.context))) {
|
||
return candidates.length > 0
|
||
? candidates.reduce((best, curr) => (curr.confidence > best.confidence ? curr : best))
|
||
: null;
|
||
}
|
||
// A retained untyped chain supplies effect/call-site evidence only. In
|
||
// particular, importing its root does not make the root its call target.
|
||
// A path through module namespaces is not untyped: `z.coerce.number()`
|
||
// after `import * as z`, where the barrel has `export * as coerce`.
|
||
if (isUnresolvedJsMemberCall(ref)) {
|
||
const root = ref.referenceName.slice(0, ref.referenceName.indexOf('.'));
|
||
const namespace = this.context.getImportMappings(ref.filePath, ref.language).some((m) => m.isNamespace && m.localName === root);
|
||
// `App.utils.fmt()` through a path an object literal was hung on (#2300).
|
||
if (!namespace) return this.gateLanguage(matchObjectPathCall(ref, this.context), ref);
|
||
const viaNamespace = this.gateLanguage(resolveViaImport(ref, this.context), ref);
|
||
const target = viaNamespace ? this.nodeById(viaNamespace.targetNodeId) : null;
|
||
return target && (target.kind === 'function' || target.kind === 'method' || target.kind === 'class' || target.kind === 'constant' || target.kind === 'variable')
|
||
? viaNamespace : null;
|
||
}
|
||
|
||
// Strategy 2: Try import-based resolution
|
||
// A TS/JS/Python call-receiver chain (`useStore.getState().reset`, #1683)
|
||
// names the ROOT's import, not the method's: letting resolveViaImport see
|
||
// it binds the call to the imported store constant and the method is
|
||
// never looked up. The name-matcher owns the chain shape for these
|
||
// languages — the Java/Kotlin/C++ chains keep their existing path.
|
||
if (
|
||
ref.referenceKind === 'calls' &&
|
||
CHAIN_SHAPE.test(ref.referenceName) &&
|
||
(ref.language === 'typescript' || ref.language === 'javascript' || ref.language === 'tsx' || ref.language === 'jsx' || ref.language === 'python')
|
||
) {
|
||
return this.gateLanguage(matchReference(ref, this.context), ref);
|
||
}
|
||
|
||
const tImp = this.profileStages ? process.hrtime.bigint() : 0n;
|
||
// `self.get_ip()` is a method call on the instance even when the file
|
||
// also imports a function named `get_ip`: the import never names it.
|
||
const selfCall = ref.language === 'python' && ref.referenceKind === 'calls' &&
|
||
this.context.getImportMappings(ref.filePath, ref.language).some((m) => m.localName === ref.referenceName) &&
|
||
isPythonSelfCall(ref, this.context);
|
||
const importResult = selfCall ? null : this.gateLanguage(resolveViaImport(ref, this.context), ref);
|
||
if (this.profileStages) this.stageAdd('viaImport', ref, !!importResult, tImp);
|
||
if (importResult) {
|
||
if (importResult.confidence >= 0.9) return importResult;
|
||
candidates.push(importResult);
|
||
}
|
||
|
||
// PHP include/require paths resolve to files via import resolution only.
|
||
// If that didn't find the file, do NOT fall back to the symbol
|
||
// name-matcher — it would mis-connect e.g. "inc/db.php" to an unrelated
|
||
// db.php elsewhere in the tree (a wrong edge is worse than none, #660).
|
||
// Terraform refs are directory-scoped by language semantics — the
|
||
// framework resolver IS the whole rulebook (`var.X` can never legally
|
||
// bind outside its module directory), so the name-matcher's
|
||
// qualified-name fallback would only ever add wrong cross-module edges.
|
||
// Nix static path imports are file references for the same reason —
|
||
// falling through would let "./x.nix" name-match an unrelated node. So
|
||
// is a Dart import's URI: `package:flutter/foundation.dart` matched by
|
||
// its last segment went to riverpod's own foundation.dart. And a Lua
|
||
// `require`, whose leaf matched the local it is assigned to.
|
||
if (isPhpIncludePathRef(ref) || isCobolCopybookRef(ref) || isNixPathImportRef(ref) || isDartImportRef(ref) || isLuaRequireRef(ref) || ref.language === 'terraform') {
|
||
return candidates.length > 0
|
||
? candidates.reduce((best, curr) =>
|
||
curr.confidence > best.confidence ? curr : best
|
||
)
|
||
: null;
|
||
}
|
||
|
||
// Strategy 3: Try name matching
|
||
const tName = this.profileStages ? process.hrtime.bigint() : 0n;
|
||
let nameResult = this.gateLanguage(matchReference(ref, this.context), ref);
|
||
if (this.profileStages) this.stageAdd('nameMatch', ref, !!nameResult, tName);
|
||
// Nix has no ambient cross-file namespace — a callee binds lexically
|
||
// (same file) or through explicit import/callPackage wiring (the import
|
||
// path above). A cross-file name match is wrong by construction: every
|
||
// module `inherit (lib) mkOption`s the same nixpkgs helpers, so the
|
||
// matcher would link each `mkOption` call to whichever file's inherit
|
||
// binding it happened to pick. Same-file matches only.
|
||
if (nameResult) {
|
||
const target = this.nodeById(nameResult.targetNodeId);
|
||
// A definition its language makes file-local — a C `static`, a Kotlin
|
||
// `private fun`, a Go unexported name in another package, a Rust
|
||
// non-`pub` item outside its module subtree — cannot be what a name in
|
||
// another file means, whichever strategy chose it (#1730).
|
||
if (target && !isVisibleAcrossFiles(target, ref, this.context)) {
|
||
nameResult = null;
|
||
} else if (ref.language === 'nix') {
|
||
if (!target || target.filePath !== ref.filePath) {
|
||
nameResult = null;
|
||
}
|
||
} else if (target && target.language === 'nix') {
|
||
// The reverse direction is just as impossible: no other language can
|
||
// symbolically call into a .nix binding (interop is eval/CLI, never a
|
||
// linkable symbol) — without this, a Python script's `split()` lands
|
||
// on some module's `split = ...` binding as a low-confidence match.
|
||
nameResult = null;
|
||
}
|
||
}
|
||
if (nameResult) {
|
||
candidates.push(nameResult);
|
||
}
|
||
|
||
if (candidates.length === 0) {
|
||
// Defer a chained static-factory/fluent call the first pass couldn't
|
||
// resolve — its method may live on a supertype the receiver conforms to,
|
||
// resolvable once implements/extends edges exist (the conformance pass).
|
||
if (
|
||
ref.referenceKind === 'calls' &&
|
||
CHAIN_LANGUAGES.has(ref.language) &&
|
||
CHAIN_SHAPE.test(ref.referenceName)
|
||
) {
|
||
this.deferReference(ref, this.deferredChainRefs);
|
||
} else if (
|
||
// PHP `$this->prop->method()` (encoded `this->prop.method`): its method
|
||
// may live on the property's declared supertype, resolvable only once
|
||
// implements/extends edges exist — defer to the same conformance pass.
|
||
ref.referenceKind === 'calls' &&
|
||
ref.language === 'php' &&
|
||
PHP_PROP_SHAPE.test(ref.referenceName)
|
||
) {
|
||
this.deferReference(ref, this.deferredChainRefs);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Return highest confidence candidate
|
||
return candidates.reduce((best, curr) =>
|
||
curr.confidence > best.confidence ? curr : best
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Create edges from resolved references
|
||
*/
|
||
createEdges(resolved: ResolvedRef[]): Edge[] {
|
||
return resolved.flatMap((ref) => {
|
||
// `function_ref` (#756) is internal-only: it persists as a `references`
|
||
// edge (the registration site depends on the callback), distinguishable
|
||
// by metadata.resolvedBy === 'function-ref'. callers/impact already
|
||
// traverse `references`, so registration sites surface with no
|
||
// graph-layer changes.
|
||
let kind: Edge['kind'] =
|
||
ref.edgeKind ??
|
||
(ref.original.referenceKind === 'function_ref' ? 'references' : ref.original.referenceKind);
|
||
|
||
// Promote "extends" to "implements" when a class/struct targets an interface
|
||
if (kind === 'extends') {
|
||
const targetNode = this.nodeById(ref.targetNodeId);
|
||
if (targetNode && (targetNode.kind === 'interface' || targetNode.kind === 'protocol')) {
|
||
const sourceNode = this.nodeById(ref.original.fromNodeId);
|
||
if (sourceNode && sourceNode.kind !== 'interface' && sourceNode.kind !== 'protocol') {
|
||
kind = 'implements';
|
||
}
|
||
}
|
||
}
|
||
|
||
// A Go call whose callee is a type is a conversion — `Category(s)`,
|
||
// `(*Pair)(p)`, `model.Kind(3)` — which uses the type, and calls or
|
||
// instantiates nothing (#2581).
|
||
if (kind === 'calls' && ref.original.language === 'go') {
|
||
const targetNode = this.nodeById(ref.targetNodeId);
|
||
if (targetNode && GO_TYPE_KINDS.has(targetNode.kind)) kind = 'references';
|
||
}
|
||
|
||
// A Pascal call written with a bare name that resolves to a type is a
|
||
// typecast: `TButton(Sender).Caption`, `PInt64Rec(P)^.Lo`, `F :=
|
||
// TFoo(Obj)`. Delphi builds an object only through a constructor
|
||
// (`TFoo.Create`), so the code refers to the type and creates nothing.
|
||
if (
|
||
kind === 'calls' &&
|
||
ref.original.language === 'pascal' &&
|
||
!ref.original.referenceName.includes('.') &&
|
||
PASCAL_CAST_TARGET_KINDS.has(this.nodeById(ref.targetNodeId)?.kind ?? '')
|
||
) {
|
||
kind = 'references';
|
||
}
|
||
|
||
// Promote "calls" to "instantiates" when the resolved target is a
|
||
// class/struct/union. Languages without a `new` keyword (Python, Ruby)
|
||
// express instantiation as `Foo()` — extraction can't tell that
|
||
// apart from a function call without symbol info, but resolution
|
||
// can: if `Foo` resolves to a class, the call IS an instantiation.
|
||
if (kind === 'calls') {
|
||
const targetNode = this.nodeById(ref.targetNodeId);
|
||
if (
|
||
targetNode &&
|
||
(targetNode.kind === 'class' || targetNode.kind === 'struct' || targetNode.kind === 'union')
|
||
) {
|
||
kind = 'instantiates';
|
||
}
|
||
}
|
||
|
||
// One reference can name several targets — a navigation whose
|
||
// destination is a conditional reaches every arm. Each becomes its own
|
||
// edge, sharing this resolution's kind and confidence.
|
||
const targets = [
|
||
{ targetNodeId: ref.targetNodeId, metadata: ref.metadata },
|
||
...(ref.alsoTargets ?? []),
|
||
];
|
||
return targets.map((t) => ({
|
||
source: ref.original.fromNodeId,
|
||
target: t.targetNodeId,
|
||
kind,
|
||
line: ref.original.line,
|
||
column: ref.original.column,
|
||
metadata: {
|
||
...(t.metadata ?? {}),
|
||
confidence: ref.confidence,
|
||
resolvedBy: ref.resolvedBy,
|
||
// The ORIGINAL reference text (and kind, when edge-kind promotion
|
||
// rewrote it — calls→instantiates, extends→implements,
|
||
// function_ref→references). If this edge's target is later removed
|
||
// by a re-index, the edge is resurrected as exactly this ref and
|
||
// re-resolved (#1240 removal case) — a faithful resurrection, so
|
||
// re-resolution can never bind anywhere a full re-index wouldn't.
|
||
// Reconstruction from the target node's name instead would strip
|
||
// receiver/qualifier context (`h.greet` → `greet`) and risk a
|
||
// wrong rebind; edges without refName (pre-#1240, synthesized) are
|
||
// deliberately NOT resurrected for the same reason.
|
||
refName: ref.original.referenceName,
|
||
...(ref.original.referenceKind !== kind ? { refKind: ref.original.referenceKind } : {}),
|
||
// Uniform marker for function-as-value edges (#756), regardless of
|
||
// which strategy resolved them (import vs matchFunctionRef) — lets
|
||
// tooling label "callback registration" and lets validation diff
|
||
// exactly the edges this feature added.
|
||
...(ref.original.referenceKind === 'function_ref' ? { fnRef: true } : {}),
|
||
},
|
||
}));
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Split resolved refs into rows deletable by id and hand-built refs that
|
||
* must fall back to the key-tuple delete. Rows loaded from the database
|
||
* carry their row id and are deleted by exactly that id; the key tuple
|
||
* omits line/col, so it also removes SIBLING rows — the same caller calling
|
||
* the same callee at other lines — that a later batch hadn't attempted yet:
|
||
* when a batch boundary split a caller's same-named call sites, the later
|
||
* sites' edges were silently never created (#1269).
|
||
*/
|
||
private static partitionResolvedCleanup(resolved: ResolvedRef[]): {
|
||
rowIds: number[];
|
||
legacyKeys: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }>;
|
||
} {
|
||
const rowIds: number[] = [];
|
||
const legacyKeys: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }> = [];
|
||
for (const r of resolved) {
|
||
if (r.original.rowId != null) rowIds.push(r.original.rowId);
|
||
else legacyKeys.push({
|
||
fromNodeId: r.original.fromNodeId,
|
||
referenceName: r.original.referenceName,
|
||
referenceKind: r.original.referenceKind,
|
||
});
|
||
}
|
||
return { rowIds, legacyKeys };
|
||
}
|
||
|
||
/**
|
||
* Same row-id precision for parking unresolvable refs as status='failed'
|
||
* (#1240): the key-tuple fallback would flip same-key sibling rows in later
|
||
* batches to 'failed' before they were ever attempted, and resolution
|
||
* outcome can differ per call site (receiver-type inference reads the
|
||
* ref's line), so a sibling must not inherit this row's failure (#1269).
|
||
*/
|
||
private static partitionFailedCleanup(unresolved: UnresolvedRef[]): {
|
||
byRowId: Array<{ rowId: number; referenceName: string; referenceKind: string; nameTail?: string }>;
|
||
legacyKeys: Array<{ fromNodeId: string; referenceName: string; referenceKind: string; nameTail?: string }>;
|
||
} {
|
||
const byRowId: Array<{ rowId: number; referenceName: string; referenceKind: string; nameTail?: string }> = [];
|
||
const legacyKeys: Array<{ fromNodeId: string; referenceName: string; referenceKind: string; nameTail?: string }> = [];
|
||
for (const r of unresolved) {
|
||
if (r.rowId != null) byRowId.push({ rowId: r.rowId, referenceName: r.referenceName, referenceKind: r.referenceKind, nameTail: r.nameTail });
|
||
else legacyKeys.push({
|
||
fromNodeId: r.fromNodeId,
|
||
referenceName: r.referenceName,
|
||
referenceKind: r.referenceKind,
|
||
nameTail: r.nameTail,
|
||
});
|
||
}
|
||
return { byRowId, legacyKeys };
|
||
}
|
||
|
||
/**
|
||
* `ref`, which no strategy resolved, with the tail it is parked under when
|
||
* that is not the one its name gives: a reference through an import
|
||
* binding the module doesn't declare by that name waits for the module
|
||
* (see importBindingTail). Decided here, where the file's import mappings
|
||
* are still cached from the attempt — in a resolver-pool worker too.
|
||
*/
|
||
private parkable(ref: UnresolvedRef): UnresolvedRef {
|
||
const tail = importBindingTail(ref, this.context);
|
||
if (tail) ref.nameTail = tail;
|
||
return ref;
|
||
}
|
||
|
||
/** A deferred attempt is unfinished work, not a final failure (#1577). */
|
||
private nonDeferredFailures(unresolved: UnresolvedRef[]): UnresolvedRef[] {
|
||
return unresolved.filter((ref) => ref.rowId == null || !this.deferredRowIds.has(ref.rowId));
|
||
}
|
||
|
||
private deferReference(ref: UnresolvedRef, queue: UnresolvedRef[]): void {
|
||
queue.push(ref);
|
||
if (ref.rowId != null) this.deferredRowIds.add(ref.rowId);
|
||
}
|
||
|
||
/**
|
||
* Resolve and persist edges to database
|
||
*/
|
||
resolveAndPersist(
|
||
unresolvedRefs: UnresolvedReference[],
|
||
onProgress?: (current: number, total: number) => void
|
||
): ResolutionResult {
|
||
const prerequisites = unresolvedRefs.filter(ReferenceResolver.isPrerequisite);
|
||
if (prerequisites.length > 0 && prerequisites.length < unresolvedRefs.length) {
|
||
const first = this.resolveAndPersist(prerequisites, (current) => onProgress?.(current, unresolvedRefs.length));
|
||
const rest = this.resolveAndPersist(
|
||
unresolvedRefs.filter((ref) => !ReferenceResolver.isPrerequisite(ref)),
|
||
(current) => onProgress?.(prerequisites.length + current, unresolvedRefs.length)
|
||
);
|
||
return ReferenceResolver.mergeResults(first, rest);
|
||
}
|
||
const result = this.resolveAll(unresolvedRefs, onProgress);
|
||
|
||
// Create edges from resolved references
|
||
const edges = this.createEdges(result.resolved);
|
||
|
||
// Insert edges into database
|
||
if (edges.length > 0) {
|
||
this.queries.insertEdges(edges);
|
||
}
|
||
|
||
// Clean up resolved refs from unresolved_refs table so metrics are accurate
|
||
if (result.resolved.length > 0) {
|
||
const { rowIds, legacyKeys } = ReferenceResolver.partitionResolvedCleanup(result.resolved);
|
||
this.queries.deleteReferencesByRowIds(rowIds);
|
||
this.queries.deleteSpecificResolvedReferences(legacyKeys);
|
||
}
|
||
|
||
// Park unresolvable refs as status='failed' — parity with
|
||
// resolveAndPersistBatched. Deleting them was wrong (#1240): a ref whose
|
||
// own file never changes is otherwise gone forever, so when a DIFFERENT
|
||
// file later gains the export/symbol that would satisfy it, no sync can
|
||
// recreate the edge — only a full re-index. Failed rows are excluded from
|
||
// the pending readers, which preserves the #1187 orphan sweep's
|
||
// invariant in status form: after a COMPLETED pass nothing it processed
|
||
// is still 'pending', so any pending row at rest belongs to an
|
||
// interrupted run and the sweep can key off the pending count.
|
||
if (result.unresolved.length > 0) {
|
||
const { byRowId, legacyKeys } = ReferenceResolver.partitionFailedCleanup(this.nonDeferredFailures(result.unresolved));
|
||
this.queries.markReferencesFailedByRowIds(byRowId);
|
||
this.queries.markReferencesFailed(legacyKeys);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Yielding counterpart of {@link resolveAndPersist} for a caller-supplied
|
||
* ref list — used by sync's failed-ref retry pass (#1240). Same persistence
|
||
* semantics: resolved refs become edges and their rows are deleted;
|
||
* still-unresolvable refs are (re-)marked failed (a no-op for rows already
|
||
* in that status). Yields per-ref because sync can run on the daemon's
|
||
* liveness-watchdog thread (#850/#1091) and a retry set is unbounded when
|
||
* a large edit lands many popular symbol names at once.
|
||
*/
|
||
async resolveAndPersistListYielding(refs: UnresolvedReference[]): Promise<ResolutionResult> {
|
||
const prerequisites = refs.filter(ReferenceResolver.isPrerequisite);
|
||
if (prerequisites.length > 0 && prerequisites.length < refs.length) {
|
||
const first = await this.resolveAndPersistListYielding(prerequisites);
|
||
const rest = await this.resolveAndPersistListYielding(refs.filter((ref) => !ReferenceResolver.isPrerequisite(ref)));
|
||
return ReferenceResolver.mergeResults(first, rest);
|
||
}
|
||
const maybeYield = createYielder();
|
||
const result = await this.resolveBatchYielding(refs, maybeYield);
|
||
await this.persistResolutionResult(result, maybeYield);
|
||
return result;
|
||
}
|
||
|
||
private async persistResolutionResult(result: ResolutionResult, maybeYield: MaybeYield): Promise<number> {
|
||
const PERSIST_CHUNK = 1000;
|
||
const edges = this.createEdges(result.resolved);
|
||
for (let i = 0; i < edges.length; i += PERSIST_CHUNK) {
|
||
this.queries.insertEdges(edges.slice(i, i + PERSIST_CHUNK));
|
||
await maybeYield();
|
||
}
|
||
|
||
const resolvedCleanup = ReferenceResolver.partitionResolvedCleanup(result.resolved);
|
||
for (let i = 0; i < resolvedCleanup.rowIds.length; i += PERSIST_CHUNK) {
|
||
this.queries.deleteReferencesByRowIds(resolvedCleanup.rowIds.slice(i, i + PERSIST_CHUNK));
|
||
await maybeYield();
|
||
}
|
||
for (let i = 0; i < resolvedCleanup.legacyKeys.length; i += PERSIST_CHUNK) {
|
||
this.queries.deleteSpecificResolvedReferences(resolvedCleanup.legacyKeys.slice(i, i + PERSIST_CHUNK));
|
||
await maybeYield();
|
||
}
|
||
|
||
const failedCleanup = ReferenceResolver.partitionFailedCleanup(this.nonDeferredFailures(result.unresolved));
|
||
for (let i = 0; i < failedCleanup.byRowId.length; i += PERSIST_CHUNK) {
|
||
this.queries.markReferencesFailedByRowIds(failedCleanup.byRowId.slice(i, i + PERSIST_CHUNK));
|
||
await maybeYield();
|
||
}
|
||
for (let i = 0; i < failedCleanup.legacyKeys.length; i += PERSIST_CHUNK) {
|
||
this.queries.markReferencesFailed(failedCleanup.legacyKeys.slice(i, i + PERSIST_CHUNK));
|
||
await maybeYield();
|
||
}
|
||
|
||
return edges.length;
|
||
}
|
||
|
||
/** Finalize the durable queue only AFTER its edges have been inserted. */
|
||
private async persistDeferredReferences(deferred: UnresolvedRef[], resolved: ResolvedRef[]): Promise<number> {
|
||
for (const ref of deferred) if (ref.rowId != null) this.deferredRowIds.delete(ref.rowId);
|
||
const matched = new Set(resolved.map((ref) => ref.original));
|
||
const unresolved = deferred.filter((ref) => !matched.has(ref));
|
||
const count = await this.persistResolutionResult({
|
||
resolved,
|
||
unresolved,
|
||
stats: { total: deferred.length, resolved: resolved.length, unresolved: unresolved.length, byMethod: {} },
|
||
}, createYielder());
|
||
if (count > 0) this.clearCaches();
|
||
return count;
|
||
}
|
||
|
||
/** Same two phases as the bounded DB reader: persist wiring before calls. */
|
||
private static isPrerequisite(ref: UnresolvedReference): boolean {
|
||
return ref.referenceKind === 'imports' || ref.referenceKind === 'extends' || ref.referenceKind === 'implements';
|
||
}
|
||
|
||
private static mergeResults(first: ResolutionResult, rest: ResolutionResult): ResolutionResult {
|
||
const byMethod = { ...first.stats.byMethod };
|
||
for (const [method, count] of Object.entries(rest.stats.byMethod)) {
|
||
byMethod[method] = (byMethod[method] ?? 0) + count;
|
||
}
|
||
return {
|
||
resolved: first.resolved.concat(rest.resolved),
|
||
unresolved: first.unresolved.concat(rest.unresolved),
|
||
stats: {
|
||
total: first.stats.total + rest.stats.total,
|
||
resolved: first.stats.resolved + rest.stats.resolved,
|
||
unresolved: first.stats.unresolved + rest.stats.unresolved,
|
||
byMethod,
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Second resolution pass for chained static-factory / fluent calls whose
|
||
* chained method is defined on a SUPERTYPE the receiver's type conforms to —
|
||
* a protocol-extension / inherited / default-interface method (#750). The
|
||
* first pass can't resolve these because `implements`/`extends` edges aren't
|
||
* built yet; this runs AFTER edges are persisted, so `context.getSupertypes`
|
||
* (and the conformance fallback in resolveMethodOnType) can walk them.
|
||
*
|
||
* Operates only on the leftover unresolved refs that have the `inner().method`
|
||
* chain shape, for the dotted-chain languages — a small set — and is idempotent
|
||
* (re-resolving an already-resolved ref is a no-op since it's been deleted).
|
||
* Returns the number of newly-created edges.
|
||
*/
|
||
async resolveChainedCallsViaConformance(): Promise<number> {
|
||
const deferred = this.deferredChainRefs;
|
||
this.deferredChainRefs = [];
|
||
if (deferred.length === 0) return 0;
|
||
|
||
// Read fresh edges (the main pass built the implements/extends edges after
|
||
// these refs were deferred). matchDottedCallChain now resolves a method on a
|
||
// supertype via context.getSupertypes -> resolveMethodOnType's conformance walk.
|
||
this.clearCaches();
|
||
// This post-pass runs synchronously on the indexer's main thread; yield
|
||
// periodically so the #850 liveness watchdog heartbeat can fire on a repo
|
||
// with many deferred chained calls (#1091).
|
||
const maybeYield = createYielder();
|
||
const resolved: ResolvedRef[] = [];
|
||
for (const ref of deferred) {
|
||
// PHP `this->prop.method` resolves via matchMethodCall (declared-type
|
||
// inference + resolveMethodOnType conformance walk); `::`-receiver
|
||
// languages (Rust) split on `::` (matchScopedCallChain); other
|
||
// dotted-receiver languages on `.` (matchDottedCallChain).
|
||
const chainMatch = (ref.language === 'php' && PHP_PROP_SHAPE.test(ref.referenceName))
|
||
? matchMethodCall(ref, this.context)
|
||
: SCOPED_CHAIN_LANGUAGES.has(ref.language)
|
||
? matchScopedCallChain(ref, this.context)
|
||
: matchDottedCallChain(ref, this.context);
|
||
const match = this.gateLanguage(chainMatch, ref);
|
||
if (match) resolved.push(match);
|
||
await maybeYield();
|
||
}
|
||
return this.persistDeferredReferences(deferred, resolved);
|
||
}
|
||
|
||
/**
|
||
* Resolve one batch with a yield checkpoint between EVERY ref so the #850
|
||
* liveness heartbeat can fire on a slow/dense batch (#1091). The checkpoint
|
||
* granularity is per-ref — not per-N-refs — because per-ref cost is unbounded
|
||
* in the worst case (a collision-heavy method name whose candidate set misses
|
||
* the LRU re-fetches tens of thousands of rows): any fixed N multiplies that
|
||
* worst case into the watchdog window, which is how v1.2.0 still got killed
|
||
* at "Resolving refs" on large Java monorepos (#1122). `maybeYield()` is a
|
||
* ~ns time check when under budget, so per-ref checkpoints cost nothing.
|
||
* Behaviourally identical to `resolveAll(batch)`: `warmCaches()` is
|
||
* idempotent (guarded) and `resolveOne` is independent per ref, so yielding
|
||
* between refs changes only timing, never which edges get created.
|
||
*/
|
||
private async resolveBatchYielding(
|
||
batch: UnresolvedReference[],
|
||
maybeYield: MaybeYield
|
||
): Promise<ResolutionResult> {
|
||
this.warmCaches();
|
||
this.advanceSupertypeGeneration();
|
||
|
||
const resolved: ResolvedRef[] = [];
|
||
const unresolved: UnresolvedRef[] = [];
|
||
const byMethod: Record<string, number> = {};
|
||
|
||
for (const raw of batch) {
|
||
const ref: UnresolvedRef = {
|
||
fromNodeId: raw.fromNodeId,
|
||
referenceName: raw.referenceName,
|
||
referenceKind: raw.referenceKind,
|
||
line: raw.line,
|
||
column: raw.column,
|
||
filePath: raw.filePath || this.getFilePathFromNodeId(raw.fromNodeId),
|
||
language: raw.language || this.getLanguageFromNodeId(raw.fromNodeId),
|
||
rowId: raw.rowId,
|
||
};
|
||
const result = this.resolveOneTimed(ref);
|
||
if (result) {
|
||
resolved.push(result);
|
||
byMethod[result.resolvedBy] = (byMethod[result.resolvedBy] || 0) + 1;
|
||
} else {
|
||
unresolved.push(this.parkable(ref));
|
||
}
|
||
// Fast-path the per-ref yield check: awaiting the async no-op costs a
|
||
// microtask hop per ref, which dominates at ~10⁵ refs (see MaybeYield).
|
||
const y = maybeYield();
|
||
if (y) await y;
|
||
}
|
||
|
||
return {
|
||
resolved,
|
||
unresolved,
|
||
stats: {
|
||
total: batch.length,
|
||
resolved: resolved.length,
|
||
unresolved: unresolved.length,
|
||
byMethod,
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Resolve a list of refs and return everything the ADMISSION side needs to
|
||
* persist the outcome: resolutions, failures, the deferred post-pass refs
|
||
* this run produced (drained, so the caller owns routing them), and stats.
|
||
* This is the resolver-worker entry point — it runs the exact per-ref loop
|
||
* of resolveBatchYielding, minus the main-thread yields (worker threads have
|
||
* no watchdog heartbeat to starve). Results are in input order.
|
||
*/
|
||
/**
|
||
* CODEGRAPH_RESOLVE_PROFILE=1: per-outcome wall-clock histogram of
|
||
* resolveOne, keyed by the winning strategy (`resolvedBy`) or
|
||
* `fail:<referenceKind>` — the §7a.2 "profile the per-ref path" probe. The
|
||
* kernel-scale batch loop is ~430s and CORE-INVARIANT (835.9s pooled-4-on-8
|
||
* ≈ 812.5s sequential-on-2 for the whole superphase), so the next lever is
|
||
* which CLASS of ref the time belongs to, not more parallelism. Off by
|
||
* default: the hrtime pair costs ~100ns/ref only when the env is set.
|
||
*/
|
||
private resolveProfile: Map<string, { n: number; ns: bigint }> | null =
|
||
process.env.CODEGRAPH_RESOLVE_PROFILE ? new Map() : null;
|
||
|
||
/**
|
||
* CODEGRAPH_RESOLVE_PROFILE=2 additionally attributes time to the
|
||
* STRATEGIES inside resolveOne (`stage:<name>|<refKind>|hit/miss` rows in
|
||
* the same histogram) — i.e. WHICH machinery a failing class of refs pays
|
||
* for, not just that it fails. =1 keeps the per-outcome rows only.
|
||
*/
|
||
private profileStages: boolean = process.env.CODEGRAPH_RESOLVE_PROFILE === '2';
|
||
|
||
private stageAdd(stage: string, ref: UnresolvedRef, hit: boolean, t0: bigint): void {
|
||
if (!this.resolveProfile) return;
|
||
const dt = process.hrtime.bigint() - t0;
|
||
const key = `stage:${stage}|${ref.referenceKind}|${hit ? 'hit' : 'miss'}`;
|
||
const slot = this.resolveProfile.get(key);
|
||
if (slot) {
|
||
slot.n++;
|
||
slot.ns += dt;
|
||
} else {
|
||
this.resolveProfile.set(key, { n: 1, ns: dt });
|
||
}
|
||
}
|
||
|
||
private resolveOneTimed(ref: UnresolvedRef): ResolvedRef | null {
|
||
if (!this.resolveProfile) return this.resolveOne(ref);
|
||
const t0 = process.hrtime.bigint();
|
||
const result = this.resolveOne(ref);
|
||
const dt = process.hrtime.bigint() - t0;
|
||
const key = result ? result.resolvedBy : `fail:${ref.referenceKind}`;
|
||
const slot = this.resolveProfile.get(key);
|
||
if (slot) {
|
||
slot.n++;
|
||
slot.ns += dt;
|
||
} else {
|
||
this.resolveProfile.set(key, { n: 1, ns: dt });
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/** Dump the CODEGRAPH_RESOLVE_PROFILE histogram to stderr (no-op when off). */
|
||
dumpResolveProfile(label: string): void {
|
||
if (!this.resolveProfile || this.resolveProfile.size === 0) return;
|
||
const rows = [...this.resolveProfile.entries()]
|
||
.map(([k, v]) => ({ k, n: v.n, ms: Number(v.ns / 1_000_000n) }))
|
||
.sort((a, b) => b.ms - a.ms);
|
||
for (const r of rows) {
|
||
console.error(
|
||
`[resolve-profile] ${label} ${r.k}: n=${r.n} total=${(r.ms / 1000).toFixed(1)}s avg=${((r.ms * 1000) / Math.max(1, r.n)).toFixed(0)}µs`
|
||
);
|
||
}
|
||
// =2 only: this thread's matchReference sub-stage table rides along.
|
||
dumpNameMatcherProfile(label);
|
||
}
|
||
|
||
resolveListForAdmission(refs: UnresolvedReference[]): {
|
||
resolved: ResolvedRef[];
|
||
unresolved: UnresolvedRef[];
|
||
deferredChain: UnresolvedRef[];
|
||
deferredThisMember: UnresolvedRef[];
|
||
byMethod: Record<string, number>;
|
||
} {
|
||
this.warmCaches();
|
||
this.advanceSupertypeGeneration();
|
||
const resolved: ResolvedRef[] = [];
|
||
const unresolved: UnresolvedRef[] = [];
|
||
const byMethod: Record<string, number> = {};
|
||
for (const raw of refs) {
|
||
const ref: UnresolvedRef = {
|
||
fromNodeId: raw.fromNodeId,
|
||
referenceName: raw.referenceName,
|
||
referenceKind: raw.referenceKind,
|
||
line: raw.line,
|
||
column: raw.column,
|
||
filePath: raw.filePath || this.getFilePathFromNodeId(raw.fromNodeId),
|
||
language: raw.language || this.getLanguageFromNodeId(raw.fromNodeId),
|
||
rowId: raw.rowId,
|
||
};
|
||
const result = this.resolveOneTimed(ref);
|
||
if (result) {
|
||
resolved.push(result);
|
||
byMethod[result.resolvedBy] = (byMethod[result.resolvedBy] || 0) + 1;
|
||
} else {
|
||
unresolved.push(this.parkable(ref));
|
||
}
|
||
}
|
||
this.deferredRowIds.clear(); // the admission side now owns both queues
|
||
return {
|
||
resolved,
|
||
unresolved,
|
||
deferredChain: this.deferredChainRefs.splice(0),
|
||
deferredThisMember: this.deferredThisMemberRefs.splice(0),
|
||
byMethod,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The resolver's live ResolutionContext — resolver-pool workers use it to
|
||
* run synthesis passes against their own read-only connection.
|
||
*/
|
||
getResolutionContext(): ResolutionContext {
|
||
return this.context;
|
||
}
|
||
|
||
/**
|
||
* Re-queue deferred post-pass refs produced by resolver workers, preserving
|
||
* their admission order so resolveChainedCallsViaConformance /
|
||
* resolveDeferredThisMemberRefs process them exactly as the sequential path
|
||
* would have.
|
||
*/
|
||
appendDeferredFromWorkers(deferredChain: UnresolvedRef[], deferredThisMember: UnresolvedRef[]): void {
|
||
for (const ref of deferredChain) this.deferReference(ref, this.deferredChainRefs);
|
||
for (const ref of deferredThisMember) this.deferReference(ref, this.deferredThisMemberRefs);
|
||
}
|
||
|
||
/**
|
||
* Resolve and persist in batches to keep memory bounded.
|
||
* Processes unresolved references in chunks, persisting edges and cleaning
|
||
* up resolved refs after each batch to avoid accumulating large arrays.
|
||
*/
|
||
async resolveAndPersistBatched(
|
||
onProgress?: (current: number, total: number) => void,
|
||
batchSize: number = 5000,
|
||
onSynthesisProgress?: (done: number, total: number) => void,
|
||
// When provided, big batches fan out across a read-only resolver-worker
|
||
// pool with results admitted in canonical order (see resolver-pool.ts).
|
||
// Sequential fallback on any pool failure. CODEGRAPH_NO_PARALLEL_RESOLVE=1
|
||
// disables entirely. bulkEdgeLoad hooks (when provided) bracket the batch
|
||
// loop with drop/recreate of the non-unique edge indexes on big runs —
|
||
// see DatabaseConnection.beginBulkEdgeLoad. backpressure (when provided)
|
||
// is the WAL valve's writer-side backstop (WalCheckpointValve.backpressure):
|
||
// called at pool-idle boundaries so a full backfill can actually complete —
|
||
// the valve's timer-driven passive passes stay perpetually partial against
|
||
// the pool's continuous reads, which is how a kernel-scale resolution grew
|
||
// a 22GB WAL on a 4.6GB DB (migration plan §7a.1).
|
||
parallel?: {
|
||
dbPath: string;
|
||
bulkEdgeLoad?: {
|
||
begin: () => void;
|
||
end: () => void | Promise<void>;
|
||
/** Indexes `end` leaves for later; built while the pool runs synthesis. */
|
||
deferred?: () => void;
|
||
};
|
||
/** unresolved_refs index window for the batched loop — the loop only
|
||
* reads the status index + PK; dropping the sync-path ref indexes cuts
|
||
* each per-batch DELETE's B-tree work (DatabaseConnection.beginBulkRefLoad). */
|
||
refIndexLoad?: { begin: () => void; end: () => void | Promise<void> };
|
||
backpressure?: () => Promise<void> | null;
|
||
},
|
||
synthesize: boolean = true
|
||
): Promise<ResolutionResult> {
|
||
// Resolution runs on the indexer's MAIN thread, and the #850 liveness
|
||
// watchdog SIGKILLs a process whose event loop stalls past its window (60s
|
||
// by default). A single dense batch's resolveAll — or the synthesis pass
|
||
// below — can exceed that on a large repo, killing a VALID in-progress index
|
||
// (#1091). A shared yielder lets both give the watchdog heartbeat a regular
|
||
// window to fire; see ./cooperative-yield.
|
||
const maybeYield = createYielder();
|
||
|
||
if (process.env.CODEGRAPH_SYNTH_TIMINGS) {
|
||
console.error(`[pool-timing] backpressure hook: ${parallel?.backpressure ? 'present' : 'absent'}`);
|
||
}
|
||
|
||
// CODEGRAPH_RESOLVE_PROFILE loop-stage attribution: the §7a.2 kernel-scale
|
||
// histogram showed resolveOne owns only ~93s of the ~436s batch loop —
|
||
// these counters name where the other ~340s goes (reads, edge build+insert,
|
||
// deletes/marks, the per-batch count guard).
|
||
const loopProf: Record<string, number> | null = process.env.CODEGRAPH_RESOLVE_PROFILE
|
||
? { read: 0, settle: 0, backpressure: 0, recycle: 0, createEdges: 0, insertEdges: 0, deletes: 0, marks: 0, countGuard: 0 }
|
||
: null;
|
||
const lp = (k: string, t0: number): void => { if (loopProf) loopProf[k] = (loopProf[k] ?? 0) + (Date.now() - t0); };
|
||
let tLp = 0;
|
||
|
||
await this.warmCachesYielding(maybeYield);
|
||
|
||
const total = this.queries.getUnresolvedReferencesCount();
|
||
let processed = 0;
|
||
const aggregateStats = {
|
||
total: 0,
|
||
resolved: 0,
|
||
unresolved: 0,
|
||
byMethod: {} as Record<string, number>,
|
||
};
|
||
|
||
// Parallel pool, started immediately but never awaited up front: early
|
||
// batches run sequentially while the workers boot (module load + readonly
|
||
// DB open + framework detect + cache warm ≈ hundreds of ms), and the loop
|
||
// switches to fan-out the moment the pool reports ready — so pool boot
|
||
// costs zero wall-clock. Any failure downgrades to sequential permanently.
|
||
let pool: ResolverPool | null = null;
|
||
let poolReady = false;
|
||
// True once pool creation has been attempted by EITHER engage site (the
|
||
// up-front ref-count gate or the adaptive projection below) — a pool that
|
||
// failed or was destroyed must stay down (downgrade is permanent), and
|
||
// tryCreate's sizing probes shouldn't re-run every batch on hosts that
|
||
// declined.
|
||
let poolEngageTried = false;
|
||
const createPool = (t0: number, why: string): ResolverPool | null => {
|
||
poolEngageTried = true;
|
||
if (!parallel) return null;
|
||
const p = ResolverPool.tryCreate(parallel.dbPath, this.projectRoot);
|
||
p?.ready().then(
|
||
() => {
|
||
poolReady = true;
|
||
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[pool-timing] pool ready after ${Date.now() - t0}ms (${why})`);
|
||
},
|
||
() => {
|
||
void p.destroy().catch(() => undefined);
|
||
if (pool === p) pool = null;
|
||
}
|
||
);
|
||
return p;
|
||
};
|
||
if (parallel && total >= minRefsForPool()) {
|
||
pool = createPool(Date.now(), 'ref-count');
|
||
}
|
||
// Adaptive engagement bar (see the batch-loop hook): projected remaining
|
||
// sequential settle above this boots the pool mid-loop. Boot is async and
|
||
// fan-out waits for ready, so a marginal engage costs background boot
|
||
// only; the bar just needs to clear the fan-out's own overhead class.
|
||
const ADAPTIVE_ENGAGE_SETTLE_MS = 400;
|
||
let adaptiveSeqMs = 0;
|
||
let adaptiveSeqRefs = 0;
|
||
|
||
// Process in PIPELINED batches (double-buffer). The enumeration is the
|
||
// head of the pending set in rowid order; every ref a persisted batch
|
||
// processed leaves the pending set (resolved rows are deleted,
|
||
// unresolvable ones flip to status='failed'), shifting the remaining
|
||
// pending rows forward.
|
||
let prevRemaining = Number.POSITIVE_INFINITY;
|
||
|
||
// Cadence for the worker connection recycling below — ~8 batches
|
||
// ≈ 40k refs between recycles keeps the WAL shallow at kernel scale
|
||
// while a small sync never recycles at all. (25 recovered only half
|
||
// the write tax — the WAL re-deepened between recycles; reopens are
|
||
// sub-millisecond so the shorter cadence is ~free.)
|
||
const RECYCLE_EVERY_BATCHES = 8;
|
||
let batchesSinceRecycle = 0;
|
||
|
||
// Fan-out result of ResolverPool.resolveBatch, settled (never rejecting)
|
||
// so a fan-out begun before the previous batch's persist can't produce an
|
||
// unhandled rejection while it waits to be awaited.
|
||
type PoolSettled =
|
||
| { ok: true; out: Awaited<ReturnType<ResolverPool['resolveBatch']>> }
|
||
| { ok: false; err: unknown };
|
||
type InFlight = { mode: 'pool'; settled: Promise<PoolSettled> } | { mode: 'seq' };
|
||
|
||
// Begin one batch: fan out to the pool when it's ready and the batch is
|
||
// big enough — workers then resolve batch k+1 WHILE the main thread
|
||
// persists batch k (persist measured at ~58% of resolution wall on a
|
||
// 255k-ref repo, all of it previously spent with the pool idle).
|
||
// Sequential batches stay lazy: they run on the main thread at settle
|
||
// time, where an early start would only contend with the persist.
|
||
const beginBatch = (batch: UnresolvedReference[]): InFlight => {
|
||
if (pool && poolReady && ResolverPool.worthParallel(batch.length)) {
|
||
return {
|
||
mode: 'pool',
|
||
settled: pool.resolveBatch(batch).then(
|
||
(out) => ({ ok: true as const, out }),
|
||
(err: unknown) => ({ ok: false as const, err })
|
||
),
|
||
};
|
||
}
|
||
return { mode: 'seq' };
|
||
};
|
||
|
||
// Settle an in-flight batch to a ResolutionResult. Deferred post-pass refs
|
||
// are appended HERE, in loop order — never inside the fan-out promise — so
|
||
// admission order stays exactly the sequential order even while a later
|
||
// batch resolves concurrently. A pool failure downgrades to sequential
|
||
// permanently and re-resolves this batch on the main thread.
|
||
const settleBatch = async (
|
||
inFlight: InFlight,
|
||
batch: UnresolvedReference[]
|
||
): Promise<ResolutionResult> => {
|
||
if (inFlight.mode === 'pool') {
|
||
const settled = await inFlight.settled;
|
||
if (settled.ok) {
|
||
this.appendDeferredFromWorkers(settled.out.deferredChain, settled.out.deferredThisMember);
|
||
return {
|
||
resolved: settled.out.resolved,
|
||
unresolved: settled.out.unresolved,
|
||
stats: {
|
||
total: batch.length,
|
||
resolved: settled.out.resolved.length,
|
||
unresolved: settled.out.unresolved.length,
|
||
byMethod: settled.out.byMethod,
|
||
},
|
||
};
|
||
}
|
||
logDebug('Parallel resolution failed; falling back to sequential', {
|
||
error: settled.err instanceof Error ? settled.err.message : String(settled.err),
|
||
});
|
||
if (pool) await pool.destroy().catch(() => undefined);
|
||
pool = null;
|
||
}
|
||
return this.resolveBatchYielding(batch, maybeYield);
|
||
};
|
||
|
||
// Bulk edge load: on big runs, drop the non-unique edge indexes for the
|
||
// duration of the batch loop (the identity index stays — OR IGNORE dedup
|
||
// and the source-keyed supertype-walk reads both live on it). Recreated in
|
||
// the inner finally BEFORE synthesis, whose passes read kind-keyed.
|
||
// Measured on a 224k-edge resolution set: insert 2.8s → 1.1s + 0.3s
|
||
// recreate. Same ref-count gate as the pool so small syncs never pay the
|
||
// recreate cost.
|
||
let bulkEdgesActive = false;
|
||
if (parallel?.bulkEdgeLoad && total >= minRefsForPool()) {
|
||
try {
|
||
parallel.bulkEdgeLoad.begin();
|
||
bulkEdgesActive = true;
|
||
} catch { /* keep the indexes; inserts just pay the per-row maintenance */ }
|
||
}
|
||
// Same gate for the ref-index window: the loop's deletes stop maintaining
|
||
// the five sync-path unresolved_refs indexes, and the end-of-loop rebuild
|
||
// is near-free (only failed refs survive the loop).
|
||
let bulkRefsActive = false;
|
||
if (parallel?.refIndexLoad && total >= minRefsForPool()) {
|
||
try {
|
||
parallel.refIndexLoad.begin();
|
||
bulkRefsActive = true;
|
||
} catch { /* keep the indexes; deletes just pay the per-row maintenance */ }
|
||
}
|
||
|
||
try {
|
||
try {
|
||
// Orphans retain interruption/re-extraction order, not clean-index order.
|
||
// A caller can precede its imports or supertypes by many batches (#1577).
|
||
// Drain those prerequisites first, then start a fresh keyset cursor over
|
||
// the remaining kinds. The disjoint filters let us prefetch across the
|
||
// phase boundary before cleanup without re-reading the current batch.
|
||
let prerequisites = true;
|
||
let afterRowId = 0;
|
||
const readNextBatch = (): UnresolvedReference[] => {
|
||
let next = this.queries.getUnresolvedReferencesBatchAfter(afterRowId, batchSize, prerequisites);
|
||
if (next.length === 0 && prerequisites) {
|
||
prerequisites = false;
|
||
afterRowId = 0;
|
||
next = this.queries.getUnresolvedReferencesBatchAfter(afterRowId, batchSize, prerequisites);
|
||
}
|
||
if (next.length > 0) afterRowId = next[next.length - 1]!.rowId!;
|
||
return next;
|
||
};
|
||
tLp = Date.now();
|
||
let batch = readNextBatch();
|
||
lp('read', tLp);
|
||
let inFlight: InFlight | null = batch.length > 0 ? beginBatch(batch) : null;
|
||
while (batch.length > 0 && inFlight) {
|
||
// Prefetch the NEXT batch before this one persists: this batch's rows
|
||
// are still pending (nothing has mutated the table since they were
|
||
// read), so seeking past this batch's last row id in the same rowid
|
||
// enumeration yields the following batch (keyset — OFFSET re-walked the
|
||
// accumulated failed prefix every read, 54.6s at kernel scale, §7a.2).
|
||
tLp = Date.now();
|
||
const nextBatch = readNextBatch();
|
||
lp('read', tLp);
|
||
|
||
const tBatch = Date.now();
|
||
const result = await settleBatch(inFlight, batch);
|
||
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[pool-timing] batch ${inFlight.mode}: ${batch.length} refs in ${Date.now() - tBatch}ms`);
|
||
lp('settle', tBatch);
|
||
|
||
// Adaptive pool engagement: the fixed ref-count gate can't see PER-REF
|
||
// cost, and settle rates differ ~9× by language (56k Rust refs cost
|
||
// more sequential settle than 154k Go refs — 36µs vs 4µs measured on
|
||
// tokio/prometheus). After each sequential batch, project the remaining
|
||
// settle from the observed rate and boot the pool mid-loop when it
|
||
// clears the bar. The loop already switches to fan-out only when the
|
||
// async boot reports ready, admission order is mode-independent, and
|
||
// 2-core/low-memory hosts still decline inside tryCreate's sizing —
|
||
// so the switch changes wall-clock, never the graph.
|
||
if (inFlight.mode === 'seq' && parallel && pool === null && !poolEngageTried) {
|
||
adaptiveSeqMs += Date.now() - tBatch;
|
||
adaptiveSeqRefs += batch.length;
|
||
const remaining = total - processed - batch.length;
|
||
const projectedMs = (adaptiveSeqMs / Math.max(1, adaptiveSeqRefs)) * Math.max(0, remaining);
|
||
if (shouldEngageAdaptively(projectedMs, remaining, ADAPTIVE_ENGAGE_SETTLE_MS)) {
|
||
if (process.env.CODEGRAPH_SYNTH_TIMINGS) {
|
||
console.error(`[pool-timing] adaptive engage: projected ${Math.round(projectedMs)}ms sequential settle over ${remaining} remaining refs`);
|
||
}
|
||
pool = createPool(Date.now(), 'adaptive');
|
||
}
|
||
}
|
||
|
||
// WAL-valve backstop at the ONE pool-idle boundary of the double-buffer
|
||
// (this batch settled, the next not yet fanned out): past the hard cap
|
||
// the writer parks for a full backfill here, where the pool's readers
|
||
// are all between statements — so the backfill completes, readers
|
||
// re-enter at SQLite's backfilled mark, and the next persist commit
|
||
// WRAPS the WAL instead of growing it. No-op (one fstat) under the cap.
|
||
tLp = Date.now();
|
||
const bp = parallel?.backpressure?.();
|
||
if (bp) await bp;
|
||
lp('backpressure', tLp);
|
||
|
||
// Recycle the workers' read connections periodically at this same
|
||
// worker-idle boundary (batch k settled, batch k+1 not yet fanned
|
||
// out): a long-lived reader pins WAL checkpoint progress, and the
|
||
// deep WAL that accumulates behind it taxes the writer's OWN page
|
||
// operations — the §7a.6 writes-under-readers finding (deletes
|
||
// 42.6s → 118.8s from 0 to 4 attached readers; an aggressive valve
|
||
// recovered the writes but paid +129s in full-park folds). Releasing
|
||
// the read marks every ~25 batches lets the existing checkpoints
|
||
// advance instead, at ~milliseconds of reopen cost. A failed recycle
|
||
// downgrades to sequential permanently, same as a failed fan-out.
|
||
if (pool && poolReady && ++batchesSinceRecycle >= RECYCLE_EVERY_BATCHES) {
|
||
batchesSinceRecycle = 0;
|
||
tLp = Date.now();
|
||
try {
|
||
await pool.recycleWorkers();
|
||
} catch (err) {
|
||
logDebug('Worker connection recycle failed; falling back to sequential', {
|
||
error: err instanceof Error ? err.message : String(err),
|
||
});
|
||
await pool.destroy().catch(() => undefined);
|
||
pool = null;
|
||
}
|
||
lp('recycle', tLp);
|
||
}
|
||
|
||
// Persist in bounded sub-transactions with yields between: a whole
|
||
// batch's edge insert / keyed deletes are otherwise one solid
|
||
// synchronous span each on a multi-GB index, sitting BETWEEN the
|
||
// per-ref yields — the last unyielded stretch of the resolution loop.
|
||
// Crash semantics are unchanged (already several transactions): edges
|
||
// land before their refs are deleted, so a kill mid-way re-resolves
|
||
// the remainder idempotently on the next run/sweep (#1187).
|
||
const PERSIST_CHUNK = 1000;
|
||
const tPersist = Date.now();
|
||
|
||
// A batch that made `extends`/`implements` edges persists them BEFORE
|
||
// fanning out the next batch: later batches read them —
|
||
// resolveMethodOnType walks supertype chains over edges that earlier
|
||
// batches resolved, so a receiver typed as a subclass only reaches a
|
||
// method declared on its base class if those edges are visible.
|
||
// (Validated on dubbo: fanning out first downgraded exactly those
|
||
// supertype-method resolutions from the 0.9 typed-receiver path to the
|
||
// 0.65 word-overlap fallback.) They are the only edges resolution reads
|
||
// (RESOLUTION_READ_EDGE_KINDS), so a batch that made none — every batch
|
||
// after the prerequisite phase readNextBatch drains first — fans the
|
||
// next batch out now and inserts while it resolves: the same writes in
|
||
// the same order, and nothing the workers read differs.
|
||
tLp = Date.now();
|
||
const edges = this.createEdges(result.resolved);
|
||
lp('createEdges', tLp);
|
||
const readEdge = edges.some((e) => RESOLUTION_READ_EDGE_KINDS.includes(e.kind));
|
||
let nextInFlight = !readEdge && nextBatch.length > 0 ? beginBatch(nextBatch) : null;
|
||
tLp = Date.now();
|
||
for (let i = 0; i < edges.length; i += PERSIST_CHUNK) {
|
||
this.queries.insertEdges(edges.slice(i, i + PERSIST_CHUNK));
|
||
await maybeYield();
|
||
}
|
||
lp('insertEdges', tLp);
|
||
|
||
// NOW fan the next batch out (if not already) — workers see exactly the
|
||
// edge state the sequential baseline would (every batch ≤ this one
|
||
// committed), while the main thread spends the REST of the persist (ref
|
||
// deletes + failed parking below) overlapped with their resolution —
|
||
// the double-buffer.
|
||
if (!nextInFlight && nextBatch.length > 0) nextInFlight = beginBatch(nextBatch);
|
||
|
||
// Clean up resolved refs so they don't appear in the next batch —
|
||
// by row id, so a same-key sibling ref in a LATER batch (same caller
|
||
// calling the same callee at another line) is left pending for its own
|
||
// attempt instead of being swept out with this batch's rows (#1269).
|
||
tLp = Date.now();
|
||
let removedThisBatch = 0;
|
||
const resolvedCleanup = ReferenceResolver.partitionResolvedCleanup(result.resolved);
|
||
for (let i = 0; i < resolvedCleanup.rowIds.length; i += PERSIST_CHUNK) {
|
||
removedThisBatch += this.queries.deleteReferencesByRowIds(resolvedCleanup.rowIds.slice(i, i + PERSIST_CHUNK));
|
||
await maybeYield();
|
||
}
|
||
for (let i = 0; i < resolvedCleanup.legacyKeys.length; i += PERSIST_CHUNK) {
|
||
removedThisBatch += this.queries.deleteSpecificResolvedReferences(resolvedCleanup.legacyKeys.slice(i, i + PERSIST_CHUNK));
|
||
await maybeYield();
|
||
}
|
||
lp('deletes', tLp);
|
||
|
||
// Park unresolvable refs from this batch as status='failed' so they
|
||
// leave the pending set (the batch reader and non-progress guard below
|
||
// only see pending rows) but stay retryable when a later sync adds a
|
||
// symbol that could satisfy them (#1240).
|
||
tLp = Date.now();
|
||
const failures = this.nonDeferredFailures(result.unresolved);
|
||
const deferredCount = result.unresolved.length - failures.length;
|
||
const failedCleanup = ReferenceResolver.partitionFailedCleanup(failures);
|
||
for (let i = 0; i < failedCleanup.byRowId.length; i += PERSIST_CHUNK) {
|
||
removedThisBatch += this.queries.markReferencesFailedByRowIds(failedCleanup.byRowId.slice(i, i + PERSIST_CHUNK));
|
||
await maybeYield();
|
||
}
|
||
for (let i = 0; i < failedCleanup.legacyKeys.length; i += PERSIST_CHUNK) {
|
||
removedThisBatch += this.queries.markReferencesFailed(failedCleanup.legacyKeys.slice(i, i + PERSIST_CHUNK));
|
||
await maybeYield();
|
||
}
|
||
lp('marks', tLp);
|
||
|
||
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[pool-timing] batch persist: ${Date.now() - tPersist}ms`);
|
||
|
||
// Aggregate stats
|
||
aggregateStats.total += result.stats.total;
|
||
aggregateStats.resolved += result.stats.resolved;
|
||
aggregateStats.unresolved += result.stats.unresolved;
|
||
for (const [method, count] of Object.entries(result.stats.byMethod)) {
|
||
aggregateStats.byMethod[method] = (aggregateStats.byMethod[method] || 0) + count;
|
||
}
|
||
|
||
processed += batch.length;
|
||
onProgress?.(processed, total);
|
||
|
||
// Yield so progress UI can render between batches
|
||
await new Promise(resolve => setImmediate(resolve));
|
||
|
||
// NOTE: there used to be an extra early break here when a batch resolved
|
||
// nothing (`result.unresolved.length === batch.length`). That was wrong:
|
||
// an all-unresolvable batch still DELETES its rows (progress), yet the
|
||
// break abandoned every batch after it in the same run — on a repo whose
|
||
// first 5000 refs are all external/stdlib calls, resolution stopped at
|
||
// batch one and left the rest of the table as permanent orphans (#1187).
|
||
// The count-based guard below catches the true no-progress case.
|
||
|
||
// Non-progress guard (defense-in-depth). Ordinary attempts must leave
|
||
// the pending set; a mismatched original reference can make legacy-key
|
||
// cleanup a no-op. Keep the guard against that broken persistence even
|
||
// though keyset pagination now advances independently of row cleanup.
|
||
// An abandoned prefetched batch has no side effects until settleBatch.
|
||
// Non-progress signal, now O(1): `changes` summed across this batch's
|
||
// deletes + failed-parks is the DIRECT evidence the guard's old count
|
||
// diff inferred — a resolver returning a mismatched name makes the keyed
|
||
// cleanup no-op, which shows up here as zero removals. The per-batch
|
||
// COUNT(*) it replaces walked every remaining pending row — O(N²/batch)
|
||
// over a run, 93.9s of the kernel-scale batch loop (§7a.2). A REAL count
|
||
// runs only on the suspicious path (claimed-work batch removed nothing —
|
||
// e.g. every row was a sibling a legacy-key sweep already consumed),
|
||
// where it arbitrates stop-vs-continue exactly as before.
|
||
// Deferred refs legitimately remain pending for the post-pass. The
|
||
// keyset cursor advances past them; they must not trigger this guard.
|
||
if (removedThisBatch + deferredCount <= 0 && batch.length > 0) {
|
||
tLp = Date.now();
|
||
const remaining = this.queries.getUnresolvedReferencesCount();
|
||
lp('countGuard', tLp);
|
||
if (remaining >= prevRemaining) break;
|
||
prevRemaining = remaining;
|
||
}
|
||
|
||
// Advance the pipeline: the prefetched batch (already fanned out when
|
||
// the pool is on) becomes the current one.
|
||
batch = nextBatch;
|
||
inFlight = nextInFlight;
|
||
}
|
||
} finally {
|
||
// Recreate the edge indexes BEFORE synthesis (kind-keyed reads) and on
|
||
// any error path. A crash before this line is healed by the next
|
||
// DatabaseConnection open (schema.sql re-applies IF NOT EXISTS).
|
||
if (bulkRefsActive) {
|
||
const tRef = Date.now();
|
||
await parallel!.refIndexLoad!.end();
|
||
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] ref-index-recreate: ${Date.now() - tRef}ms`);
|
||
}
|
||
if (bulkEdgesActive) {
|
||
const tIdx = Date.now();
|
||
await parallel!.bulkEdgeLoad!.end();
|
||
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] edge-index-recreate: ${Date.now() - tIdx}ms`);
|
||
// The recreate just wrote every non-unique edge index into the WAL
|
||
// (multi-GB at kernel scale) with the pool idle — fold before the
|
||
// synthesis passes pin readers against it for minutes.
|
||
const bp = parallel?.backpressure?.();
|
||
if (bp) await bp;
|
||
}
|
||
}
|
||
|
||
// Indexes the bulk-edge window's end deferred: built once, while the pool
|
||
// is busy with synthesis when it is, and in any case before this returns.
|
||
let deferredBuilt = !bulkEdgesActive || !parallel?.bulkEdgeLoad?.deferred;
|
||
const buildDeferredIndexes = (): void => {
|
||
if (deferredBuilt) return;
|
||
deferredBuilt = true;
|
||
const tDeferred = Date.now();
|
||
try {
|
||
parallel!.bulkEdgeLoad!.deferred!();
|
||
} catch { /* healed by schema.sql on the next open, like a crash in the window */ }
|
||
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] deferred-edge-index: ${Date.now() - tDeferred}ms`);
|
||
};
|
||
|
||
// Dynamic-edge synthesis: now that all base `calls` edges are persisted,
|
||
// synthesize observer/callback dispatch edges (dispatcher → registered
|
||
// callbacks) that static parsing leaves out. Best-effort — never fail the
|
||
// index on it. The pool (when it survived resolution) is REUSED to fan the
|
||
// independent passes across its read-only workers — that's why its destroy
|
||
// lives in the finally below, after synthesis, not at the end of the batch
|
||
// loop. See docs/design/callback-edge-synthesis.md.
|
||
const tSynth = Date.now();
|
||
try {
|
||
if (synthesize) aggregateStats.byMethod['callback-synthesis'] = await synthesizeCallbackEdges(
|
||
this.queries,
|
||
this.context,
|
||
onSynthesisProgress,
|
||
pool,
|
||
parallel?.backpressure,
|
||
buildDeferredIndexes
|
||
);
|
||
} catch {
|
||
// synthesis is additive and optional; ignore failures
|
||
}
|
||
buildDeferredIndexes();
|
||
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] callback-synthesis: ${Date.now() - tSynth}ms`);
|
||
} finally {
|
||
if (pool) await pool.destroy().catch(() => undefined);
|
||
}
|
||
|
||
if (loopProf) {
|
||
const parts = Object.entries(loopProf).map(([k, v]) => `${k}=${(v / 1000).toFixed(1)}s`).join(' ');
|
||
console.error(`[resolve-profile] loop-stages ${parts}`);
|
||
}
|
||
this.dumpResolveProfile('main');
|
||
|
||
return {
|
||
resolved: [],
|
||
unresolved: [],
|
||
stats: aggregateStats,
|
||
};
|
||
}
|
||
|
||
/** Replace synthesis only after every base-resolution pass has finished. */
|
||
async refreshSynthesis(
|
||
dbPath: string,
|
||
onProgress?: (done: number, total: number) => void,
|
||
backpressure?: () => Promise<void> | null
|
||
): Promise<number> {
|
||
this.clearCaches();
|
||
const stage = new SynthesisStage(dbPath);
|
||
try {
|
||
const fresh = new ReferenceResolver(this.projectRoot, stage.queries);
|
||
const count = await synthesizeCallbackEdges(stage.queries, fresh.context, onProgress, null, backpressure);
|
||
await stage.publish(backpressure);
|
||
return count;
|
||
} finally {
|
||
stage.close();
|
||
this.clearCaches();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Digest of the package roots Python import resolution rests on (see
|
||
* `pythonRootFingerprint`). Build configs are read from disk rather than
|
||
* the file cache, so a sync after an edit sees the edit. Empty for a
|
||
* project with no Python.
|
||
*/
|
||
getPythonRootFingerprint(): string {
|
||
return pythonRootFingerprint(this.diskContext());
|
||
}
|
||
|
||
/**
|
||
* The workspace packages and path dependencies as package.json files
|
||
* declare them now, read again from disk, and their fingerprint (see
|
||
* `workspaceFingerprint`). The resolver keeps the map for its lifetime, so
|
||
* a sync calls this to see a manifest edit; when the map moved, the
|
||
* resolver takes the new one and drops every memo resolved through the old
|
||
* one, and the caller re-opens what was linked by it.
|
||
*/
|
||
refreshWorkspacePackages(): string {
|
||
const fresh = loadWorkspacePackages(this.projectRoot);
|
||
const fingerprint = workspaceFingerprint(fresh);
|
||
if (this.workspacePackages === undefined || workspaceFingerprint(this.workspacePackages) !== fingerprint) {
|
||
this.workspacePackages = fresh;
|
||
this.clearCaches();
|
||
}
|
||
return fingerprint;
|
||
}
|
||
|
||
/** The fingerprint of the workspace map this resolver resolves by (see `workspaceFingerprint`). */
|
||
getWorkspaceFingerprint(): string {
|
||
return workspaceFingerprint(this.context.getWorkspacePackages?.() ?? null);
|
||
}
|
||
|
||
/** The folder of the workspace package or path dependency `file` is in (see `workspacePackageDirOf`), or null. */
|
||
workspacePackageDirOf(file: string): string | null {
|
||
const ws = this.context.getWorkspacePackages?.();
|
||
return ws ? workspacePackageDirOf(ws, file) : null;
|
||
}
|
||
|
||
/** What a sync must re-open after these Python files were added or removed (see `pythonReopenScope`). */
|
||
getPythonReopenScope(changedFiles: string[]): ReturnType<typeof pythonReopenScope> {
|
||
return pythonReopenScope(this.diskContext(), changedFiles);
|
||
}
|
||
|
||
/** Importers of Python packages whose `__init__.py` was edited, with the names they bind (see `pythonPackageImporters`). */
|
||
getPythonPackageImporters(initFiles: string[]): Map<string, Map<string, string | null>> {
|
||
return pythonPackageImporters(this.context, initFiles);
|
||
}
|
||
|
||
/** The resolution context, reading build configs from disk rather than the file cache. */
|
||
private diskContext(): ResolutionContext {
|
||
return {
|
||
...this.context,
|
||
readFile: (filePath: string) => {
|
||
try {
|
||
return fs.readFileSync(path.join(this.projectRoot, filePath), 'utf-8');
|
||
} catch {
|
||
return null;
|
||
}
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Get detected frameworks
|
||
*/
|
||
getDetectedFrameworks(): string[] {
|
||
return this.frameworks.map((f) => f.name);
|
||
}
|
||
|
||
/** The project file a module specifier written in `fromFile` names, as its import resolves it; null when none. */
|
||
resolveModuleFile(specifier: string, fromFile: string, language: Language): string | null {
|
||
return resolveImportPath(specifier, fromFile, language, this.context);
|
||
}
|
||
|
||
/**
|
||
* What `ref` resolves to against the graph as it is now, writing nothing:
|
||
* its target and how it was found, or null. A sync compares it with the
|
||
* edge the reference made before, to re-open only an edge an edit moved.
|
||
*/
|
||
resolveNow(ref: UnresolvedReference): { targetNodeId: string; resolvedBy: string } | null {
|
||
if (!ref.filePath || !ref.language) return null;
|
||
const resolved = this.resolveOne({ ...ref, filePath: ref.filePath, language: ref.language });
|
||
return resolved ? { targetNodeId: resolved.targetNodeId, resolvedBy: resolved.resolvedBy } : null;
|
||
}
|
||
|
||
/** True when a detected router binds navigation calls to its routes (see {@link reopenNavigationsFor}). */
|
||
hasNavigationRouters(): boolean {
|
||
return this.frameworks.some((f) => f.navigation !== undefined);
|
||
}
|
||
|
||
/**
|
||
* Put back in the pending set the navigation calls `routes` — route nodes a
|
||
* sync added, removed or renamed ({@link changedRoutes}) — may now resolve
|
||
* differently, for the sync's resolution sweep: the calls parked as failed,
|
||
* and the calls whose `navigates` edges a router's resolver made. Calls in
|
||
* `changedFilePaths` are left alone; the sync resolved them against the new
|
||
* routes already. Returns the number of references put back.
|
||
*
|
||
* A navigation call names its route by path (`navigate('/login')`) and its
|
||
* reference by the router's method (`navigate`, `history.push`), so a route
|
||
* table that changed in another file is invisible to the rest of sync. A
|
||
* call parked as failed while its route was missing is keyed on `push`,
|
||
* which no synced file defines, and a resolved call keeps the route it bound
|
||
* to while a better one was missing (a catch-all, a parameter route, one arm
|
||
* of a conditional) or a route renamed under it. A full index resolves every
|
||
* call against the final table. Each router says which calls a route of its
|
||
* own can answer (`FrameworkResolver.navigation`): the method tails, and the
|
||
* apps whose files match against that route's table. A name more of those
|
||
* calls share than `perNameCeiling` is skipped, as the symbol retry skips a
|
||
* common tail.
|
||
*/
|
||
reopenNavigationsFor(routes: readonly Node[], changedFilePaths: readonly string[] = [], perNameCeiling: number = 500): number {
|
||
if (routes.length === 0) return 0;
|
||
const plans: Array<{ router: FrameworkResolver; scopes: string[] }> = [];
|
||
const tails = new Set<string>();
|
||
for (const router of this.frameworks) {
|
||
if (!router.navigation) continue;
|
||
const scopes = new Set<string>();
|
||
for (const route of routes) {
|
||
for (const scope of router.navigation.scope(route, this.context) ?? []) scopes.add(scope);
|
||
}
|
||
if (scopes.size === 0) continue;
|
||
plans.push({ router, scopes: [...scopes] });
|
||
for (const tail of router.navigation.tails) tails.add(tail);
|
||
}
|
||
if (plans.length === 0) return 0;
|
||
const claimed = (name: string, filePath: string): boolean =>
|
||
plans.some(({ router, scopes }) => scopes.some((scope) => filePath.startsWith(scope)) && router.claimsReference?.(name) === true);
|
||
|
||
const fresh = new Set(changedFilePaths);
|
||
const failed = new Map<string, number[]>();
|
||
for (const ref of this.queries.getFailedCallsByTail([...tails])) {
|
||
if (fresh.has(ref.filePath) || !claimed(ref.referenceName, ref.filePath)) continue;
|
||
const rowIds = failed.get(ref.referenceName);
|
||
if (rowIds) rowIds.push(ref.rowId);
|
||
else failed.set(ref.referenceName, [ref.rowId]);
|
||
}
|
||
const resolved = new Map<string, Array<Edge & { edgeId: number; sourceFilePath: string; sourceLanguage: Language }>>();
|
||
for (const edge of this.queries.getResolvedNavigations()) {
|
||
const name = edge.metadata?.refName;
|
||
if (typeof name !== 'string' || fresh.has(edge.sourceFilePath) || !claimed(name, edge.sourceFilePath)) continue;
|
||
const edges = resolved.get(name);
|
||
if (edges) edges.push(edge);
|
||
else resolved.set(name, [edge]);
|
||
}
|
||
|
||
const rowIds: number[] = [];
|
||
const edgeIds: number[] = [];
|
||
const refs: UnresolvedReference[] = [];
|
||
// A conditional's arms are edges of one call: it goes back as one reference.
|
||
const sites = new Set<string>();
|
||
for (const name of new Set([...failed.keys(), ...resolved.keys()])) {
|
||
const rows = failed.get(name) ?? [];
|
||
const edges = resolved.get(name) ?? [];
|
||
if (rows.length + edges.length > perNameCeiling) continue;
|
||
for (const rowId of rows) rowIds.push(rowId);
|
||
for (const edge of edges) {
|
||
const ref = resurrectRefFromDroppedEdge(edge);
|
||
if (!ref) continue;
|
||
edgeIds.push(edge.edgeId);
|
||
const site = `${ref.fromNodeId}\0${ref.referenceName}\0${ref.line}\0${ref.column}`;
|
||
if (sites.has(site)) continue;
|
||
sites.add(site);
|
||
refs.push(ref);
|
||
}
|
||
}
|
||
if (edgeIds.length > 0) this.queries.replaceResolutionEdgesWithUnresolvedRefs(edgeIds, refs);
|
||
return this.queries.reopenFailedReferences(rowIds) + refs.length;
|
||
}
|
||
|
||
/**
|
||
* The edited modules among `modifiedFilePaths` whose default export moved:
|
||
* an edit made `export default A` into `export default B`, or took the
|
||
* default away, while `A` stayed declared. A sync moves the edges other
|
||
* files made into a changed file onto the node that replaces each target, so
|
||
* `new Y()` after `import Y from './x'` kept `A` where a full index links
|
||
* `B`. An `export default` statement is no node, so neither the definition
|
||
* delta nor a failed-reference retry sees it change.
|
||
*
|
||
* The default a module had is read off the edges into it, with no file read:
|
||
* a default or namespace import of it is an `imports` edge into its file
|
||
* node named for the binding, and a whole-name reference through that
|
||
* binding (`Y`, not `Y.run`) that resolved through the import names exactly
|
||
* its default (a namespace binding's `NS` names the file). Only edges bound
|
||
* before this sync resolved anything count (up to `resolvedUpToEdgeId`), and
|
||
* only from files it left alone. A module that forwards its default from
|
||
* another file (`export { default } from './impl'`) has those edges there,
|
||
* so a few of its importers' are read instead. An edit that leaves the
|
||
* default alone, nearly every one, finds them all still on it. Each module
|
||
* comes with the declarations its default was (`was`), as those edges say.
|
||
*/
|
||
changedDefaultExports(
|
||
modifiedFilePaths: readonly string[],
|
||
changedFilePaths: readonly string[],
|
||
resolvedUpToEdgeId: number,
|
||
): Array<{ path: string; language: Language; was: string[] }> {
|
||
const fresh = new Set(changedFilePaths);
|
||
const moved: Array<{ path: string; language: Language; was: string[] }> = [];
|
||
for (const filePath of modifiedFilePaths) {
|
||
if (!isJsFamilyFile(filePath)) continue;
|
||
const bindings = new Map<string, Set<string>>();
|
||
for (const i of this.queries.getImportsInto(`file:${filePath}`, filePath)) {
|
||
if (i.refName === null || fresh.has(i.filePath) || !isModuleBindingName(i.refName)) continue;
|
||
const names = bindings.get(i.filePath) ?? new Set<string>();
|
||
names.add(i.refName);
|
||
bindings.set(i.filePath, names);
|
||
}
|
||
if (bindings.size === 0) continue;
|
||
const language = this.queries.getFileByPath(filePath)?.language;
|
||
if (!language) continue;
|
||
const readsDefault = (e: Edge & { edgeId: number; sourceFilePath: string }): boolean => {
|
||
const name = e.metadata?.refName;
|
||
return typeof name === 'string' && e.metadata?.resolvedBy === 'import' && e.edgeId <= resolvedUpToEdgeId &&
|
||
!e.target.startsWith('file:') && bindings.get(e.sourceFilePath)?.has(name) === true;
|
||
};
|
||
let readers: Array<Edge & { edgeId: number; sourceFilePath: string; sourceLanguage: Language }> =
|
||
this.queries.getCrossFileIncomingEdgesWithTarget(filePath).filter(readsDefault);
|
||
if (readers.length === 0) {
|
||
const now = moduleDefaultExport(filePath, language, this.context);
|
||
if (!now || now.filePath === filePath) continue;
|
||
readers = [...bindings].slice(0, DEFAULT_READER_SAMPLE_FILES)
|
||
.flatMap(([file, names]) => this.queries.getResolutionEdgesThrough(file, [...names]))
|
||
.filter(readsDefault);
|
||
}
|
||
const was = new Set<string>();
|
||
for (const e of readers) {
|
||
const now = moduleDefaultExport(filePath, e.sourceLanguage, this.context);
|
||
// A call that lands on an alias binding goes on to the callable it names (resolveOne).
|
||
if (now && (e.target === now.id || (e.kind === 'calls' && resolveAliasBinding(now, null, this.context)?.id === e.target))) continue;
|
||
was.add(e.target);
|
||
}
|
||
if (was.size > 0) moved.push({ path: filePath, language, was: [...was] });
|
||
}
|
||
return moved;
|
||
}
|
||
|
||
/**
|
||
* Whether a failed import could name one of `files`, the files a sync just
|
||
* added: the test the import retry puts a crowded key's imports to
|
||
* (`QueryBuilder.getRetryableFailedImports`). `utils` is the path stem of a
|
||
* project's own `./utils` and of every `jotai/utils` and `@scope/pkg/utils`
|
||
* import alike, and `assert` the name of every binding of `import * as
|
||
* assert from 'assert'`, so past the retry's ceiling a key holds the few
|
||
* imports an added `utils.ts` or `assert.ts` is for among hundreds that no
|
||
* file of the project is.
|
||
*
|
||
* A JavaScript or TypeScript import reaches a file by the module it is
|
||
* written as (a path, a tsconfig alias or a workspace package, an `index`
|
||
* file and the emitted `.js` of a `.ts` included), by a framework's alias
|
||
* (SvelteKit's `$lib/…`) or by the file's own name (`…/utils.js`). One that
|
||
* none of those lead to an added file — a package, an import binding, a
|
||
* workspace package's subpath only its `exports` map resolves — names none
|
||
* of them. Of an import written in another language this knows nothing, so
|
||
* a crowded key skips it, as it always has.
|
||
*/
|
||
couldNameAnyOf(files: readonly string[]): (reference: UnresolvedReference) => boolean {
|
||
const added = new Set(files);
|
||
const names = new Set(files.map((file) => file.slice(file.lastIndexOf('/') + 1)));
|
||
return (reference) => {
|
||
const language = reference.language || this.getLanguageFromNodeId(reference.fromNodeId);
|
||
if (!ESM_IMPORT_LANGUAGES.has(language)) return false;
|
||
const ref: UnresolvedRef = {
|
||
fromNodeId: reference.fromNodeId,
|
||
referenceName: reference.referenceName,
|
||
referenceKind: reference.referenceKind,
|
||
line: reference.line,
|
||
column: reference.column,
|
||
filePath: reference.filePath || this.getFilePathFromNodeId(reference.fromNodeId),
|
||
language,
|
||
rowId: reference.rowId,
|
||
};
|
||
const name = ref.referenceName;
|
||
if (names.has(name.slice(name.lastIndexOf('/') + 1))) return true;
|
||
// Where the specifier leads first: a package's leads nowhere, and only
|
||
// then is it worth asking whether the resolver reads the reference as a
|
||
// module, which for a bare name means reading the file's imports.
|
||
const target = resolveImportPath(name, ref.filePath, language, this.context);
|
||
if (target !== null && added.has(target) && jsModuleFile(ref, this.context) === target) return true;
|
||
// A framework's alias is a path (`$lib/x`, `~/x`); a bare name, which
|
||
// every binding is, is no alias, and asking reads the file.
|
||
return name.includes('/') && this.frameworksFor(language).some((framework) => {
|
||
const result = framework.resolve(ref, this.context);
|
||
return result !== null && added.has(this.nodeById(result.targetNodeId)?.filePath ?? '');
|
||
});
|
||
};
|
||
}
|
||
|
||
/**
|
||
* True when `receiver` is a local name bound by an import that resolves to a
|
||
* file IN THIS PROJECT — the only case where letting a python
|
||
* built-in-method name through the filter is safe (#1681).
|
||
*
|
||
* Asking only whether SOME import bound the local name is not enough: every
|
||
* import produces a mapping, stdlib and PyPI included, so that would also be
|
||
* true for `os`, `requests`, `np`. Opening the filter for them lets
|
||
* resolveViaImport find no project file, fall through to bare-name matching,
|
||
* and bind `os.remove(p)` to whatever project method happens to be named
|
||
* `remove` — reintroducing, through its own escape hatch, the fabricated-edge
|
||
* class this filter exists to prevent.
|
||
*
|
||
* Resolving the specifier is the same question resolveViaImport will ask
|
||
* next, so a receiver that passes here is one the qualified path can actually
|
||
* serve; anything else stays a silent miss rather than a wrong edge.
|
||
*/
|
||
private isPythonProjectModule(ref: UnresolvedRef, receiver: string): boolean {
|
||
for (const imp of this.context.getImportMappings(ref.filePath, ref.language)) {
|
||
if (imp.localName !== receiver) continue;
|
||
// `import pkg.mod` / `import pkg.mod as m` binds the module `source`
|
||
// names. `from pkg import mod` binds `pkg.mod`, and `from . import mod`
|
||
// binds `.mod` — join without doubling the dot that makes `.` mean the
|
||
// current package.
|
||
const specifier = imp.isNamespace
|
||
? imp.source
|
||
: imp.source.endsWith('.')
|
||
? `${imp.source}${imp.exportedName}`
|
||
: `${imp.source}.${imp.exportedName}`;
|
||
if (resolveImportPath(specifier, ref.filePath, ref.language!, this.context)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** A python call `x.get(…)` of a method named like a builtin collection, string or file method. */
|
||
private isPythonBuiltinNamedMethodCall(ref: UnresolvedRef): boolean {
|
||
if (ref.language !== 'python' || ref.referenceKind !== 'calls') return false;
|
||
const dot = ref.referenceName.indexOf('.');
|
||
return dot > 0 && PYTHON_BUILT_IN_METHODS.has(ref.referenceName.slice(dot + 1)) &&
|
||
!PYTHON_BUILT_IN_TYPES.has(ref.referenceName.slice(0, dot));
|
||
}
|
||
|
||
/**
|
||
* Check if reference is to a built-in or external symbol
|
||
*/
|
||
private isBuiltInOrExternal(ref: UnresolvedRef): boolean {
|
||
const name = ref.referenceName;
|
||
const isJsTs = ref.language === 'typescript' || ref.language === 'javascript'
|
||
|| ref.language === 'tsx' || ref.language === 'jsx' || ref.language === 'arkts'
|
||
|| ref.language === 'vue' || ref.language === 'svelte' || ref.language === 'astro';
|
||
|
||
// JavaScript/TypeScript built-ins — unless the file imports its own
|
||
// binding of that name (`import Map from './Map.svelte'`).
|
||
if (isJsTs && JS_BUILT_INS.has(name) &&
|
||
!this.context.getImportMappings(ref.filePath, ref.language).some((m) => m.localName === name)) {
|
||
return true;
|
||
}
|
||
|
||
// ArkTS resource-reference intrinsics — `$r('app.string.x')` /
|
||
// `$rawfile('x.png')` are framework-provided and appear dozens of times
|
||
// per UI file; without this they can resolve to a stray same-named
|
||
// symbol (e.g. a checked-in hvigor wrapper's `$r`).
|
||
if (ref.language === 'arkts' && (name === '$r' || name === '$rawfile')) {
|
||
return true;
|
||
}
|
||
|
||
// Common JS/TS library calls (console.log, Math.floor, JSON.parse)
|
||
if (isJsTs && (name.startsWith('console.') || name.startsWith('Math.') || name.startsWith('JSON.'))) {
|
||
return true;
|
||
}
|
||
|
||
// React hooks from React itself
|
||
if (isJsTs && REACT_HOOKS.has(name)) {
|
||
return true;
|
||
}
|
||
|
||
// Python built-ins (bare calls only — dotted calls like console.print are method calls)
|
||
if (ref.language === 'python' && PYTHON_BUILT_INS.has(name)) {
|
||
return true;
|
||
}
|
||
|
||
// Python built-in method calls (e.g., list.extend, dict.update)
|
||
if (ref.language === 'python') {
|
||
const dotIdx = name.indexOf('.');
|
||
if (dotIdx > 0) {
|
||
const receiver = name.substring(0, dotIdx);
|
||
const method = name.substring(dotIdx + 1);
|
||
// Filter calls on built-in types (list.append, dict.update, etc.)
|
||
if (PYTHON_BUILT_IN_TYPES.has(receiver)) {
|
||
return true;
|
||
}
|
||
// Filter built-in methods on non-class receivers
|
||
// (e.g., items.append where items is a local list variable)
|
||
// But allow if the capitalized receiver matches a known codebase class,
|
||
// OR the receiver is itself an imported project module — a module can
|
||
// export a top-level function sharing a common collection-method name
|
||
// (`ledger.append`, `from . import ledger`), and that call is a real
|
||
// project dependency, not `list.append` (#1681). Without this, the
|
||
// qualified ref never reaches resolveViaImport / resolvePythonModuleMember.
|
||
if (PYTHON_BUILT_IN_METHODS.has(method)) {
|
||
// A module-scope collection binding is stronger evidence than a
|
||
// coincidentally matching class name (#1652). Only use this file's
|
||
// binding: an unrelated module may reuse the receiver for a collection.
|
||
const isCollection = this.context.getNodesByName(receiver).some((node) =>
|
||
node.language === 'python' && node.filePath === ref.filePath &&
|
||
(node.kind === 'variable' || node.kind === 'constant') &&
|
||
node.qualifiedName === receiver &&
|
||
/^=\s*(?:[\[{]|(?:dict|list|set|tuple|frozenset)\s*\(|\(\s*\)|\([^()]*,)/.test(node.signature ?? '')
|
||
);
|
||
if (isCollection) return true;
|
||
|
||
const capitalized = receiver.charAt(0).toUpperCase() + receiver.slice(1);
|
||
const isKnownClass = this.context.getNodesByName(capitalized).some((node) =>
|
||
node.language === 'python' &&
|
||
(node.kind === 'class' || node.kind === 'struct' || node.kind === 'interface')
|
||
);
|
||
const isProjectModule =
|
||
!isKnownClass && this.isPythonProjectModule(ref, receiver);
|
||
if (!isKnownClass && !isProjectModule) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
// A bare name colliding with a builtin method (index, get, update, count…)
|
||
// is only a builtin when NOTHING in the codebase declares it. A declared
|
||
// symbol with that exact name — e.g. a Flask/FastAPI view `def index()` or
|
||
// `def get()` — is a real reference target. Without this guard, every
|
||
// handler named after a builtin method silently loses its route→handler edge.
|
||
if (PYTHON_BUILT_IN_METHODS.has(name) && !this.knownNames?.has(name)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// Go standard library packages — refs like "fmt.Println", "http.ListenAndServe", etc.
|
||
if (ref.language === 'go') {
|
||
const dotIdx = name.indexOf('.');
|
||
if (dotIdx > 0) {
|
||
const pkg = name.substring(0, dotIdx);
|
||
// Not a local bound from a type assertion that is named like one:
|
||
// grpc-go's `parser.ParseConfig(…)` after `parser, ok :=
|
||
// b.(balancer.ConfigParser)`. Nor a call on any other parameter or
|
||
// local of the name that can hold one of the project's values: gin's
|
||
// `context.AbortWithError(…)` in `func(context *Context)`.
|
||
if (GO_STDLIB_PACKAGES.has(pkg) && !isGoAssertedLocal(pkg, ref, this.context) &&
|
||
!isGoProjectLocalCall(ref, this.context)) {
|
||
return true;
|
||
}
|
||
}
|
||
if (GO_BUILT_INS.has(name)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// Pascal/Delphi built-ins, whatever their case: Pascal names ignore it
|
||
// (`length(S)`, `system.TypeInfo(T)`). And standard library units.
|
||
if (ref.language === 'pascal') {
|
||
const lower = name.toLowerCase();
|
||
if (PASCAL_UNIT_PREFIXES.some((p) => name.startsWith(p)) || lower.startsWith('system.')) {
|
||
return true;
|
||
}
|
||
if (PASCAL_BUILT_INS.has(lower)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// C/C++ standard library symbols (printf, malloc, std::vector, etc.).
|
||
// Names that collide with user-defined symbols are NOT filtered —
|
||
// C and C++ projects routinely shadow stdlib names (custom allocators
|
||
// define `malloc`/`free`, stream wrappers define `read`/`write`/`open`,
|
||
// containers define `move`/`swap`, logging libs wrap `printf`). Killing
|
||
// those resolutions makes the graph wrong, not cleaner. We only filter
|
||
// when there's no user node with this name — then name-matching would
|
||
// produce zero edges anyway and the filter just short-circuits work.
|
||
if (ref.language === 'c' || ref.language === 'cpp') {
|
||
// A leading `::` names the global scope: `::std::move` is `std::move`,
|
||
// `::memset` is `memset`.
|
||
const scoped = name.startsWith('::') ? name.slice(2) : name;
|
||
// C++ std:: namespace prefix — safe to filter unconditionally,
|
||
// since `std::foo` is never a user-defined qualified name in
|
||
// tree-sitter output.
|
||
if (scoped.startsWith('std::')) return true;
|
||
if (C_BUILT_INS.has(scoped) || CPP_BUILT_INS.has(scoped)) {
|
||
return !this.hasAnyPossibleMatch(scoped);
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Get file path from node ID
|
||
*/
|
||
private getFilePathFromNodeId(nodeId: string): string {
|
||
const node = this.nodeById(nodeId);
|
||
return node?.filePath || '';
|
||
}
|
||
|
||
/**
|
||
* Get language from node ID
|
||
*/
|
||
private getLanguageFromNodeId(nodeId: string): UnresolvedRef['language'] {
|
||
const node = this.nodeById(nodeId);
|
||
return node?.language || 'unknown';
|
||
}
|
||
|
||
/**
|
||
* Collect the `@using` namespaces in scope for a `.razor`/`.cshtml` file: its
|
||
* own `@using` directives plus every `_Imports.razor` from the file's folder up
|
||
* to the project root (Razor `_Imports` cascade). Cached per file.
|
||
*/
|
||
private getRazorUsings(filePath: string): string[] {
|
||
const cached = this.razorUsingsCache.get(filePath);
|
||
if (cached) return cached;
|
||
const usings = new Set<string>();
|
||
const addFrom = (src: string | null): void => {
|
||
if (!src) return;
|
||
for (const m of src.matchAll(/^\s*@using\s+(?:static\s+)?([A-Za-z_][\w.]*)/gm)) usings.add(m[1]!);
|
||
};
|
||
addFrom(this.context.readFile(filePath));
|
||
let dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/')) : '';
|
||
// Walk up to the project root, reading each level's _Imports.razor.
|
||
for (;;) {
|
||
addFrom(this.context.readFile(dir ? `${dir}/_Imports.razor` : '_Imports.razor'));
|
||
if (!dir) break;
|
||
const slash = dir.lastIndexOf('/');
|
||
dir = slash >= 0 ? dir.slice(0, slash) : '';
|
||
}
|
||
const arr = [...usings];
|
||
this.razorUsingsCache.set(filePath, arr);
|
||
return arr;
|
||
}
|
||
|
||
/**
|
||
* Resolve a Razor/Blazor simple type ref through the file's `@using`
|
||
* namespaces: `CatalogBrand` + `@using BlazorShared.Models` → the node whose
|
||
* qualified name is `BlazorShared.Models::CatalogBrand`. Only resolves when the
|
||
* `@using` set yields exactly ONE type (otherwise it stays ambiguous and falls
|
||
* through to name-matching).
|
||
*/
|
||
private resolveRazorUsing(ref: UnresolvedRef): ResolvedRef | null {
|
||
if (ref.referenceName.includes('.') || ref.referenceName.includes('::')) return null;
|
||
const usings = this.getRazorUsings(ref.filePath);
|
||
if (usings.length === 0) return null;
|
||
const found = new Map<string, Node>();
|
||
for (const ns of usings) {
|
||
for (const cand of this.context.getNodesByQualifiedName(`${ns}::${ref.referenceName}`)) {
|
||
found.set(cand.id, cand);
|
||
}
|
||
}
|
||
if (found.size !== 1) return null;
|
||
const target = found.values().next().value!;
|
||
return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' };
|
||
}
|
||
|
||
/**
|
||
* Resolve a CFML inheritance reference written as a component path (#1152).
|
||
* Two forms exist in real code:
|
||
*
|
||
* - Dotted: `extends="coldbox.system.web.Controller"` — dots are directory
|
||
* separators from the webroot or a CFML mapping. Mappings live in server
|
||
* config / Application.cfc, so the leading segments may not exist in the
|
||
* repo at all (in the coldbox repo itself the path is `system/web/
|
||
* Controller.cfc` — the `coldbox.` root IS the repo). Matched by final
|
||
* segment (the class), corroborated right-to-left against the candidate's
|
||
* parent directories.
|
||
* - Relative: `extends="../base"` / `extends="./base"` (the FW/1 style) —
|
||
* resolved against the referencing file's own directory.
|
||
*
|
||
* Conservative by design: a candidate needs at least one corroborating
|
||
* directory segment (a dotted path whose only same-named class sits in an
|
||
* unrelated directory is almost always an out-of-repo library supertype —
|
||
* mxunit/testbox/coldbox-as-dependency), and a corroboration tie yields no
|
||
* edge. Directory comparison is case-insensitive (CFML path resolution is);
|
||
* the class segment itself is matched exactly, which real code satisfies —
|
||
* dotted paths are written to match the on-disk file name.
|
||
*
|
||
* A test suite is no candidate for production code (#2209's rule), and on a
|
||
* tie it yields to the one component outside the test suites (#2650): a
|
||
* TestBox spec mirroring the component's path (`tests/specs/unit/core/
|
||
* basecore.cfc` beside `core/basecore.cfc`) corroborates the same segments,
|
||
* and a mapping root like `contenscms` (onto `contens/`) matches neither.
|
||
*/
|
||
private resolveCfmlComponentPath(ref: UnresolvedRef): ResolvedRef | null {
|
||
const cfmlCandidates = (name: string): Node[] =>
|
||
this.context
|
||
.getNodesByName(name)
|
||
.filter(
|
||
(n) =>
|
||
(n.kind === 'class' || n.kind === 'interface') &&
|
||
(n.language === 'cfml' || n.language === 'cfscript')
|
||
);
|
||
const norm = (p: string): string => p.replace(/\\/g, '/').toLowerCase();
|
||
|
||
// Relative-path form: `../base`, `./base`, `sub/thing` — resolve against
|
||
// the referencing file's directory and require an exact (case-insensitive)
|
||
// file match.
|
||
if (ref.referenceName.includes('/')) {
|
||
const rel = ref.referenceName.replace(/\.cfc$/i, '');
|
||
const fromDir = ref.filePath.replace(/\\/g, '/').split('/').slice(0, -1);
|
||
const parts = [...fromDir];
|
||
for (const seg of rel.split('/')) {
|
||
if (seg === '' || seg === '.') continue;
|
||
if (seg === '..') {
|
||
if (parts.length === 0) return null; // escapes the project root
|
||
parts.pop();
|
||
} else {
|
||
parts.push(seg);
|
||
}
|
||
}
|
||
const wantPath = norm(parts.join('/') + '.cfc');
|
||
const className = parts[parts.length - 1];
|
||
if (!className) return null;
|
||
const target = cfmlCandidates(className).find((c) => norm(c.filePath) === wantPath);
|
||
return target
|
||
? { original: ref, targetNodeId: target.id, confidence: 0.95, resolvedBy: 'file-path' }
|
||
: null;
|
||
}
|
||
|
||
// Dotted form.
|
||
const segments = ref.referenceName.split('.').map((s) => s.trim()).filter(Boolean);
|
||
if (segments.length < 2) return null;
|
||
const className = segments[segments.length - 1]!;
|
||
const dirSegments = segments.slice(0, -1);
|
||
|
||
// Production code never names a component in a test suite; a test can
|
||
// name one, by the path to it.
|
||
const fromTest = isTestPath(ref.filePath);
|
||
let bestScore = 0;
|
||
let tied: Node[] = [];
|
||
for (const cand of cfmlCandidates(className)) {
|
||
if (!fromTest && isTestSuitePath(cand.filePath)) continue;
|
||
const dirs = cand.filePath.replace(/\\/g, '/').split('/').slice(0, -1);
|
||
// Count matching directory segments right-to-left: for
|
||
// `coldbox.system.web.Controller` vs `system/web/Controller.cfc`,
|
||
// `web` and `system` match, then the repo root ends the run → score 2.
|
||
let score = 0;
|
||
while (
|
||
score < dirSegments.length &&
|
||
score < dirs.length &&
|
||
dirSegments[dirSegments.length - 1 - score]!.toLowerCase() ===
|
||
dirs[dirs.length - 1 - score]!.toLowerCase()
|
||
) {
|
||
score++;
|
||
}
|
||
if (score > bestScore) {
|
||
bestScore = score;
|
||
tied = [cand];
|
||
} else if (score === bestScore && score > 0) {
|
||
tied.push(cand);
|
||
}
|
||
}
|
||
// A test's tie between a component and a suite mirroring its path: the
|
||
// path names the component, as it does from production code.
|
||
if (tied.length > 1) tied = tied.filter((c) => !isTestSuitePath(c.filePath));
|
||
if (bestScore === 0 || tied.length !== 1) return null;
|
||
return { original: ref, targetNodeId: tied[0]!.id, confidence: 0.9, resolvedBy: 'qualified-name' };
|
||
}
|
||
|
||
/**
|
||
* The file a CFML include names (#2651) — `include "x.cfm";`,
|
||
* `<cfinclude template="x.cfm">`, `cfinclude(template="x.cfm")`. A template
|
||
* path is relative to the directory of the file that includes it
|
||
* (`formlistshared.cfm`, `../shared/helpers.cfm`), or to the web root when
|
||
* it starts with `/`: the project root, when the project is the web root —
|
||
* an assumption, so such a match is less sure. Null when no indexed file is
|
||
* there. CFML code written on a case-blind file system often spells a path
|
||
* in another case, so a single file that matches ignoring case counts too.
|
||
*/
|
||
private resolveCfmlIncludePath(ref: UnresolvedRef): ResolvedRef | null {
|
||
const written = ref.referenceName.trim().replace(/\\/g, '/');
|
||
const fromRoot = written.startsWith('/');
|
||
const parts = fromRoot ? [] : ref.filePath.replace(/\\/g, '/').split('/').slice(0, -1);
|
||
for (const seg of written.split('/')) {
|
||
if (seg === '' || seg === '.') continue;
|
||
if (seg === '..') {
|
||
if (parts.length === 0) return null; // above the project root
|
||
parts.pop();
|
||
} else {
|
||
parts.push(seg);
|
||
}
|
||
}
|
||
const fileName = parts[parts.length - 1];
|
||
if (!fileName) return null;
|
||
const wanted = parts.join('/');
|
||
const files = this.context.getNodesByLowerName(fileName.toLowerCase()).filter((n) => n.kind === 'file');
|
||
const exact = files.find((n) => n.filePath === wanted);
|
||
if (exact) return { original: ref, targetNodeId: exact.id, confidence: fromRoot ? 0.85 : 0.95, resolvedBy: 'file-path' };
|
||
const folded = files.filter((n) => n.filePath.toLowerCase() === wanted.toLowerCase());
|
||
return folded.length === 1
|
||
? { original: ref, targetNodeId: folded[0]!.id, confidence: fromRoot ? 0.8 : 0.9, resolvedBy: 'file-path' }
|
||
: null;
|
||
}
|
||
|
||
/**
|
||
* Resolve a `this.<member>` function-as-value reference (#756/#808) to the
|
||
* ENCLOSING CLASS's own member — never a same-named symbol elsewhere. The
|
||
* registration idiom (`btn.on('click', this.handleClick)`) names a member
|
||
* of the class being defined, so the only valid target shares the
|
||
* from-symbol's qualified-name scope. Function/method targets only — a
|
||
* property (a data field, post-#808 classification) yields no edge — same
|
||
* file required, no fallback of any kind.
|
||
*/
|
||
private resolveThisMemberFnRef(ref: UnresolvedRef): ResolvedRef | null {
|
||
const member = ref.referenceName.slice('this.'.length);
|
||
if (!member) return null;
|
||
const written = this.nodeById(ref.fromNodeId);
|
||
if (!written) return null;
|
||
// Inside an object literal: its own method's `this` is the object, an
|
||
// arrow member's is the method around the literal (#2300).
|
||
const fromNode = thisScopeCaller(written, this.context);
|
||
// A hook declared at class-body level (Ruby `before_action :authenticate`)
|
||
// attributes to the CLASS node itself — its qualified name IS the scope.
|
||
// For members, strip the member segment.
|
||
let classPrefix: string;
|
||
if (SUPERTYPE_BEARING_KINDS.has(fromNode.kind) || fromNode.kind === 'module') {
|
||
classPrefix = fromNode.qualifiedName;
|
||
} else {
|
||
const sep = fromNode.qualifiedName.lastIndexOf('::');
|
||
if (sep <= 0) return null; // not inside a class scope
|
||
classPrefix = fromNode.qualifiedName.slice(0, sep);
|
||
}
|
||
const candidates = this.context
|
||
.getNodesByQualifiedName(`${classPrefix}::${member}`)
|
||
.filter(
|
||
(n) =>
|
||
(n.kind === 'function' || n.kind === 'method') &&
|
||
n.filePath === ref.filePath &&
|
||
n.id !== ref.fromNodeId
|
||
);
|
||
if (candidates.length === 0) {
|
||
// Not on the class itself — possibly INHERITED. implements/extends
|
||
// edges don't exist yet in this pass, so retry in the supertype pass
|
||
// (resolveDeferredThisMemberRefs) instead of giving up.
|
||
this.deferReference(ref, this.deferredThisMemberRefs);
|
||
return null;
|
||
}
|
||
const target = candidates.reduce((a, b) => (a.startLine <= b.startLine ? a : b));
|
||
return {
|
||
original: ref,
|
||
targetNodeId: target.id,
|
||
confidence: 0.95,
|
||
resolvedBy: 'function-ref',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Second pass for `this.<member>` refs whose member wasn't on the enclosing
|
||
* class itself (#808): once implements/extends edges exist, walk the
|
||
* class's supertypes (transitively, depth-capped) and resolve the member on
|
||
* the nearest one that declares it — `this.handleSubmit` registered in a
|
||
* subclass resolves to `FormBase::handleSubmit`. Validated targets only
|
||
* (function/method kind, same language family); no match → no edge.
|
||
* Mirrors resolveChainedCallsViaConformance's lifecycle. Returns the number
|
||
* of newly-created edges.
|
||
*/
|
||
async resolveDeferredThisMemberRefs(): Promise<number> {
|
||
const deferred = this.deferredThisMemberRefs;
|
||
this.deferredThisMemberRefs = [];
|
||
if (deferred.length === 0) return 0;
|
||
|
||
this.clearCaches();
|
||
// Synchronous main-thread post-pass with a per-ref supertype BFS — yield
|
||
// periodically so the #850 liveness watchdog heartbeat can fire (#1091).
|
||
const maybeYield = createYielder();
|
||
const resolved: ResolvedRef[] = [];
|
||
// Refs share supertypes (every `this.dispose` walks the same chain), and
|
||
// nothing is written until the loop ends: read each type's supertype
|
||
// edges, and each supertype's callable members by name, once.
|
||
const supertypeEdges = new Map<string, Edge[]>();
|
||
const callableMembers = new Map<string, Map<string, Node[]>>();
|
||
const supertypesOf = (id: string): Edge[] => {
|
||
let edges = supertypeEdges.get(id);
|
||
if (!edges) {
|
||
edges = this.queries.getOutgoingEdges(id, ['implements', 'extends']);
|
||
supertypeEdges.set(id, edges);
|
||
}
|
||
return edges;
|
||
};
|
||
const callableMembersOf = (id: string): Map<string, Node[]> => {
|
||
let byName = callableMembers.get(id);
|
||
if (!byName) {
|
||
byName = new Map();
|
||
for (const c of this.queries.getOutgoingEdges(id, ['contains'])) {
|
||
const m = this.nodeById(c.target);
|
||
if (!m || (m.kind !== 'function' && m.kind !== 'method')) continue;
|
||
const list = byName.get(m.name);
|
||
if (list) list.push(m);
|
||
else byName.set(m.name, [m]);
|
||
}
|
||
callableMembers.set(id, byName);
|
||
}
|
||
return byName;
|
||
};
|
||
for (const ref of deferred) {
|
||
await maybeYield();
|
||
const member = ref.referenceName.slice('this.'.length);
|
||
const written = this.nodeById(ref.fromNodeId);
|
||
if (!written || !member) continue;
|
||
const fromNode = thisScopeCaller(written, this.context);
|
||
// Class-body-level hooks (Ruby) attribute to the CLASS node itself.
|
||
let className: string;
|
||
if (SUPERTYPE_BEARING_KINDS.has(fromNode.kind) || fromNode.kind === 'module') {
|
||
className = fromNode.name;
|
||
} else {
|
||
const sep = fromNode.qualifiedName.lastIndexOf('::');
|
||
if (sep <= 0) continue;
|
||
const classPrefix = fromNode.qualifiedName.slice(0, sep);
|
||
className = classPrefix.includes('::')
|
||
? classPrefix.slice(classPrefix.lastIndexOf('::') + 2)
|
||
: classPrefix;
|
||
}
|
||
|
||
// NODE-anchored BFS up the supertype graph: start from the class node
|
||
// in the ref's own file (never a same-named class elsewhere — rails has
|
||
// a dozen `Engine`s), follow implements/extends EDGES to supertype
|
||
// NODES, and look members up through `contains` edges. No name-based
|
||
// unions anywhere — a name-keyed getSupertypes('Engine') merged every
|
||
// Engine's parents and produced a cross-class wrong edge on rails.
|
||
let frontierNodes = this.context
|
||
.getNodesByName(className)
|
||
.filter(
|
||
(n) =>
|
||
SUPERTYPE_BEARING_KINDS.has(n.kind) &&
|
||
n.filePath === ref.filePath
|
||
);
|
||
if (frontierNodes.length === 0) {
|
||
// The class itself may be declared in another file (partial/reopened
|
||
// classes); fall back to same-family nodes of that name.
|
||
frontierNodes = this.context
|
||
.getNodesByName(className)
|
||
.filter(
|
||
(n) =>
|
||
SUPERTYPE_BEARING_KINDS.has(n.kind) &&
|
||
sameLanguageFamily(n.language, ref.language)
|
||
);
|
||
}
|
||
const seenNodes = new Set<string>(frontierNodes.map((n) => n.id));
|
||
let target: Node | null = null;
|
||
for (let depth = 0; depth < 5 && frontierNodes.length > 0 && !target; depth++) {
|
||
const next: Node[] = [];
|
||
for (const typeNode of frontierNodes) {
|
||
for (const edge of supertypesOf(typeNode.id)) {
|
||
const superNode = this.nodeById(edge.target);
|
||
if (!superNode || seenNodes.has(superNode.id)) continue;
|
||
seenNodes.add(superNode.id);
|
||
if (!SUPERTYPE_BEARING_KINDS.has(superNode.kind)) continue;
|
||
// Member lookup anchored on the supertype's contains edges.
|
||
target =
|
||
callableMembersOf(superNode.id)
|
||
.get(member)
|
||
?.find((m) => sameLanguageFamily(m.language, ref.language)) ?? null;
|
||
if (target) break;
|
||
next.push(superNode);
|
||
}
|
||
if (target) break;
|
||
}
|
||
frontierNodes = next;
|
||
}
|
||
|
||
if (target) {
|
||
resolved.push({
|
||
original: ref,
|
||
targetNodeId: target.id,
|
||
confidence: 0.85,
|
||
resolvedBy: 'function-ref',
|
||
});
|
||
}
|
||
}
|
||
return this.persistDeferredReferences(deferred, resolved);
|
||
}
|
||
|
||
/**
|
||
* Drop a resolution whose target cannot be what the reference names.
|
||
* Applied at the `resolveOne` seam so it covers every strategy uniformly —
|
||
* framework, import, name-match, chain, CFML component path.
|
||
*
|
||
* For `imports`: the target must be importable. A member that only exists
|
||
* inside a type never is, and neither is the import statement the reference
|
||
* was written in.
|
||
*
|
||
* For a Go type position: the target is a type of the package Go reads the
|
||
* name from, moved there when a strategy found a method, a function or
|
||
* another package's type (see goTypePositionTarget).
|
||
*
|
||
* For `extends`/`implements`, it cannot be describing a real supertype when:
|
||
*
|
||
* 1. The target's kind can never be a supertype (an enum member, a method,
|
||
* a variable). `matchByExactName` additionally narrows its candidate
|
||
* pool by the same set, so a legitimate supertype outranks a same-named
|
||
* non-type rather than merely losing its edge.
|
||
* 2. The name is imported from outside the repo, so NO local node is the
|
||
* referent. Without this, filtering by kind alone just relocates the
|
||
* false edge onto the next same-named local type.
|
||
*
|
||
* One exception to (1): a TypeScript VALUE that shares its name with a type
|
||
* in the same file. `export const IFoo = createDecorator<IFoo>('foo')` beside
|
||
* `export interface IFoo` is how VS Code declares every service, and an
|
||
* import of `IFoo` resolves to the file with both — a strategy that takes the
|
||
* first export of that name gets the value. The strategy found the right
|
||
* file and name; the type declared there is the supertype, so the edge moves
|
||
* to it rather than being dropped (dropping it lost ~900 `implements` edges
|
||
* on vscode).
|
||
*
|
||
* Otherwise this only ever REMOVES an edge, never adds one. A dropped ref
|
||
* stays in `unresolved_refs` as `failed`, which is the honest record for a
|
||
* supertype that lives outside the repo — silent beats wrong.
|
||
*/
|
||
private gateTargetKind(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null {
|
||
if (!result) return result;
|
||
|
||
// A Go name called or passed bare that the function around it binds
|
||
// itself — a parameter, a `:=`, a `var` — is that binding, never a
|
||
// declaration of the package: client-go's `init := func(…)` then
|
||
// `init(t)` calls no `func init()`.
|
||
if (ref.language === 'go' && (ref.referenceKind === 'calls' || ref.referenceKind === 'function_ref') &&
|
||
isGoBareName(ref, this.context) && isGoLocallyBound(ref, this.context)) return null;
|
||
|
||
// A `#define` is a value, never a callee (#1838): a macro defined only in
|
||
// an unrelated file is not what `NAME(x)` here expands to either.
|
||
if (ref.referenceKind === 'calls') {
|
||
const target = this.nodeById(result.targetNodeId);
|
||
if (target?.kind === 'constant' && CPP_DEFINE_SIGNATURE.test(target.signature ?? '')) return null;
|
||
// A Go call made through an expression — `h.Validate().Error()`, a
|
||
// `.Metrics()` chained onto the line above — calls a method of whatever
|
||
// the expression is. A type is called only bare (`T(x)`, `(*T)(x)`) or
|
||
// through its package; as a struct here it read as an instantiation.
|
||
if (target && ref.language === 'go' && GO_TYPE_KINDS.has(target.kind) &&
|
||
!ref.referenceName.includes('.') && !isGoBareName(ref, this.context)) return null;
|
||
}
|
||
|
||
// A TypeScript interface or type alias is no value: nothing calls one or
|
||
// makes an instance of it. A strategy that found one by name meant the
|
||
// value its file declares beside it (`interface Ok` and `function Ok` in a
|
||
// result module); with none there, the name is a value from elsewhere
|
||
// that the project only types (`new Response()`, `new Worker()`), and
|
||
// links nothing.
|
||
if ((ref.referenceKind === 'calls' || ref.referenceKind === 'instantiates') && ESM_IMPORT_LANGUAGES.has(ref.language)) {
|
||
const target = this.nodeById(result.targetNodeId);
|
||
if (target && (target.kind === 'interface' || target.kind === 'type_alias')) {
|
||
const value = this.sameNamedValueOfType(target);
|
||
if (!value) return null;
|
||
result = { ...result, targetNodeId: value.id };
|
||
}
|
||
}
|
||
|
||
// An `imports` reference names something importable — never a member that
|
||
// only exists inside a type, nor its own import statement. A module path
|
||
// that names no file (`./missing`, `stdio.h`, `pkg.mod`, a C# `using` of
|
||
// an outside namespace) reaches the statement by its qualified name; as an
|
||
// edge to itself it is never retried, while a failed ref is retried by the
|
||
// sync that adds the file it names.
|
||
if (ref.referenceKind === 'imports') {
|
||
const target = this.nodeById(result.targetNodeId);
|
||
if (target?.kind === 'import' && target.filePath === ref.filePath) return null;
|
||
return target && !isImportableKind(target.kind) ? null : result;
|
||
}
|
||
|
||
// A Go type declaration — an alias, a defined type, a struct's fields —
|
||
// names its types as an embedding does (below): written through a package
|
||
// that is none of the file's imports as indexed, the type found by its
|
||
// bare name is another package's namesake, or the declaration itself
|
||
// (`op clientv3.Op` beside the file's own `Op`).
|
||
if (ref.language === 'go' && ref.referenceKind === 'references' &&
|
||
GO_TYPE_KINDS.has(this.nodeById(ref.fromNodeId)?.kind ?? '') && isGoUnknownQualified(ref, this.context)) return null;
|
||
|
||
// A Go type position — a parameter or result type, a composite literal's
|
||
// type — names a type of the package Go reads it from (route handlers are
|
||
// `references` too, but values).
|
||
if (ref.language === 'go' && (ref.referenceKind === 'instantiates' ||
|
||
(ref.referenceKind === 'references' && this.nodeById(ref.fromNodeId)?.kind !== 'route'))) {
|
||
return goTypePositionTarget(result, ref, this.context);
|
||
}
|
||
|
||
if (!isInheritanceRef(ref)) return result;
|
||
const target = this.nodeById(result.targetNodeId);
|
||
if (target && !isSupertypeTarget(target)) {
|
||
const type = this.sameNamedTypeOfValue(target);
|
||
if (!type) return null;
|
||
result = { ...result, targetNodeId: type.id };
|
||
}
|
||
if (isBoundToOutOfRepoImport(ref, this.context)) return null;
|
||
// A Go type embedded through a package (`clientv3.KV`) is that package's.
|
||
// When the package is none of the file's imports as indexed (clientv3
|
||
// imported unaliased as `go.etcd.io/etcd/client/v3`), a type found by its
|
||
// bare name is some other package's namesake.
|
||
if (ref.language === 'go' && isGoUnknownQualified(ref, this.context)) return null;
|
||
// One embedded bare that only a package the file dot-imports declares is
|
||
// that package's, not a namesake as near the file.
|
||
if (ref.language === 'go') return goDotImportedSupertype(result, ref, this.context);
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* `super.didMoveToWindow()` inside an override of `didMoveToWindow` calls
|
||
* the PARENT's implementation, never the method making the call. Extraction
|
||
* keeps a `super` call under the bare method name, so every strategy found
|
||
* the enclosing method itself: one expo-camera view had eleven of its
|
||
* overrides "calling" themselves. The parent's method is usually a
|
||
* framework's (UIKit, Android, React); a self-edge is never it. Real
|
||
* recursion keeps its edge — only a call written through `super` / `base`
|
||
* (C#) / `[super …]` (Objective-C) / `parent::` (PHP) / `super().` (Python)
|
||
* is declined.
|
||
*/
|
||
private gateSuperSelfCall(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null {
|
||
if (!result || ref.referenceKind !== 'calls' || result.targetNodeId !== ref.fromNodeId) return result;
|
||
// A TS/JS call is read where it starts, so a `this.run()` that shares its
|
||
// line with a `super.run()` keeps its edge (#2497).
|
||
if (isJsSuperCallLanguage(ref.language)) return isJsSuperCallSite(ref, this.context) ? null : result;
|
||
const name = ref.referenceName.slice(Math.max(ref.referenceName.lastIndexOf('.'), ref.referenceName.lastIndexOf(':')) + 1);
|
||
if (!/^[A-Za-z_$][\w$]*$/.test(name)) return result;
|
||
const line = (this.context.getFileLines?.(ref.filePath) ?? this.context.readFile(ref.filePath)?.split(/\r?\n/))?.[ref.line - 1];
|
||
if (!line) return result;
|
||
const escaped = name.replace(/\$/g, '\\$');
|
||
const viaSuper = new RegExp(
|
||
String.raw`(?:\b(?:super|base)\s*(?:\(\s*(?:[\w.]+\s*,\s*\w+)?\s*\))?\s*\??\.\s*|\[\s*super\s+|\bparent\s*::\s*)` + escaped + String.raw`\b`,
|
||
);
|
||
return viaSuper.test(line) ? null : result;
|
||
}
|
||
|
||
/**
|
||
* A bare Rust name reaches only what is in scope — every strategy's result,
|
||
* a framework resolver's `Ok(x)` → `struct Ok` construction included (see
|
||
* isRustNameInScope). A call through a path whose type is from outside the
|
||
* project reaches only the project's impls for that type (or of that trait,
|
||
* isRustPathCallReachable). And a call, however written, reaches a project
|
||
* impl on a std type only as Rust would pick it (isRustForeignImplCallable).
|
||
*/
|
||
private gateRustScope(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null {
|
||
if (!result || ref.language !== 'rust') return result;
|
||
const bare = /^[A-Za-z_]\w*$/.test(ref.referenceName);
|
||
if (!bare && ref.referenceKind !== 'calls') return result;
|
||
const target = this.nodeById(result.targetNodeId);
|
||
if (!target) return result;
|
||
if (bare && !isRustNameInScope(target, ref, this.context)) return null;
|
||
if (ref.referenceKind !== 'calls') return result;
|
||
if (!bare && !isRustPathCallReachable(target, ref, this.context)) return null;
|
||
return isRustForeignImplCallable(target, ref, this.context) ? result : null;
|
||
}
|
||
|
||
/**
|
||
* The context production code is resolved in when the project has Jest or
|
||
* Vitest manual mocks (`__mocks__/`): the graph without them. A mock
|
||
* re-declares its module's names by design and stands in for the module
|
||
* only while a test runs, so production code means what it would mean were
|
||
* the mocks not there (#2455): `formatLabel(name)`, whose import of a
|
||
* workspace package was not followed, is the package's `formatLabel`, never
|
||
* the nearer mock's. Adding or deleting a mock then changes nothing for
|
||
* production code, as a fresh index would have it. Test code, a mock
|
||
* included, keeps the whole graph: a mock is what Jest hands it. Null when
|
||
* the whole graph applies.
|
||
*/
|
||
private mocklessContextFor(ref: UnresolvedRef, base: ResolutionContext): ResolutionContext | null {
|
||
if (isTestPath(ref.filePath)) return null;
|
||
const mocks = this.manualMockNames();
|
||
if (!mocks) return null;
|
||
if (this.mocklessContext && this.mocklessBase === base) return this.mocklessContext;
|
||
const isMock = (n: Node): boolean => isManualMockPath(n.filePath);
|
||
const real = (nodes: Node[]): Node[] => (nodes.some(isMock) ? nodes.filter((n) => !isMock(n)) : nodes);
|
||
// Only a name some mock declares can lose a node: other lookups pass
|
||
// straight through, and the filtered answers are kept.
|
||
const byName = new Map<string, Node[]>();
|
||
const byLowerName = new Map<string, Node[]>();
|
||
const byKind = new Map<Node['kind'], Node[]>();
|
||
const getMethodMatches = base.getMethodMatches;
|
||
const iterateNodesByKind = base.iterateNodesByKind;
|
||
this.mocklessBase = base;
|
||
this.mocklessContext = {
|
||
...base,
|
||
getNodesByName: (name) => {
|
||
if (!mocks.names.has(name)) return base.getNodesByName(name);
|
||
let nodes = byName.get(name);
|
||
if (!nodes) byName.set(name, (nodes = real(base.getNodesByName(name))));
|
||
return nodes;
|
||
},
|
||
getNodesByLowerName: (lowerName) => {
|
||
if (!mocks.lowerNames.has(lowerName)) return base.getNodesByLowerName(lowerName);
|
||
let nodes = byLowerName.get(lowerName);
|
||
if (!nodes) byLowerName.set(lowerName, (nodes = real(base.getNodesByLowerName(lowerName))));
|
||
return nodes;
|
||
},
|
||
getNodesByQualifiedName: (qualifiedName) => real(base.getNodesByQualifiedName(qualifiedName)),
|
||
getNodesByKind: (kind) => {
|
||
let nodes = byKind.get(kind);
|
||
if (!nodes) byKind.set(kind, (nodes = real(base.getNodesByKind(kind))));
|
||
return nodes;
|
||
},
|
||
getMethodMatches: getMethodMatches
|
||
? (typeName, methodName, language) => {
|
||
const nodes = getMethodMatches(typeName, methodName, language);
|
||
return mocks.names.has(methodName) ? real(nodes) : nodes;
|
||
}
|
||
: undefined,
|
||
iterateNodesByKind: iterateNodesByKind
|
||
? function* (kind) {
|
||
for (const n of iterateNodesByKind(kind)) if (!isMock(n)) yield n;
|
||
}
|
||
: undefined,
|
||
};
|
||
return this.mocklessContext;
|
||
}
|
||
|
||
/** Built on first use from `mocklessBase`; clearCaches drops it, and the memos keyed by it go with it. */
|
||
private mocklessContext: ResolutionContext | null = null;
|
||
private mocklessBase: ResolutionContext | null = null;
|
||
/** The names the project's manual mocks declare, null when it has none; read once per cache window. */
|
||
private manualMocks: { names: Set<string>; lowerNames: Set<string> } | null | undefined;
|
||
|
||
private manualMockNames(): { names: Set<string>; lowerNames: Set<string> } | null {
|
||
if (this.manualMocks !== undefined) return this.manualMocks;
|
||
const files = [...(this.knownFiles ?? this.queries.getAllFilePaths())].filter(isManualMockPath);
|
||
const names = new Set<string>();
|
||
const lowerNames = new Set<string>();
|
||
if (files.length > 0) {
|
||
for (const n of this.queries.getNodesByFiles(files)) {
|
||
names.add(n.name);
|
||
lowerNames.add(n.name.toLowerCase());
|
||
}
|
||
}
|
||
this.manualMocks = names.size > 0 ? { names, lowerNames } : null;
|
||
return this.manualMocks;
|
||
}
|
||
|
||
/**
|
||
* The context a TS/JS reference is resolved in outside the named class
|
||
* expressions (#2499): one without the classes they declare or their
|
||
* members. An expression's name is bound inside it alone, and its class's
|
||
* value travels in ways resolution doesn't follow. So outside, a reference
|
||
* means what it would were those classes not indexed, with the confidence
|
||
* it had: `const Panel = class Panel {…}` keeps `new Panel()` on the
|
||
* constant, an interface beside a same-named class expression keeps its
|
||
* typed calls, and an `HTMLElement` type stays clear of lit's
|
||
* `HTMLElementShim = class HTMLElement {…}`. A class a CommonJS module
|
||
* exports isn't among them (classExpressions). Null inside a class
|
||
* expression, where the whole graph applies.
|
||
*/
|
||
private classExpressionFreeContextFor(ref: UnresolvedRef, base: ResolutionContext): ResolutionContext | null {
|
||
if (!CLASS_EXPRESSION_REF_LANGUAGES.has(ref.language)) return null;
|
||
const expressions = this.classExpressions();
|
||
if (!expressions) return null;
|
||
const dot = ref.referenceName.indexOf('.');
|
||
const named = expressions.byName.get(dot > 0 ? ref.referenceName.slice(0, dot) : ref.referenceName);
|
||
// Written in a class expression, a reference sees its class and the
|
||
// classes around it, as JavaScript scopes them, unless it names another
|
||
// one, which is bound only inside that one.
|
||
const inside = expressions.byFile.get(ref.filePath)?.some((n) => writtenIn(n, ref)) ?? false;
|
||
if (inside && !(named && !named.some((n) => writtenIn(n, ref)))) return null;
|
||
let view = this.classExpressionFreeViews.get(base);
|
||
if (!view) {
|
||
view = this.classExpressionFreeView(base);
|
||
this.classExpressionFreeViews.set(base, view);
|
||
this.classExpressionFreeBases.set(view, base);
|
||
}
|
||
return view;
|
||
}
|
||
|
||
/**
|
||
* `base` without the classes class expressions declare and their members.
|
||
* The lookups an import reads a module's exports by (getNodesInFileNamed,
|
||
* getExportedNodesInFile) are left whole.
|
||
*/
|
||
private classExpressionFreeView(base: ResolutionContext): ResolutionContext {
|
||
const { hiddenIds } = this.classExpressions()!;
|
||
const hidden = (n: Node): boolean => hiddenIds.has(n.id);
|
||
const keep = (nodes: Node[]): Node[] => (nodes.some(hidden) ? nodes.filter((n) => !hidden(n)) : nodes);
|
||
const byKind = new Map<Node['kind'], Node[]>();
|
||
const getMethodMatches = base.getMethodMatches;
|
||
const iterateNodesByKind = base.iterateNodesByKind;
|
||
const { files } = this.classExpressions()!;
|
||
return {
|
||
...base,
|
||
getNodesByName: (name) => keep(base.getNodesByName(name)),
|
||
getNodesByLowerName: (lowerName) => keep(base.getNodesByLowerName(lowerName)),
|
||
getNodesByQualifiedName: (qualifiedName) => keep(base.getNodesByQualifiedName(qualifiedName)),
|
||
getNodesInFile: (filePath) => (files.has(filePath) ? keep(base.getNodesInFile(filePath)) : base.getNodesInFile(filePath)),
|
||
getNodesByKind: (kind) => {
|
||
let nodes = byKind.get(kind);
|
||
if (!nodes) byKind.set(kind, (nodes = keep(base.getNodesByKind(kind))));
|
||
return nodes;
|
||
},
|
||
getMethodMatches: getMethodMatches
|
||
? (typeName, methodName, language) => keep(getMethodMatches(typeName, methodName, language))
|
||
: undefined,
|
||
iterateNodesByKind: iterateNodesByKind
|
||
? function* (kind) {
|
||
for (const n of iterateNodesByKind(kind)) if (!hidden(n)) yield n;
|
||
}
|
||
: undefined,
|
||
};
|
||
}
|
||
|
||
/** classExpressionFreeContextFor's views, by the context each hides from and what it hides, and each view's context; clearCaches drops both. */
|
||
private classExpressionFreeViews = new WeakMap<ResolutionContext, ResolutionContext>();
|
||
private classExpressionFreeBases = new WeakMap<ResolutionContext, ResolutionContext>();
|
||
/**
|
||
* The names that only class expressions' classes and members carry, which
|
||
* name nothing where classExpressionFreeView hides those: lit's DOM shims
|
||
* (`class HTMLElement`, `class Event`) would otherwise send every
|
||
* `HTMLElement` and `new Event()` across the project through the whole
|
||
* resolution only to find nothing, where before the name pre-filter
|
||
* dropped them. Read once per cache window.
|
||
*/
|
||
private classExpressionOnlyNames(): Set<string> {
|
||
if (this.classExpressionOnlyNameSet) return this.classExpressionOnlyNameSet;
|
||
const only = new Set<string>();
|
||
const expressions = this.classExpressions();
|
||
if (expressions) {
|
||
const names = new Set<string>();
|
||
for (const id of expressions.hiddenIds) {
|
||
const n = this.nodeById(id);
|
||
if (n) names.add(n.name);
|
||
}
|
||
for (const name of names) {
|
||
if (!this.queries.getNodesByName(name).some((n) => !expressions.hiddenIds.has(n.id))) only.add(name);
|
||
}
|
||
}
|
||
this.classExpressionOnlyNameSet = only;
|
||
return only;
|
||
}
|
||
|
||
/** classExpressionOnlyNames' answer; clearCaches drops it. */
|
||
private classExpressionOnlyNameSet: Set<string> | null = null;
|
||
|
||
/**
|
||
* The classes named class expressions declare: by name, the files holding
|
||
* them, the ids of them and their members, and the one a member belongs
|
||
* to. Null when there are none. Read once per cache window. A class a
|
||
* CommonJS module exports is left out: the module binds it, so it is read
|
||
* as a declaration is.
|
||
*/
|
||
private classExpressionIndex: ClassExpressionIndex | null | undefined;
|
||
|
||
private classExpressions(): ClassExpressionIndex | null {
|
||
if (this.classExpressionIndex !== undefined) return this.classExpressionIndex;
|
||
const byName = new Map<string, Node[]>();
|
||
const byFile = new Map<string, Node[]>();
|
||
const byKey = new Map<string, Node>();
|
||
const files = new Set<string>();
|
||
const hiddenIds = new Set<string>();
|
||
const linesOf = (file: string): readonly string[] =>
|
||
this.context.getFileLines?.(file) ?? this.context.readFile(file)?.split(/\r?\n/) ?? [];
|
||
for (const n of this.queries.getNodesByKind('class')) {
|
||
if (!isJsClassExpression(n) || isCommonJsExportedClass(n, linesOf(n.filePath))) continue;
|
||
const named = byName.get(n.name);
|
||
if (named) named.push(n); else byName.set(n.name, [n]);
|
||
const inFile = byFile.get(n.filePath);
|
||
if (inFile) inFile.push(n); else byFile.set(n.filePath, [n]);
|
||
byKey.set(`${n.filePath}\0${n.qualifiedName}`, n);
|
||
files.add(n.filePath);
|
||
hiddenIds.add(n.id);
|
||
}
|
||
// A member is its class's when it is declared in its file under its
|
||
// qualified name (`Widget::run` of `Widget`).
|
||
const ownerOf = (member: Node): Node | undefined => {
|
||
const cut = member.qualifiedName.lastIndexOf('::');
|
||
return cut > 0 ? byKey.get(`${member.filePath}\0${member.qualifiedName.slice(0, cut)}`) : undefined;
|
||
};
|
||
if (files.size > 0) {
|
||
for (const n of this.queries.getNodesByFiles([...files])) {
|
||
if ((n.kind === 'method' || n.kind === 'property' || n.kind === 'field') && ownerOf(n)) hiddenIds.add(n.id);
|
||
}
|
||
}
|
||
this.classExpressionIndex = byName.size > 0 ? { byName, byFile, files, hiddenIds, ownerOf } : null;
|
||
return this.classExpressionIndex;
|
||
}
|
||
|
||
/** Is `filePath` (project-relative) one of the files the index holds? */
|
||
private isIndexedFile(filePath: string): boolean {
|
||
const normalized = filePath.replace(/\\/g, '/');
|
||
return this.knownFiles ? this.knownFiles.has(normalized) : this.queries.getFileByPath(normalized) !== null;
|
||
}
|
||
|
||
/** The repository's own package name, from its root package.json; null without one. */
|
||
/** Per directory: the package names its package.json and every enclosing one own and depend on. */
|
||
private manifestScopes = new Map<string, { own: Set<string>; deps: Set<string> }>();
|
||
|
||
/**
|
||
* Is `source` a package from outside the repository? Only when the importing
|
||
* file's package.json (or an enclosing one) declares it, or it is a Node
|
||
* built-in or a runtime's virtual module. A specifier nothing declares is
|
||
* an alias this resolver does not know — SvelteKit's `$lib/…`, Nuxt's
|
||
* `~/…`, a nested app's own `@/…` — and stays the project's.
|
||
*/
|
||
private isDeclaredOutsidePackage(source: string, fromFile: string): boolean {
|
||
// Deno's standard library is `@std/…` from JSR.
|
||
if (/^(?:node|bun|jsr|npm|https?):/.test(source) || source.startsWith('@std/') || NODE_BUILTINS.has(source)) return true;
|
||
const name = source.startsWith('@') ? source.split('/').slice(0, 2).join('/') : source.split('/')[0]!;
|
||
const normalized = fromFile.replace(/\\/g, '/');
|
||
const scope = this.manifestScope(normalized.includes('/') ? normalized.slice(0, normalized.lastIndexOf('/')) : '');
|
||
if (scope.own.has(name)) return false;
|
||
if (scope.deps.has(name)) return true;
|
||
// SvelteKit's `$app/…` and Astro's `astro:…` belong to the framework —
|
||
// unless this repository is that framework.
|
||
const provider = source.startsWith('astro:') ? 'astro' : /^\$(?:app|env|service-worker)(?:\/|$)/.test(source) ? '@sveltejs/kit' : null;
|
||
return provider !== null && !scope.own.has(provider) && !this.context.getWorkspacePackages?.()?.byName.has(provider);
|
||
}
|
||
|
||
private manifestScope(dir: string): { own: Set<string>; deps: Set<string> } {
|
||
const memo = this.manifestScopes.get(dir);
|
||
if (memo) return memo;
|
||
const parent = dir === '' ? null : this.manifestScope(dir.includes('/') ? dir.slice(0, dir.lastIndexOf('/')) : '');
|
||
let scope = parent ?? { own: new Set<string>(), deps: new Set<string>() };
|
||
try {
|
||
const json = JSON.parse(this.context.readFile(dir ? `${dir}/package.json` : 'package.json') ?? 'null') as Record<string, unknown> | null;
|
||
if (json && typeof json === 'object') {
|
||
scope = { own: new Set(scope.own), deps: new Set(scope.deps) };
|
||
if (typeof json.name === 'string' && json.name.length > 0) scope.own.add(json.name);
|
||
for (const field of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
|
||
const deps = json[field];
|
||
if (!deps || typeof deps !== 'object') continue;
|
||
for (const [dep, version] of Object.entries(deps)) {
|
||
// `workspace:*`, `file:../shared`, `link:…`: a package in this repository.
|
||
if (typeof version === 'string' && /^(?:workspace|file|link|portal):/.test(version)) scope.own.add(dep);
|
||
else scope.deps.add(dep);
|
||
}
|
||
}
|
||
}
|
||
} catch { /* no or unreadable package.json */ }
|
||
// A Deno import map names packages the same way — an entry that maps to a
|
||
// registry or a URL, not one that maps to a path in the repository.
|
||
for (const file of ['deno.json', 'deno.jsonc']) {
|
||
const raw = this.context.readFile(dir ? `${dir}/${file}` : file);
|
||
if (!raw) continue;
|
||
const json = parseJsonc(raw) as { imports?: Record<string, unknown> } | undefined;
|
||
const imports = json && typeof json === 'object' ? json.imports : undefined;
|
||
if (!imports || typeof imports !== 'object') continue;
|
||
for (const [key, target] of Object.entries(imports)) {
|
||
if (typeof target !== 'string' || !/^(?:jsr|npm|https?):/.test(target)) continue;
|
||
if (scope === parent) scope = { own: new Set(scope.own), deps: new Set(scope.deps) };
|
||
scope.deps.add(key.replace(/\/$/, ''));
|
||
}
|
||
}
|
||
this.manifestScopes.set(dir, scope);
|
||
return scope;
|
||
}
|
||
|
||
/** The one supertype-kind node a TypeScript value shares its name and file with. */
|
||
/**
|
||
* The value a file declares beside a type of the same name — `export
|
||
* function Ok()` beside `export interface Ok`, a class merged with an
|
||
* interface — which a call or `new` written with the name reaches. The
|
||
* first by position; null when the file declares none.
|
||
*/
|
||
private sameNamedValueOfType(type: Node): Node | null {
|
||
return this.context
|
||
.getNodesInFile(type.filePath)
|
||
.filter((n) => n.name === type.name && TYPE_COMPANION_VALUE_KINDS.has(n.kind))
|
||
.sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn)[0] ?? null;
|
||
}
|
||
|
||
private sameNamedTypeOfValue(value: Node): Node | null {
|
||
if (value.kind !== 'constant' && value.kind !== 'variable') return null;
|
||
if (value.language !== 'typescript' && value.language !== 'tsx') return null;
|
||
const types = this.context
|
||
.getNodesInFile(value.filePath)
|
||
.filter((n) => n.name === value.name && isSupertypeTarget(n));
|
||
return types.length === 1 ? types[0]! : null;
|
||
}
|
||
|
||
private gateLanguage(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null {
|
||
if (!result) return result;
|
||
return gateLanguageMatch(result, ref, this.context);
|
||
}
|
||
|
||
/**
|
||
* Framework calls carry bridge evidence (RN/Expo JS → native). Other
|
||
* framework results obey the same code-family boundary as name matches;
|
||
* markup/config transitions remain open.
|
||
*/
|
||
private gateFrameworkLanguage(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null {
|
||
if (!result) return result;
|
||
if (ref.referenceKind === 'calls') return result;
|
||
const tgt = this.getLanguageFromNodeId(result.targetNodeId);
|
||
// Package imports cannot target prose found by a framework's name lookup.
|
||
if (ref.referenceKind === 'imports' && (tgt as string) === 'markdown' && (ref.language as string) !== 'markdown') return null;
|
||
if (tgt && ref.language && crossesCodeBoundary(tgt, ref.language)) return null;
|
||
return result;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Whether a CFML `imports` name is the path of a template an include names
|
||
* (`formlistshared.cfm`, `../shared/helpers.cfm`, `/app/x.cfm`), not a
|
||
* cfscript `import com.foo.Bar;` or `import "java:…";` (#2651).
|
||
*/
|
||
function isCfmlTemplatePath(name: string): boolean {
|
||
return !name.includes(':') && (name.includes('/') || name.includes('\\') || /\.(?:cfml?|cfc|cfs)$/i.test(name.trim()));
|
||
}
|
||
|
||
/** The project-relative directory holding `p` ('' for the project root). */
|
||
function goDirOf(p: string): string {
|
||
const dir = path.posix.dirname(p.replace(/\\/g, '/'));
|
||
return dir === '.' || dir === '/' ? '' : dir;
|
||
}
|
||
|
||
/**
|
||
* The routes a sync added, removed or renamed: `before` is every route node
|
||
* before the sync changed a file, `after` every one after its post-extract
|
||
* pass, and a route whose file and path are on one side only changed. A route
|
||
* renamed in place — the same node under a new path — is in the result twice,
|
||
* as its old self and its new one.
|
||
*/
|
||
export function changedRoutes(before: readonly Node[], after: readonly Node[]): Node[] {
|
||
const key = (route: Node): string => `${route.filePath}\0${route.name}`;
|
||
const beforeKeys = new Set(before.map(key));
|
||
const afterKeys = new Set(after.map(key));
|
||
return [...before.filter((route) => !afterKeys.has(key(route))), ...after.filter((route) => !beforeKeys.has(key(route)))];
|
||
}
|
||
|
||
/**
|
||
* Create a reference resolver instance
|
||
*/
|
||
export function createResolver(projectRoot: string, queries: QueryBuilder): ReferenceResolver {
|
||
const resolver = new ReferenceResolver(projectRoot, queries);
|
||
resolver.initialize();
|
||
return resolver;
|
||
}
|