mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-11 03:38:07 +00:00
fix(scope): resolve local imports by lexical ownership (#3532)
This commit is contained in:
parent
50aa4be3b2
commit
82347f0f07
139 changed files with 10499 additions and 1298 deletions
191
docs/languages/local-import-scoping.md
Normal file
191
docs/languages/local-import-scoping.md
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
# Local import scoping
|
||||
|
||||
GitNexus resolves statically known local imports through their lexical owner and
|
||||
actual declaration identity. A nearer name that is unresolved, unavailable, or
|
||||
noncallable blocks an unrelated outer or workspace fallback. An absent name can
|
||||
continue through the existing lookup rules. This is static analysis: it does not
|
||||
execute loaders or infer arbitrary runtime initialization order.
|
||||
|
||||
## Shared contract
|
||||
|
||||
Language rules stay in providers. The shared
|
||||
[name-claim lookup](../../gitnexus-shared/src/scope-resolution/name-claims.ts)
|
||||
distinguishes `absent`, `blocked`, and `resolved` before filtering by target kind.
|
||||
Its [regressions](../../gitnexus/test/unit/scope-resolution/lexical-name-claims.test.ts)
|
||||
cover noncallable barriers, unresolved imports, type/value lookup, declaration
|
||||
positions, and inferred aliases that retain their initializer's lookup position.
|
||||
Calls, constructors, receiver/type inference, inheritance, namespace lookup, and
|
||||
fallback consumers use these ownership facts rather than independently choosing a
|
||||
same-named symbol.
|
||||
|
||||
The [shared records](../../gitnexus-shared/src/scope-resolution/types.ts) carry
|
||||
ownership, activation positions, directive destinations, and lookup purpose as
|
||||
serializable data. They retain declaration identity and ambiguity; they contain
|
||||
no AST nodes or callbacks. `declaredAtScope` describes ownership, while
|
||||
`runsOnlyWhenCalled` describes deferred execution. A function-local Rust `use` or
|
||||
C `#include` is not deferred merely because of its position; Python and Ruby
|
||||
loading can be. The
|
||||
[function-local import chain tests](../../gitnexus/test/unit/scope-resolution/function-local-import-chain.test.ts)
|
||||
pin that distinction.
|
||||
|
||||
## Providers with expanded lexical support
|
||||
|
||||
These seven entries are part of the
|
||||
[18-provider registry](../../gitnexus/src/core/ingestion/languages/index.ts).
|
||||
The evidence links cover exact targets and negative cases, not just edge counts.
|
||||
|
||||
| Provider | Supported behavior | Source and regression evidence |
|
||||
| --- | --- | --- |
|
||||
| Python | Function-local named, aliased, and namespace imports; compiler-local ownership; ordered class lookup; `global`/`nonlocal` destinations. | [Lexical facts](../../gitnexus/src/core/ingestion/languages/python/lexical-bindings.ts), [local imports](../../gitnexus/test/unit/scope-resolution/python/python-local-imports.test.ts), [namespace semantics](../../gitnexus/test/integration/resolvers/python-namespace-semantics.test.ts) |
|
||||
| TypeScript | Literal genuine `require`, immediately awaited `import`, and import-equals; namespace, named, and renamed bindings; type/value separation. | [Loader collector](../../gitnexus/src/core/ingestion/languages/typescript/local-loaders.ts), [loader tests](../../gitnexus/test/unit/scope-resolution/typescript/local-loader-imports.test.ts), [type ownership](../../gitnexus/test/unit/scope-resolution/typescript/lexical-type-ownership.test.ts) |
|
||||
| JavaScript | The same supported runtime loaders and lexical barriers, using JavaScript captures for JS/JSX. | [Captures](../../gitnexus/src/core/ingestion/languages/javascript/captures.ts), [JS/JSX loader cases](../../gitnexus/test/unit/scope-resolution/typescript/local-loader-imports.test.ts) |
|
||||
| Vue | Local loader facts in extracted scripts, with original declaration graph positions preserved. Explicit JS/JSX uses its own semantic grammar inside the worker; TS reuses the cached tree. | [Script delegation](../../gitnexus/src/core/ingestion/languages/vue/captures.ts), [direct/pre-extracted parity](../../gitnexus/test/unit/scope-resolution/typescript/local-loader-imports.test.ts), [real workers](../../gitnexus/test/integration/local-import-worker-parity.test.ts) |
|
||||
| Rust | Lexical `use`, producer identity for imported factories/types, and Cargo-aware crate/module identity. Unresolved explicit imports block unrelated fallback. | [Lexical facts](../../gitnexus/src/core/ingestion/languages/rust/lexical-bindings.ts), [import targets](../../gitnexus/src/core/ingestion/languages/rust/import-target.ts), [local scope](../../gitnexus/test/integration/resolvers/rust-import-scope.test.ts), [Cargo boundaries](../../gitnexus/test/integration/resolvers/rust-cargo-target-fallback.test.ts) |
|
||||
| C++ | Position-sensitive local `using` declarations/directives, separate from includes; propagated header claims retain unresolved named `using` barriers. | [Captures](../../gitnexus/src/core/ingestion/languages/cpp/captures.ts), [local using](../../gitnexus/test/integration/resolvers/cpp-local-using.test.ts), [header using](../../gitnexus/test/integration/resolvers/cpp-local-using-headers.test.ts) |
|
||||
| Zig | Literal `@import` namespace/member bindings, block activation, container hoisting, stable deep aliases, and inline `@import` receivers. | [Captures](../../gitnexus/src/core/ingestion/languages/zig/captures.ts), [local imports](../../gitnexus/test/integration/resolvers/zig-local-imports.test.ts), [static gating](../../gitnexus/test/integration/resolvers/zig-static-gating.test.ts) |
|
||||
|
||||
### Python boundaries
|
||||
|
||||
Compiler-local binders include parameters, assignment patterns, loop/with/except
|
||||
targets, match captures, augmented assignment, deletion, and comprehension/walrus
|
||||
bindings. Comprehension targets do not leak, and the first iterable is evaluated
|
||||
outside the comprehension environment. Class bodies use ordered locals; methods
|
||||
skip class locals for bare-name lookup while explicit instance receivers remain
|
||||
available. Annotation-only declarations distinguish compiler ownership from a
|
||||
runtime store. These cases are characterized in the
|
||||
[Python local-import tests](../../gitnexus/test/unit/scope-resolution/python/python-local-imports.test.ts).
|
||||
|
||||
Mixed import/value rebinding and conflicting import identities conservatively
|
||||
abstain throughout the environment, including simple sequential rebinding; this
|
||||
is not a control-flow evaluator. A definite `def`/`class` declaration can still
|
||||
supply its own target after activation. The
|
||||
[provider policy](../../gitnexus/src/core/ingestion/languages/python/lexical-bindings.ts)
|
||||
and [rebinding regressions](../../gitnexus/test/integration/resolvers/python-namespace-semantics.test.ts)
|
||||
define this boundary. There is no full annotation evaluator, `exec` evaluation,
|
||||
or general closure scheduling model. A deferred `global`/`nonlocal` installer is
|
||||
not assumed to have executed for unrelated callers.
|
||||
|
||||
Conditional imports with one compatible identity remain available for structural
|
||||
reference resolution, including `TYPE_CHECKING` annotations and `try` imports.
|
||||
Their runtime execution is not guaranteed by a resolved graph edge.
|
||||
|
||||
Default expressions look up names in the enclosing environment. FastAPI's
|
||||
synthetic dependency graph caller is recorded separately as `callerScope`; it
|
||||
does not move lexical lookup into the handler body. Both
|
||||
[direct extraction](../../gitnexus/test/unit/scope-resolution/python/python-local-imports.test.ts)
|
||||
and [worker transport](../../gitnexus/test/integration/local-import-worker-parity.test.ts)
|
||||
check this distinction.
|
||||
|
||||
### JavaScript-family boundaries
|
||||
|
||||
Loader recognition checks AST shape and binding identity, including parameters,
|
||||
catch/destructuring bindings, temporal-dead-zone ownership, and writes to the
|
||||
loader or imported handle. CommonJS forwarding uses that same identity check.
|
||||
Reassigning one destructured import leaves unchanged sibling re-exports intact.
|
||||
Writes to ordinary locals retain their declared type and callable-value facts.
|
||||
Unawaited dynamic imports remain Promise/file-dependency facts rather than module
|
||||
namespace aliases. Computed specifiers, indirect loader/Promise chains, and
|
||||
unsupported destructuring remain unresolved; see the
|
||||
[loader regressions](../../gitnexus/test/unit/scope-resolution/typescript/local-loader-imports.test.ts).
|
||||
|
||||
Vue's explicit JS/JSX script requires an additional bounded semantic parse because
|
||||
the worker's structural tree uses TypeScript. That parse runs inside the real
|
||||
worker. The [parity test](../../gitnexus/test/integration/local-import-worker-parity.test.ts)
|
||||
witnesses returned worker facts; a main-thread fallback does not satisfy it.
|
||||
|
||||
### Rust, C++, and Zig boundaries
|
||||
|
||||
Rust uses Cargo editions, target roots, and module identities, including files
|
||||
with multiple valid identities. Reusable per-analysis indexes bound repeated
|
||||
lookup work; this is not a claim of a global indexing speedup. See
|
||||
[Cargo metadata](../../gitnexus/src/core/ingestion/languages/rust/cargo-targets.ts)
|
||||
and its [tests](../../gitnexus/test/unit/scope-resolution/rust-cargo-targets.test.ts).
|
||||
Exact same-name inline `pub use` forwarding is supported. A forwarding alias that
|
||||
changes symbol spelling through a file-only target conservatively remains
|
||||
unresolved; ambiguous same-file homonyms and re-export cycles cannot select an
|
||||
unrelated declaration. These limits are explicit in the
|
||||
[Cargo fallback regressions](../../gitnexus/test/integration/resolvers/rust-cargo-target-fallback.test.ts).
|
||||
Inferred receiver types retain their receiving declaration range. When same-block
|
||||
shadowing replaces a retained type fact, earlier uses cannot consume the later
|
||||
declaration's type and may remain unresolved; this is not full SSA inference. See
|
||||
the [factory ownership regressions](../../gitnexus/test/unit/scope-resolution/rust/rust-range-binding-order.test.ts).
|
||||
|
||||
C++ includes keep their compilation-unit behavior alongside lexical `using`.
|
||||
The header tests cover literal includes, declaration positions, unresolved named
|
||||
using claims, and overload identity. They do not establish a full preprocessor,
|
||||
namespace-alias evaluator, or arbitrary transitive `using namespace` chain support.
|
||||
Zig nonliteral imports remain unresolved; its block-local names do not escape or
|
||||
activate before their declarations.
|
||||
|
||||
## Retained provider behavior
|
||||
|
||||
The other eleven providers retain their existing import/loading models. This
|
||||
work does not reinterpret every dependency construct as a local value import.
|
||||
|
||||
| Provider | Retained model and boundary | Evidence |
|
||||
| --- | --- | --- |
|
||||
| Ruby | Wildcard loading; method-local loading is deferred. The extractor recognizes string arguments to `require`, `require_relative`, and `load`. The linked behavioral tests establish `require`/`require_relative` and deferral, not arbitrary `load` execution. | [Extractor](../../gitnexus/src/core/ingestion/languages/ruby/captures.ts), [loading](../../gitnexus/test/integration/resolvers/ruby-scope.test.ts), [deferral](../../gitnexus/test/unit/scope-resolution/function-local-import-chain.test.ts) |
|
||||
| PHP | Namespace `use` imports are distinct from closure capture and trait `use`. The scope provider does not propagate names through `include`/`require`. | [Query](../../gitnexus/src/core/ingestion/languages/php/query.ts), [ownership](../../gitnexus/src/core/ingestion/languages/php/simple-hooks.ts), [resolver coverage](../../gitnexus/test/integration/resolvers/php.test.ts) |
|
||||
| C | Header includes remain preprocessor/compilation-unit dependencies, including includes written inside functions; no full preprocessing is added. | [Include interpretation](../../gitnexus/src/core/ingestion/languages/c/interpret.ts), [include tests](../../gitnexus/test/unit/scope-resolution/c/c-imports.test.ts), [deferral boundary](../../gitnexus/test/unit/scope-resolution/function-local-import-chain.test.ts) |
|
||||
| Objective-C | Headers, modules, and compilation-unit siblings retain the dedicated provider rules. | [Provider guide](objective-c-provider.md), [resolver](../../gitnexus/src/core/ingestion/languages/objective-c/scope-resolver.ts), [workspace tests](../../gitnexus/test/unit/scope-resolution/objective-c/objc-workspace.test.ts) |
|
||||
| COBOL | `COPY` stays on the dedicated copybook path with module ownership. | [Interpretation](../../gitnexus/src/core/ingestion/languages/cobol/interpret.ts), [COPY target parity](../../gitnexus/test/unit/scope-resolution/cobol-import-target-parity.test.ts) |
|
||||
| Go | File imports and package sibling visibility retain separate provider rules. | [Import ownership](../../gitnexus/src/core/ingestion/languages/go/simple-hooks.ts), [imports](../../gitnexus/test/unit/scope-resolution/go/go-imports.test.ts), [package siblings](../../gitnexus/test/unit/scope-resolution/go/go-package-siblings.test.ts) |
|
||||
| Java | Imports belong to the compilation unit. | [Ownership](../../gitnexus/src/core/ingestion/languages/java/simple-hooks.ts), [named/wildcard import resolution](../../gitnexus/test/integration/resolvers/java.test.ts) |
|
||||
| Kotlin | File import headers retain their existing alias/package resolution. | [Header decomposition](../../gitnexus/src/core/ingestion/languages/kotlin/import-decomposer.ts), [resolver tests](../../gitnexus/test/integration/resolvers/kotlin.test.ts) |
|
||||
| C# | Namespace/file `using` directives remain imports; resource `using` statements are not import directives. | [Directive query](../../gitnexus/src/core/ingestion/languages/csharp/query.ts), [import tests](../../gitnexus/test/unit/scope-resolution/csharp/csharp-imports.test.ts) |
|
||||
| Swift | Imports and target/module visibility retain provider ownership. | [Ownership](../../gitnexus/src/core/ingestion/languages/swift/simple-hooks.ts), [import decomposition](../../gitnexus/test/unit/scope-resolution/swift/import-decomposer.test.ts), [target siblings](../../gitnexus/test/unit/scope-resolution/swift/target-siblings.test.ts) |
|
||||
| Dart | Library imports retain existing package identity and visibility rules. | [Ownership](../../gitnexus/src/core/ingestion/languages/dart/simple-hooks.ts), [library/package regressions](../../gitnexus/test/integration/resolvers/dart.test.ts) |
|
||||
|
||||
## Taint provenance
|
||||
|
||||
Taint source, sink, and sanitizer matching uses the same lexical provenance.
|
||||
A genuine external import may match its raw module name even when that package
|
||||
has no indexed source. A sibling import, local shadow, or type-only import cannot
|
||||
authorize that match. General copied-handle tracking is outside this support.
|
||||
The [taint regressions](../../gitnexus/test/unit/taint/lexical-imports.test.ts)
|
||||
cover extraction, emission, and function summaries, including abstention when
|
||||
exact call-site positions are missing.
|
||||
|
||||
## Cache migration and verification evidence
|
||||
|
||||
[Parse-cache schema 136](../../gitnexus/src/storage/parse-cache.ts) replaces 133,
|
||||
134, and 135. It invalidates overly broad Python/TS/JS binding barriers,
|
||||
incorrect parameter-annotation lookup ownership, and embedded Vue grammar and
|
||||
source positions. The same version gate applies to the
|
||||
[durable ParsedFile store](../../gitnexus/src/storage/parsedfile-store.ts).
|
||||
Incompatible records are rejected and rebuilt on the next analysis; unchanged
|
||||
records can then be reused. The
|
||||
[schema regression](../../gitnexus/test/unit/incremental-parse-cache.test.ts)
|
||||
explicitly rejects valid schema-133, schema-134, and schema-135 records in both stores.
|
||||
PDG-mode chunk keys also advance to `pdg:6`, so embedded-script CFGs with
|
||||
incorrect call anchors are rebuilt before the resolved-callee join.
|
||||
|
||||
The [real-worker parity suite](../../gitnexus/test/integration/local-import-worker-parity.test.ts)
|
||||
witnesses worker boot, dispatch, and returned ParsedFiles, then compares complete
|
||||
provider facts with direct extraction, including `callerScope` and C++ side-channel
|
||||
data. Optional grammar availability is explicit in the test gate.
|
||||
|
||||
The [persistence suite](../../gitnexus/test/integration/resolvers/local-import-persistence.test.ts)
|
||||
loads both stores afresh from disk. Its parse-cache chunks contain no embedded
|
||||
ParsedFiles, so warm equality requires durable-store reuse. It checks identical
|
||||
`CALLS`/`IMPORTS` identities with zero extraction and zero worker dispatch on an
|
||||
unchanged warm run. Editing a local import target or shadowing `require` removes
|
||||
the stale target, and a second warm run preserves the edited graph.
|
||||
|
||||
Both suites are in the [cross-platform manifest](../../gitnexus/scripts/cross-platform-tests.ts);
|
||||
the [shard regression](../../gitnexus/test/unit/cross-platform-shard.test.ts)
|
||||
ensures each is selected exactly once across the four Windows/macOS shards.
|
||||
[CI](../../.github/workflows/ci-tests.yml) owns native execution on those systems;
|
||||
Ubuntu's full suite discovers the tests normally. These are focused evidence
|
||||
boundaries, not a statement that the final full suite or every platform has passed.
|
||||
|
||||
## Relationship to main and issue #3499
|
||||
|
||||
The integrated main baseline `50aa4be3` already includes #3502's nested Python
|
||||
declaration ownership fix for the original #3499 case and #3504's aliased package
|
||||
re-export fix. Their [Python integration coverage](../../gitnexus/test/integration/resolvers/python.test.ts)
|
||||
and [module namespace coverage](../../gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts)
|
||||
remain relevant. This branch extends that base with the lexical claims, provider
|
||||
semantics, and persistence evidence described above. Issue coverage is **Related
|
||||
#3499**; this document does not assert that all dynamic Python behavior is solved
|
||||
or make an issue-status claim.
|
||||
|
|
@ -223,3 +223,22 @@ export {
|
|||
stripGitSuffix,
|
||||
} from './integrations/understand-quickly.js';
|
||||
export type { UqDispatchPayload } from './integrations/understand-quickly.js';
|
||||
|
||||
export type {
|
||||
SourcePosition,
|
||||
LookupPurpose,
|
||||
NameClaim,
|
||||
ScopeLookupPolicy,
|
||||
NameLookupOptions,
|
||||
} from './scope-resolution/types.js';
|
||||
export {
|
||||
lookupLexicalName,
|
||||
nameClaimsFor,
|
||||
selectClaimsAtScope,
|
||||
rangesOverlap,
|
||||
} from './scope-resolution/name-claims.js';
|
||||
export type {
|
||||
NameClaimResult,
|
||||
NameClaimSources,
|
||||
ScopeClaimSelection,
|
||||
} from './scope-resolution/name-claims.js';
|
||||
|
|
|
|||
|
|
@ -488,13 +488,14 @@ function makeEdgeDrafts(
|
|||
workspace: WorkspaceIndex,
|
||||
): ImportEdgeDraft[] {
|
||||
const fromScope =
|
||||
hooks.importsBindAtLexicalScope === true
|
||||
(parsed.bindsAtLexicalScope ?? hooks.importsBindAtLexicalScope) === true
|
||||
? (parsed.declaredAtScope ?? file.moduleScope)
|
||||
: file.moduleScope;
|
||||
// Dynamic-unresolved passes through — no `BindingRef`, no target file.
|
||||
if (parsed.kind === 'dynamic-unresolved') {
|
||||
const base: ImportEdge = {
|
||||
localName: parsed.localName,
|
||||
...(parsed.atRange !== undefined ? { atRange: parsed.atRange } : {}),
|
||||
targetFile: null,
|
||||
targetExportedName: '',
|
||||
kind: 'dynamic-unresolved',
|
||||
|
|
@ -522,6 +523,7 @@ function makeEdgeDrafts(
|
|||
if (targetFile === null) {
|
||||
const base: ImportEdge = {
|
||||
localName: extractLocalName(parsed),
|
||||
...(parsed.atRange !== undefined ? { atRange: parsed.atRange } : {}),
|
||||
targetFile: null,
|
||||
targetExportedName: extractExportedName(parsed),
|
||||
kind: edgeKindFor(parsed),
|
||||
|
|
@ -560,6 +562,7 @@ function makeEdgeDrafts(
|
|||
return targetFiles.map((tf) => {
|
||||
const base: ImportEdge = {
|
||||
localName: extractLocalName(parsed),
|
||||
...(parsed.atRange !== undefined ? { atRange: parsed.atRange } : {}),
|
||||
targetFile: tf,
|
||||
targetExportedName: extractExportedName(parsed),
|
||||
kind:
|
||||
|
|
@ -611,22 +614,25 @@ function typeOnlyFor(parsed: ParsedImport): { typeOnly?: true } {
|
|||
}
|
||||
|
||||
/**
|
||||
* Re-carry both runtime-presence flags from an existing edge onto a derived
|
||||
* one.
|
||||
* Carry runtime-presence flags and source provenance onto a derived edge.
|
||||
*
|
||||
* `expandWildcard` builds each `wildcard-expanded` edge from scratch rather
|
||||
* than spreading the source (three fields differ per exported name), so every
|
||||
* field it does not name is dropped. That is exactly how both flags were lost
|
||||
* once already. Naming the pair here keeps "these two travel together" in one
|
||||
* place, so a third presence flag is added in one place too.
|
||||
* field it does not name is dropped. Keep presence flags and the source range
|
||||
* together here so wildcard expansion preserves both runtime policy and the
|
||||
* location used to select the owning import claim.
|
||||
*/
|
||||
function carriedPresenceFlags(edge: Pick<ImportEdge, 'typeOnly' | 'runsOnlyWhenCalled'>): {
|
||||
function carriedPresenceFlags(
|
||||
edge: Pick<ImportEdge, 'typeOnly' | 'runsOnlyWhenCalled' | 'atRange'>,
|
||||
): {
|
||||
typeOnly?: true;
|
||||
runsOnlyWhenCalled?: true;
|
||||
atRange?: ImportEdge['atRange'];
|
||||
} {
|
||||
return {
|
||||
...(edge.typeOnly === true ? { typeOnly: true } : {}),
|
||||
...(edge.runsOnlyWhenCalled === true ? { runsOnlyWhenCalled: true } : {}),
|
||||
...(edge.atRange !== undefined ? { atRange: edge.atRange } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
337
gitnexus-shared/src/scope-resolution/name-claims.ts
Normal file
337
gitnexus-shared/src/scope-resolution/name-claims.ts
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
/** Pure lexical ownership shared by registries, inference, and import provenance. */
|
||||
import type {
|
||||
BindingRef,
|
||||
ImportEdge,
|
||||
NameClaim,
|
||||
NameLookupOptions,
|
||||
Range,
|
||||
Scope,
|
||||
ScopeId,
|
||||
ScopeLookup,
|
||||
SourcePosition,
|
||||
TypeRef,
|
||||
} from './types.js';
|
||||
|
||||
export interface ScopeClaimSelection {
|
||||
readonly status: 'absent' | 'blocked' | 'selected' | 'module';
|
||||
readonly claims: readonly NameClaim[];
|
||||
}
|
||||
export interface NameClaimResult {
|
||||
readonly status: 'absent' | 'blocked' | 'resolved';
|
||||
readonly scope?: Scope;
|
||||
readonly typeBinding?: TypeRef;
|
||||
readonly bindings: readonly BindingRef[];
|
||||
readonly imports: readonly ImportEdge[];
|
||||
readonly claims: readonly NameClaim[];
|
||||
}
|
||||
export interface NameClaimSources {
|
||||
readonly scopes: ScopeLookup;
|
||||
readonly bindingsAt?: (scope: Scope, name: string) => readonly BindingRef[];
|
||||
readonly importsAt?: (scope: Scope) => readonly ImportEdge[];
|
||||
}
|
||||
const ABSENT: NameClaimResult = Object.freeze({
|
||||
status: 'absent',
|
||||
bindings: [],
|
||||
imports: [],
|
||||
claims: [],
|
||||
});
|
||||
// Published scope arrays are readonly. Cache by the array, not the scope ID:
|
||||
// finalization and cache hydration can replace a scope's import/claim snapshot.
|
||||
const claimsByName = new WeakMap<readonly NameClaim[], ReadonlyMap<string, readonly NameClaim[]>>();
|
||||
const importsByName = new WeakMap<
|
||||
readonly ImportEdge[],
|
||||
ReadonlyMap<string, readonly ImportEdge[]>
|
||||
>();
|
||||
const NO_CLAIMS: readonly NameClaim[] = Object.freeze([]);
|
||||
const NO_IMPORTS: readonly ImportEdge[] = Object.freeze([]);
|
||||
|
||||
export function nameClaimsFor(scope: Scope, name: string): readonly NameClaim[] {
|
||||
const claims = scope.nameClaims;
|
||||
if (claims === undefined || claims.length === 0) return NO_CLAIMS;
|
||||
let byName = claimsByName.get(claims);
|
||||
if (byName === undefined) {
|
||||
const indexed = new Map<string, NameClaim[]>();
|
||||
for (const claim of claims) {
|
||||
const bucket = indexed.get(claim.name);
|
||||
if (bucket === undefined) indexed.set(claim.name, [claim]);
|
||||
else bucket.push(claim);
|
||||
}
|
||||
byName = indexed;
|
||||
claimsByName.set(claims, byName);
|
||||
}
|
||||
return byName.get(name) ?? NO_CLAIMS;
|
||||
}
|
||||
|
||||
function importsFor(imports: readonly ImportEdge[], name: string): readonly ImportEdge[] {
|
||||
if (imports.length === 0) return NO_IMPORTS;
|
||||
let byName = importsByName.get(imports);
|
||||
if (byName === undefined) {
|
||||
const indexed = new Map<string, ImportEdge[]>();
|
||||
for (const edge of imports) {
|
||||
if (edge.kind === 'side-effect' || edge.kind === 'dynamic-resolved') continue;
|
||||
const bucket = indexed.get(edge.localName);
|
||||
if (bucket === undefined) indexed.set(edge.localName, [edge]);
|
||||
else bucket.push(edge);
|
||||
}
|
||||
byName = indexed;
|
||||
importsByName.set(imports, byName);
|
||||
}
|
||||
return byName.get(name) ?? NO_IMPORTS;
|
||||
}
|
||||
const compare = (a: SourcePosition, b: SourcePosition): number =>
|
||||
a.startLine - b.startLine || a.startCol - b.startCol;
|
||||
export function rangesOverlap(a: Range, b: Range): boolean {
|
||||
return (
|
||||
compare(a, { startLine: b.endLine, startCol: b.endCol }) < 0 &&
|
||||
compare(b, { startLine: a.endLine, startCol: a.endCol }) < 0
|
||||
);
|
||||
}
|
||||
|
||||
/** Ownership is selected before target availability or target kind is inspected. */
|
||||
export function selectClaimsAtScope(
|
||||
scope: Scope,
|
||||
name: string,
|
||||
options: NameLookupOptions = {},
|
||||
): ScopeClaimSelection {
|
||||
const purpose = options.purpose ?? 'value';
|
||||
const claims = nameClaimsFor(scope, name).filter(
|
||||
(c) => c.purpose === undefined || c.purpose === 'both' || c.purpose === purpose,
|
||||
);
|
||||
if (claims.length === 0) return { status: 'absent', claims };
|
||||
const active = claims.filter(
|
||||
(c) =>
|
||||
c.availableFrom === undefined ||
|
||||
options.position === undefined ||
|
||||
compare(c.availableFrom, options.position) <= 0,
|
||||
);
|
||||
if (active.length === 0) {
|
||||
const first = claims.reduce((a, b) => (compare(a.range, b.range) <= 0 ? a : b));
|
||||
return {
|
||||
status:
|
||||
first.inactive === 'outer' ? 'absent' : first.inactive === 'module' ? 'module' : 'blocked',
|
||||
claims,
|
||||
};
|
||||
}
|
||||
// Same-position alternatives retain ambiguity. Providers can mark uncertain
|
||||
// rebinding explicitly blocked rather than claiming a control-flow winner.
|
||||
const ordered = active.filter((c) => c.hoisted !== true);
|
||||
const relevant = ordered.length > 0 ? ordered : active;
|
||||
const latest = relevant.reduce((a, b) => (compare(a.range, b.range) >= 0 ? a : b)).range;
|
||||
let selected = relevant.filter((c) => compare(c.range, latest) === 0);
|
||||
if (selected.some((c) => c.merge === true))
|
||||
selected = active.filter((c) => c.merge === true || compare(c.range, latest) === 0);
|
||||
return {
|
||||
status: selected.some((c) => c.kind === 'blocked' && c.redirect === undefined)
|
||||
? 'blocked'
|
||||
: 'selected',
|
||||
claims: selected,
|
||||
};
|
||||
}
|
||||
|
||||
function moduleScope(start: Scope, scopes: ScopeLookup): Scope | undefined {
|
||||
let scope: Scope | undefined = start;
|
||||
const seen = new Set<ScopeId>();
|
||||
while (scope !== undefined && !seen.has(scope.id)) {
|
||||
if (scope.kind === 'Module') return scope;
|
||||
seen.add(scope.id);
|
||||
scope = scope.parent === null ? undefined : scopes.getScope(scope.parent);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
function redirectScope(
|
||||
scope: Scope,
|
||||
name: string,
|
||||
claim: NameClaim,
|
||||
scopes: ScopeLookup,
|
||||
): Scope | undefined {
|
||||
if (claim.redirect === 'module') return moduleScope(scope, scopes);
|
||||
let parent = scope.parent === null ? undefined : scopes.getScope(scope.parent);
|
||||
const seen = new Set<ScopeId>();
|
||||
while (parent !== undefined && !seen.has(parent.id)) {
|
||||
seen.add(parent.id);
|
||||
if (
|
||||
parent.kind === 'Function' &&
|
||||
(nameClaimsFor(parent, name).some((c) => c.redirect === undefined) ||
|
||||
parent.lexicalNames?.has(name) ||
|
||||
parent.bindings.has(name) ||
|
||||
parent.typeBindings.has(name))
|
||||
)
|
||||
return parent;
|
||||
parent = parent.parent === null ? undefined : scopes.getScope(parent.parent);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
function matchesClaim(range: Range | undefined, claim: NameClaim): boolean {
|
||||
return range === undefined || rangesOverlap(range, claim.range);
|
||||
}
|
||||
|
||||
function matchesBindingClaim(range: Range | undefined, claim: NameClaim): boolean {
|
||||
return (
|
||||
range === undefined ||
|
||||
(compare(claim.range, range) <= 0 &&
|
||||
compare(
|
||||
{ startLine: range.endLine, startCol: range.endCol },
|
||||
{ startLine: claim.range.endLine, startCol: claim.range.endCol },
|
||||
) <= 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function lookupLexicalName(
|
||||
startScope: ScopeId,
|
||||
name: string,
|
||||
sources: NameClaimSources,
|
||||
options: NameLookupOptions = {},
|
||||
): NameClaimResult {
|
||||
let id: ScopeId | null = startScope;
|
||||
let position = options.position;
|
||||
let forcedClaims: readonly NameClaim[] | undefined;
|
||||
const visited = new Set<ScopeId>();
|
||||
while (id !== null && !visited.has(id)) {
|
||||
visited.add(id);
|
||||
const scope = sources.scopes.getScope(id);
|
||||
if (scope === undefined) return { ...ABSENT, status: 'blocked' };
|
||||
if (
|
||||
scope.kind === 'Object' ||
|
||||
(id !== startScope &&
|
||||
((scope.lookupPolicy?.skipFromChildren &&
|
||||
!scope.lookupPolicy.visibleNamesFromChildren?.includes(name)) ||
|
||||
(options.skipEnclosingClasses && scope.kind === 'Class')))
|
||||
) {
|
||||
id = scope.lookupPolicy?.parentScope ?? scope.parent;
|
||||
continue;
|
||||
}
|
||||
const selected: ScopeClaimSelection =
|
||||
forcedClaims === undefined
|
||||
? selectClaimsAtScope(scope, name, { ...options, position })
|
||||
: { status: 'selected', claims: forcedClaims };
|
||||
forcedClaims = undefined;
|
||||
const redirected = selected.claims.find((c) => c.redirect !== undefined);
|
||||
if (redirected !== undefined) {
|
||||
const target = redirectScope(scope, name, redirected, sources.scopes);
|
||||
if (target === undefined || target.id === id)
|
||||
return { ...ABSENT, status: 'blocked', scope, claims: selected.claims };
|
||||
if (selected.status === 'selected' && redirected.kind !== 'blocked') {
|
||||
forcedClaims = selected.claims.map((c) => ({ ...c, redirect: undefined }));
|
||||
}
|
||||
id = target.id;
|
||||
position = undefined;
|
||||
continue;
|
||||
}
|
||||
if (selected.status === 'module') {
|
||||
const target = moduleScope(scope, sources.scopes);
|
||||
if (target === undefined || target.id === id)
|
||||
return { ...ABSENT, status: 'blocked', scope, claims: selected.claims };
|
||||
let skipped: Scope | undefined = scope;
|
||||
const skippedIds = new Set<ScopeId>();
|
||||
while (skipped !== undefined && skipped.id !== target.id && !skippedIds.has(skipped.id)) {
|
||||
skippedIds.add(skipped.id);
|
||||
if (skipped.lookupPolicy?.deferParentActivation) position = undefined;
|
||||
skipped = skipped.parent === null ? undefined : sources.scopes.getScope(skipped.parent);
|
||||
}
|
||||
id = target.id;
|
||||
continue;
|
||||
}
|
||||
if (selected.status === 'blocked')
|
||||
return { ...ABSENT, status: 'blocked', scope, claims: selected.claims };
|
||||
const nameClaims = nameClaimsFor(scope, name);
|
||||
const hasClaimsForName = nameClaims.length > 0;
|
||||
const allBindings = sources.bindingsAt?.(scope, name) ?? scope.bindings.get(name) ?? [];
|
||||
const allImports = importsFor(sources.importsAt?.(scope) ?? scope.imports ?? NO_IMPORTS, name);
|
||||
let bindings = allBindings.filter(
|
||||
(b) =>
|
||||
b.availableFrom === undefined ||
|
||||
position === undefined ||
|
||||
compare(b.availableFrom, position) <= 0,
|
||||
);
|
||||
let imports = allImports;
|
||||
if (selected.status === 'selected') {
|
||||
bindings = bindings.filter((b) =>
|
||||
selected.claims.some((c) =>
|
||||
c.kind === 'import'
|
||||
? b.via !== undefined && matchesClaim(b.via.atRange, c)
|
||||
: c.kind === 'binding' &&
|
||||
(b.origin === 'local' || b.declarationRange !== undefined) &&
|
||||
matchesBindingClaim(b.declarationRange, c),
|
||||
),
|
||||
);
|
||||
imports = allImports.filter((e) =>
|
||||
selected.claims.some((c) => c.kind === 'import' && matchesClaim(e.atRange, c)),
|
||||
);
|
||||
} else if (hasClaimsForName) {
|
||||
// A declaration in another namespace, or a not-yet-visible declaration,
|
||||
// must not sneak back through the unfiltered legacy binding channels.
|
||||
bindings = bindings.filter(
|
||||
(b) =>
|
||||
b.availableFrom !== undefined &&
|
||||
b.declarationRange !== undefined &&
|
||||
!nameClaims.some((c) => rangesOverlap(c.range, b.declarationRange!)),
|
||||
);
|
||||
imports = [];
|
||||
}
|
||||
const ownType = scope.typeBindings.get(name);
|
||||
const typeFactOwnsName =
|
||||
ownType !== undefined &&
|
||||
!(scope.lookupPolicy?.nameClaimsComplete && ownType.source === 'assignment-inferred');
|
||||
const owns =
|
||||
selected.status === 'selected' ||
|
||||
bindings.length > 0 ||
|
||||
imports.length > 0 ||
|
||||
scope.ownsReceivers?.has(name) === true ||
|
||||
(!hasClaimsForName && (scope.lexicalNames?.has(name) === true || typeFactOwnsName));
|
||||
if (owns) {
|
||||
let typeBinding = ownType;
|
||||
if (
|
||||
selected.status === 'selected' &&
|
||||
ownType?.bindingRange !== undefined &&
|
||||
!selected.claims.some(
|
||||
(c) =>
|
||||
c.kind === 'import' ||
|
||||
(c.kind === 'binding' && rangesOverlap(c.range, ownType.bindingRange!)),
|
||||
)
|
||||
) {
|
||||
// A later assignment can infer the type of the already-owned binding.
|
||||
// Check ownership after that fact binds its value, not at the producer's
|
||||
// lookup position: a later shadowing let still reads the earlier binding
|
||||
// in its initializer, but must not lend that binding its inferred type.
|
||||
const factClaims =
|
||||
ownType.source === 'constructor-inferred' || ownType.source === 'assignment-inferred'
|
||||
? selectClaimsAtScope(scope, name, {
|
||||
...options,
|
||||
position: {
|
||||
startLine: ownType.bindingRange.endLine,
|
||||
startCol: ownType.bindingRange.endCol,
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
if (
|
||||
factClaims?.status !== 'selected' ||
|
||||
factClaims.claims.length !== selected.claims.length ||
|
||||
!selected.claims.every(
|
||||
(claim) => claim.kind === 'binding' && factClaims.claims.includes(claim),
|
||||
)
|
||||
)
|
||||
typeBinding = undefined;
|
||||
}
|
||||
const usable = bindings.filter(
|
||||
(b) => (options.purpose ?? 'value') !== 'value' || b.via?.typeOnly !== true,
|
||||
);
|
||||
return {
|
||||
status:
|
||||
usable.length > 0 ||
|
||||
imports.some(
|
||||
(e) => e.kind === 'namespace' && e.targetFile !== null && e.linkStatus !== 'unresolved',
|
||||
)
|
||||
? 'resolved'
|
||||
: 'blocked',
|
||||
scope,
|
||||
typeBinding,
|
||||
bindings: usable,
|
||||
imports,
|
||||
claims: selected.claims,
|
||||
};
|
||||
}
|
||||
if (scope.lookupPolicy?.deferParentActivation) position = undefined;
|
||||
id = scope.lookupPolicy?.parentScope ?? scope.parent;
|
||||
}
|
||||
return id === null ? ABSENT : { ...ABSENT, status: 'blocked' };
|
||||
}
|
||||
|
|
@ -68,6 +68,12 @@ export type ReferenceKind =
|
|||
export type CallForm = 'free' | 'member' | 'constructor' | 'index';
|
||||
|
||||
export interface ReferenceSite {
|
||||
/** Graph caller for a synthesized invocation whose callable is looked up elsewhere.
|
||||
* Never changes lexical resolution from `inScope` (for example FastAPI Depends). */
|
||||
readonly callerScope?: ScopeId;
|
||||
/** Lexical lookup anchor when it differs from the reference's structural owner. */
|
||||
readonly lookupScope?: ScopeId;
|
||||
readonly lookupPurpose?: import('./types.js').LookupPurpose;
|
||||
/** The name being referenced (e.g., `'save'`, `'User'`, `'count'`). */
|
||||
readonly name: string;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -24,7 +24,11 @@ export interface ClassRegistry {
|
|||
* `scope`. Returns a confidence-ranked `Resolution[]`; consume `[0]`
|
||||
* for the best answer.
|
||||
*/
|
||||
lookup(name: string, scope: ScopeId): readonly Resolution[];
|
||||
lookup(
|
||||
name: string,
|
||||
scope: ScopeId,
|
||||
options?: Pick<CoreLookupParams, 'lookupPosition' | 'lookupPurpose'>,
|
||||
): readonly Resolution[];
|
||||
}
|
||||
|
||||
export function buildClassRegistry(ctx: RegistryContext): ClassRegistry {
|
||||
|
|
@ -34,8 +38,12 @@ export function buildClassRegistry(ctx: RegistryContext): ClassRegistry {
|
|||
ownerScopedContributor: null,
|
||||
};
|
||||
return {
|
||||
lookup(name: string, scope: ScopeId) {
|
||||
return lookupCore(name, scope, params, ctx);
|
||||
lookup(
|
||||
name: string,
|
||||
scope: ScopeId,
|
||||
options: Pick<CoreLookupParams, 'lookupPosition' | 'lookupPurpose'> = {},
|
||||
) {
|
||||
return lookupCore(name, scope, { ...params, lookupPurpose: 'type', ...options }, ctx);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ import { lookupCore, type CoreLookupParams } from './lookup-core.js';
|
|||
import type { OwnerScopedContributor, RegistryContext } from './context.js';
|
||||
import { FIELD_KINDS } from './context.js';
|
||||
|
||||
export interface FieldLookupOptions {
|
||||
export interface FieldLookupOptions extends Pick<
|
||||
CoreLookupParams,
|
||||
'lookupPosition' | 'lookupPurpose'
|
||||
> {
|
||||
readonly explicitReceiver?: { readonly name: string };
|
||||
readonly ownerScopedContributor?: OwnerScopedContributor;
|
||||
}
|
||||
|
|
@ -30,6 +33,8 @@ export function buildFieldRegistry(ctx: RegistryContext): FieldRegistry {
|
|||
return {
|
||||
lookup(name: string, scope: ScopeId, options: FieldLookupOptions = {}) {
|
||||
const params: CoreLookupParams = {
|
||||
lookupPosition: options.lookupPosition,
|
||||
lookupPurpose: options.lookupPurpose,
|
||||
acceptedKinds: FIELD_KINDS,
|
||||
useReceiverTypeBinding: true,
|
||||
ownerScopedContributor: options.ownerScopedContributor ?? null,
|
||||
|
|
|
|||
|
|
@ -67,27 +67,23 @@
|
|||
* - No caching. Callers that want memoization can wrap this function.
|
||||
*/
|
||||
|
||||
import { lookupLexicalName } from '../name-claims.js';
|
||||
import type { NodeLabel } from '../../graph/types.js';
|
||||
import type { SymbolDefinition } from '../symbol-definition.js';
|
||||
import type {
|
||||
BindingRef,
|
||||
Callsite,
|
||||
DefId,
|
||||
LookupParams,
|
||||
Resolution,
|
||||
Scope,
|
||||
ScopeId,
|
||||
} from '../types.js';
|
||||
import type { BindingRef, Callsite, DefId, LookupParams, Resolution, ScopeId } from '../types.js';
|
||||
import type { OriginForTieBreak } from '../origin-priority.js';
|
||||
import { composeEvidence, confidenceFromEvidence, type RawSignals } from './evidence.js';
|
||||
import { compareByConfidenceWithTiebreaks, type TieBreakKey } from './tie-breaks.js';
|
||||
import { lookupQualified } from './lookup-qualified.js';
|
||||
import type { ArityVerdict, OwnerScopedContributor, RegistryContext } from './context.js';
|
||||
import { CLASS_KINDS } from './context.js';
|
||||
|
||||
// ─── Public entry point ─────────────────────────────────────────────────────
|
||||
|
||||
/** Extended `LookupParams` narrowing `ownerScopedContributor` to the concrete shape. */
|
||||
export interface CoreLookupParams extends Omit<LookupParams, 'ownerScopedContributor'> {
|
||||
/** A separate namespace whose ownership is determined only by accepted kinds. */
|
||||
readonly independentKindNamespace?: boolean;
|
||||
readonly ownerScopedContributor: OwnerScopedContributor | null;
|
||||
/** Call-site description forwarded to `arityCompatibility`. Optional — for non-call lookups. */
|
||||
readonly callsite?: Callsite;
|
||||
|
|
@ -132,15 +128,22 @@ export function lookupCore(
|
|||
!IMPLICIT_RECEIVERS.includes(params.explicitReceiver.name);
|
||||
const lexicalShadowed = skipLexical
|
||||
? false
|
||||
: walkLexicalChain(name, startScope, acceptedKinds, ctx, perCandidate);
|
||||
: walkLexicalChain(name, startScope, acceptedKinds, ctx, perCandidate, params);
|
||||
|
||||
// ── Step 2: type-binding / MRO walk (methods/fields) ──────────────────
|
||||
if (params.useReceiverTypeBinding && ctx.methodDispatch !== undefined) {
|
||||
if (
|
||||
(!lexicalShadowed || perCandidate.size > 0 || skipLexical) &&
|
||||
params.useReceiverTypeBinding &&
|
||||
ctx.methodDispatch !== undefined
|
||||
) {
|
||||
walkReceiverTypeBinding(name, startScope, acceptedKinds, params, ctx, perCandidate);
|
||||
}
|
||||
|
||||
// ── Step 3: owner-scoped contributor ──────────────────────────────────
|
||||
if (params.ownerScopedContributor !== null) {
|
||||
if (
|
||||
(!lexicalShadowed || perCandidate.size > 0 || skipLexical) &&
|
||||
params.ownerScopedContributor !== null
|
||||
) {
|
||||
seedFromOwnerScopedContributor(
|
||||
name,
|
||||
params.ownerScopedContributor,
|
||||
|
|
@ -222,32 +225,50 @@ function walkLexicalChain(
|
|||
acceptedKinds: ReadonlySet<NodeLabel>,
|
||||
ctx: RegistryContext,
|
||||
perCandidate: Map<DefId, CandidateState>,
|
||||
params: CoreLookupParams,
|
||||
): boolean {
|
||||
let currentId: ScopeId | null = startScope;
|
||||
let depth = 0;
|
||||
const visited = new Set<ScopeId>();
|
||||
|
||||
while (currentId !== null) {
|
||||
if (visited.has(currentId)) return false;
|
||||
visited.add(currentId);
|
||||
|
||||
const scope: Scope | undefined = ctx.scopes.getScope(currentId);
|
||||
if (scope === undefined) return false;
|
||||
|
||||
const bindings = scope.bindings.get(name);
|
||||
if (bindings !== undefined && bindings.length > 0) {
|
||||
for (const binding of bindings) {
|
||||
if (!acceptedKinds.has(binding.def.type)) continue;
|
||||
recordLexicalHit(perCandidate, binding, depth);
|
||||
if (params.independentKindNamespace === true) {
|
||||
let id: ScopeId | null = startScope;
|
||||
let depth = 0;
|
||||
const visited = new Set<ScopeId>();
|
||||
while (id !== null && !visited.has(id)) {
|
||||
visited.add(id);
|
||||
const scope = ctx.scopes.getScope(id);
|
||||
if (scope === undefined) return true;
|
||||
const bindings = (scope.bindings.get(name) ?? []).filter((binding) =>
|
||||
acceptedKinds.has(binding.def.type),
|
||||
);
|
||||
if (bindings.length > 0) {
|
||||
for (const binding of bindings) recordLexicalHit(perCandidate, binding, depth);
|
||||
return true;
|
||||
}
|
||||
return true; // hard shadow regardless of kind-filter survivorship
|
||||
id = scope.lookupPolicy?.parentScope ?? scope.parent;
|
||||
depth++;
|
||||
}
|
||||
|
||||
currentId = scope.parent;
|
||||
depth++;
|
||||
return id !== null;
|
||||
}
|
||||
|
||||
return false;
|
||||
const options = { position: params.lookupPosition, purpose: params.lookupPurpose };
|
||||
const claim = lookupLexicalName(startScope, name, { scopes: ctx.scopes }, options);
|
||||
if (claim.status !== 'absent') {
|
||||
let depth = 0;
|
||||
let owner = ctx.scopes.getScope(startScope);
|
||||
const visited = new Set<ScopeId>();
|
||||
while (owner !== undefined && owner.id !== claim.scope?.id && !visited.has(owner.id)) {
|
||||
visited.add(owner.id);
|
||||
const parent = owner.lookupPolicy?.parentScope ?? owner.parent;
|
||||
owner = parent === null ? undefined : ctx.scopes.getScope(parent);
|
||||
depth++;
|
||||
}
|
||||
for (const binding of claim.bindings) {
|
||||
if (acceptedKinds.has(binding.def.type)) recordLexicalHit(perCandidate, binding, depth);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const rootName = name.split('.', 1)[0]!;
|
||||
return (
|
||||
rootName !== name &&
|
||||
lookupLexicalName(startScope, rootName, { scopes: ctx.scopes }, options).status !== 'absent'
|
||||
);
|
||||
}
|
||||
|
||||
function recordLexicalHit(
|
||||
|
|
@ -310,12 +331,12 @@ function resolveReceiverOwner(
|
|||
// ready resolveTypeRef call (that module is separate), we do a direct
|
||||
// lookup and trust the caller to have populated the binding.
|
||||
if (params.explicitReceiver !== undefined) {
|
||||
return lookupReceiverType(startScope, params.explicitReceiver.name, ctx);
|
||||
return lookupReceiverType(startScope, params.explicitReceiver.name, ctx, params);
|
||||
}
|
||||
|
||||
// Implicit `self` / `this` — the scope's typeBindings should carry it.
|
||||
for (const implicitName of IMPLICIT_RECEIVERS) {
|
||||
const owner = lookupReceiverType(startScope, implicitName, ctx);
|
||||
const owner = lookupReceiverType(startScope, implicitName, ctx, params);
|
||||
if (owner !== undefined) return owner;
|
||||
}
|
||||
return undefined;
|
||||
|
|
@ -353,38 +374,38 @@ function lookupReceiverType(
|
|||
startScope: ScopeId,
|
||||
receiverName: string,
|
||||
ctx: RegistryContext,
|
||||
params: CoreLookupParams,
|
||||
): DefId | undefined {
|
||||
let currentId: ScopeId | null = startScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
while (currentId !== null) {
|
||||
if (visited.has(currentId)) return undefined;
|
||||
visited.add(currentId);
|
||||
|
||||
const scope = ctx.scopes.getScope(currentId);
|
||||
if (scope === undefined) return undefined;
|
||||
|
||||
const typeRef = scope.typeBindings.get(receiverName);
|
||||
if (typeRef !== undefined) {
|
||||
// rawName must resolve to a def via qualifiedNames; if it doesn't, we
|
||||
// can't claim the receiver type. No fallback — that's what
|
||||
// `resolveTypeRef` would do, but we keep this path lean and let
|
||||
// callers pre-resolve if they want the richer semantics.
|
||||
const candidateIds = ctx.qualifiedNames.get(typeRef.rawName);
|
||||
if (candidateIds.length === 1) return candidateIds[0];
|
||||
// Ambiguous (≥ 2) or missing (0) — caller must pre-resolve via
|
||||
// `resolveTypeRef` (#916) if they want the richer semantics. We
|
||||
// intentionally do NOT re-implement a simple-name fallback here.
|
||||
return undefined;
|
||||
}
|
||||
// The scope binds this receiver itself but carries no type for it — a
|
||||
// JS/TS ordinary `function` whose `this` is bound at call time, not the
|
||||
// enclosing instance (#2701). Stop rather than borrowing an enclosing
|
||||
// scope's binding; see `Scope.ownsReceivers`. Mirrors the same gate in
|
||||
// the ingestion-side twin of this walk, `findReceiverTypeBinding`.
|
||||
if (scope.ownsReceivers?.has(receiverName) === true) return undefined;
|
||||
currentId = scope.parent;
|
||||
const claim = lookupLexicalName(
|
||||
startScope,
|
||||
receiverName,
|
||||
{ scopes: ctx.scopes },
|
||||
{
|
||||
position: params.lookupPosition,
|
||||
purpose: 'value',
|
||||
},
|
||||
);
|
||||
const typeRef = claim.typeBinding;
|
||||
if (typeRef === undefined) return undefined;
|
||||
const typeClaim = lookupLexicalName(
|
||||
typeRef.declaredAtScope,
|
||||
typeRef.rawName,
|
||||
{ scopes: ctx.scopes },
|
||||
{
|
||||
position: typeRef.lookupPosition,
|
||||
purpose: typeRef.lookupPurpose ?? 'type',
|
||||
},
|
||||
);
|
||||
if (typeClaim.status !== 'absent') {
|
||||
const candidates = new Map(
|
||||
typeClaim.bindings
|
||||
.filter((binding) => CLASS_KINDS.includes(binding.def.type))
|
||||
.map((binding) => [binding.def.nodeId, binding.def]),
|
||||
);
|
||||
return candidates.size === 1 ? candidates.keys().next().value : undefined;
|
||||
}
|
||||
return undefined;
|
||||
const candidateIds = ctx.qualifiedNames.get(typeRef.rawName);
|
||||
return candidateIds.length === 1 ? candidateIds[0] : undefined;
|
||||
}
|
||||
|
||||
function collectOwnedMembers(
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export interface MacroRegistry {
|
|||
export function buildMacroRegistry(ctx: RegistryContext): MacroRegistry {
|
||||
const params: CoreLookupParams = {
|
||||
acceptedKinds: MACRO_KINDS,
|
||||
independentKindNamespace: true,
|
||||
useReceiverTypeBinding: false,
|
||||
ownerScopedContributor: null,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -23,7 +23,10 @@ import { METHOD_KINDS } from './context.js';
|
|||
* concise while still exposing the explicit-receiver + owner-contributor +
|
||||
* arity knobs the RFC algorithm needs.
|
||||
*/
|
||||
export interface MethodLookupOptions {
|
||||
export interface MethodLookupOptions extends Pick<
|
||||
CoreLookupParams,
|
||||
'lookupPosition' | 'lookupPurpose'
|
||||
> {
|
||||
/** Call-site arity for `provider.arityCompatibility`. */
|
||||
readonly callsite?: Callsite;
|
||||
/** Explicit receiver (e.g., `user` in `user.save()`). See §4.1. */
|
||||
|
|
@ -40,6 +43,8 @@ export function buildMethodRegistry(ctx: RegistryContext): MethodRegistry {
|
|||
return {
|
||||
lookup(name: string, scope: ScopeId, options: MethodLookupOptions = {}) {
|
||||
const params: CoreLookupParams = {
|
||||
lookupPosition: options.lookupPosition,
|
||||
lookupPurpose: options.lookupPurpose,
|
||||
acceptedKinds: METHOD_KINDS,
|
||||
useReceiverTypeBinding: true,
|
||||
ownerScopedContributor: options.ownerScopedContributor ?? null,
|
||||
|
|
|
|||
|
|
@ -38,7 +38,8 @@
|
|||
|
||||
import type { NodeLabel } from '../graph/types.js';
|
||||
import type { SymbolDefinition } from './symbol-definition.js';
|
||||
import type { BindingRef, ScopeId, ScopeLookup, TypeRef } from './types.js';
|
||||
import { lookupLexicalName } from './name-claims.js';
|
||||
import type { BindingRef, ScopeLookup, TypeRef } from './types.js';
|
||||
import type { DefIndex } from './def-index.js';
|
||||
import type { QualifiedNameIndex } from './qualified-name-index.js';
|
||||
|
||||
|
|
@ -100,36 +101,27 @@ const TYPE_KINDS: ReadonlySet<NodeLabel> = new Set<NodeLabel>([
|
|||
// ─── Main entry point ──────────────────────────────────────────────────────
|
||||
|
||||
export function resolveTypeRef(ref: TypeRef, ctx: ResolveTypeRefContext): SymbolDefinition | null {
|
||||
// Phase 1: scope-chain walk anchored at the declaration site.
|
||||
let currentId: ScopeId | null = ref.declaredAtScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
|
||||
while (currentId !== null) {
|
||||
// Cycle guard — a well-formed scope tree never loops, but a bug in the
|
||||
// construction path should fail fast here rather than hanging.
|
||||
if (visited.has(currentId)) return null;
|
||||
visited.add(currentId);
|
||||
|
||||
const scope = ctx.scopes.getScope(currentId);
|
||||
if (scope === undefined) return null; // broken chain = unresolvable
|
||||
|
||||
const bindings = scope.bindings.get(ref.rawName);
|
||||
if (bindings !== undefined && bindings.length > 0) {
|
||||
// At least one binding exists at this scope → it is the shadowing site.
|
||||
// Either one of them qualifies, or the name is shadowed by a non-type.
|
||||
for (const binding of bindings) {
|
||||
if (!STRICT_ORIGINS.has(binding.origin)) continue;
|
||||
if (TYPE_KINDS.has(binding.def.type)) {
|
||||
return binding.def;
|
||||
}
|
||||
}
|
||||
// Shadowed by a non-type / non-strict-origin binding. Fail fast — no
|
||||
// global fallback, no walk to the parent.
|
||||
return null;
|
||||
}
|
||||
|
||||
currentId = scope.parent;
|
||||
const options = { position: ref.lookupPosition, purpose: ref.lookupPurpose ?? ('type' as const) };
|
||||
const claim = lookupLexicalName(
|
||||
ref.declaredAtScope,
|
||||
ref.rawName,
|
||||
{ scopes: ctx.scopes },
|
||||
options,
|
||||
);
|
||||
if (claim.status !== 'absent') {
|
||||
const candidates = claim.bindings.filter(
|
||||
(binding) => STRICT_ORIGINS.has(binding.origin) && TYPE_KINDS.has(binding.def.type),
|
||||
);
|
||||
const distinct = new Map(candidates.map((binding) => [binding.def.nodeId, binding.def]));
|
||||
return distinct.size === 1 ? distinct.values().next().value! : null;
|
||||
}
|
||||
const rootName = ref.rawName.split('.', 1)[0]!;
|
||||
if (
|
||||
rootName !== ref.rawName &&
|
||||
lookupLexicalName(ref.declaredAtScope, rootName, { scopes: ctx.scopes }, options).status !==
|
||||
'absent'
|
||||
)
|
||||
return null;
|
||||
|
||||
// Phase 2: dotted fallback via `QualifiedNameIndex`. Only accept a unique
|
||||
// type-kind hit; anything ambiguous returns null (strict: no guesses).
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
*/
|
||||
|
||||
import type { NodeLabel } from '../graph/types.js';
|
||||
import type { SourcePosition } from './types.js';
|
||||
|
||||
export interface ParameterTypeClass {
|
||||
/** Normalized base type, matching the coarse `parameterTypes` vocabulary when known. */
|
||||
|
|
@ -60,6 +61,9 @@ export interface SymbolDefinition {
|
|||
nodeId: string;
|
||||
filePath: string;
|
||||
type: NodeLabel;
|
||||
/** Optional graph-source position (1-based line, 0-based column) for an
|
||||
* embedded source buffer. Lexical scope identity remains in `nodeId`. */
|
||||
graphPosition?: SourcePosition;
|
||||
/** Canonical dot-separated qualified type name for class-like symbols
|
||||
* (e.g. `App.Models.User`). Falls back to the simple symbol name when no
|
||||
* package/namespace/module scope exists or no explicit qualified metadata is provided. */
|
||||
|
|
|
|||
|
|
@ -112,6 +112,9 @@ export type ParsedImport = ParsedImportSyntax & {
|
|||
* Absent for legacy or synthesized imports. Binding semantics remain opt-in
|
||||
* through FinalizeHooks.importsBindAtLexicalScope. */
|
||||
readonly declaredAtScope?: ScopeId;
|
||||
readonly atRange?: Range;
|
||||
/** Per-import lexical binding override; useful when a provider also has file-wide dependencies. */
|
||||
readonly bindsAtLexicalScope?: boolean;
|
||||
};
|
||||
|
||||
type ParsedImportSyntax =
|
||||
|
|
@ -526,6 +529,7 @@ export interface Callsite {
|
|||
* bounded-fixpoint linking (RFC §3.2).
|
||||
*/
|
||||
export interface ImportEdge {
|
||||
readonly atRange?: Range;
|
||||
/** How this scope sees the imported name (after alias). */
|
||||
readonly localName: string;
|
||||
/** Namespace-import alias syntax, retained for providers whose unaliased imports bind a path prefix. */
|
||||
|
|
@ -595,6 +599,9 @@ export interface ImportEdge {
|
|||
* stamping first-class instead of reconstructing provenance from a side table.
|
||||
*/
|
||||
export interface BindingRef {
|
||||
readonly availableFrom?: SourcePosition;
|
||||
/** Source declaration range, independent of the target definition's identity. */
|
||||
readonly declarationRange?: Range;
|
||||
readonly def: SymbolDefinition;
|
||||
readonly origin: 'local' | 'import' | 'namespace' | 'wildcard' | 'reexport';
|
||||
/** Non-null for non-local origins; carries the `ImportEdge` that brought the name into this scope. */
|
||||
|
|
@ -618,6 +625,10 @@ export interface BindingRef {
|
|||
* re-exports, and nested modules. Generics deferred to V2 via `typeArgs`.
|
||||
*/
|
||||
export interface TypeRef {
|
||||
/** Declaration that binds this value, independent of its type's lookup anchor. */
|
||||
readonly bindingRange?: Range;
|
||||
readonly lookupPosition?: SourcePosition;
|
||||
readonly lookupPurpose?: LookupPurpose;
|
||||
/**
|
||||
* The type name AFTER the language's capture-time normalization — NOT
|
||||
* necessarily what the source says. Every provider's `interpretTypeBinding`
|
||||
|
|
@ -665,6 +676,49 @@ export interface TypeRef {
|
|||
|
||||
// ─── §2.2 Scope ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SourcePosition {
|
||||
readonly startLine: number;
|
||||
readonly startCol: number;
|
||||
}
|
||||
|
||||
export type LookupPurpose = 'value' | 'type';
|
||||
|
||||
/** Provider-owned name ownership; a claim does not require an indexed target. */
|
||||
export interface NameClaim {
|
||||
readonly name: string;
|
||||
readonly range: Range;
|
||||
readonly kind: 'binding' | 'import' | 'blocked';
|
||||
/** Proven compatible declarations may accumulate their import paths. */
|
||||
readonly merge?: boolean;
|
||||
/** Hoisted declarations rank before active ordered rebinding at this scope. */
|
||||
readonly hoisted?: boolean;
|
||||
readonly purpose?: LookupPurpose | 'both';
|
||||
readonly availableFrom?: SourcePosition;
|
||||
readonly inactive?: 'blocked' | 'outer' | 'module';
|
||||
readonly redirect?: 'module' | 'enclosing-function';
|
||||
}
|
||||
|
||||
export interface ScopeLookupPolicy {
|
||||
/** All lexical declarations have claims; inferred assignments alone do not declare names. */
|
||||
readonly nameClaimsComplete?: boolean;
|
||||
/** Reference scopes already identify the evaluating environment; do not infer
|
||||
* a nested caller from physical source containment. */
|
||||
readonly callerScopeIsAuthoritative?: boolean;
|
||||
/** Exact lexical self names retained when other bindings are skipped by children. */
|
||||
readonly visibleNamesFromChildren?: readonly string[];
|
||||
readonly parentScope?: ScopeId;
|
||||
/** The scope's locals are not an enclosing lexical environment. */
|
||||
readonly skipFromChildren?: boolean;
|
||||
/** Child execution is deferred, so source order in parents cannot imply a TDZ. */
|
||||
readonly deferParentActivation?: boolean;
|
||||
}
|
||||
|
||||
export interface NameLookupOptions {
|
||||
readonly skipEnclosingClasses?: boolean;
|
||||
readonly position?: SourcePosition;
|
||||
readonly purpose?: LookupPurpose;
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical lexical-scope node. Forms the spine of the SemanticModel.
|
||||
*
|
||||
|
|
@ -672,6 +726,8 @@ export interface TypeRef {
|
|||
* — deterministic, stable across reparses of the same source, interned.
|
||||
*/
|
||||
export interface Scope {
|
||||
readonly nameClaims?: readonly NameClaim[];
|
||||
readonly lookupPolicy?: ScopeLookupPolicy;
|
||||
readonly id: ScopeId;
|
||||
readonly parent: ScopeId | null;
|
||||
readonly kind: ScopeKind;
|
||||
|
|
@ -762,7 +818,7 @@ export interface Resolution {
|
|||
* during the emit phase.
|
||||
*/
|
||||
export interface Reference {
|
||||
/** Innermost lexical scope containing `atRange`. */
|
||||
/** Scope owning the graph source; may differ from lexical lookup for a synthesized call. */
|
||||
readonly fromScope: ScopeId;
|
||||
readonly toDef: DefId;
|
||||
/** Location of the reference in source. */
|
||||
|
|
@ -818,6 +874,8 @@ export type RegistryContributor = unknown;
|
|||
* RFC §4.4 for per-registry specializations.
|
||||
*/
|
||||
export interface LookupParams {
|
||||
readonly lookupPosition?: SourcePosition;
|
||||
readonly lookupPurpose?: LookupPurpose;
|
||||
readonly acceptedKinds: readonly NodeLabel[];
|
||||
/** Class lookups: false. Method/Field lookups: true. */
|
||||
readonly useReceiverTypeBinding: boolean;
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@
|
|||
"zig": 7
|
||||
},
|
||||
"heap_ceiling_bytes": {
|
||||
"rust": 31300000,
|
||||
"kotlin": 6800000,
|
||||
"go": 4497696,
|
||||
"dart": 11751300,
|
||||
|
|
@ -113,6 +114,7 @@
|
|||
"_heap_reading_note": "heap_reading_bytes records each measured large-corpus reading so the 0.5x floor is independent of its ceiling. Ceilings use the standard 1.5x allowance for cross-platform and Node heap-accounting differences; re-baseline the reading and ceiling together.",
|
||||
"_kotlin_declared_package_gate": "#2960 replaces Kotlin\u0027s path-suffix cascade with one declared-package/module-binding index. The 32000-file arm measured 4516944 retained bytes (4.31 MiB), with a 6800000-byte ceiling. Correctness and reuse are pinned separately by kotlin-module-resolution.test.ts, external-import-conformance.test.ts, the shared index-reuse contract, and bench/kotlin-import-target.",
|
||||
"heap_reading_bytes": {
|
||||
"rust": 20853888,
|
||||
"kotlin": 4516944,
|
||||
"go": 2998464,
|
||||
"dart": 7834200,
|
||||
|
|
@ -130,7 +132,6 @@
|
|||
"heap_bound_bytes": {
|
||||
"cobol": 6200000,
|
||||
"swift": 5200000,
|
||||
"rust": 1048576,
|
||||
"javascript": 1048576,
|
||||
"typescript": 1048576,
|
||||
"vue": 1048576,
|
||||
|
|
@ -673,6 +674,12 @@
|
|||
}
|
||||
},
|
||||
"rust": {
|
||||
"_lexical_context_3532": "The fifth argument now reaches Rust's lexical module workspace index. Existing scale-arm counts and fingerprints remain unchanged. A declared sibling module resolves locally with context and to the crate-root decoy without it. Retained heap at 8000/32000 files measured 5186248/20853888 bytes (linear ratio 1.005); promote Rust from bounded to budgeted with the standard 1.5x ceiling and the existing floor/ratio gates.",
|
||||
"context": {
|
||||
"target": "local::X",
|
||||
"with_context": "src/parent/local.rs",
|
||||
"without_context": "src/local.rs"
|
||||
},
|
||||
"small": {
|
||||
"files": 400,
|
||||
"imports": 3200,
|
||||
|
|
|
|||
|
|
@ -604,6 +604,8 @@ const HEAP_BUDGETED = [
|
|||
'go',
|
||||
'cpp',
|
||||
'objc',
|
||||
// Lexical module lookup retains a workspace index over parsed Rust files.
|
||||
'rust',
|
||||
];
|
||||
// javascript, typescript and vue were budgeted here until #2953 and are now
|
||||
// BOUNDED, which is a demotion in gate strength and a promotion in what the
|
||||
|
|
@ -632,7 +634,7 @@ const HEAP_BUDGETED = [
|
|||
* parameters cannot observe a context and are not listed, so their timed
|
||||
* numbers stay on the three-argument shape.
|
||||
*/
|
||||
const CONTEXT_LANGS = ['php', 'java', 'kotlin', 'python', 'swift', 'c', 'cpp'];
|
||||
const CONTEXT_LANGS = ['php', 'java', 'kotlin', 'python', 'swift', 'rust', 'c', 'cpp'];
|
||||
|
||||
/**
|
||||
* Needs `node --expose-gc` to force collection for a clean delta; without it
|
||||
|
|
@ -1890,7 +1892,15 @@ function resolveOne(lang, from, target, pass) {
|
|||
{ fromFile: from, allFilePaths, parsedFiles: pass.parsedFiles },
|
||||
);
|
||||
}
|
||||
if (lang === 'rust') return resolveRustImportTarget(target, from, allFilePaths, undefined);
|
||||
if (lang === 'rust') {
|
||||
return resolveRustImportTarget(
|
||||
target,
|
||||
from,
|
||||
allFilePaths,
|
||||
pass.config,
|
||||
contextFor(pass, { kind: 'named', localName: 'X', importedName: 'X', targetRaw: target }),
|
||||
);
|
||||
}
|
||||
// Quotes already stripped — `configs/zig.ts` strips them before this call in
|
||||
// production too. `null` build config: this arm pins the path walk alone
|
||||
// (see the header); the config legs are gated by their own unit tests.
|
||||
|
|
@ -2300,6 +2310,37 @@ function measureHeap(lang) {
|
|||
* argument at all.
|
||||
*/
|
||||
const CONTEXT_PROBE = {
|
||||
// A declared sibling module outranks a crate-root file with the same name.
|
||||
// Empty module tables on the scaling corpora keep their existing target set;
|
||||
// this probe witnesses the lexical branch that reads the workspace index.
|
||||
rust: {
|
||||
from: 'src/parent/mod.rs',
|
||||
target: 'local::X',
|
||||
parsedFiles: (() => {
|
||||
const from = probeFile('src/parent/mod.rs', [['Namespace', 'local']]);
|
||||
const def = from.localDefs[0];
|
||||
return [
|
||||
{
|
||||
...from,
|
||||
scopes: [
|
||||
{
|
||||
id: from.moduleScope,
|
||||
parent: null,
|
||||
kind: 'Module',
|
||||
filePath: from.filePath,
|
||||
range: { startLine: 1, startCol: 0, endLine: 2, endCol: 0 },
|
||||
bindings: new Map([['local', [{ def, origin: 'local' }]]]),
|
||||
ownedDefs: [def],
|
||||
imports: [],
|
||||
typeBindings: new Map(),
|
||||
},
|
||||
],
|
||||
},
|
||||
probeFile('src/parent/local.rs', [['Struct', 'X']]),
|
||||
probeFile('src/local.rs', [['Struct', 'X']]),
|
||||
];
|
||||
})(),
|
||||
},
|
||||
/**
|
||||
* `use function App\Ns0\Dup;` where the CLASS `Dup` lives in `Dup.php` and
|
||||
* the FUNCTION `Dup` lives in `Helpers.php`. PHP keeps the two in separate
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
b6754e5362716211a348f3aafda8bbc00082e3fb0e45cb3aa79a1be5e3df1a8b
|
||||
95a98cf485746489f3ecf32e9a6bc7ee40604fe156d4cdd3ce36bc40581f1629
|
||||
|
|
|
|||
|
|
@ -155,7 +155,9 @@ function computeFingerprint() {
|
|||
const perFixtureDigests = [];
|
||||
for (const { key, absPath } of collectPythonFixtures()) {
|
||||
const src = fs.readFileSync(absPath, 'utf8');
|
||||
const matches = emitPythonScopeCaptures(src, absPath);
|
||||
// Lexical policies carry scope IDs containing the input path. Keep the
|
||||
// fingerprint independent of the checkout's absolute location.
|
||||
const matches = emitPythonScopeCaptures(src, key);
|
||||
groups += matches.length;
|
||||
perFixtureDigests.push(`${key}\t${matches.length}\t${digestCaptures(matches)}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -199,11 +199,11 @@
|
|||
}
|
||||
},
|
||||
"countArm": {
|
||||
"callDrops": 132,
|
||||
"totalDropsAllKinds": 178,
|
||||
"callDrops": 125,
|
||||
"totalDropsAllKinds": 163,
|
||||
"bySiteKind": {
|
||||
"call": 132,
|
||||
"read": 27,
|
||||
"call": 125,
|
||||
"read": 19,
|
||||
"write": 19
|
||||
},
|
||||
"callDropsByExtension": {
|
||||
|
|
@ -212,7 +212,6 @@
|
|||
".ts": 15,
|
||||
".zig": 11,
|
||||
".cs": 8,
|
||||
".cpp": 7,
|
||||
".tsx": 6,
|
||||
".go": 5,
|
||||
".php": 4,
|
||||
|
|
@ -223,15 +222,15 @@
|
|||
".swift": 1
|
||||
},
|
||||
"callDropsByShape": {
|
||||
"chain-field": 60,
|
||||
"chain-call": 35,
|
||||
"chain-field": 56,
|
||||
"chain-call": 32,
|
||||
"no-chain": 23,
|
||||
"<<unclassified>>": 11,
|
||||
"chain-mixed": 2,
|
||||
"chain-unwrap": 1
|
||||
},
|
||||
"callDropsByOrigin": {
|
||||
"in-program": 62,
|
||||
"in-program": 55,
|
||||
"external": 44,
|
||||
"unknown": 26
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,8 @@
|
|||
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)."
|
||||
},
|
||||
"cpp": {
|
||||
"fingerprint": "3aaee42523f02718795ba348782d78ec1418f35eff2e1b602422b192544fed96",
|
||||
"_lexical_ownership_3532": "#3532: lexical ownership captures and fixture updates; fixture identities are relative to lang-resolution so serialized scope IDs are independent of checkout location. Full capture content remains hashed; scaling_budget is unchanged.",
|
||||
"fingerprint": "f18beeadd100e5479c2997876bc541412822cf3a4ae33b0d4a6ea6c35cae7b0d",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_2833_qualified_member_fields": "#2833 follow-up: the six per-qualifier-depth `field_declaration` type-binding rules for a QUALIFIED generic member are replaced by three depth-agnostic ones that match the outer `qualified_identifier` itself, with the qualifier reduced to its top-level tail in `interpret.ts` (`cppQualifiedTail`). This is a CAPTURE-LOGIC change and it moves the fingerprint in two places at once. (1) A qualified NON-generic member (`ns::Address addr;`, `std::string name;`) was captured by nothing at all and now binds \u2014 that is the whole +24 on the fixture corpus, every one of them a `std::string` member. (2) Qualifier depth is no longer enumerated, so `a::b::c::Repo<User>` (depth 3+) is captured where the old rules stopped at 2. Capture-name histogram, cpp-* corpus (278 files): `@type-binding.field` 8 -> 32, `@type-binding.name` and `@type-binding.type` 401 -> 425; synthetic DAO-20: `@type-binding.field` 40 -> 60, `@type-binding.name` and `@type-binding.type` 61 -> 81 (= 20 entities x the one `std::string name;` member the DAO unit already declared). NO OTHER TAG MOVED in either set \u2014 not one `@declaration.*`, `@scope.*` or `@reference.*` count \u2014 which is the property that says three rules replaced six without widening what a field_declaration matches. Measured over the 13 cpp-* fixture repos whose sources gained a binding, the distinct CALLS edge set is byte-identical before and after (32 edges): a reduced tail that names no workspace class binds nothing. Prior bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a -> db1156d81b3e3341faf5e938a4a34417f4fd246588b6150b4686481823262529; scaling 1.04 < 1.5.",
|
||||
"_rebaselined_2833_generic_member_fields": "#2833 review follow-up: the cpp DAO generator's unit gains two GENERIC member fields \u2014 `Repo<Entity_n> repo;` (bare template_type) and `std::vector<Entity_n> items;` (qualified_identifier wrapping a template_type) \u2014 plus the header declaring `template <typename T> class Repo`. CORPUS CHANGE, NOT A CAPTURE-LOGIC CHANGE: no extractor edit accompanies it. It exists because the corpus had ZERO template-typed member fields and, across 279 cpp-* fixtures, not one qualified generic member either, so BOTH rounds of new `field_declaration` type-binding rules landed with a byte-identical cpp fingerprint \u2014 the gate was structurally blind to the exact thing being changed. Measured under the new corpus, the three states now differ: pre-#2833 query 0e7cbda71360b7ff35dd76091c77f288d6af6a5cfa9185ad85a372aae8c85191 (4521 groups) -> the three template_type field rules de07d8b5300ed867b460918e16b4d80259c7eb6efc1034d32bebe9ff7cab126d (4541) -> the six qualified rules bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a (4561); under the OLD corpus all three were 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc. Capture-name histogram over the synthetic DAO-20: `@type-binding.field` 0 -> 40, `@declaration.field` 40 -> 80, `@type-binding.type`/`@type-binding.name` 20 -> 61, `@declaration.name` 104 -> 147 \u2014 40 = 20 entities x 2 fields, with the residual +1/+2/+3 attributable to the one-off header declaration; every `@reference.*` count is unchanged. Prior 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc -> bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a; scaling 1.058 < 1.5. `c` is unaffected (3418cded..., unchanged).",
|
||||
|
|
@ -77,7 +78,8 @@
|
|||
"_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Only drift: `string.Join(\", \", args)` (csharp-variadic-resolution/Utils/Logger.cs) loses `direct-callee-name|Join`; the argument fact itself is unchanged, capture_groups_fp 2657 (unchanged). Prior 2930ef49fdce984a4c051409880bddfe8445e30e1c6bf802bd90a0a0f8f6b094 -> 9c4d2ca55707a03185ea45e554462e81e6d83c9f8d0ea3cd63db5c362ad1bfec."
|
||||
},
|
||||
"rust": {
|
||||
"fingerprint": "e61653008ff2de506cfd47f905fa9eb22d82fbbfe94d2a1d8190c358211b57b7",
|
||||
"_lexical_ownership_3532": "#3532: lexical ownership captures and fixture updates; fixture identities are relative to lang-resolution so serialized scope IDs are independent of checkout location. Full capture content remains hashed; scaling_budget is unchanged.",
|
||||
"fingerprint": "8c9f271349ab0a35a32e6f064303d63e191e019daa6dab8c5fc80784717a3321",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_generic_instantiation_2912": "#2912: RUST_SCOPE_QUERY tags trait-impl heritage with the instantiation the impl was written with (`impl Validator<String> for V`), so interface dispatch can prune implementors of an instantiation the receiver cannot hold. Additive capture text on existing impl matches \u2014 the same matches are minted, carrying one more field \u2014 so this is digest drift, not a capture-set change: capture_groups_fp (3556) and fixture_count (202) are both unchanged, which is the check that no match appeared or vanished. Prior 116a971fee0004f340477aff69fa110a1d92bd8ba882d7c926483c6b1e8ca2b9 -> e61653008ff2de506cfd47f905fa9eb22d82fbbfe94d2a1d8190c358211b57b7; scaling 1.018 < 1.5. Only rust and dart move; the other 13 languages are byte-identical.",
|
||||
"_rebaselined_mod_node_identity_2745_review": "#2745 review: added rust-2742-mod-members, rust-2742-nested-mods and rust-2742-type-vs-module under lang-resolution for the container/owner-edge fix, nested inline modules, and the imported-type-vs-module precedence. emitRustScopeCaptures is unchanged \u2014 verified by removing ONLY those three fixture dirs and re-running, which reproduces the prior fingerprint exactly, so the shift is purely corpus growth (fixture_count 196 -> 202, capture_groups_fp 3432 -> 3556). Prior 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5 -> 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300; scaling 1.022 local / 1.057 CI < 1.5. NOTE for the next fixture author: a new rust-* fixture drifts BOTH this bench baseline and the rust-captures-golden snapshot. Updating only the golden is how this reached CI red.",
|
||||
|
|
@ -197,7 +199,8 @@
|
|||
"capture_groups_fp": 680
|
||||
},
|
||||
"typescript": {
|
||||
"fingerprint": "a7972d87abd253583dafa37443bee8ddf5c635d41b4dfbb0c831efda71d82b47",
|
||||
"_lexical_ownership_3532": "#3532: lexical ownership captures and fixture updates; fixture identities are relative to lang-resolution so serialized scope IDs are independent of checkout location. Full capture content remains hashed; scaling_budget is unchanged.",
|
||||
"fingerprint": "bccca22b59d4c4d8aa4766c69092f29912fa035f08f037a639de3beaa725eaf4",
|
||||
"_rebaselined_3446_mcp_tools_fixture": "#3446 adds typescript-mcp-tools/src/{handlers,server,tools}.ts: corpus growth only, with fixture_count 171 -> 174 and capture_groups_fp 2753 -> 2894. Excluding only that fixture directory restores the prior fingerprint 77c9b4ea654123a64972db8348190ec250b669472b150db65ea8c7f20b355467 exactly. The scope emitter, synthetic capture counts (4503/14403), scaling budget, and other language baselines are unchanged.",
|
||||
"_rebaselined_3190": "Capture matches now retain explicit ESM export/private evidence, including synthesized default HOCs; CommonJS surfaces remain undecided. Capture group counts unchanged. Scaling budget unchanged.",
|
||||
"scaling_budget": 1.5,
|
||||
|
|
@ -226,7 +229,8 @@
|
|||
"_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: typescript-callable-alternatives/{index,compare,worker,sweep/index}.ts (+4 files, +288 groups). Emitter delta: +18 / -14: `??` / `||` / `&&` / ternary seeds and copies now name each operand (`&&` its right operand only); the 14 removed matches are the whole-expression seeds and copies carrying a qualified name of the compound source, including the comparison branches in compare.ts that now stay opaque. capture_groups_fp 2465 -> 2753, fixture_count 167 -> 171; synthetic counts unchanged; scaling 0.963 < 1.5. Prior 60e75bbe846f7200005f12c4d95c32e82d9d3feae9f2fd594e484670762b82b0 -> 77c9b4ea654123a64972db8348190ec250b669472b150db65ea8c7f20b355467."
|
||||
},
|
||||
"javascript": {
|
||||
"fingerprint": "b916c7072b30d09b4949803810604830ea1aca1cfdda0d9d9312f7cb22f7a10a",
|
||||
"_lexical_ownership_3532": "#3532: lexical ownership captures and fixture updates; fixture identities are relative to lang-resolution so serialized scope IDs are independent of checkout location. Full capture content remains hashed; scaling_budget is unchanged.",
|
||||
"fingerprint": "857b1257a001f8741412ea08a4f43d9253cc4addfa63421f3013b3ff77a390c9",
|
||||
"_rebaselined_3190": "Capture matches now retain explicit ESM export/private evidence, including synthesized default HOCs; CommonJS surfaces remain undecided. Capture group counts unchanged. Scaling budget unchanged.",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3 -> 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b; scaling 1.050 < 1.5.",
|
||||
|
|
@ -268,7 +272,8 @@
|
|||
"_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: kotlin-callable-alternatives/App.kt (+1 file, +71 groups). Emitter delta: +8 / -2: elvis and `if` expression (bare and braced) seeds and copies now name each operand; the 2 removed matches are the whole-expression seed and copy carrying a qualified name of the compound source; the multi-statement `if` stays opaque. capture_groups_fp 2563 -> 2634, fixture_count 141 -> 142; synthetic counts unchanged; scaling 1.17 < 1.5. Prior a9d3f0db7547ff47856159debf15d2a6f427efca97a6af27b4004174ed432132 -> f3fefe058484823761b52df5715f41d65fa4ff0223843bcc86893d7b0a6b3de2."
|
||||
},
|
||||
"typescript-deep-chain": {
|
||||
"fingerprint": "c4dd97ad7be50a554930237f79035993ffa67957de6dc8c1d6606518071204b2",
|
||||
"_lexical_ownership_3532": "#3532: lexical ownership captures and fixture updates; fixture identities are relative to lang-resolution so serialized scope IDs are independent of checkout location. Full capture content remains hashed; scaling_budget is unchanged.",
|
||||
"fingerprint": "3f41a3c6d9161f95c0c906cc979e6ef853c25b39052c5cab274dba2aabf270ba",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#3354 review: one TS `handler || w === \"k0\" || \u2026` chain, operand count = entity count (250 -> 800). Guards callable-flow value-alternative expansion (valueAlternatives + per-leaf visibility walks) against per-leaf cost that grows with chain depth: before the fix this measured 7.5-8.0 (super-linear; 8000 operands overflowed the stack), after it 1.11-1.22. Synthetic-only fingerprint (no fixture corpus); identical before and after the fix."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -389,7 +389,8 @@ function measureLang(lang) {
|
|||
const fixtures =
|
||||
lang.fixturePrefix === undefined ? [] : collectFixtures(lang.fixturePrefix, lang.exts);
|
||||
for (const { key, absPath } of fixtures) {
|
||||
const matches = lang.emit(fs.readFileSync(absPath, 'utf8'), absPath);
|
||||
// Scope policies contain file identities; hash stable fixture paths.
|
||||
const matches = lang.emit(fs.readFileSync(absPath, 'utf8'), key);
|
||||
groups += matches.length;
|
||||
perFixture.push(`${key}\t${matches.length}\t${digestCaptures(matches)}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"_comment": "Baselines for bench/scope-emission/measure.mjs --check (#2699), one entry per language. `scopes` is an EXACT count over a synthetic corpus fixed in measure.mjs — a correctness gate, not a timing one, so drift means the emitted scope set moved and must be explained, never re-baselined to make CI green. The two emit-side filters this guards (function-body blocks, and blocks that declare no binding) cut block scopes 19389 -> 5331 on a 762-file TypeScript corpus and took the block-scope overhead from ~+10% to ~+2% of analyze wall time. `@scope.block` = 400 is 2 per module: only the two `if`/`else` branches that declare `const chosen`. If that number jumps, the filters regressed and every scope-chain walk in every function got deeper. BOTH languages are baselined because the filters are implemented twice — FUNCTION_BODY_OWNER_TYPES in typescript/captures.ts and JS_FUNCTION_BODY_OWNER_TYPES in javascript/captures.ts, each with its own blockDeclaresBinding — so a TypeScript-only gate would let a JavaScript-only regression ship green. The two agree exactly on this corpus; that is a measured result, not an invariant the gate depends on. `emit_ms_budget` carries deliberate headroom for shared CI runners and exists to catch an order-of-magnitude regression, not a few percent.",
|
||||
"_comment": "Baselines for bench/scope-emission/measure.mjs --check (#2699, #3532). Exact scope counts over the fixed synthetic corpus guard function-body suppression and removal of blocks without bindings. Each module has four Block scopes: two const-chosen branches, the lexical for environment, and the catch environment. Lookup-policy and name-claims captures decorate scopes and are excluded from this scope census; bench/scope-capture pins their full content. TypeScript and JavaScript retain separate baselines. emit_ms_budget is unchanged and carries headroom for shared CI runners.",
|
||||
"typescript": {
|
||||
"scopes": {
|
||||
"@scope.block": 400,
|
||||
"@scope.block": 800,
|
||||
"@scope.class": 200,
|
||||
"@scope.function": 1400,
|
||||
"@scope.module": 200
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
},
|
||||
"javascript": {
|
||||
"scopes": {
|
||||
"@scope.block": 400,
|
||||
"@scope.block": 800,
|
||||
"@scope.class": 200,
|
||||
"@scope.function": 1400,
|
||||
"@scope.module": 200
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@
|
|||
* `blockDeclaresBinding` and its own `BLOCK_BINDING_CHILD_TYPES` — so a
|
||||
* TypeScript-only bench would let a JavaScript-only regression ship green.
|
||||
*
|
||||
* On this corpus the two currently agree exactly (2 blocks per module, 2200
|
||||
* Loop and catch environments also carry lexical ownership. On this corpus
|
||||
* the two currently agree exactly (4 blocks per module, 2600
|
||||
* scopes). That is a measured result, not a required invariant: the fixtures
|
||||
* are structurally parallel and the TS-only syntax they drop carries no extra
|
||||
* scopes. Each language is still gated against its OWN baseline, because the
|
||||
|
|
@ -54,7 +55,8 @@ const { emitJsScopeCaptures } =
|
|||
*
|
||||
* Deliberately mixes the shapes the filters discriminate between:
|
||||
* - function/method/arrow bodies → block scope must be SUPPRESSED
|
||||
* - `if`/`else`/`for`/`while`/`try` → suppressed when they declare nothing
|
||||
* - empty `if`/`else`/`while`/`try` → suppressed
|
||||
* - lexical `for` and `catch` → environment scope REQUIRED
|
||||
* - blocks declaring `let`/`const` → block scope REQUIRED (shadowing)
|
||||
* - a block declaring only `var` → suppressed (`var` hoists past it)
|
||||
*/
|
||||
|
|
@ -164,6 +166,8 @@ const measure = ({ ext, emit, moduleSource }) => {
|
|||
for (const { path, source } of corpus) {
|
||||
for (const match of emit(source, path)) {
|
||||
for (const key of Object.keys(match)) {
|
||||
// These decorate an existing scope; they do not create scopes.
|
||||
if (key === '@scope.lookup-policy' || key === '@scope.name-claims') continue;
|
||||
if (key.startsWith('@scope.')) counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -292,6 +292,10 @@ const WORKER_THREADS = [
|
|||
'test/unit/community-processor.test.ts',
|
||||
'test/integration/worker-pool.test.ts',
|
||||
'test/integration/parse-impl-quarantine-cache-skip.test.ts',
|
||||
// Real parse workers and both on-disk parse stores must retain lexical
|
||||
// local-import ownership on each platform's native grammar/runtime pair.
|
||||
'test/integration/local-import-worker-parity.test.ts',
|
||||
'test/integration/resolvers/local-import-persistence.test.ts',
|
||||
];
|
||||
|
||||
// Tree-sitter native addon smoke tests — verify that native grammars
|
||||
|
|
|
|||
|
|
@ -83,11 +83,17 @@ function shiftCfgLines(cfg: FunctionCfg, offset: number): FunctionCfg {
|
|||
};
|
||||
}
|
||||
|
||||
function remapCfgLines(cfg: FunctionCfg, mapLine: (row: number) => number): FunctionCfg {
|
||||
function remapCfgLines(
|
||||
cfg: FunctionCfg,
|
||||
mapLine: (row: number) => number,
|
||||
siteCoordinates: 'source' | 'parse-buffer',
|
||||
): FunctionCfg {
|
||||
// CFG visitors store 1-based source lines; `mapLine` maps 0-based tree-sitter rows.
|
||||
const map1 = (line: number): number => mapLine(line - 1) + 1;
|
||||
const mapSite = (site: SiteRecord): SiteRecord =>
|
||||
site.at !== undefined ? { ...site, at: [map1(site.at[0]), site.at[1]] } : site;
|
||||
site.at !== undefined && siteCoordinates === 'source'
|
||||
? { ...site, at: [map1(site.at[0]), site.at[1]] }
|
||||
: site;
|
||||
return {
|
||||
...cfg,
|
||||
functionStartLine: map1(cfg.functionStartLine),
|
||||
|
|
@ -115,6 +121,8 @@ export function collectFunctionCfgs(
|
|||
maxFunctionLines = 0,
|
||||
lineOffset = 0,
|
||||
mapLine?: (row: number) => number,
|
||||
// Site anchors must match the coordinate space of scope reference captures.
|
||||
siteCoordinates: 'source' | 'parse-buffer' = 'source',
|
||||
): CollectedCfgs {
|
||||
const cfgs: FunctionCfg[] = [];
|
||||
let tooManyLines = 0;
|
||||
|
|
@ -136,7 +144,11 @@ export function collectFunctionCfgs(
|
|||
try {
|
||||
const cfg = visitor.buildFunctionCfg(node, filePath);
|
||||
if (cfg) {
|
||||
cfgs.push(mapLine ? remapCfgLines(cfg, mapLine) : shiftCfgLines(cfg, lineOffset));
|
||||
cfgs.push(
|
||||
mapLine
|
||||
? remapCfgLines(cfg, mapLine, siteCoordinates)
|
||||
: shiftCfgLines(cfg, lineOffset),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof CfgNestingDepthError) tooDeeplyNested++;
|
||||
|
|
|
|||
|
|
@ -299,6 +299,9 @@ interface LanguageProviderConfig {
|
|||
*/
|
||||
readonly preprocessSource?: (sourceText: string, filePath: string) => string;
|
||||
|
||||
/** Select the native grammar for a pre-extracted embedded script. */
|
||||
readonly selectEmbeddedGrammar?: (scriptLanguage: string) => object;
|
||||
|
||||
/**
|
||||
* Runtime/compiler identity reconciliation for framework metadata. The
|
||||
* central importer owns ambiguity handling; providers only supply aliases
|
||||
|
|
@ -870,6 +873,12 @@ interface LanguageProviderConfig {
|
|||
*/
|
||||
sourceMeta?: {
|
||||
readonly sourceKind?: 'full-file' | 'pre-extracted-script';
|
||||
/** Explicit language of an embedded script, interpreted by its provider. */
|
||||
readonly scriptLanguage?: string;
|
||||
/** Row offset from an embedded parse buffer to graph-source positions. */
|
||||
readonly lineOffset?: number;
|
||||
/** Original zero-based source row for each embedded buffer row. */
|
||||
readonly sourceLineMap?: readonly number[];
|
||||
/** Python `.ipynb` only: JSON line segments for the pre-extracted buffer. */
|
||||
readonly notebookSegments?: readonly NotebookLineSegment[];
|
||||
},
|
||||
|
|
|
|||
|
|
@ -295,7 +295,14 @@ function buildAdlIndex(
|
|||
if (parentQName !== '' && parentQName !== qName) keys.push(parentQName);
|
||||
}
|
||||
}
|
||||
for (const def of scope.ownedDefs) {
|
||||
// Function definitions own their body scope structurally, while their
|
||||
// ordinary declaration binds in this namespace. Include those lexical
|
||||
// bindings alongside prototypes owned directly by the namespace.
|
||||
const namespaceDefs = new Map(scope.ownedDefs.map((def) => [def.nodeId, def]));
|
||||
for (const bindings of scope.bindings.values()) {
|
||||
for (const binding of bindings) namespaceDefs.set(binding.def.nodeId, binding.def);
|
||||
}
|
||||
for (const def of namespaceDefs.values()) {
|
||||
if (def.type === 'Function' || def.type === 'Method') {
|
||||
const sn = adlSimpleName(def);
|
||||
pushFlat(idx.nsFunctionsBySimple, sn, def);
|
||||
|
|
|
|||
167
gitnexus/src/core/ingestion/languages/cpp/callable-bindings.ts
Normal file
167
gitnexus/src/core/ingestion/languages/cpp/callable-bindings.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
/** Ordinary unqualified callable lookup and ADL barriers owned by C++. */
|
||||
import type { BindingRef, NameLookupOptions, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { lookupBindingsAt, lookupNameClaim } from '../../scope-resolution/scope/walkers.js';
|
||||
import { definitionIdPosition } from '../../scope-resolution/utils/definition-id.js';
|
||||
|
||||
/**
|
||||
* ISO C++ `[basic.lookup.unqual]` §7: ADL is suppressed when ordinary
|
||||
* unqualified lookup finds:
|
||||
* - a name that is NOT a function or function template, OR
|
||||
* - a block-scope function declaration that is NOT a using-declaration.
|
||||
*
|
||||
* Combined walker that stops at the **nearest scope** where `name` has any
|
||||
* binding (callable or non-callable) and returns:
|
||||
* - `callables`: Function/Method/Constructor defs found at that scope
|
||||
* - `nonCallableFound`: a non-function binding was present (variable, class, etc.)
|
||||
* - `blockScopeDeclFound`: a callable was found at a Function or Block scope
|
||||
* (block-scope function declaration that blocks ADL)
|
||||
*
|
||||
* One pass, one stop — no divergence between callable collection and blocker
|
||||
* detection.
|
||||
*/
|
||||
export function findCallableBindingsAndAdlBlocker(
|
||||
startScope: ScopeId,
|
||||
name: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
options?: NameLookupOptions,
|
||||
): {
|
||||
callables: readonly SymbolDefinition[];
|
||||
nonCallableFound: boolean;
|
||||
blockScopeDeclFound: boolean;
|
||||
} {
|
||||
// Use the same ownership and provenance selection as every other consumer.
|
||||
// Deduplicating finalized and augmented refs before selection can discard the
|
||||
// using-declaration range that proves a callable belongs to this scope.
|
||||
const claim = lookupNameClaim(startScope, name, scopes, {
|
||||
purpose: 'value',
|
||||
...options,
|
||||
expandLocalBindings: (scope, local, indexed) =>
|
||||
(scope.kind === 'Module' || scope.kind === 'Namespace') &&
|
||||
local.every((binding) => binding.def.type === 'Function')
|
||||
? indexed.filter(
|
||||
(binding) =>
|
||||
binding.def.type === 'Function' && binding.via?.kind === 'wildcard-expanded',
|
||||
)
|
||||
: [],
|
||||
});
|
||||
const isCallable = (binding: BindingRef): boolean =>
|
||||
binding.def.type === 'Function' ||
|
||||
binding.def.type === 'Method' ||
|
||||
binding.def.type === 'Constructor';
|
||||
const callables = new Map<string, SymbolDefinition>();
|
||||
for (const binding of normalizeIncludedCallableDeclarations(claim.bindings, scopes)) {
|
||||
if (isCallable(binding)) callables.set(binding.def.nodeId, binding.def);
|
||||
}
|
||||
// C++ constructor-form calls share the class spelling. Local class lookup
|
||||
// takes precedence over include refs, but its own constructor compatibility
|
||||
// refs still belong to that selected entity. Defaulted C++ constructors can
|
||||
// retain the parser's Function label; exact class ownership is authoritative.
|
||||
const classOwners = new Set(
|
||||
claim.bindings
|
||||
.filter(
|
||||
(binding) =>
|
||||
binding.def.type === 'Class' ||
|
||||
binding.def.type === 'Struct' ||
|
||||
binding.def.type === 'Record',
|
||||
)
|
||||
.map((binding) => binding.def.nodeId),
|
||||
);
|
||||
if (claim.status === 'resolved' && claim.scope !== undefined && classOwners.size > 0) {
|
||||
for (const { def } of lookupBindingsAt(claim.scope.id, name, scopes)) {
|
||||
if (
|
||||
(def.type === 'Constructor' || def.type === 'Method' || def.type === 'Function') &&
|
||||
def.ownerId !== undefined &&
|
||||
classOwners.has(def.ownerId)
|
||||
)
|
||||
callables.set(def.nodeId, def);
|
||||
}
|
||||
}
|
||||
return {
|
||||
callables: [...callables.values()],
|
||||
nonCallableFound:
|
||||
claim.status === 'blocked' || claim.bindings.some((binding) => !isCallable(binding)),
|
||||
// Imported functions introduced by using declarations do not suppress ADL.
|
||||
blockScopeDeclFound:
|
||||
(claim.scope?.kind === 'Function' || claim.scope?.kind === 'Block') &&
|
||||
claim.bindings.some((binding) => binding.origin === 'local' && isCallable(binding)),
|
||||
};
|
||||
}
|
||||
|
||||
const callableDefinitionScopes = new WeakMap<
|
||||
ScopeResolutionIndexes['scopeTree'],
|
||||
{
|
||||
readonly files: ReadonlySet<string>;
|
||||
readonly anchors: ReadonlySet<string>;
|
||||
}
|
||||
>();
|
||||
|
||||
/** Coalesce a literal-include prototype with its unique local definition. */
|
||||
function normalizeIncludedCallableDeclarations(
|
||||
bindings: readonly BindingRef[],
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): readonly BindingRef[] {
|
||||
if (!bindings.some((binding) => binding.via?.kind === 'wildcard-expanded')) return bindings;
|
||||
let definitions = callableDefinitionScopes.get(scopes.scopeTree);
|
||||
if (definitions === undefined) {
|
||||
const files = new Set<string>();
|
||||
const anchors = new Set<string>();
|
||||
for (const scope of scopes.scopeTree.byId.values()) {
|
||||
files.add(scope.filePath);
|
||||
if (scope.kind === 'Function') {
|
||||
anchors.add(`${scope.filePath}\0${scope.range.startLine}\0${scope.range.startCol}`);
|
||||
}
|
||||
}
|
||||
definitions = { files, anchors };
|
||||
callableDefinitionScopes.set(scopes.scopeTree, definitions);
|
||||
}
|
||||
const definitionIndex = definitions;
|
||||
const hasBody = (def: SymbolDefinition): boolean => {
|
||||
const position = definitionIdPosition(def.nodeId, def.filePath);
|
||||
return (
|
||||
position !== undefined &&
|
||||
definitionIndex.anchors.has(`${def.filePath}\0${position.line}\0${position.column}`)
|
||||
);
|
||||
};
|
||||
const localBySignature = new Map<string, Set<string>>();
|
||||
for (const binding of bindings) {
|
||||
if (binding.origin !== 'local' || !hasBody(binding.def)) continue;
|
||||
const signature = callableRedeclarationSignature(binding.def);
|
||||
if (signature === undefined) continue;
|
||||
const ids = localBySignature.get(signature) ?? new Set<string>();
|
||||
ids.add(binding.def.nodeId);
|
||||
localBySignature.set(signature, ids);
|
||||
}
|
||||
return bindings.filter((binding) => {
|
||||
if (
|
||||
binding.via?.kind !== 'wildcard-expanded' ||
|
||||
!definitionIndex.files.has(binding.def.filePath) ||
|
||||
hasBody(binding.def)
|
||||
)
|
||||
return true;
|
||||
const signature = callableRedeclarationSignature(binding.def);
|
||||
return signature === undefined || localBySignature.get(signature)?.size !== 1;
|
||||
});
|
||||
}
|
||||
|
||||
function callableRedeclarationSignature(def: SymbolDefinition): string | undefined {
|
||||
if (
|
||||
def.type !== 'Function' ||
|
||||
def.ownerId !== undefined ||
|
||||
def.qualifiedName === undefined ||
|
||||
def.parameterCount === undefined ||
|
||||
def.parameterTypes === undefined ||
|
||||
def.parameterTypes.length !== def.parameterCount ||
|
||||
def.parameterTypes.some((type) => type === '') ||
|
||||
(def.typeParameters?.length ?? 0) > 0 ||
|
||||
def.templateConstraints !== undefined
|
||||
)
|
||||
return undefined;
|
||||
return JSON.stringify([
|
||||
def.namespacePrefix ?? '',
|
||||
def.qualifiedName,
|
||||
def.parameterCount,
|
||||
def.parameterTypes,
|
||||
def.parameterTypeClasses,
|
||||
]);
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@
|
|||
*/
|
||||
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import { collectCppUsingDeclarations, type CppUsingDeclaration } from './using-bindings.js';
|
||||
import { collectCppAdlSideChannel, applyCppAdlSideChannel, type CppAdlSideChannel } from './adl.js';
|
||||
import {
|
||||
collectCppInlineNamespaceSideChannel,
|
||||
|
|
@ -68,6 +69,7 @@ export interface CppCaptureSideChannel {
|
|||
readonly fileLocal: CppFileLocalSideChannel;
|
||||
readonly twoPhase: CppTwoPhaseSideChannel;
|
||||
readonly memberLookup: CppMemberLookupSideChannel;
|
||||
readonly usingDeclarations?: readonly CppUsingDeclaration[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -81,6 +83,7 @@ export function collectCppCaptureSideChannel(filePath: string): CppCaptureSideCh
|
|||
const fileLocal = collectCppFileLocalSideChannel(filePath);
|
||||
const twoPhase = collectCppTwoPhaseSideChannel(filePath);
|
||||
const memberLookup = collectCppMemberLookupSideChannel(filePath);
|
||||
const usingDeclarations = collectCppUsingDeclarations(filePath);
|
||||
|
||||
const isEmpty =
|
||||
adl.argInfoBySite.length === 0 &&
|
||||
|
|
@ -91,10 +94,19 @@ export function collectCppCaptureSideChannel(filePath: string): CppCaptureSideCh
|
|||
twoPhase.dependentBases.length === 0 &&
|
||||
twoPhase.dependentPackBaseClasses.length === 0 &&
|
||||
memberLookup.baseEdges.length === 0 &&
|
||||
memberLookup.memberUsings.length === 0;
|
||||
memberLookup.memberUsings.length === 0 &&
|
||||
usingDeclarations.length === 0;
|
||||
if (isEmpty) return undefined;
|
||||
|
||||
return { kind: 'cpp', adl, inlineNamespaceRanges, fileLocal, twoPhase, memberLookup };
|
||||
return {
|
||||
kind: 'cpp',
|
||||
adl,
|
||||
inlineNamespaceRanges,
|
||||
fileLocal,
|
||||
twoPhase,
|
||||
memberLookup,
|
||||
usingDeclarations,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ import { markCppInlineNamespaceRange } from './inline-namespaces.js';
|
|||
import { extractCppTemplateConstraints } from './constraint-extractor.js';
|
||||
import { captureCppMemberLookupFacts } from './member-lookup.js';
|
||||
import { CPP_BRACED_INIT_TYPE_PREFIX } from './conversion-rank.js';
|
||||
import {
|
||||
attachCppUsingClaims,
|
||||
recordCppUsingDeclaration,
|
||||
resetCppUsingDeclarations,
|
||||
} from './using-bindings.js';
|
||||
import { logger } from '../../../logger.js';
|
||||
import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
|
||||
import {
|
||||
|
|
@ -171,6 +176,7 @@ export function emitCppScopeCaptures(
|
|||
|
||||
const rawMatches = getCppScopeQuery().matches(tree.rootNode);
|
||||
const out: CaptureMatch[] = [];
|
||||
resetCppUsingDeclarations(filePath);
|
||||
|
||||
// #2432: reset the per-file lookup index. The identifier-argument type
|
||||
// lookups below used to re-walk the AST per identifier (full-tree DFS in
|
||||
|
|
@ -238,6 +244,7 @@ export function emitCppScopeCaptures(
|
|||
if (usingNode !== null) {
|
||||
const split = splitCppUsingDecl(usingNode);
|
||||
if (split !== null) {
|
||||
recordCppUsingDeclaration(filePath, split);
|
||||
out.push(split);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -610,6 +617,16 @@ export function emitCppScopeCaptures(
|
|||
}
|
||||
}
|
||||
|
||||
const parameterNode = nodeMap['@type-binding.parameter'];
|
||||
const parameterCapture = grouped['@type-binding.parameter'];
|
||||
if (parameterNode !== undefined && parameterCapture !== undefined) {
|
||||
// Parameter names in a prototype or nested function-pointer type are
|
||||
// not locals in the containing function. Arity metadata still comes
|
||||
// from the declaration AST, independently of these local type facts.
|
||||
if (!isCppLexicalParameter(parameterNode)) continue;
|
||||
grouped['@type-binding.lexical-parameter'] = parameterCapture;
|
||||
}
|
||||
|
||||
// Structural receiver chain for a call whose receiver is itself an
|
||||
// expression, so resolution can type it by folding over structure
|
||||
// instead of re-parsing the receiver's source text. Self-gating: a
|
||||
|
|
@ -638,9 +655,25 @@ export function emitCppScopeCaptures(
|
|||
captureCppMemberLookupFacts(tree.rootNode, filePath);
|
||||
|
||||
out.push(...synthesizeCallableFlowCaptures(tree.rootNode, CPP_CALLABLE_CAPTURE_OPTIONS));
|
||||
attachCppUsingClaims(out, filePath);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Prototype and nested function-pointer parameter names do not bind the body. */
|
||||
function isCppLexicalParameter(node: SyntaxNode): boolean {
|
||||
for (let parent = node.parent; parent !== null; parent = parent.parent) {
|
||||
if (parent.type === 'function_definition' || parent.type === 'lambda_expression') return true;
|
||||
if (
|
||||
parent.type === 'declaration' ||
|
||||
parent.type === 'field_declaration' ||
|
||||
parent.type === 'parameter_declaration' ||
|
||||
parent.type === 'optional_parameter_declaration'
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function cppFunctionDeclaratorSignature(node: SyntaxNode): CallableCaptureSignature | undefined {
|
||||
const declarator = cppFindDescendantOfType(node, 'function_declarator');
|
||||
const parameters = declarator?.childForFieldName('parameters');
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ export function populateCppAnonymousNamespaceScopes(parsed: {
|
|||
* scope is reflected before any cross-file resolution pass consults the
|
||||
* set.
|
||||
*
|
||||
* A def is "not globally visible" when its nearest structurally enclosing
|
||||
* A def is "not globally visible" when its nearest lexical binding
|
||||
* scope is a `Namespace` or `Class` — those require qualification
|
||||
* (`ns::name`, `Class::method`) for cross-file unqualified lookup.
|
||||
* Module-scoped defs remain globally visible.
|
||||
|
|
@ -183,6 +183,10 @@ export function populateCppNonGloballyVisible(parsed: {
|
|||
readonly id: ScopeId;
|
||||
readonly kind: string;
|
||||
readonly ownedDefs: readonly { readonly nodeId: string }[];
|
||||
readonly bindings?: ReadonlyMap<
|
||||
string,
|
||||
readonly { readonly def: { readonly nodeId: string } }[]
|
||||
>;
|
||||
}[];
|
||||
}): void {
|
||||
let set = nonGloballyVisibleNodeIds.get(parsed.filePath);
|
||||
|
|
@ -204,8 +208,15 @@ export function populateCppNonGloballyVisible(parsed: {
|
|||
// unqualified lookup that does not go through #include, so dropping
|
||||
// the structural visibility exclusion here is safe.
|
||||
if (scope.kind === 'Namespace' && anonymousNamespaceScopeIds.has(scope.id)) continue;
|
||||
for (const def of scope.ownedDefs) {
|
||||
set.add(def.nodeId);
|
||||
// Scope-creating definitions own themselves structurally. Their lexical
|
||||
// binding is what distinguishes a namespace function from a file function.
|
||||
if (scope.bindings !== undefined) {
|
||||
for (const bindings of scope.bindings.values()) {
|
||||
for (const binding of bindings) set.add(binding.def.nodeId);
|
||||
}
|
||||
} else {
|
||||
// Compatibility for manually constructed records without binding facts.
|
||||
for (const def of scope.ownedDefs) set.add(def.nodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -226,8 +237,7 @@ export function isCppDefGloballyVisible(filePath: string, nodeId: string): boole
|
|||
}
|
||||
|
||||
/**
|
||||
* Return the names visible through a C++ wildcard import (`#include` or
|
||||
* `using namespace`).
|
||||
* Return the names visible through a C++ header include.
|
||||
*
|
||||
* ## Contract
|
||||
*
|
||||
|
|
@ -247,10 +257,9 @@ export function isCppDefGloballyVisible(filePath: string, nodeId: string): boole
|
|||
* only dot-qualifies `qualifiedName` for `Class` scopes. Namespace-nested
|
||||
* defs (`namespace ns { void foo(); }`) arrive in `localDefs` with
|
||||
* `qualifiedName === 'foo'` and `ownerId === undefined`, indistinguishable
|
||||
* from a top-level free function. The structural truth lives in
|
||||
* `Scope.ownedDefs`: each scope lists what it structurally owns; the
|
||||
* Module scope owns only top-level symbols. We look the def up by
|
||||
* `nodeId` against the scope tree to identify its owning kind.
|
||||
* from a top-level free function. `Scope.bindings` records lexical ownership;
|
||||
* a function's own body scope structurally owns its definition. Prefer the
|
||||
* binding owner and retain ownedDefs for older records without binding facts.
|
||||
*
|
||||
* ## `localDefs` consumer survey (recorded for future maintainers)
|
||||
*
|
||||
|
|
@ -306,16 +315,21 @@ export function expandCppWildcardNames(
|
|||
const target = moduleScopeIndex(parsedFiles).get(targetModuleScope);
|
||||
if (target === undefined) return [];
|
||||
|
||||
// Build nodeId → owning Scope map from the structural scope tree.
|
||||
// `Scope.ownedDefs` is the canonical source of structural ownership;
|
||||
// `localDefs` is its flattened union, which is why the original code
|
||||
// leaked: walking only `localDefs` discards the owning-scope context.
|
||||
// Build nodeId → owning Scope, retaining structural facts as a fallback
|
||||
// before lexical binding ownership refines the definition's visibility.
|
||||
const ownerScopeByNodeId = new Map<string, Scope>();
|
||||
for (const scope of target.scopes) {
|
||||
for (const ownedDef of scope.ownedDefs) {
|
||||
ownerScopeByNodeId.set(ownedDef.nodeId, scope);
|
||||
}
|
||||
}
|
||||
// Prefer lexical ownership over a definition's own body scope. A function
|
||||
// inside a namespace has an owning Function scope and a Namespace binding.
|
||||
for (const scope of target.scopes) {
|
||||
for (const bindings of scope.bindings?.values() ?? []) {
|
||||
for (const binding of bindings) ownerScopeByNodeId.set(binding.def.nodeId, scope);
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const names: string[] = [];
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ export function splitCppUsingDecl(node: SyntaxNode): CaptureMatch | null {
|
|||
// lookup set; it is not a namespace import. The C++ member-lookup sidecar
|
||||
// captures it separately, so suppress import decomposition here.
|
||||
for (let parent = node.parent; parent !== null; parent = parent.parent) {
|
||||
if (parent.type === 'function_definition' || parent.type === 'lambda_expression') break;
|
||||
if (parent.type === 'class_specifier' || parent.type === 'struct_specifier') return null;
|
||||
}
|
||||
|
||||
|
|
@ -109,6 +110,7 @@ export function splitCppUsingDecl(node: SyntaxNode): CaptureMatch | null {
|
|||
'@import.kind': syntheticCapture('@import.kind', node, 'wildcard'),
|
||||
'@import.source': syntheticCapture('@import.source', node, namespaceName),
|
||||
'@import.using-namespace': syntheticCapture('@import.using-namespace', node, 'true'),
|
||||
'@import.using-decl': nodeToCapture('@import.using-decl', node),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -124,16 +126,18 @@ export function splitCppUsingDecl(node: SyntaxNode): CaptureMatch | null {
|
|||
if (qualId === null) return null;
|
||||
|
||||
// Extract the imported name (last identifier) and source (namespace part)
|
||||
const nameNode = qualId.childForFieldName?.('name') ?? null;
|
||||
const scopeNode = qualId.childForFieldName?.('scope') ?? null;
|
||||
|
||||
const importedName = nameNode?.text ?? qualId.text.split('::').pop() ?? '';
|
||||
const source = scopeNode?.text ?? qualId.text.replace(new RegExp('::' + importedName + '$'), '');
|
||||
// Qualified identifiers nest on their name side in tree-sitter-cpp. Taking
|
||||
// only the outer `scope` loses intermediate namespaces in a::b::member.
|
||||
const separator = qualId.text.lastIndexOf('::');
|
||||
if (separator < 0) return null;
|
||||
const importedName = qualId.text.slice(separator + 2).trim();
|
||||
const source = qualId.text.slice(0, separator).trim() || '::';
|
||||
|
||||
return {
|
||||
'@import.statement': nodeToCapture('@import.statement', node),
|
||||
'@import.kind': syntheticCapture('@import.kind', node, 'named'),
|
||||
'@import.source': syntheticCapture('@import.source', node, source),
|
||||
'@import.name': syntheticCapture('@import.name', node, importedName),
|
||||
'@import.using-decl': nodeToCapture('@import.using-decl', node),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,15 +3,21 @@ import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'git
|
|||
/**
|
||||
* Interpret a C++ import capture into a ParsedImport.
|
||||
*
|
||||
* C++ has three import forms:
|
||||
* 1. #include "file.h" → wildcard import (all symbols from header)
|
||||
* 2. using namespace X; → wildcard import (all symbols from namespace X)
|
||||
* 3. using X::name; → named import (single symbol from namespace X)
|
||||
* Header includes become wildcard file imports. Namespace using captures
|
||||
* are resolved separately against exact namespace members and their lexical
|
||||
* declaration positions by populateCppUsingBindings.
|
||||
*
|
||||
* Angle `#include <...>` sets `isSystem`. The resolver searches header
|
||||
* paths for that form and does not use the basename index.
|
||||
*/
|
||||
export function interpretCppImport(captures: CaptureMatch): ParsedImport | null {
|
||||
// Namespace using facts are linked by populateCppUsingBindings. They are
|
||||
// not file imports: suffix-matching a namespace against a header is wrong.
|
||||
if (
|
||||
captures['@import.using-decl'] !== undefined ||
|
||||
captures['@import.using-namespace'] !== undefined
|
||||
)
|
||||
return null;
|
||||
const source = captures['@import.source']?.text;
|
||||
if (source === undefined) return null;
|
||||
|
||||
|
|
@ -19,13 +25,13 @@ export function interpretCppImport(captures: CaptureMatch): ParsedImport | null
|
|||
const kind = captures['@import.kind']?.text;
|
||||
|
||||
if (kind === 'named') {
|
||||
// using X::name — named import
|
||||
// Compatibility for synthetic named-import captures without a using marker.
|
||||
const importedName = captures['@import.name']?.text;
|
||||
if (importedName === undefined) return null;
|
||||
return { kind: 'named', targetRaw: source, localName: importedName, importedName };
|
||||
}
|
||||
|
||||
// #include or using namespace — wildcard import
|
||||
// #include — wildcard file import
|
||||
return { kind: 'wildcard', targetRaw: source, isSystem };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
isCppDependentBaseMember,
|
||||
} from './two-phase-lookup.js';
|
||||
import { populateCppAssociatedNamespaces, clearCppAdlState, pickCppAdlCandidates } from './adl.js';
|
||||
import { findCallableBindingsAndAdlBlocker } from './callable-bindings.js';
|
||||
import { applyCppCaptureSideChannel } from './capture-side-channel.js';
|
||||
import {
|
||||
clearCppInlineNamespaces,
|
||||
|
|
@ -54,6 +55,11 @@ import {
|
|||
} from './member-lookup.js';
|
||||
import { stripCppSpecifiers } from './interpret.js';
|
||||
import { perFileSet } from '../../import-resolvers/per-file-set.js';
|
||||
import {
|
||||
clearCppUsingDeclarations,
|
||||
populateCppIncludedUsingClaims,
|
||||
populateCppUsingBindings,
|
||||
} from './using-bindings.js';
|
||||
|
||||
/** A pointee worth binding: a bare identifier, not `T**`, `T[]`, `A::B` or a
|
||||
* template spelling. Hoisted — a literal here would mint a fresh RegExp on
|
||||
|
|
@ -115,6 +121,7 @@ export const cppScopeResolver: ScopeResolver = {
|
|||
clearCppInlineNamespaces();
|
||||
clearCppUserDefinedConversions();
|
||||
clearCppMemberLookupState();
|
||||
clearCppUsingDeclarations();
|
||||
return loadCFamilyResolutionConfig(repoPath, CPP_HEADER_EXTENSIONS);
|
||||
},
|
||||
|
||||
|
|
@ -207,6 +214,21 @@ export const cppScopeResolver: ScopeResolver = {
|
|||
populateCppDependentBases(parsedFiles);
|
||||
},
|
||||
|
||||
populateWorkspaceReferences: (parsedFiles, context) => {
|
||||
const allFilePaths = new Set(parsedFiles.map((parsed) => parsed.filePath));
|
||||
populateCppIncludedUsingClaims(parsedFiles, (parsed, imported) =>
|
||||
cppScopeResolver.resolveImportTarget(
|
||||
imported.targetRaw,
|
||||
parsed.filePath,
|
||||
allFilePaths,
|
||||
context.resolutionConfig,
|
||||
{ parsedFiles, parsedImport: imported },
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
populateNamespaceSiblings: populateCppUsingBindings,
|
||||
|
||||
// Simple `isSuperReceiver` returns false for C++. Real super
|
||||
// classification is caller-context-dependent and lives in
|
||||
// `isSuperReceiverInContext` below — without scope context the
|
||||
|
|
@ -356,43 +378,8 @@ export const cppScopeResolver: ScopeResolver = {
|
|||
// specializations with explicit type arguments contribute associated
|
||||
// namespaces. Function-pointer args and full conversion-ranking remain
|
||||
// excluded.
|
||||
resolveAdlCandidates: (site, callerParsed, scopes, parsedFiles) => {
|
||||
// `using ns::name;` introduces `name` into ordinary unqualified lookup.
|
||||
// For template-class method bodies, lexical scope walks can miss this
|
||||
// named-using visibility; recover by resolving the imported namespace
|
||||
// member directly when the local call name matches a named using import.
|
||||
const usingNamedHits: SymbolDefinition[] = [];
|
||||
const seenUsing = new Set<string>();
|
||||
for (const imp of callerParsed.parsedImports) {
|
||||
if (imp.kind !== 'named') continue;
|
||||
if (imp.localName !== site.name) continue;
|
||||
const member = resolveCppQualifiedNamespaceMember(
|
||||
imp.targetRaw,
|
||||
imp.importedName,
|
||||
parsedFiles,
|
||||
scopes,
|
||||
);
|
||||
if (member === undefined || member === 'ambiguous') continue;
|
||||
if (seenUsing.has(member.nodeId)) continue;
|
||||
seenUsing.add(member.nodeId);
|
||||
usingNamedHits.push(member);
|
||||
}
|
||||
const adlHits = pickCppAdlCandidates(site, callerParsed, scopes, parsedFiles);
|
||||
if (usingNamedHits.length === 0) return adlHits;
|
||||
if (adlHits === undefined || adlHits.length === 0) return usingNamedHits;
|
||||
const merged: SymbolDefinition[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const hit of usingNamedHits) {
|
||||
seen.add(hit.nodeId);
|
||||
merged.push(hit);
|
||||
}
|
||||
for (const hit of adlHits) {
|
||||
if (seen.has(hit.nodeId)) continue;
|
||||
seen.add(hit.nodeId);
|
||||
merged.push(hit);
|
||||
}
|
||||
return merged;
|
||||
},
|
||||
resolveAdlCandidates: pickCppAdlCandidates,
|
||||
resolveOrdinaryCallables: findCallableBindingsAndAdlBlocker,
|
||||
|
||||
// C++ qualified namespace-member resolution (U5 of plan 2026-05-13-001).
|
||||
// Handles `outer::foo()` where `outer` is a namespace (not a class).
|
||||
|
|
|
|||
514
gitnexus/src/core/ingestion/languages/cpp/using-bindings.ts
Normal file
514
gitnexus/src/core/ingestion/languages/cpp/using-bindings.ts
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
import type {
|
||||
BindingRef,
|
||||
Capture,
|
||||
CaptureMatch,
|
||||
NameClaim,
|
||||
ParsedFile,
|
||||
ParsedImport,
|
||||
Range,
|
||||
Scope,
|
||||
ScopeId,
|
||||
} from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { isCppInlineNamespaceScope } from './inline-namespaces.js';
|
||||
|
||||
/** Namespace lookup facts, deliberately distinct from preprocessor file imports. */
|
||||
export interface CppUsingDeclaration {
|
||||
readonly namespace: string;
|
||||
readonly name?: string;
|
||||
readonly range: Range;
|
||||
}
|
||||
|
||||
const declarationsByFile = new Map<string, CppUsingDeclaration[]>();
|
||||
|
||||
export function clearCppUsingDeclarations(): void {
|
||||
declarationsByFile.clear();
|
||||
}
|
||||
|
||||
export function resetCppUsingDeclarations(filePath: string): void {
|
||||
declarationsByFile.delete(filePath);
|
||||
}
|
||||
|
||||
export function recordCppUsingDeclaration(filePath: string, capture: CaptureMatch): void {
|
||||
const namespace = capture['@import.source']?.text;
|
||||
const range = capture['@import.statement']?.range;
|
||||
if (namespace === undefined || range === undefined) return;
|
||||
const name = capture['@import.name']?.text;
|
||||
const declarations = declarationsByFile.get(filePath) ?? [];
|
||||
declarations.push({ namespace, range, ...(name === undefined ? {} : { name }) });
|
||||
declarationsByFile.set(filePath, declarations);
|
||||
}
|
||||
|
||||
export function collectCppUsingDeclarations(filePath: string): readonly CppUsingDeclaration[] {
|
||||
return declarationsByFile.get(filePath) ?? [];
|
||||
}
|
||||
|
||||
const compare = (
|
||||
a: Pick<Range, 'startLine' | 'startCol'>,
|
||||
b: Pick<Range, 'startLine' | 'startCol'>,
|
||||
) => a.startLine - b.startLine || a.startCol - b.startCol;
|
||||
const end = (range: Range) => ({ startLine: range.endLine, startCol: range.endCol });
|
||||
const contains = (outer: Range, inner: Range): boolean =>
|
||||
compare(outer, inner) <= 0 && compare(end(inner), end(outer)) <= 0;
|
||||
|
||||
/** Claims attach to the same innermost capture range as the shared extractor. */
|
||||
export function attachCppUsingClaims(captures: CaptureMatch[], filePath: string): void {
|
||||
const scopes = captures.flatMap((match) =>
|
||||
Object.values(match)
|
||||
.filter((capture) =>
|
||||
[
|
||||
'@scope.module',
|
||||
'@scope.namespace',
|
||||
'@scope.class',
|
||||
'@scope.function',
|
||||
'@scope.block',
|
||||
].includes(capture.name),
|
||||
)
|
||||
.map((capture) => ({ match, capture })),
|
||||
);
|
||||
const byOwner = new Map<CaptureMatch, NameClaim[]>();
|
||||
const add = (claim: NameClaim, functionOnly = false, callable = false): void => {
|
||||
let owner: (typeof scopes)[number] | undefined;
|
||||
for (const candidate of scopes) {
|
||||
if (functionOnly && candidate.capture.name !== '@scope.function') continue;
|
||||
if (
|
||||
callable &&
|
||||
candidate.capture.name === '@scope.function' &&
|
||||
compare(candidate.capture.range, claim.range) === 0 &&
|
||||
compare(end(candidate.capture.range), end(claim.range)) === 0
|
||||
)
|
||||
continue;
|
||||
if (!contains(candidate.capture.range, claim.range)) continue;
|
||||
if (owner === undefined || contains(owner.capture.range, candidate.capture.range))
|
||||
owner = candidate;
|
||||
}
|
||||
if (owner === undefined) return;
|
||||
const claims = byOwner.get(owner.match) ?? [];
|
||||
// A namespace using-declaration extends the existing overload set, while a
|
||||
// block using-declaration still hides functions declared in outer scopes.
|
||||
if (callable && !claims.some((existing) => existing.name === claim.name && existing.merge))
|
||||
return;
|
||||
claims.push(claim);
|
||||
byOwner.set(owner.match, claims);
|
||||
};
|
||||
for (const declaration of collectCppUsingDeclarations(filePath)) {
|
||||
if (declaration.name === undefined) continue;
|
||||
add({
|
||||
name: declaration.name,
|
||||
range: declaration.range,
|
||||
kind: 'binding',
|
||||
merge: true,
|
||||
availableFrom: end(declaration.range),
|
||||
inactive: 'outer',
|
||||
});
|
||||
}
|
||||
for (const match of captures) {
|
||||
const variable = match['@declaration.variable'];
|
||||
const parameter = match['@type-binding.lexical-parameter'];
|
||||
const callable = match['@declaration.function'];
|
||||
const anchor = variable ?? parameter ?? callable;
|
||||
const name = parameter !== undefined ? match['@type-binding.name'] : match['@declaration.name'];
|
||||
if (anchor === undefined || name === undefined) continue;
|
||||
add(
|
||||
{
|
||||
name: name.text,
|
||||
range: anchor.range,
|
||||
kind: 'binding',
|
||||
availableFrom: end(name.range),
|
||||
inactive: 'outer',
|
||||
...(callable === undefined ? {} : { merge: true }),
|
||||
},
|
||||
parameter !== undefined,
|
||||
callable !== undefined,
|
||||
);
|
||||
}
|
||||
for (const [match, claims] of byOwner) {
|
||||
const anchor = Object.values(match).find((capture) => capture.name.startsWith('@scope.'))!;
|
||||
(match as Record<string, Capture>)['@scope.name-claims'] = {
|
||||
name: '@scope.name-claims',
|
||||
range: anchor.range,
|
||||
text: JSON.stringify(claims),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function owningScope(parsed: ParsedFile, range: Range): Scope | undefined {
|
||||
let owner: Scope | undefined;
|
||||
for (const scope of parsed.scopes) {
|
||||
if (contains(scope.range, range) && (owner === undefined || contains(owner.range, scope.range)))
|
||||
owner = scope;
|
||||
}
|
||||
return owner;
|
||||
}
|
||||
|
||||
interface NamespaceNode {
|
||||
readonly scope: Scope;
|
||||
readonly inlineChildren: NamespaceNode[];
|
||||
}
|
||||
|
||||
function usingDeclarations(parsed: ParsedFile): readonly CppUsingDeclaration[] {
|
||||
return (
|
||||
(
|
||||
parsed.captureSideChannel as
|
||||
| { usingDeclarations?: readonly CppUsingDeclaration[] }
|
||||
| undefined
|
||||
)?.usingDeclarations ?? collectCppUsingDeclarations(parsed.filePath)
|
||||
);
|
||||
}
|
||||
|
||||
function createNamespaceLookup(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
getScope: (id: ScopeId) => Scope | undefined,
|
||||
) {
|
||||
const paths = new Map<ScopeId, string>();
|
||||
const nodes = new Map<ScopeId, NamespaceNode>();
|
||||
const namespaces = new Map<string, NamespaceNode[]>();
|
||||
const namespacePath = (scope: Scope): string => {
|
||||
const cached = paths.get(scope.id);
|
||||
if (cached !== undefined) return cached;
|
||||
const parts: string[] = [];
|
||||
let current: Scope | undefined = scope;
|
||||
while (current !== undefined) {
|
||||
if (current.kind === 'Namespace') {
|
||||
const name = current.ownedDefs.find((def) => def.type === 'Namespace')?.qualifiedName;
|
||||
if (name !== undefined) parts.unshift(name.split('.').pop()!);
|
||||
}
|
||||
current = current.parent === null ? undefined : getScope(current.parent);
|
||||
}
|
||||
const result = parts.join('.');
|
||||
paths.set(scope.id, result);
|
||||
return result;
|
||||
};
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const scope of parsed.scopes) {
|
||||
if (scope.kind !== 'Namespace' && scope.kind !== 'Module') continue;
|
||||
const path = namespacePath(scope);
|
||||
if (path === '' && scope.kind !== 'Module') continue;
|
||||
const node: NamespaceNode = { scope, inlineChildren: [] };
|
||||
nodes.set(scope.id, node);
|
||||
const group = namespaces.get(path) ?? [];
|
||||
group.push(node);
|
||||
namespaces.set(path, group);
|
||||
}
|
||||
}
|
||||
for (const node of nodes.values()) {
|
||||
if (node.scope.parent !== null && isCppInlineNamespaceScope(node.scope.id)) {
|
||||
nodes.get(node.scope.parent)?.inlineChildren.push(node);
|
||||
}
|
||||
}
|
||||
return { namespacePath, namespaces };
|
||||
}
|
||||
|
||||
function resolveUsingMembers(
|
||||
declaration: CppUsingDeclaration,
|
||||
owner: Scope,
|
||||
filePath: string,
|
||||
visible: ReadonlySet<string>,
|
||||
{ namespacePath, namespaces }: ReturnType<typeof createNamespaceLookup>,
|
||||
) {
|
||||
const raw = declaration.namespace.replaceAll('::', '.').replace(/^\./, '');
|
||||
let prefix = declaration.namespace.startsWith('::') ? '' : namespacePath(owner);
|
||||
let target: string | undefined;
|
||||
while (true) {
|
||||
const candidate = prefix === '' ? raw : `${prefix}.${raw}`;
|
||||
if (namespaces.has(candidate)) {
|
||||
target = candidate;
|
||||
break;
|
||||
}
|
||||
if (prefix === '') break;
|
||||
prefix = prefix.includes('.') ? prefix.slice(0, prefix.lastIndexOf('.')) : '';
|
||||
}
|
||||
if (target === undefined) return undefined;
|
||||
const members = new Map<string, BindingRef[]>();
|
||||
const pending = [...(namespaces.get(target) ?? [])];
|
||||
while (pending.length > 0) {
|
||||
const node = pending.pop()!;
|
||||
if (!visible.has(node.scope.filePath)) continue;
|
||||
pending.push(...node.inlineChildren);
|
||||
for (const [name, refs] of node.scope.bindings) {
|
||||
if (declaration.name !== undefined && declaration.name !== name) continue;
|
||||
const bucket = members.get(name) ?? [];
|
||||
for (const ref of refs) {
|
||||
if (ref.def.type === 'Namespace') continue;
|
||||
// A named using snapshots the declarations visible at that point.
|
||||
if (
|
||||
declaration.name !== undefined &&
|
||||
ref.def.filePath === filePath &&
|
||||
ref.declarationRange !== undefined &&
|
||||
compare(ref.declarationRange, declaration.range) > 0
|
||||
)
|
||||
continue;
|
||||
if (!bucket.some((entry) => entry.def.nodeId === ref.def.nodeId)) bucket.push(ref);
|
||||
}
|
||||
members.set(name, bucket);
|
||||
}
|
||||
}
|
||||
return { target, members };
|
||||
}
|
||||
|
||||
function createIncludeClosure(targetsOf: (filePath: string) => readonly string[]) {
|
||||
const visibleByFile = new Map<string, Set<string>>();
|
||||
return (filePath: string): Set<string> => {
|
||||
const cached = visibleByFile.get(filePath);
|
||||
if (cached !== undefined) return cached;
|
||||
const visible = new Set<string>();
|
||||
const pending = [filePath];
|
||||
while (pending.length > 0) {
|
||||
const file = pending.pop()!;
|
||||
if (visible.has(file)) continue;
|
||||
visible.add(file);
|
||||
pending.push(...targetsOf(file));
|
||||
}
|
||||
visibleByFile.set(filePath, visible);
|
||||
return visible;
|
||||
};
|
||||
}
|
||||
|
||||
/** Preserve unresolved header declarations in the ordinary pre-finalize scope facts. */
|
||||
export function populateCppIncludedUsingClaims(
|
||||
parsedFiles: ParsedFile[],
|
||||
resolveInclude: (parsed: ParsedFile, imported: ParsedImport) => string | readonly string[] | null,
|
||||
): void {
|
||||
const namedByFile = new Map<
|
||||
string,
|
||||
{ owner: Scope; declarations: readonly CppUsingDeclaration[] }
|
||||
>();
|
||||
for (const parsed of parsedFiles) {
|
||||
const declarations = usingDeclarations(parsed).filter(
|
||||
(declaration) =>
|
||||
declaration.name !== undefined &&
|
||||
owningScope(parsed, declaration.range)?.id === parsed.moduleScope,
|
||||
);
|
||||
if (declarations.length === 0) continue;
|
||||
const owner = parsed.scopes.find((scope) => scope.id === parsed.moduleScope);
|
||||
if (owner !== undefined) namedByFile.set(parsed.filePath, { owner, declarations });
|
||||
}
|
||||
if (namedByFile.size === 0) return;
|
||||
|
||||
const scopes = new Map(
|
||||
parsedFiles.flatMap((parsed) => parsed.scopes.map((scope) => [scope.id, scope] as const)),
|
||||
);
|
||||
const namespaceLookup = createNamespaceLookup(parsedFiles, (id) => scopes.get(id));
|
||||
const includes = new Map<string, { target: string; range: Range }[]>();
|
||||
for (const parsed of parsedFiles) {
|
||||
const edges: { target: string; range: Range }[] = [];
|
||||
for (const imported of parsed.parsedImports) {
|
||||
if (imported.kind !== 'wildcard' || imported.atRange === undefined) continue;
|
||||
const target = resolveInclude(parsed, imported);
|
||||
if (typeof target === 'string') edges.push({ target, range: imported.atRange });
|
||||
}
|
||||
includes.set(parsed.filePath, edges);
|
||||
}
|
||||
const visibleFiles = createIncludeClosure((filePath) =>
|
||||
(includes.get(filePath) ?? []).map((edge) => edge.target),
|
||||
);
|
||||
for (let index = 0; index < parsedFiles.length; index++) {
|
||||
const parsed = parsedFiles[index];
|
||||
const added: NameClaim[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const edge of includes.get(parsed.filePath) ?? []) {
|
||||
for (const includedFile of visibleFiles(edge.target)) {
|
||||
if (includedFile === parsed.filePath) continue;
|
||||
const named = namedByFile.get(includedFile);
|
||||
if (named === undefined) continue;
|
||||
for (const declaration of named.declarations) {
|
||||
const name = declaration.name!;
|
||||
const resolved = resolveUsingMembers(
|
||||
declaration,
|
||||
named.owner,
|
||||
includedFile,
|
||||
visibleFiles(parsed.filePath),
|
||||
namespaceLookup,
|
||||
);
|
||||
if ((resolved?.members.get(name)?.length ?? 0) > 0) continue;
|
||||
const key = `${name}:${edge.range.startLine}:${edge.range.startCol}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
added.push({
|
||||
name,
|
||||
kind: 'blocked',
|
||||
merge: true,
|
||||
range: edge.range,
|
||||
availableFrom: end(edge.range),
|
||||
inactive: 'outer',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (added.length === 0) continue;
|
||||
const module = parsed.scopes.find((scope) => scope.id === parsed.moduleScope);
|
||||
if (module === undefined) continue;
|
||||
const claims = [...(module.nameClaims ?? [])];
|
||||
// An inactive include claim must not erase earlier local declarations of
|
||||
// the same name. Give those bindings their existing declaration evidence;
|
||||
// the blocked include claim takes effect only at its later source position.
|
||||
for (const name of new Set(added.map((claim) => claim.name))) {
|
||||
for (const binding of module.bindings.get(name) ?? []) {
|
||||
const range = binding.declarationRange;
|
||||
if (
|
||||
range === undefined ||
|
||||
claims.some((claim) => claim.name === name && contains(claim.range, range))
|
||||
)
|
||||
continue;
|
||||
claims.push({
|
||||
name,
|
||||
kind: 'binding',
|
||||
merge: true,
|
||||
range,
|
||||
availableFrom: { startLine: range.startLine, startCol: range.startCol },
|
||||
inactive: 'outer',
|
||||
});
|
||||
}
|
||||
}
|
||||
// Extraction snapshots are frozen and may also live in the parse cache.
|
||||
// Replace only affected records before registries and ScopeTree are built.
|
||||
parsedFiles[index] = {
|
||||
...parsed,
|
||||
scopes: parsed.scopes.map((scope) =>
|
||||
scope.id === parsed.moduleScope ? { ...scope, nameClaims: [...claims, ...added] } : scope,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Materialize namespace identities once, then augment only the using's owner. */
|
||||
export function populateCppUsingBindings(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
indexes: ScopeResolutionIndexes,
|
||||
): void {
|
||||
if (!parsedFiles.some((parsed) => usingDeclarations(parsed).length > 0)) return;
|
||||
const namespaceLookup = createNamespaceLookup(parsedFiles, (id) =>
|
||||
indexes.scopeTree.getScope(id),
|
||||
);
|
||||
const { namespacePath } = namespaceLookup;
|
||||
const byFile = new Map(parsedFiles.map((parsed) => [parsed.filePath, parsed]));
|
||||
const visibleFiles = createIncludeClosure((filePath) => {
|
||||
const parsed = byFile.get(filePath);
|
||||
if (parsed === undefined) return [];
|
||||
return (indexes.imports.get(parsed.moduleScope) ?? []).flatMap((edge) =>
|
||||
edge.targetFile !== null && edge.linkStatus !== 'unresolved' ? [edge.targetFile] : [],
|
||||
);
|
||||
});
|
||||
const augmentations = indexes.bindingAugmentations as Map<ScopeId, Map<string, BindingRef[]>>;
|
||||
const append = (
|
||||
scope: Scope,
|
||||
name: string,
|
||||
refs: readonly BindingRef[],
|
||||
declaration: CppUsingDeclaration,
|
||||
): void => {
|
||||
let bindings = augmentations.get(scope.id);
|
||||
if (bindings === undefined) {
|
||||
bindings = new Map();
|
||||
augmentations.set(scope.id, bindings);
|
||||
}
|
||||
const bucket = bindings.get(name) ?? [];
|
||||
for (const ref of refs) {
|
||||
bucket.push({
|
||||
def: ref.def,
|
||||
origin: 'namespace',
|
||||
declarationRange: declaration.range,
|
||||
availableFrom: end(declaration.range),
|
||||
});
|
||||
}
|
||||
bindings.set(name, bucket);
|
||||
};
|
||||
for (const parsed of parsedFiles) {
|
||||
const facts = usingDeclarations(parsed);
|
||||
if (facts.length === 0) continue;
|
||||
const visible = visibleFiles(parsed.filePath);
|
||||
for (const declaration of facts) {
|
||||
const owner = owningScope(parsed, declaration.range);
|
||||
if (owner === undefined) continue;
|
||||
const resolved = resolveUsingMembers(
|
||||
declaration,
|
||||
owner,
|
||||
parsed.filePath,
|
||||
visible,
|
||||
namespaceLookup,
|
||||
);
|
||||
if (resolved === undefined) continue;
|
||||
const { target, members } = resolved;
|
||||
for (const [name, refs] of members) {
|
||||
if (declaration.name !== undefined) {
|
||||
append(owner, name, refs, declaration);
|
||||
continue;
|
||||
}
|
||||
// A directive contributes at the nearest common namespace. A nearer
|
||||
// ordinary declaration hides it; declarations in that namespace join
|
||||
// its overload/ambiguity set instead of being silently replaced.
|
||||
let current: Scope | undefined = owner;
|
||||
let hidden = false;
|
||||
const candidates = [...refs];
|
||||
while (current !== undefined) {
|
||||
const currentPath = namespacePath(current);
|
||||
const common =
|
||||
current.kind === 'Module' ||
|
||||
(current.kind === 'Namespace' &&
|
||||
(target === currentPath || target.startsWith(`${currentPath}.`)));
|
||||
const ordinary = (current.bindings.get(name) ?? []).filter(
|
||||
(ref) =>
|
||||
ref.declarationRange === undefined ||
|
||||
compare(ref.declarationRange, declaration.range) <= 0,
|
||||
);
|
||||
if (common) {
|
||||
candidates.push(...ordinary);
|
||||
break;
|
||||
}
|
||||
const typedLocal =
|
||||
current.typeBindings.has(name) &&
|
||||
current.nameClaims?.some(
|
||||
(claim) =>
|
||||
claim.name === name &&
|
||||
compare(claim.availableFrom ?? claim.range, declaration.range) <= 0,
|
||||
);
|
||||
if (ordinary.length > 0 || typedLocal) {
|
||||
hidden = true;
|
||||
break;
|
||||
}
|
||||
current =
|
||||
current.parent === null ? undefined : indexes.scopeTree.getScope(current.parent);
|
||||
}
|
||||
if (!hidden) append(owner, name, candidates, declaration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A module-scope using written in a header is part of the including
|
||||
// translation unit. Snapshot before propagation: include cycles and input
|
||||
// file order must not change the surface copied to another file.
|
||||
const moduleUsings = new Map<ScopeId, Map<string, BindingRef[]>>();
|
||||
for (const parsed of parsedFiles) {
|
||||
const bindings = augmentations.get(parsed.moduleScope);
|
||||
if (bindings !== undefined) {
|
||||
moduleUsings.set(
|
||||
parsed.moduleScope,
|
||||
new Map([...bindings].map(([name, refs]) => [name, [...refs]])),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (moduleUsings.size === 0) return;
|
||||
for (const parsed of parsedFiles) {
|
||||
const owner = indexes.scopeTree.getScope(parsed.moduleScope);
|
||||
if (owner === undefined) continue;
|
||||
const seenIncludes = new Set<string>();
|
||||
for (const edge of indexes.imports.get(parsed.moduleScope) ?? []) {
|
||||
if (
|
||||
edge.targetFile === null ||
|
||||
edge.linkStatus === 'unresolved' ||
|
||||
edge.atRange === undefined
|
||||
)
|
||||
continue;
|
||||
const key = `${edge.targetFile}:${edge.atRange.startLine}:${edge.atRange.startCol}`;
|
||||
if (seenIncludes.has(key)) continue;
|
||||
seenIncludes.add(key);
|
||||
for (const includedFile of visibleFiles(edge.targetFile)) {
|
||||
if (includedFile === parsed.filePath) continue;
|
||||
const included = byFile.get(includedFile);
|
||||
if (included === undefined) continue;
|
||||
for (const [name, refs] of moduleUsings.get(included.moduleScope) ?? []) {
|
||||
append(owner, name, refs, { namespace: '', range: edge.atRange });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -52,6 +52,7 @@ import { reducesToContainedType } from '../typescript/interpret.js';
|
|||
const JS_CLASS_FIELD_DEFINITION_TYPES: ReadonlySet<string> = new Set(['field_definition']);
|
||||
import { hasKeyword } from '../../field-extractors/configs/helpers.js';
|
||||
import { synthesizeCjsModuleExports } from '../typescript/cjs-module-exports.js';
|
||||
import { synthesizeTsLocalImports } from '../typescript/local-loaders.js';
|
||||
import {
|
||||
isShadowedCjsExportAssignment,
|
||||
isUnexportedMemberAssignmentValue,
|
||||
|
|
@ -248,104 +249,6 @@ function inferArgType(argNode: SyntaxNode): string {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── CJS require() decomposition ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Walk the AST and synthesize `@import.*` captures for CJS `require()` calls:
|
||||
*
|
||||
* - `const { X, Y } = require('./m')` → one match per destructured name,
|
||||
* `@import.kind = 'named'`, `@import.name = X / Y`.
|
||||
* - `const X = require('./m')` → `@import.kind = 'namespace'`,
|
||||
* `@import.alias = X` (the whole module is bound to X).
|
||||
* - `require('./m')` as a bare expression-statement → side-effect.
|
||||
*
|
||||
* CJS named-alias form (`const { X: alias } = require('./m')`) emits
|
||||
* `@import.kind = 'named-alias'` with `@import.name = X` and
|
||||
* `@import.alias = alias`.
|
||||
*
|
||||
* The synthesized markers are identical to those produced by
|
||||
* `splitImportStatement` for ESM, so `interpretJsImport` can delegate
|
||||
* unchanged to `interpretTsImport` for all cases.
|
||||
*/
|
||||
function synthesizeCjsImports(root: SyntaxNode, out: CaptureMatch[]): void {
|
||||
const stack: SyntaxNode[] = [root];
|
||||
for (;;) {
|
||||
const node = stack.pop();
|
||||
if (node === undefined) break;
|
||||
for (const child of node.namedChildren) {
|
||||
if (child !== null) stack.push(child);
|
||||
}
|
||||
|
||||
if (node.type !== 'call_expression') continue;
|
||||
|
||||
// Require call: function must be bare identifier "require".
|
||||
const fn = node.childForFieldName('function');
|
||||
if (fn === null || fn.type !== 'identifier' || fn.text !== 'require') continue;
|
||||
|
||||
const argsNode = node.childForFieldName('arguments');
|
||||
if (argsNode === null) continue;
|
||||
|
||||
// Source must be a string literal.
|
||||
const firstArg = argsNode.namedChild(0);
|
||||
if (firstArg === null || firstArg.type !== 'string') continue;
|
||||
const rawSource = firstArg.text; // includes surrounding quotes
|
||||
const source = firstArg.namedChild(0)?.text ?? rawSource.slice(1, -1);
|
||||
|
||||
const parent = node.parent;
|
||||
|
||||
// Case 1: const { X } = require('./m') OR const X = require('./m')
|
||||
if (parent?.type === 'variable_declarator') {
|
||||
const nameNode = parent.childForFieldName('name');
|
||||
if (nameNode === null) continue;
|
||||
|
||||
if (nameNode.type === 'object_pattern') {
|
||||
// Destructured: emit one match per specifier.
|
||||
for (const field of nameNode.namedChildren) {
|
||||
if (field === null) continue;
|
||||
if (field.type === 'shorthand_property_identifier_pattern') {
|
||||
const name = field.text;
|
||||
out.push({
|
||||
'@import.statement': syntheticCapture('@import.statement', node, rawSource),
|
||||
'@import.kind': syntheticCapture('@import.kind', node, 'named'),
|
||||
'@import.name': syntheticCapture('@import.name', field, name),
|
||||
'@import.source': syntheticCapture('@import.source', firstArg, source),
|
||||
});
|
||||
} else if (field.type === 'pair_pattern') {
|
||||
const key = field.childForFieldName('key');
|
||||
const value = field.childForFieldName('value');
|
||||
if (key === null || value === null || value.type !== 'identifier') continue;
|
||||
out.push({
|
||||
'@import.statement': syntheticCapture('@import.statement', node, rawSource),
|
||||
'@import.kind': syntheticCapture('@import.kind', node, 'named-alias'),
|
||||
'@import.name': syntheticCapture('@import.name', key, key.text),
|
||||
'@import.alias': syntheticCapture('@import.alias', value, value.text),
|
||||
'@import.source': syntheticCapture('@import.source', firstArg, source),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (nameNode.type === 'identifier') {
|
||||
// Namespace-style: const X = require('./m') → bind whole module to X.
|
||||
out.push({
|
||||
'@import.statement': syntheticCapture('@import.statement', node, rawSource),
|
||||
'@import.kind': syntheticCapture('@import.kind', node, 'namespace'),
|
||||
'@import.alias': syntheticCapture('@import.alias', nameNode, nameNode.text),
|
||||
'@import.source': syntheticCapture('@import.source', firstArg, source),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Case 2: bare require('./m') — side-effect import.
|
||||
if (parent?.type === 'expression_statement') {
|
||||
out.push({
|
||||
'@import.statement': syntheticCapture('@import.statement', node, rawSource),
|
||||
'@import.kind': syntheticCapture('@import.kind', node, 'side-effect'),
|
||||
'@import.source': syntheticCapture('@import.source', firstArg, source),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── JSDoc type binding synthesis ────────────────────────────────────────
|
||||
|
||||
interface JsDocParam {
|
||||
|
|
@ -973,18 +876,20 @@ export function emitJsScopeCaptures(
|
|||
sourceText: string,
|
||||
filePath: string,
|
||||
cachedTree?: unknown,
|
||||
sourceMeta?: { scriptLanguage?: string },
|
||||
): readonly CaptureMatch[] {
|
||||
const grammarPath = sourceMeta?.scriptLanguage === 'jsx' ? `${filePath}.jsx` : filePath;
|
||||
let tree = cachedTree as ReturnType<ReturnType<typeof getJsParser>['parse']> | undefined;
|
||||
if (tree !== undefined && !jsCachedTreeMatchesGrammar(tree)) {
|
||||
tree = undefined;
|
||||
}
|
||||
if (tree === undefined) {
|
||||
tree = parseSourceSafe(getJsParser(filePath), sourceText, undefined, {
|
||||
tree = parseSourceSafe(getJsParser(grammarPath), sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
}
|
||||
|
||||
const rawMatches = getJsScopeQuery(filePath).matches(tree.rootNode);
|
||||
const rawMatches = getJsScopeQuery(grammarPath).matches(tree.rootNode);
|
||||
// Export evidence, read once per file (see `ts-js-export-marker.ts`).
|
||||
const exportEvidence = collectEsmExportEvidence(tree.rootNode, filePath);
|
||||
const out: CaptureMatch[] = [];
|
||||
|
|
@ -1257,8 +1162,8 @@ export function emitJsScopeCaptures(
|
|||
}
|
||||
|
||||
// Post-query synthesis passes.
|
||||
synthesizeCjsImports(tree.rootNode, out);
|
||||
synthesizeCjsModuleExports(tree.rootNode, filePath, out);
|
||||
const validRequireCalls = synthesizeTsLocalImports(tree.rootNode, filePath, out);
|
||||
synthesizeCjsModuleExports(tree.rootNode, filePath, out, validRequireCalls);
|
||||
synthesizeJsDocBindings(tree.rootNode, out);
|
||||
synthesizeConstructorFieldBindings(tree.rootNode, out);
|
||||
synthesizeDestructuringBindings(tree.rootNode, out);
|
||||
|
|
|
|||
|
|
@ -30,15 +30,13 @@
|
|||
* and `@type {T}` on variable declarations are synthesized. `@typedef`
|
||||
* is not yet synthesized (tracked in #1646).
|
||||
* 2. **CJS chained destructuring** — `const { X: { Y } } = require(...)`
|
||||
* (nested destructuring) emits only the outer `X` binding; `Y` is not
|
||||
* (nested destructuring) creates no imported alias; `Y` is not
|
||||
* resolved.
|
||||
* 3. **Dynamic require** — `require(computedPath)` is skipped (non-literal
|
||||
* argument — cannot statically resolve the target).
|
||||
* 4. **CommonJS `require()` in TypeScript** — `require()` decomposition is a
|
||||
* JavaScript-emitter concern, so a `.ts` file's `const m = require('./m')`
|
||||
* is not decomposed into an import. The EXPORT side of a `.ts` CommonJS
|
||||
* module is now declared (shared with the JS emitter), but an importer
|
||||
* written in TypeScript still cannot resolve through it.
|
||||
* 4. **Indirect loaders** — computed sources, unawaited import promises,
|
||||
* default/rest binding targets and indirect loader chains are not module
|
||||
* aliases. TS/TSX, JS/JSX and Vue share the static local-loader pass.
|
||||
* 5. **`.cjs` module-level `this`** — the CommonJS/ESM gate consults the file
|
||||
* extension where it can, but `provider.labelOverride` receives no file
|
||||
* path, so a `.cjs` file whose only export is a module-level `this.X = fn`
|
||||
|
|
|
|||
|
|
@ -139,6 +139,12 @@ export const JAVASCRIPT_SCOPE_QUERY = `
|
|||
name: (identifier) @declaration.name
|
||||
value: (function_expression) @declaration.function))
|
||||
|
||||
;; Generator expressions have the same callable binding and scope anchor.
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (generator_function) @declaration.function))
|
||||
|
||||
(export_statement
|
||||
declaration: (lexical_declaration
|
||||
(variable_declarator
|
||||
|
|
@ -161,6 +167,11 @@ export const JAVASCRIPT_SCOPE_QUERY = `
|
|||
name: (identifier) @declaration.name
|
||||
value: (function_expression) @declaration.function))
|
||||
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (generator_function) @declaration.function))
|
||||
|
||||
;; CJS property-assignment exports (#2723): \`exports.foo = function () {}\`,
|
||||
;; \`module.exports.foo = (a) => a\`. The graph node for these comes from
|
||||
;; TYPESCRIPT/JAVASCRIPT_QUERIES; this block is the other half — without a
|
||||
|
|
@ -728,8 +739,7 @@ export const JAVASCRIPT_SCOPE_QUERY = `
|
|||
|
||||
(for_in_statement
|
||||
left: (identifier) @type-binding.name
|
||||
right: (member_expression
|
||||
property: (property_identifier) @type-binding.type)) @type-binding.alias
|
||||
right: (member_expression) @type-binding.type) @type-binding.alias
|
||||
|
||||
;; ── References ────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -951,7 +961,7 @@ export function getJsScopeQuery(filePath?: string): Parser.Query {
|
|||
/** Validate that a cached Tree was produced by the JS grammar. */
|
||||
export function jsCachedTreeMatchesGrammar(tree: unknown): boolean {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const lang = (tree as any)?.getLanguage?.();
|
||||
const lang = (tree as any)?.getLanguage?.() ?? (tree as any)?.language;
|
||||
if (lang === undefined || lang === null) return true;
|
||||
return lang === JS_GRAMMAR;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ const javascriptScopeResolver: ScopeResolver = {
|
|||
language: SupportedLanguages.JavaScript,
|
||||
languageProvider: javascriptProvider,
|
||||
importEdgeReason: 'javascript-scope: import',
|
||||
importsBindAtLexicalScope: true,
|
||||
ownedMembersBindAtModuleScope: false,
|
||||
|
||||
resolveImportTarget: makeJsResolveImportTarget(),
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
|||
import { isPythonGlobalDeclaration, pythonFunctionDefinitionLabel } from './simple-hooks.js';
|
||||
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
|
||||
import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
|
||||
import { applyPythonLexicalBindings } from './lexical-bindings.js';
|
||||
import {
|
||||
beginPythonSubtypeDispatchCapture,
|
||||
recordPythonSimplePositionalCall,
|
||||
|
|
@ -286,10 +287,16 @@ export function emitPythonScopeCaptures(
|
|||
out.push(...synthesizePythonInheritanceReferences(tree.rootNode));
|
||||
out.push(...synthesizeCallableFlowCaptures(tree.rootNode, PYTHON_CALLABLE_CAPTURE_OPTIONS));
|
||||
|
||||
const lexicalCaptures = applyPythonLexicalBindings(
|
||||
out,
|
||||
tree.rootNode,
|
||||
filePath,
|
||||
notebookSegments === undefined ? undefined : (range) => remapRange(range, notebookSegments),
|
||||
);
|
||||
if (notebookSegments !== undefined) {
|
||||
return out.map((match) => remapCaptureMatch(match, notebookSegments));
|
||||
return lexicalCaptures.map((match) => remapCaptureMatch(match, notebookSegments));
|
||||
}
|
||||
return out;
|
||||
return lexicalCaptures;
|
||||
}
|
||||
|
||||
function resolveNotebookCaptureSource(
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export function synthesizeDependsReferences(fnNode: SyntaxNode): readonly Captur
|
|||
results.push({
|
||||
'@reference.call.free': nodeToCapture('@reference.call.free', firstArg),
|
||||
'@reference.name': nodeToCapture('@reference.name', firstArg),
|
||||
'@reference.caller-function': nodeToCapture('@reference.caller-function', fnNode),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
|
@ -63,6 +64,7 @@ export function synthesizeDependsReferences(fnNode: SyntaxNode): readonly Captur
|
|||
'@reference.call.member': nodeToCapture('@reference.call.member', attrName),
|
||||
'@reference.name': nodeToCapture('@reference.name', attrName),
|
||||
'@reference.receiver': nodeToCapture('@reference.receiver', obj),
|
||||
'@reference.caller-function': nodeToCapture('@reference.caller-function', fnNode),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
491
gitnexus/src/core/ingestion/languages/python/lexical-bindings.ts
Normal file
491
gitnexus/src/core/ingestion/languages/python/lexical-bindings.ts
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
/** Compiler binding ownership, kept separate from successful symbol extraction. */
|
||||
import {
|
||||
makeScopeId,
|
||||
type Capture,
|
||||
type CaptureMatch,
|
||||
type NameClaim,
|
||||
type Range,
|
||||
type ScopeKind,
|
||||
} from 'gitnexus-shared';
|
||||
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { splitImportStatement } from './import-decomposer.js';
|
||||
import { interpretPythonImport } from './interpret.js';
|
||||
|
||||
const COMPREHENSIONS = new Set([
|
||||
'list_comprehension',
|
||||
'set_comprehension',
|
||||
'dictionary_comprehension',
|
||||
'generator_expression',
|
||||
]);
|
||||
const CONDITIONAL = new Set([
|
||||
'if_statement',
|
||||
'for_statement',
|
||||
'while_statement',
|
||||
'try_statement',
|
||||
'match_statement',
|
||||
]);
|
||||
interface Environment {
|
||||
node: SyntaxNode;
|
||||
kind: ScopeKind;
|
||||
parent?: Environment;
|
||||
claims: NameClaim[];
|
||||
directives: Map<string, 'module' | 'enclosing-function'>;
|
||||
imports: Map<string, Array<{ identity: string; packageRoot?: string; claim: NameClaim }>>;
|
||||
}
|
||||
interface Event {
|
||||
env: Environment;
|
||||
node: SyntaxNode;
|
||||
name: string;
|
||||
kind: NameClaim['kind'];
|
||||
declaration?: boolean;
|
||||
annotationOnly?: boolean;
|
||||
deletion?: boolean;
|
||||
hoisted?: boolean;
|
||||
conditional?: boolean;
|
||||
identity?: string;
|
||||
packageRoot?: string;
|
||||
}
|
||||
|
||||
/** Attach plain-data facts only: no syntax nodes survive worker transport. */
|
||||
export function applyPythonLexicalBindings(
|
||||
matches: readonly CaptureMatch[],
|
||||
root: SyntaxNode,
|
||||
filePath: string,
|
||||
mapRange: (range: Range) => Range = (range) => range,
|
||||
): CaptureMatch[] {
|
||||
const environments: Environment[] = [];
|
||||
const environmentsByRange = new Map<string, Environment>();
|
||||
const byNode = new Map<number, Environment>();
|
||||
const byRange = new Map<string, SyntaxNode>();
|
||||
const events: Event[] = [];
|
||||
const annotationOnly = (node: SyntaxNode): boolean =>
|
||||
node.type === 'assignment' &&
|
||||
node.childForFieldName('type') !== null &&
|
||||
node.childForFieldName('right') === null;
|
||||
const range = (node: SyntaxNode): Range => ({
|
||||
startLine: node.startPosition.row + 1,
|
||||
startCol: node.startPosition.column,
|
||||
endLine: node.endPosition.row + 1,
|
||||
endCol: node.endPosition.column,
|
||||
});
|
||||
const key = (r: Range): string => `${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`;
|
||||
const id = (env: Environment) =>
|
||||
makeScopeId({ filePath, kind: env.kind, range: mapRange(range(env.node)) });
|
||||
const mark = (node: SyntaxNode, env: Environment): void => {
|
||||
byNode.set(node.id, env);
|
||||
byRange.set(key(range(node)), node);
|
||||
};
|
||||
const create = (node: SyntaxNode, kind: ScopeKind, parent?: Environment): Environment => {
|
||||
const env: Environment = {
|
||||
node,
|
||||
kind,
|
||||
parent,
|
||||
claims: [],
|
||||
directives: new Map(),
|
||||
imports: new Map(),
|
||||
};
|
||||
environments.push(env);
|
||||
environmentsByRange.set(`${kind}:${key(range(node))}`, env);
|
||||
mark(node, env);
|
||||
return env;
|
||||
};
|
||||
const bind = (
|
||||
env: Environment,
|
||||
node: SyntaxNode | null,
|
||||
kind: NameClaim['kind'] = 'binding',
|
||||
hoisted = false,
|
||||
conditional = false,
|
||||
anchor?: SyntaxNode,
|
||||
): void => {
|
||||
if (node === null) return;
|
||||
if (node.type === 'identifier') {
|
||||
events.push({ env, node: anchor ?? node, name: node.text, kind, hoisted, conditional });
|
||||
return;
|
||||
}
|
||||
if (['attribute', 'subscript', 'type'].includes(node.type)) return;
|
||||
for (const child of node.namedChildren) bind(env, child, kind, hoisted, conditional, anchor);
|
||||
};
|
||||
const pattern = (env: Environment, node: SyntaxNode): void => {
|
||||
if (node.type === 'dotted_name') {
|
||||
if (node.namedChildCount === 1 && node.text !== '_') bind(env, node.firstNamedChild);
|
||||
return;
|
||||
}
|
||||
if (node.type === 'identifier') {
|
||||
if (node.text !== '_') bind(env, node);
|
||||
return;
|
||||
}
|
||||
if (node.type === 'class_pattern') {
|
||||
for (const child of node.namedChildren.slice(1)) pattern(env, child);
|
||||
return;
|
||||
}
|
||||
if (node.type === 'keyword_pattern') {
|
||||
for (const child of node.namedChildren.slice(1)) pattern(env, child);
|
||||
return;
|
||||
}
|
||||
if (node.type === 'dict_pattern') {
|
||||
for (const child of node.childrenForFieldName('value')) pattern(env, child);
|
||||
for (const child of node.namedChildren)
|
||||
if (child.type === 'splat_pattern') pattern(env, child);
|
||||
return;
|
||||
}
|
||||
if (['string', 'concatenated_string', 'attribute'].includes(node.type)) return;
|
||||
for (const child of node.namedChildren) pattern(env, child);
|
||||
};
|
||||
const visit = (node: SyntaxNode, env: Environment, conditional = false): void => {
|
||||
mark(node, env);
|
||||
if (
|
||||
node.type === 'function_definition' ||
|
||||
node.type === 'lambda' ||
|
||||
node.type === 'class_definition'
|
||||
) {
|
||||
const isClass = node.type === 'class_definition';
|
||||
if (node.type !== 'lambda') {
|
||||
const name = node.childForFieldName('name');
|
||||
if (name)
|
||||
events.push({
|
||||
env,
|
||||
node,
|
||||
name: name.text,
|
||||
kind: 'binding',
|
||||
declaration: true,
|
||||
conditional,
|
||||
});
|
||||
}
|
||||
const childEnv = create(node, isClass ? 'Class' : 'Function', env);
|
||||
const parameters = node.childForFieldName('parameters');
|
||||
if (parameters) {
|
||||
mark(parameters, childEnv);
|
||||
for (const parameter of parameters.namedChildren) {
|
||||
mark(parameter, childEnv);
|
||||
const target =
|
||||
parameter.childForFieldName('name') ??
|
||||
(parameter.type === 'identifier' ? parameter : parameter.firstNamedChild);
|
||||
if (target && !['positional_separator', 'keyword_separator'].includes(parameter.type))
|
||||
bind(childEnv, target, 'binding', true);
|
||||
const defaultValue = parameter.childForFieldName('value');
|
||||
if (defaultValue) visit(defaultValue, env, conditional);
|
||||
const annotation = parameter.childForFieldName('type');
|
||||
if (annotation) visit(annotation, env, conditional);
|
||||
}
|
||||
}
|
||||
const body = node.childForFieldName('body');
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.id === parameters?.id || child.id === node.childForFieldName('name')?.id)
|
||||
continue;
|
||||
visit(
|
||||
child,
|
||||
child.id === body?.id ? childEnv : env,
|
||||
child.id === body?.id ? false : conditional,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (COMPREHENSIONS.has(node.type)) {
|
||||
const childEnv = create(node, 'Expression', env);
|
||||
const firstFor = node.namedChildren.find((child) => child.type === 'for_in_clause');
|
||||
const firstIterable = firstFor?.childForFieldName('right');
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type === 'for_in_clause') {
|
||||
mark(child, childEnv);
|
||||
bind(childEnv, child.childForFieldName('left'), 'binding', true);
|
||||
for (const part of child.namedChildren)
|
||||
visit(part, part.id === firstIterable?.id ? env : childEnv, conditional);
|
||||
} else visit(child, childEnv, conditional);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node.type === 'global_statement' || node.type === 'nonlocal_statement') {
|
||||
for (const child of node.namedChildren) {
|
||||
if (env.kind !== 'Module' && child.type === 'identifier')
|
||||
env.directives.set(
|
||||
child.text,
|
||||
node.type === 'global_statement' ? 'module' : 'enclosing-function',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node.type === 'import_statement' || node.type === 'import_from_statement') {
|
||||
for (const match of splitImportStatement(node)) {
|
||||
const imp = interpretPythonImport(match);
|
||||
if (imp && 'localName' in imp && imp.localName !== '') {
|
||||
events.push({
|
||||
env,
|
||||
node,
|
||||
name: imp.localName,
|
||||
kind: 'import',
|
||||
conditional,
|
||||
identity: JSON.stringify([
|
||||
imp.kind,
|
||||
imp.targetRaw,
|
||||
'importedName' in imp ? imp.importedName : '',
|
||||
'explicitAlias' in imp && imp.explicitAlias,
|
||||
]),
|
||||
...(imp.kind === 'namespace' && !imp.explicitAlias
|
||||
? { packageRoot: imp.localName }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node.type === 'assignment' || node.type === 'augmented_assignment') {
|
||||
const first = events.length;
|
||||
bind(env, node.childForFieldName('left'), 'binding', false, conditional, node);
|
||||
if (annotationOnly(node))
|
||||
for (let i = first; i < events.length; i++) events[i]!.annotationOnly = true;
|
||||
}
|
||||
if (node.type === 'for_statement') bind(env, node.childForFieldName('left'));
|
||||
if (node.type === 'delete_statement') {
|
||||
const first = events.length;
|
||||
for (const child of node.namedChildren) bind(env, child, 'blocked');
|
||||
for (let i = first; i < events.length; i++) events[i]!.deletion = true;
|
||||
}
|
||||
if (node.type === 'named_expression') {
|
||||
let owner = env;
|
||||
while (COMPREHENSIONS.has(owner.node.type) && owner.parent) owner = owner.parent;
|
||||
bind(owner, node.childForFieldName('name'));
|
||||
}
|
||||
if (node.type === 'as_pattern' && node.childForFieldName('alias'))
|
||||
bind(env, node.childForFieldName('alias'));
|
||||
if (node.type === 'case_clause')
|
||||
for (const child of node.namedChildren)
|
||||
if (child.type === 'case_pattern') pattern(env, child);
|
||||
if (node.type === 'type_alias_statement') {
|
||||
const first = node.firstNamedChild;
|
||||
let target = first?.firstNamedChild;
|
||||
if (target?.type === 'generic_type') target = target.firstNamedChild;
|
||||
if (target?.type === 'identifier') bind(env, target, 'blocked');
|
||||
}
|
||||
const childConditional = conditional || CONDITIONAL.has(node.type);
|
||||
for (const child of node.namedChildren) visit(child, env, childConditional);
|
||||
};
|
||||
const module = create(root, 'Module');
|
||||
for (const child of root.namedChildren) visit(child, module);
|
||||
|
||||
const localNames = new Map<Environment, Set<string>>();
|
||||
const storedNames = new Map<Environment, Set<string>>();
|
||||
const definiteDeclarations = new Set<NameClaim>();
|
||||
for (const event of events) {
|
||||
const names = localNames.get(event.env) ?? new Set<string>();
|
||||
names.add(event.name);
|
||||
localNames.set(event.env, names);
|
||||
if (!event.annotationOnly) {
|
||||
const stores = storedNames.get(event.env) ?? new Set<string>();
|
||||
stores.add(event.name);
|
||||
storedNames.set(event.env, stores);
|
||||
}
|
||||
}
|
||||
const ownerFor = (env: Environment, name: string): Environment => {
|
||||
const redirect = env.directives.get(name);
|
||||
if (redirect === 'module') return module;
|
||||
if (redirect === 'enclosing-function') {
|
||||
for (let parent = env.parent; parent; parent = parent.parent) {
|
||||
if (
|
||||
parent.kind === 'Function' &&
|
||||
!parent.directives.has(name) &&
|
||||
localNames.get(parent)?.has(name)
|
||||
)
|
||||
return parent;
|
||||
}
|
||||
}
|
||||
return env;
|
||||
};
|
||||
for (const env of environments) {
|
||||
for (const [name, redirect] of env.directives)
|
||||
env.claims.push({
|
||||
name,
|
||||
kind: 'blocked',
|
||||
hoisted: true,
|
||||
range: mapRange(range(env.node)),
|
||||
redirect,
|
||||
});
|
||||
}
|
||||
for (const event of events) {
|
||||
// An annotation records a compiler local, but never overwrites a value.
|
||||
// Keep existing imports/declarations when that name has a runtime store.
|
||||
if (event.annotationOnly && storedNames.get(event.env)?.has(event.name)) continue;
|
||||
const owner = ownerFor(event.env, event.name);
|
||||
const eventRange = mapRange(range(event.node));
|
||||
const claim: NameClaim = {
|
||||
name: event.name,
|
||||
// Imports under guards (including TYPE_CHECKING) still provide static
|
||||
// references. The identity/mixed-binding pass below blocks ambiguity.
|
||||
kind: event.annotationOnly && owner.kind === 'Class' ? 'blocked' : event.kind,
|
||||
range: eventRange,
|
||||
...(event.hoisted || event.annotationOnly
|
||||
? { hoisted: true }
|
||||
: { availableFrom: { startLine: eventRange.endLine, startCol: eventRange.endCol } }),
|
||||
inactive: owner.kind === 'Class' ? 'module' : 'blocked',
|
||||
...((event.deletion || event.annotationOnly) && owner.kind === 'Class'
|
||||
? { redirect: 'module' }
|
||||
: {}),
|
||||
};
|
||||
const redirected = owner !== event.env;
|
||||
if (redirected)
|
||||
event.env.claims.push({ ...claim, redirect: event.env.directives.get(event.name) });
|
||||
const ownerClaim: NameClaim =
|
||||
redirected && !event.declaration ? { ...claim, kind: 'blocked' } : claim;
|
||||
owner.claims.push(ownerClaim);
|
||||
if (event.declaration && !event.conditional) definiteDeclarations.add(ownerClaim);
|
||||
if (event.identity) {
|
||||
const imports = owner.imports.get(event.name) ?? [];
|
||||
imports.push({ identity: event.identity, packageRoot: event.packageRoot, claim: ownerClaim });
|
||||
owner.imports.set(event.name, imports);
|
||||
}
|
||||
}
|
||||
for (const env of environments) {
|
||||
for (const [name, imports] of env.imports) {
|
||||
const first = imports[0]!;
|
||||
const compatible = imports.every(
|
||||
(imp) =>
|
||||
imp.identity === first.identity ||
|
||||
(first.packageRoot !== undefined && imp.packageRoot === first.packageRoot),
|
||||
);
|
||||
const mixed = env.claims.some(
|
||||
(claim) =>
|
||||
claim.name === name &&
|
||||
claim.redirect === undefined &&
|
||||
!imports.some((imp) => imp.claim === claim),
|
||||
);
|
||||
// Conflicting imports remain uncertain, but a definite def/class store
|
||||
// still supplies its own local target after the declaration executes.
|
||||
env.claims = env.claims.map((claim) =>
|
||||
claim.name !== name || claim.redirect !== undefined || definiteDeclarations.has(claim)
|
||||
? claim
|
||||
: !compatible || mixed
|
||||
? { ...claim, kind: 'blocked' }
|
||||
: { ...claim, merge: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
const environmentAt = (node: SyntaxNode): Environment => {
|
||||
for (let current: SyntaxNode | null = node; current; current = current.parent) {
|
||||
const env = byNode.get(current.id);
|
||||
if (env) return env;
|
||||
}
|
||||
return module;
|
||||
};
|
||||
const policyFor = (env: Environment) => ({
|
||||
callerScopeIsAuthoritative: true,
|
||||
...(env.kind === 'Class' ? { skipFromChildren: true } : {}),
|
||||
...(env.kind === 'Function' || env.node.type === 'generator_expression'
|
||||
? { deferParentActivation: true }
|
||||
: {}),
|
||||
...(env.parent ? { parentScope: id(env.parent) } : {}),
|
||||
});
|
||||
const decorateScope = (match: CaptureMatch, env: Environment): CaptureMatch => ({
|
||||
...match,
|
||||
'@scope.name-claims': syntheticCapture(
|
||||
'@scope.name-claims',
|
||||
env.node,
|
||||
JSON.stringify(env.claims),
|
||||
),
|
||||
'@scope.lookup-policy': syntheticCapture(
|
||||
'@scope.lookup-policy',
|
||||
env.node,
|
||||
JSON.stringify(policyFor(env)),
|
||||
),
|
||||
});
|
||||
const out = matches
|
||||
.filter((match) => {
|
||||
const declaration = match['@declaration.variable'];
|
||||
const node = declaration && byRange.get(key(declaration.range));
|
||||
const name = match['@declaration.name']?.text;
|
||||
// Keep standalone annotations as structural fields/type declarations.
|
||||
// They must not replace an import in the local-over-import merge: an
|
||||
// annotation with no initializer does not store a new runtime value.
|
||||
return (
|
||||
node === undefined ||
|
||||
!annotationOnly(node) ||
|
||||
name === undefined ||
|
||||
!environmentAt(node).imports.has(name)
|
||||
);
|
||||
})
|
||||
.map((match): CaptureMatch => {
|
||||
const scopeAnchor =
|
||||
match['@scope.module'] ?? match['@scope.class'] ?? match['@scope.function'];
|
||||
if (scopeAnchor) {
|
||||
// A file consisting of exactly one definition can share its range with
|
||||
// that child. Physical extent alone does not identify an environment.
|
||||
const kind = match['@scope.module']
|
||||
? 'Module'
|
||||
: match['@scope.class']
|
||||
? 'Class'
|
||||
: 'Function';
|
||||
const env = environmentsByRange.get(`${kind}:${key(scopeAnchor.range)}`);
|
||||
if (env) return decorateScope(match, env);
|
||||
}
|
||||
// A framework dependency is invoked for the handler, but its callable
|
||||
// is evaluated in the default expression's enclosing environment.
|
||||
// Keep the synthetic caller marker out of lexical anchor selection.
|
||||
const { '@reference.caller-function': callerFunction, ...referenceMatch } = match;
|
||||
const entries = Object.values(referenceMatch);
|
||||
const anchor = entries.reduce<Capture | undefined>(
|
||||
(best, cap) =>
|
||||
!best ||
|
||||
cap.range.endLine - cap.range.startLine > best.range.endLine - best.range.startLine ||
|
||||
(cap.range.endLine - cap.range.startLine === best.range.endLine - best.range.startLine &&
|
||||
cap.range.endCol - cap.range.startCol > best.range.endCol - best.range.startCol)
|
||||
? cap
|
||||
: best,
|
||||
undefined,
|
||||
);
|
||||
if (!anchor) return referenceMatch;
|
||||
const node = byRange.get(key(anchor.range));
|
||||
if (!node) return referenceMatch;
|
||||
const env = environmentAt(node);
|
||||
const extra: Record<string, Capture> = {};
|
||||
if (callerFunction) {
|
||||
const caller = environmentsByRange.get(`Function:${key(callerFunction.range)}`);
|
||||
if (caller)
|
||||
extra['@reference.caller-scope'] = syntheticCapture(
|
||||
'@reference.caller-scope',
|
||||
node,
|
||||
id(caller),
|
||||
);
|
||||
}
|
||||
if (entries.some((cap) => cap.name.startsWith('@reference.')))
|
||||
extra['@reference.lookup-scope'] = syntheticCapture(
|
||||
'@reference.lookup-scope',
|
||||
node,
|
||||
id(env),
|
||||
);
|
||||
const declarationName = match['@declaration.name']?.text;
|
||||
const typeName = match['@type-binding.name']?.text;
|
||||
if (declarationName) {
|
||||
const declarationEnv = env.node.id === node.id && env.parent ? env.parent : env;
|
||||
extra['@binding.scope'] = syntheticCapture(
|
||||
'@binding.scope',
|
||||
node,
|
||||
id(ownerFor(declarationEnv, declarationName)),
|
||||
);
|
||||
} else if (
|
||||
typeName &&
|
||||
!match['@type-binding.instance-field'] &&
|
||||
!match['@type-binding.self'] &&
|
||||
!match['@type-binding.cls']
|
||||
) {
|
||||
let owner = match['@type-binding.return'] && env.parent ? env.parent : env;
|
||||
if (node.type === 'named_expression')
|
||||
while (COMPREHENSIONS.has(owner.node.type) && owner.parent) owner = owner.parent;
|
||||
extra['@binding.scope'] = syntheticCapture(
|
||||
'@binding.scope',
|
||||
node,
|
||||
id(ownerFor(owner, typeName)),
|
||||
);
|
||||
const lookupEnv =
|
||||
(match['@type-binding.parameter'] || match['@type-binding.return']) && env.parent
|
||||
? env.parent
|
||||
: env;
|
||||
extra['@type.lookup-scope'] = syntheticCapture('@type.lookup-scope', node, id(lookupEnv));
|
||||
}
|
||||
return { ...referenceMatch, ...extra };
|
||||
});
|
||||
for (const env of environments) {
|
||||
if (COMPREHENSIONS.has(env.node.type))
|
||||
out.push(
|
||||
decorateScope({ '@scope.expression': nodeToCapture('@scope.expression', env.node) }, env),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -13,14 +13,18 @@
|
|||
import type { CaptureMatch } from 'gitnexus-shared';
|
||||
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { decoratorNames, isBuiltinDescriptor } from './builtin-descriptors.js';
|
||||
import { isPythonGlobalDeclaration } from './simple-hooks.js';
|
||||
import { isPythonGlobalDeclaration, isPythonNonlocalDeclaration } from './simple-hooks.js';
|
||||
|
||||
/** Walk up to the enclosing `class_definition`, ignoring the immediate
|
||||
* `decorated_definition` wrapper. Returns `null` when the function is
|
||||
* free, lambda-bodied, or nested inside another function. */
|
||||
function findEnclosingClassDefinition(node: SyntaxNode): SyntaxNode | null {
|
||||
const name = node.childForFieldName('name')?.text;
|
||||
if (name !== undefined && isPythonGlobalDeclaration(node, name)) return null;
|
||||
if (
|
||||
name !== undefined &&
|
||||
(isPythonGlobalDeclaration(node, name) || isPythonNonlocalDeclaration(node, name))
|
||||
)
|
||||
return null;
|
||||
let cur: SyntaxNode | null = node.parent;
|
||||
while (cur !== null) {
|
||||
if (cur.type === 'class_definition') return cur;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { walkToScope } from '../../utils/scope-tree-walk.js';
|
|||
* in the cache so repeated declaration/receiver classification scans each tree
|
||||
* once, without retaining its AST after the tree is released. */
|
||||
const globalsByTree = new WeakMap<object, ReadonlyMap<number, ReadonlySet<string>>>();
|
||||
const nonlocalsByTree = new WeakMap<object, ReadonlyMap<number, ReadonlySet<string>>>();
|
||||
|
||||
function enclosingPythonCodeBlock(node: SyntaxNode): SyntaxNode | null {
|
||||
let enclosing = node.parent;
|
||||
|
|
@ -64,6 +65,28 @@ export function isPythonGlobalDeclaration(node: SyntaxNode, name: string): boole
|
|||
return globals.get(owner.id)?.has(name) ?? false;
|
||||
}
|
||||
|
||||
/** A class suite may store a declaration in an enclosing function cell. */
|
||||
export function isPythonNonlocalDeclaration(node: SyntaxNode, name: string): boolean {
|
||||
const owner = enclosingPythonCodeBlock(node);
|
||||
if (owner === null || owner.type === 'module') return false;
|
||||
let nonlocals = nonlocalsByTree.get(node.tree);
|
||||
if (nonlocals === undefined) {
|
||||
const byBlock = new Map<number, Set<string>>();
|
||||
for (const statement of node.tree.rootNode.descendantsOfType('nonlocal_statement')) {
|
||||
const block = enclosingPythonCodeBlock(statement);
|
||||
if (block === null || block.type === 'module') continue;
|
||||
const names = byBlock.get(block.id) ?? new Set<string>();
|
||||
for (const identifier of statement.namedChildren) {
|
||||
if (identifier.type === 'identifier') names.add(identifier.text);
|
||||
}
|
||||
byBlock.set(block.id, names);
|
||||
}
|
||||
nonlocals = byBlock;
|
||||
nonlocalsByTree.set(node.tree, nonlocals);
|
||||
}
|
||||
return nonlocals.get(owner.id)?.has(name) ?? false;
|
||||
}
|
||||
|
||||
export function pythonFunctionDefinitionLabel(
|
||||
functionNode: SyntaxNode,
|
||||
defaultLabel: NodeLabel,
|
||||
|
|
@ -71,7 +94,9 @@ export function pythonFunctionDefinitionLabel(
|
|||
if (defaultLabel !== 'Function') return defaultLabel;
|
||||
if (enclosingPythonCodeBlock(functionNode)?.type !== 'class_definition') return 'Function';
|
||||
const name = functionNode.childForFieldName('name')?.text;
|
||||
return name !== undefined && isPythonGlobalDeclaration(functionNode, name)
|
||||
return name !== undefined &&
|
||||
(isPythonGlobalDeclaration(functionNode, name) ||
|
||||
isPythonNonlocalDeclaration(functionNode, name))
|
||||
? 'Function'
|
||||
: 'Method';
|
||||
}
|
||||
|
|
@ -93,6 +118,8 @@ export function pythonBindingScopeFor(
|
|||
if (decl['@type-binding.instance-field'] !== undefined) {
|
||||
return walkToScope(innermost, tree, 'Class');
|
||||
}
|
||||
const explicitOwner = decl['@binding.scope']?.text;
|
||||
if (explicitOwner !== undefined && tree.has(explicitOwner)) return explicitOwner;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -103,10 +130,29 @@ export function pythonBindingScopeFor(
|
|||
* `class A: import x` makes `x` a class attribute) attach to the class.
|
||||
* Module-level imports delegate to the central default. */
|
||||
export function pythonImportOwningScope(
|
||||
_imp: ParsedImport,
|
||||
imp: ParsedImport,
|
||||
innermost: Scope,
|
||||
_tree: ScopeTree,
|
||||
tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
const name = 'localName' in imp ? imp.localName : undefined;
|
||||
const redirect =
|
||||
name === undefined
|
||||
? undefined
|
||||
: innermost.nameClaims?.find(
|
||||
(claim) => claim.name === name && claim.hoisted === true && claim.redirect !== undefined,
|
||||
)?.redirect;
|
||||
if (redirect === 'module') return walkToScope(innermost, tree, 'Module');
|
||||
if (redirect === 'enclosing-function') {
|
||||
let parent = innermost.parent === null ? undefined : tree.getScope(innermost.parent);
|
||||
while (parent !== undefined) {
|
||||
if (
|
||||
parent.kind === 'Function' &&
|
||||
parent.nameClaims?.some((claim) => claim.name === name && claim.redirect === undefined)
|
||||
)
|
||||
return parent.id;
|
||||
parent = parent.parent === null ? undefined : tree.getScope(parent.parent);
|
||||
}
|
||||
}
|
||||
if (innermost.kind === 'Function' || innermost.kind === 'Class') return innermost.id;
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { getTreeSitterBufferSize } from '../../constants.js';
|
|||
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
||||
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
|
||||
import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
|
||||
import { collectRustNameClaims } from './lexical-bindings.js';
|
||||
|
||||
const RUST_CALLABLE_CAPTURE_OPTIONS = {
|
||||
functionNodeTypes: new Set(['function_item', 'closure_expression']),
|
||||
|
|
@ -45,6 +46,7 @@ export function emitRustScopeCaptures(
|
|||
|
||||
const rawMatches = getRustScopeQuery().matches(tree.rootNode);
|
||||
const out: CaptureMatch[] = [];
|
||||
const nameClaims = collectRustNameClaims(tree.rootNode);
|
||||
|
||||
for (const m of rawMatches) {
|
||||
const grouped: Record<string, Capture> = {};
|
||||
|
|
@ -62,6 +64,19 @@ export function emitRustScopeCaptures(
|
|||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
const scopeTag = Object.keys(nodeMap).find((tag) => tag.startsWith('@scope.'));
|
||||
if (scopeTag !== undefined) {
|
||||
const scopeNode = nodeMap[scopeTag];
|
||||
const claims = nameClaims.get(scopeNode.id);
|
||||
if (claims !== undefined) {
|
||||
grouped['@scope.name-claims'] = syntheticCapture(
|
||||
'@scope.name-claims',
|
||||
scopeNode,
|
||||
JSON.stringify(claims),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Decompose use declarations into individual import captures
|
||||
if (grouped['@import.statement'] !== undefined) {
|
||||
const useNode = nodeIfType(nodeMap['@import.statement'], 'use_declaration');
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@ import path from 'node:path';
|
|||
import type Parser from 'tree-sitter';
|
||||
import { splitRustUseDeclaration } from './import-decomposer.js';
|
||||
|
||||
export interface RustModuleLink {
|
||||
readonly module: string;
|
||||
readonly file: string;
|
||||
readonly inline: boolean;
|
||||
}
|
||||
|
||||
// Built-in attributes cannot expand to new module declarations. cfg is a union:
|
||||
// visiting both alternatives is conservative; cfg_attr may change a path.
|
||||
const NON_EXPANDING_ATTRIBUTES = new Set([
|
||||
|
|
@ -140,6 +146,7 @@ export function rustModuleFiles(
|
|||
files: ReadonlySet<string>,
|
||||
missingFiles?: Set<string>,
|
||||
isCrateRoot = false,
|
||||
moduleLinks?: RustModuleLink[],
|
||||
): readonly { file: string; ownsDirectory: boolean }[] | undefined {
|
||||
if (root.hasError) return undefined;
|
||||
// Built-in spellings are not proof when an import/local macro can shadow
|
||||
|
|
@ -216,7 +223,7 @@ export function rustModuleFiles(
|
|||
ownsDirectory || path.posix.basename(file) === 'mod.rs'
|
||||
? fileDir
|
||||
: file.slice(0, -'.rs'.length);
|
||||
const pending = [{ node: root, moduleDir, attributeDir: fileDir }];
|
||||
const pending = [{ node: root, moduleDir, attributeDir: fileDir, module: '', lexical: false }];
|
||||
const result: { file: string; ownsDirectory: boolean }[] = [];
|
||||
let unresolved = false;
|
||||
while (pending.length) {
|
||||
|
|
@ -291,7 +298,13 @@ export function rustModuleFiles(
|
|||
node.type !== 'token_tree' &&
|
||||
node.namedChildCount > 0
|
||||
) {
|
||||
pending.push({ ...context, node });
|
||||
pending.push({
|
||||
...context,
|
||||
node,
|
||||
lexical:
|
||||
context.lexical ||
|
||||
['function_item', 'closure_expression', 'block'].includes(node.type),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
@ -308,12 +321,20 @@ export function rustModuleFiles(
|
|||
return undefined;
|
||||
}
|
||||
const body = node.childForFieldName('body');
|
||||
const module = [context.module, name].filter(Boolean).join('::');
|
||||
if (body) {
|
||||
const dir =
|
||||
override === undefined
|
||||
? path.posix.join(context.moduleDir, name)
|
||||
: path.posix.join(context.attributeDir, override);
|
||||
pending.push({ node: body, moduleDir: dir, attributeDir: dir });
|
||||
if (!context.lexical) moduleLinks?.push({ module, file, inline: true });
|
||||
pending.push({
|
||||
node: body,
|
||||
moduleDir: dir,
|
||||
attributeDir: dir,
|
||||
module,
|
||||
lexical: context.lexical,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const candidates =
|
||||
|
|
@ -334,6 +355,9 @@ export function rustModuleFiles(
|
|||
// #[path] makes the loaded file own its containing directory, just like
|
||||
// a crate root; its children do NOT acquire the file stem as a prefix.
|
||||
result.push(...existing.map((file) => ({ file, ownsDirectory: override !== undefined })));
|
||||
if (!context.lexical) {
|
||||
moduleLinks?.push(...existing.map((file) => ({ module, file, inline: false })));
|
||||
}
|
||||
}
|
||||
}
|
||||
return unresolved ? undefined : result;
|
||||
|
|
|
|||
|
|
@ -8,13 +8,39 @@ import { SupportedLanguages } from 'gitnexus-shared';
|
|||
import { getLanguageGrammar } from '../../../tree-sitter/parser-loader.js';
|
||||
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
||||
import { readRepoControlFile } from '../../../../config/repo-control-file.js';
|
||||
import { rustModuleFiles, rustPublicUses } from './cargo-module-files.js';
|
||||
import { rustModuleFiles, rustPublicUses, type RustModuleLink } from './cargo-module-files.js';
|
||||
|
||||
const MAX_FILES = 100_000;
|
||||
type Table = Record<string, unknown>;
|
||||
const table = (value: unknown): value is Table =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
function cargoPackageEdition(
|
||||
manifest: string,
|
||||
data: Table,
|
||||
workspaceEditions?: ReadonlyMap<string, string>,
|
||||
): string | undefined {
|
||||
if (!table(data.package)) return undefined;
|
||||
const pkg = data.package;
|
||||
const dir = path.posix.dirname(manifest);
|
||||
let edition: unknown = pkg.edition ?? '2015';
|
||||
if (table(edition) && edition.workspace === true) {
|
||||
let workspace =
|
||||
typeof pkg.workspace === 'string' ? path.posix.join(dir, pkg.workspace, '.') : dir;
|
||||
while (
|
||||
!workspaceEditions?.has(workspace) &&
|
||||
typeof pkg.workspace !== 'string' &&
|
||||
workspace !== '.'
|
||||
) {
|
||||
workspace = path.posix.dirname(workspace);
|
||||
}
|
||||
edition = workspaceEditions?.get(workspace);
|
||||
}
|
||||
return typeof edition === 'string' && ['2015', '2018', '2021', '2024'].includes(edition)
|
||||
? edition
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** A target is identified by its entry file, not its package directory. */
|
||||
export function cargoTargetRoots(
|
||||
manifest: string,
|
||||
|
|
@ -34,21 +60,8 @@ export function cargoTargetRoots(
|
|||
if (pkg.build !== undefined && typeof pkg.build !== 'string' && typeof pkg.build !== 'boolean')
|
||||
return undefined;
|
||||
const dir = path.posix.dirname(manifest);
|
||||
let edition: unknown = pkg.edition ?? '2015';
|
||||
if (table(edition) && edition.workspace === true) {
|
||||
let workspace =
|
||||
typeof pkg.workspace === 'string' ? path.posix.join(dir, pkg.workspace, '.') : dir;
|
||||
while (
|
||||
!workspaceEditions?.has(workspace) &&
|
||||
typeof pkg.workspace !== 'string' &&
|
||||
workspace !== '.'
|
||||
) {
|
||||
workspace = path.posix.dirname(workspace);
|
||||
}
|
||||
edition = workspaceEditions?.get(workspace);
|
||||
}
|
||||
if (typeof edition !== 'string' || !['2015', '2018', '2021', '2024'].includes(edition))
|
||||
return undefined;
|
||||
const edition = cargoPackageEdition(manifest, data, workspaceEditions);
|
||||
if (edition === undefined) return undefined;
|
||||
const relative = (file: string): string => path.posix.normalize(path.posix.join(dir, file));
|
||||
const roots = new Set<string>();
|
||||
for (const [kind, folder] of [
|
||||
|
|
@ -115,14 +128,106 @@ export function cargoTargetRoots(
|
|||
return [...roots];
|
||||
}
|
||||
|
||||
export interface RustModuleLocation {
|
||||
readonly file: string;
|
||||
/** Namespace within this source file; external file modules start empty. */
|
||||
readonly module: string;
|
||||
}
|
||||
|
||||
const moduleKey = (file: string, module: string): string => JSON.stringify([file, module]);
|
||||
|
||||
class RustCargoTargets {
|
||||
constructor(
|
||||
readonly targetsByFile: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
readonly rootImports: ReadonlyMap<string, ReadonlyMap<string, ReadonlySet<string>>>,
|
||||
readonly publicUsesByFile: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
readonly relativeUseByFile: ReadonlyMap<string, boolean | null>,
|
||||
readonly moduleChildren: ReadonlyMap<string, readonly RustModuleLocation[]>,
|
||||
readonly moduleParents: ReadonlyMap<string, readonly RustModuleLocation[]>,
|
||||
) {}
|
||||
}
|
||||
|
||||
/** Resolve only module topology proven by the already-loaded Cargo ASTs. */
|
||||
export function rustCargoImportedModules(
|
||||
config: unknown,
|
||||
caller: string,
|
||||
importedModule: readonly string[],
|
||||
ownerModule: string,
|
||||
absolute: boolean,
|
||||
): readonly RustModuleLocation[] | undefined {
|
||||
if (!(config instanceof RustCargoTargets)) return undefined;
|
||||
const targets = config.targetsByFile.get(caller);
|
||||
if (targets === undefined) return undefined;
|
||||
const parts = [...importedModule];
|
||||
const head = parts.shift();
|
||||
if (head === undefined) return undefined;
|
||||
const roots = (): RustModuleLocation[] => [...targets].map((file) => ({ file, module: '' }));
|
||||
let locations: readonly RustModuleLocation[];
|
||||
if (head === 'crate' || head === '$crate') {
|
||||
locations = roots();
|
||||
} else if (head === 'self' || head === 'super') {
|
||||
locations = [{ file: caller, module: ownerModule }];
|
||||
if (head === 'super') parts.unshift(head);
|
||||
while (parts[0] === 'super') {
|
||||
parts.shift();
|
||||
locations = locations.flatMap((location) =>
|
||||
location.module !== ''
|
||||
? [{ file: location.file, module: location.module.split('::').slice(0, -1).join('::') }]
|
||||
: (config.moduleParents.get(location.file) ?? []),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const local =
|
||||
!absolute &&
|
||||
config.moduleChildren.get(moduleKey(caller, [ownerModule, head].filter(Boolean).join('::')));
|
||||
const mode = config.relativeUseByFile.get(caller);
|
||||
const importedRoots = new Set<string>();
|
||||
for (const target of targets) {
|
||||
for (const file of config.rootImports.get(target)?.get(head) ?? []) importedRoots.add(file);
|
||||
}
|
||||
const external = [...importedRoots].map((file) => ({ file, module: '' }));
|
||||
if (!absolute && mode === null) {
|
||||
const legacy = roots().flatMap(
|
||||
(root) => config.moduleChildren.get(moduleKey(root.file, head)) ?? [],
|
||||
);
|
||||
locations = [...(local || external), ...legacy, ...external];
|
||||
if (locations.length === 0) return undefined;
|
||||
} else if (local && mode !== false) {
|
||||
locations = local;
|
||||
} else {
|
||||
if (importedRoots.size > 0) {
|
||||
locations = external;
|
||||
} else if (!absolute && mode === false) {
|
||||
locations = roots();
|
||||
parts.unshift(head);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const part of parts) {
|
||||
locations = locations.flatMap(
|
||||
(location) =>
|
||||
config.moduleChildren.get(
|
||||
moduleKey(location.file, [location.module, part].filter(Boolean).join('::')),
|
||||
) ?? [],
|
||||
);
|
||||
}
|
||||
return [
|
||||
...new Map(
|
||||
locations.map((location) => [moduleKey(location.file, location.module), location]),
|
||||
).values(),
|
||||
];
|
||||
}
|
||||
|
||||
/** null retains mixed-edition ownership; undefined means no Cargo edition proof. */
|
||||
export function rustUsesRelativeImportPaths(
|
||||
config: unknown,
|
||||
file: string,
|
||||
): boolean | null | undefined {
|
||||
return config instanceof RustCargoTargets ? config.relativeUseByFile.get(file) : undefined;
|
||||
}
|
||||
|
||||
/** Exact public-use evidence, independent of the capture's coarse reexport kind. */
|
||||
export function rustCargoPubliclyReexports(
|
||||
config: unknown,
|
||||
|
|
@ -443,17 +548,39 @@ export async function loadRustCargoTargets(repoPath: string): Promise<unknown> {
|
|||
await discover(paths.map((file) => path.posix.join(path.posix.dirname(manifest), file)));
|
||||
}
|
||||
const roots = new Set<string>();
|
||||
const relativeUseByTarget = new Map<string, boolean | null>();
|
||||
const targetsByManifest = new Map<string, readonly string[]>();
|
||||
for (const [manifest, content] of contents) {
|
||||
const targets = cargoTargetRoots(manifest, content, files, workspaceEditions);
|
||||
if (targets === undefined) return undefined;
|
||||
targetsByManifest.set(manifest, targets);
|
||||
for (const target of targets) roots.add(target);
|
||||
const relative =
|
||||
cargoPackageEdition(manifest, manifestData.get(manifest)!, workspaceEditions) !== '2015';
|
||||
for (const target of targets) {
|
||||
roots.add(target);
|
||||
const previous = relativeUseByTarget.get(target);
|
||||
relativeUseByTarget.set(
|
||||
target,
|
||||
previous === undefined || previous === relative ? relative : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(getLanguageGrammar(SupportedLanguages.Rust));
|
||||
const targetsByFile = new Map<string, Set<string>>();
|
||||
const publicUsesByFile = new Map<string, ReadonlySet<string>>();
|
||||
const relativeUseByFile = new Map<string, boolean | null>();
|
||||
const moduleChildren = new Map<string, Map<string, RustModuleLocation>>();
|
||||
const moduleParents = new Map<string, Map<string, RustModuleLocation>>();
|
||||
const addLocation = (
|
||||
index: Map<string, Map<string, RustModuleLocation>>,
|
||||
key: string,
|
||||
location: RustModuleLocation,
|
||||
): void => {
|
||||
let entries = index.get(key);
|
||||
if (entries === undefined) index.set(key, (entries = new Map()));
|
||||
entries.set(moduleKey(location.file, location.module), location);
|
||||
};
|
||||
// A file may be reached conventionally AND through #[path]. Those have
|
||||
// different submodule bases, so cache and visit both contexts separately.
|
||||
const childrenByFile = new Map<string, NonNullable<ReturnType<typeof rustModuleFiles>>>();
|
||||
|
|
@ -470,10 +597,17 @@ export async function loadRustCargoTargets(repoPath: string): Promise<unknown> {
|
|||
let owners = targetsByFile.get(file);
|
||||
if (!owners) targetsByFile.set(file, (owners = new Set()));
|
||||
owners.add(target);
|
||||
const relative = relativeUseByTarget.get(target)!;
|
||||
const previous = relativeUseByFile.get(file);
|
||||
relativeUseByFile.set(
|
||||
file,
|
||||
previous === undefined || previous === relative ? relative : null,
|
||||
);
|
||||
let children = childrenByFile.get(key);
|
||||
if (!children) {
|
||||
const tree = parseSourceSafe(parser, await read(file));
|
||||
const missing = new Set<string>();
|
||||
const moduleLinks: RustModuleLink[] = [];
|
||||
let result = rustModuleFiles(
|
||||
tree.rootNode,
|
||||
file,
|
||||
|
|
@ -481,9 +615,11 @@ export async function loadRustCargoTargets(repoPath: string): Promise<unknown> {
|
|||
files,
|
||||
missing,
|
||||
file === target,
|
||||
moduleLinks,
|
||||
);
|
||||
if (result === undefined && missing.size > 0) {
|
||||
await discover(missing);
|
||||
moduleLinks.length = 0;
|
||||
result = rustModuleFiles(
|
||||
tree.rootNode,
|
||||
file,
|
||||
|
|
@ -491,12 +627,25 @@ export async function loadRustCargoTargets(repoPath: string): Promise<unknown> {
|
|||
files,
|
||||
undefined,
|
||||
file === target,
|
||||
moduleLinks,
|
||||
);
|
||||
}
|
||||
if (result === undefined) return undefined;
|
||||
publicUsesByFile.set(file, rustPublicUses(tree.rootNode));
|
||||
children = result;
|
||||
childrenByFile.set(key, children);
|
||||
for (const link of moduleLinks) {
|
||||
addLocation(moduleChildren, moduleKey(file, link.module), {
|
||||
file: link.file,
|
||||
module: link.inline ? link.module : '',
|
||||
});
|
||||
if (!link.inline) {
|
||||
addLocation(moduleParents, link.file, {
|
||||
file,
|
||||
module: link.module.split('::').slice(0, -1).join('::'),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
pending.push(...children);
|
||||
}
|
||||
|
|
@ -505,6 +654,9 @@ export async function loadRustCargoTargets(repoPath: string): Promise<unknown> {
|
|||
targetsByFile,
|
||||
cargoRootImports(manifestData, targetsByManifest),
|
||||
publicUsesByFile,
|
||||
relativeUseByFile,
|
||||
new Map([...moduleChildren].map(([key, entries]) => [key, [...entries.values()]])),
|
||||
new Map([...moduleParents].map(([key, entries]) => [key, [...entries.values()]])),
|
||||
);
|
||||
} catch {
|
||||
// I/O, parse or containment failure cannot establish target separation.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,301 @@
|
|||
import type { ParsedFile, ParsedImport, Scope, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { ImportResolutionContext } from '../../scope-resolution/contract/scope-resolver.js';
|
||||
import {
|
||||
rustCargoImportedModules,
|
||||
rustIsExclusiveCargoRoot,
|
||||
rustUsesRelativeImportPaths,
|
||||
} from './cargo-targets.js';
|
||||
|
||||
interface ImportWorkspace {
|
||||
readonly files: ReadonlyMap<string, ParsedFile>;
|
||||
readonly scopes: ReadonlyMap<string, Scope>;
|
||||
readonly definitionsByFile: ReadonlyMap<string, ReadonlyMap<string, readonly SymbolDefinition[]>>;
|
||||
readonly moduleDefinitions: ReadonlySet<string>;
|
||||
readonly importsByScope: ReadonlyMap<string, ReadonlyMap<string, readonly ParsedImport[]>>;
|
||||
readonly reexportsByFile: ReadonlyMap<string, ReadonlyMap<string, readonly ScopedReexport[]>>;
|
||||
}
|
||||
|
||||
interface ScopedReexport {
|
||||
readonly module: string;
|
||||
readonly imported: Extract<ParsedImport, { kind: 'reexport' }>;
|
||||
}
|
||||
|
||||
const importWorkspace = new WeakMap<readonly ParsedFile[], ImportWorkspace>();
|
||||
|
||||
function workspaceFor(context: ImportResolutionContext): ImportWorkspace {
|
||||
let workspace = importWorkspace.get(context.parsedFiles);
|
||||
if (workspace !== undefined) return workspace;
|
||||
const definitionsByFile = new Map<string, Map<string, SymbolDefinition[]>>();
|
||||
const moduleDefinitions = new Set<string>();
|
||||
const importsByScope = new Map<string, Map<string, ParsedImport[]>>();
|
||||
const reexportsByFile = new Map<string, Map<string, ScopedReexport[]>>();
|
||||
const scopes = new Map(
|
||||
context.parsedFiles.flatMap((file) => file.scopes.map((scope) => [scope.id, scope] as const)),
|
||||
);
|
||||
const moduleByScope = new Map<string, string>();
|
||||
const moduleAt = (scopeId: string): string => {
|
||||
const cached = moduleByScope.get(scopeId);
|
||||
if (cached !== undefined) return cached;
|
||||
const scope = scopes.get(scopeId);
|
||||
if (scope === undefined) return '';
|
||||
const parent = scope.parent === null ? '' : moduleAt(scope.parent);
|
||||
const own =
|
||||
scope.kind === 'Namespace'
|
||||
? scope.ownedDefs
|
||||
.find((def) => def.type === 'Namespace')
|
||||
?.qualifiedName?.split('.')
|
||||
.pop()
|
||||
: undefined;
|
||||
const module = [parent, own].filter(Boolean).join('::');
|
||||
moduleByScope.set(scopeId, module);
|
||||
return module;
|
||||
};
|
||||
for (const file of context.parsedFiles) {
|
||||
const byName = new Map<string, SymbolDefinition[]>();
|
||||
const reexports = new Map<string, ScopedReexport[]>();
|
||||
definitionsByFile.set(file.filePath, byName);
|
||||
reexportsByFile.set(file.filePath, reexports);
|
||||
for (const def of file.localDefs) {
|
||||
const name = def.qualifiedName?.split('.').pop();
|
||||
if (!name) continue;
|
||||
const entries = byName.get(name) ?? [];
|
||||
entries.push(def);
|
||||
byName.set(name, entries);
|
||||
}
|
||||
for (const scope of file.scopes) {
|
||||
if (scope.kind !== 'Module' && scope.kind !== 'Namespace') continue;
|
||||
for (const bindings of scope.bindings.values()) {
|
||||
for (const binding of bindings) {
|
||||
if (binding.origin === 'local') moduleDefinitions.add(binding.def.nodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const imp of file.parsedImports) {
|
||||
if (!('localName' in imp)) continue;
|
||||
const scope = imp.declaredAtScope ?? file.moduleScope;
|
||||
let names = importsByScope.get(scope);
|
||||
if (names === undefined) importsByScope.set(scope, (names = new Map()));
|
||||
const entries = names.get(imp.localName) ?? [];
|
||||
entries.push(imp);
|
||||
names.set(imp.localName, entries);
|
||||
const owner = scopes.get(scope);
|
||||
if (imp.kind === 'reexport' && (owner?.kind === 'Module' || owner?.kind === 'Namespace')) {
|
||||
const modules = reexports.get(imp.localName) ?? [];
|
||||
modules.push({ module: moduleAt(scope), imported: imp });
|
||||
reexports.set(imp.localName, modules);
|
||||
}
|
||||
}
|
||||
}
|
||||
workspace = {
|
||||
files: new Map(context.parsedFiles.map((file) => [file.filePath, file])),
|
||||
scopes,
|
||||
definitionsByFile,
|
||||
moduleDefinitions,
|
||||
importsByScope,
|
||||
reexportsByFile,
|
||||
};
|
||||
importWorkspace.set(context.parsedFiles, workspace);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
/** Named imports can use Cargo's exact root and #[path] topology. */
|
||||
function cargoNamedTarget(
|
||||
segments: readonly string[],
|
||||
targetRaw: string,
|
||||
fromFile: string,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
config: unknown,
|
||||
context: ImportResolutionContext,
|
||||
visited: ReadonlySet<ParsedImport>,
|
||||
): { readonly target: string | readonly string[] | null } | undefined {
|
||||
const imported = context.parsedImport;
|
||||
if (
|
||||
imported === undefined ||
|
||||
!['named', 'alias', 'reexport'].includes(imported.kind) ||
|
||||
!('importedName' in imported)
|
||||
)
|
||||
return undefined;
|
||||
if (rustUsesRelativeImportPaths(config, fromFile) === undefined) return undefined;
|
||||
const workspace = workspaceFor(context);
|
||||
const parsed = workspace.files.get(fromFile);
|
||||
if (parsed === undefined) return undefined;
|
||||
if (
|
||||
!targetRaw.startsWith('::') &&
|
||||
!['crate', '$crate', 'self', 'super'].includes(segments[0] ?? '')
|
||||
) {
|
||||
let lexical = workspace.scopes.get(imported.declaredAtScope ?? parsed.moduleScope);
|
||||
while (lexical !== undefined) {
|
||||
// A lexical alias owns this path head even when its target is unknown.
|
||||
// Cargo's extern-prelude name must not bypass that binding.
|
||||
if (
|
||||
workspace.importsByScope
|
||||
.get(lexical.id)
|
||||
?.get(segments[0])
|
||||
?.some((imp) => imp !== imported)
|
||||
) {
|
||||
return { target: null };
|
||||
}
|
||||
const localTypes = (lexical.bindings.get(segments[0]) ?? []).filter(
|
||||
(binding) =>
|
||||
binding.origin === 'local' &&
|
||||
['Namespace', 'Class', 'Struct', 'Enum', 'Trait', 'TypeAlias', 'Union'].includes(
|
||||
binding.def.type,
|
||||
),
|
||||
);
|
||||
if (localTypes.length > 0) {
|
||||
if (localTypes.some((binding) => binding.def.type !== 'Namespace')) return { target: null };
|
||||
// Function/block-local modules are not published in Cargo's module tree.
|
||||
if (lexical.kind !== 'Module' && lexical.kind !== 'Namespace') return undefined;
|
||||
break;
|
||||
}
|
||||
if (lexical.kind === 'Namespace') break;
|
||||
lexical = lexical.parent === null ? undefined : workspace.scopes.get(lexical.parent);
|
||||
}
|
||||
}
|
||||
const owner: string[] = [];
|
||||
let scope = workspace.scopes.get(imported.declaredAtScope ?? parsed.moduleScope);
|
||||
while (scope !== undefined) {
|
||||
if (scope.kind === 'Namespace') {
|
||||
const name = scope.ownedDefs
|
||||
.find((def) => def.type === 'Namespace')
|
||||
?.qualifiedName?.split('.')
|
||||
.pop();
|
||||
if (name) owner.unshift(name);
|
||||
}
|
||||
scope = scope.parent === null ? undefined : workspace.scopes.get(scope.parent);
|
||||
}
|
||||
const modules = rustCargoImportedModules(
|
||||
config,
|
||||
fromFile,
|
||||
segments.slice(0, -1),
|
||||
owner.join('::'),
|
||||
targetRaw.startsWith('::'),
|
||||
);
|
||||
if (modules === undefined) return undefined;
|
||||
const candidates = new Set<string>();
|
||||
for (const location of modules) {
|
||||
if (!allFilePaths.has(location.file)) continue;
|
||||
const definitions =
|
||||
workspace.definitionsByFile.get(location.file)?.get(imported.importedName) ?? [];
|
||||
// The shared finalizer matches a file-wide simple name. A precise module
|
||||
// receipt must not let it pick a different namespace or a same-file homonym.
|
||||
if (definitions.length > 1) return { target: null };
|
||||
if (
|
||||
definitions.length === 1 &&
|
||||
(!workspace.moduleDefinitions.has(definitions[0]!.nodeId) ||
|
||||
(definitions[0]!.namespacePrefix ?? '').replaceAll('.', '::') !== location.module)
|
||||
)
|
||||
continue;
|
||||
if (definitions.length === 0) {
|
||||
const reexports =
|
||||
workspace.reexportsByFile.get(location.file)?.get(imported.importedName) ?? [];
|
||||
// The closure is file-wide too. A sole explicit re-export in the named
|
||||
// module is the identity evidence it needs; other namespaces or competing
|
||||
// re-exports cannot safely be collapsed to one file-wide name.
|
||||
if (reexports.length !== 1) return { target: null };
|
||||
const reexport = reexports[0]!;
|
||||
if (reexport.module !== location.module) continue;
|
||||
if (location.module !== '') {
|
||||
// Namespace-scoped exports deliberately do not enter the shared
|
||||
// file-level closure. Follow this exact receipt instead. A file-only
|
||||
// target cannot communicate an alias's changed symbol spelling.
|
||||
if (reexport.imported.importedName !== imported.importedName) return { target: null };
|
||||
const target = resolveRustImportTarget(
|
||||
reexport.imported.targetRaw,
|
||||
location.file,
|
||||
allFilePaths,
|
||||
config,
|
||||
{ parsedFiles: context.parsedFiles, parsedImport: reexport.imported },
|
||||
visited,
|
||||
);
|
||||
if (target === null) return { target: null };
|
||||
for (const file of typeof target === 'string' ? [target] : target) candidates.add(file);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
candidates.add(location.file);
|
||||
}
|
||||
const files = [...candidates];
|
||||
return { target: files.length === 0 ? null : files.length === 1 ? files[0] : files };
|
||||
}
|
||||
|
||||
/** A declared module is evidence; a similarly named file elsewhere is not. */
|
||||
function declaredRelativeTarget(
|
||||
segments: readonly string[],
|
||||
fromFile: string,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
resolutionConfig: unknown,
|
||||
context: ImportResolutionContext,
|
||||
): { readonly target: string | readonly string[] | null } | undefined {
|
||||
const workspace = workspaceFor(context);
|
||||
const parsed = workspace.files.get(fromFile);
|
||||
if (parsed === undefined) return undefined;
|
||||
let scope = workspace.scopes.get(context.parsedImport?.declaredAtScope ?? parsed.moduleScope);
|
||||
const visited = new Set<string>();
|
||||
while (scope !== undefined && !visited.has(scope.id)) {
|
||||
visited.add(scope.id);
|
||||
const ownNamespace =
|
||||
scope.kind === 'Namespace'
|
||||
? scope.ownedDefs.find((def) => def.type === 'Namespace')?.nodeId
|
||||
: undefined;
|
||||
const declarations = new Set(
|
||||
(scope.bindings.get(segments[0]) ?? [])
|
||||
.filter(
|
||||
(binding) =>
|
||||
binding.origin === 'local' &&
|
||||
binding.def.type === 'Namespace' &&
|
||||
binding.def.nodeId !== ownNamespace,
|
||||
)
|
||||
.map((binding) => binding.def.nodeId),
|
||||
);
|
||||
if (declarations.size > 0) {
|
||||
if (declarations.size !== 1) return { target: null };
|
||||
const inline: string[] = [];
|
||||
let owner: Scope | undefined = scope;
|
||||
while (owner !== undefined) {
|
||||
if (owner.kind === 'Namespace') {
|
||||
const name = owner.ownedDefs
|
||||
.find((def) => def.type === 'Namespace')
|
||||
?.qualifiedName?.split('.')
|
||||
.pop();
|
||||
if (name !== undefined) inline.unshift(name);
|
||||
}
|
||||
owner = owner.parent === null ? undefined : workspace.scopes.get(owner.parent);
|
||||
}
|
||||
const normalized = fromFile.replace(/\\/g, '/');
|
||||
const slash = normalized.lastIndexOf('/');
|
||||
const directory = slash === -1 ? '' : normalized.slice(0, slash);
|
||||
const filename = normalized.slice(slash + 1);
|
||||
const ownsDirectory =
|
||||
['lib.rs', 'main.rs', 'mod.rs'].includes(filename) ||
|
||||
rustIsExclusiveCargoRoot(resolutionConfig, fromFile);
|
||||
const base = ownsDirectory ? directory : normalized.replace(/\.rs$/, '');
|
||||
const modulePath = [...inline, ...segments];
|
||||
const target = resolveModulePath(modulePath, base, allFilePaths);
|
||||
if (ownsDirectory || rustUsesRelativeImportPaths(resolutionConfig, fromFile) !== undefined) {
|
||||
return { target };
|
||||
}
|
||||
// A standalone source may be passed directly to rustc as a custom crate
|
||||
// root. Without Cargo membership, both its module layout and that root
|
||||
// layout are possible; keep ambiguity when both contain the module.
|
||||
const rootTarget = resolveModulePath(modulePath, directory, allFilePaths);
|
||||
const candidates = [
|
||||
...new Set(
|
||||
[target, rootTarget].flatMap((candidate) =>
|
||||
candidate === null ? [] : typeof candidate === 'string' ? [candidate] : candidate,
|
||||
),
|
||||
),
|
||||
];
|
||||
return {
|
||||
target:
|
||||
candidates.length === 0 ? null : candidates.length === 1 ? candidates[0] : candidates,
|
||||
};
|
||||
}
|
||||
scope = scope.parent === null ? undefined : workspace.scopes.get(scope.parent);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Rust `use` import path to a repo-relative file path.
|
||||
*
|
||||
|
|
@ -11,13 +309,33 @@ export function resolveRustImportTarget(
|
|||
targetRaw: string,
|
||||
fromFile: string,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
_resolutionConfig?: unknown,
|
||||
resolutionConfig?: unknown,
|
||||
context?: ImportResolutionContext,
|
||||
visited: ReadonlySet<ParsedImport> = new Set(),
|
||||
): string | readonly string[] | null {
|
||||
if (!targetRaw) return null;
|
||||
|
||||
const segments = targetRaw.split('::').filter(Boolean);
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
if (context !== undefined) {
|
||||
// Inline re-export chains are a provider-only supplement to the shared
|
||||
// SCC closure. Cycles and excessive depth conservatively stay unresolved.
|
||||
const imported = context.parsedImport;
|
||||
if (imported !== undefined && (visited.has(imported) || visited.size >= 64)) return null;
|
||||
const nextVisited = imported === undefined ? visited : new Set([...visited, imported]);
|
||||
const cargo = cargoNamedTarget(
|
||||
segments,
|
||||
targetRaw,
|
||||
fromFile,
|
||||
allFilePaths,
|
||||
resolutionConfig,
|
||||
context,
|
||||
nextVisited,
|
||||
);
|
||||
if (cargo !== undefined) return cargo.target;
|
||||
}
|
||||
|
||||
const fromNormalized = fromFile.replace(/\\/g, '/');
|
||||
const fromDir = fromNormalized.includes('/')
|
||||
? fromNormalized.slice(0, fromNormalized.lastIndexOf('/'))
|
||||
|
|
@ -39,13 +357,30 @@ export function resolveRustImportTarget(
|
|||
return resolveModulePath(restPath, fromDir, allFilePaths);
|
||||
}
|
||||
|
||||
const relativeMode = rustUsesRelativeImportPaths(resolutionConfig, fromFile);
|
||||
const relative =
|
||||
relativeMode !== false && !targetRaw.startsWith('::') && context !== undefined
|
||||
? declaredRelativeTarget(segments, fromFile, allFilePaths, resolutionConfig, context)
|
||||
: undefined;
|
||||
// Standalone sources have no edition receipt. Prefer a declared local module,
|
||||
// matching modern Rust; context-free callers keep their legacy resolution.
|
||||
if (relative !== undefined && relativeMode !== null) return relative.target;
|
||||
|
||||
// External crate — try workspace-level resolution
|
||||
const workspaceResult = resolveWorkspaceCrate(segments, allFilePaths);
|
||||
if (workspaceResult !== null) return workspaceResult;
|
||||
|
||||
// Fallback: treat as implicit crate-relative (Rust 2015 edition or
|
||||
// when the first segment matches a sibling module name).
|
||||
return resolveModulePath(segments, findSrcRoot(fromNormalized), allFilePaths);
|
||||
const legacyTarget =
|
||||
workspaceResult ?? resolveModulePath(segments, findSrcRoot(fromNormalized), allFilePaths);
|
||||
if (relative === undefined) return legacyTarget;
|
||||
// A file shared by 2015 and modern targets has both interpretations. Keep
|
||||
// every possible target instead of selecting an edition by visitation order.
|
||||
const candidates = [
|
||||
...new Set(
|
||||
[relative.target, legacyTarget].flatMap((target) =>
|
||||
target === null ? [] : typeof target === 'string' ? [target] : target,
|
||||
),
|
||||
),
|
||||
];
|
||||
return candidates.length === 0 ? null : candidates.length === 1 ? candidates[0] : candidates;
|
||||
}
|
||||
|
||||
function findSrcRoot(filePath: string): string {
|
||||
|
|
|
|||
144
gitnexus/src/core/ingestion/languages/rust/lexical-bindings.ts
Normal file
144
gitnexus/src/core/ingestion/languages/rust/lexical-bindings.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import type { NameClaim } from 'gitnexus-shared';
|
||||
import { nodeToCapture, walkNamedTree, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { splitRustUseDeclaration } from './import-decomposer.js';
|
||||
|
||||
const SCOPE_TYPES = new Set([
|
||||
'source_file',
|
||||
'function_item',
|
||||
'function_signature_item',
|
||||
'closure_expression',
|
||||
'block',
|
||||
'if_expression',
|
||||
'match_expression',
|
||||
'match_arm',
|
||||
'for_expression',
|
||||
'while_expression',
|
||||
'loop_expression',
|
||||
'mod_item',
|
||||
'struct_item',
|
||||
'trait_item',
|
||||
'impl_item',
|
||||
'enum_item',
|
||||
'union_item',
|
||||
]);
|
||||
|
||||
/** Pattern identifiers own values even when no useful target/type was indexed. */
|
||||
function patternNames(pattern: SyntaxNode): string[] {
|
||||
if (pattern.type === 'identifier' || pattern.type === 'shorthand_field_identifier') {
|
||||
return [pattern.text];
|
||||
}
|
||||
if (
|
||||
['scoped_identifier', 'scoped_type_identifier', 'type_identifier', 'generic_type'].includes(
|
||||
pattern.type,
|
||||
)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
if (pattern.type === 'field_pattern') {
|
||||
const nested = pattern.childForFieldName('pattern');
|
||||
if (nested !== null) return patternNames(nested);
|
||||
const name = pattern.childForFieldName('name');
|
||||
return name?.type === 'shorthand_field_identifier' ? [name.text] : [];
|
||||
}
|
||||
const type = pattern.childForFieldName('type');
|
||||
return pattern.namedChildren.flatMap((child) =>
|
||||
child.id === type?.id ? [] : patternNames(child),
|
||||
);
|
||||
}
|
||||
|
||||
function ownerOf(node: SyntaxNode | null): SyntaxNode | null {
|
||||
while (node !== null && !SCOPE_TYPES.has(node.type)) node = node.parent;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** One AST pass. Captures carry these plain facts through serial and worker parsing. */
|
||||
export function collectRustNameClaims(root: SyntaxNode): ReadonlyMap<number, readonly NameClaim[]> {
|
||||
const byScope = new Map<number, NameClaim[]>();
|
||||
const add = (
|
||||
owner: SyntaxNode | null,
|
||||
names: readonly string[],
|
||||
anchor: SyntaxNode,
|
||||
extra: Partial<NameClaim> = {},
|
||||
) => {
|
||||
if (owner === null || names.length === 0) return;
|
||||
const claims = byScope.get(owner.id) ?? [];
|
||||
for (const name of names) {
|
||||
if (name === '_') continue;
|
||||
claims.push({
|
||||
name,
|
||||
range: nodeToCapture('', anchor).range,
|
||||
kind: 'binding',
|
||||
purpose: 'value',
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
byScope.set(owner.id, claims);
|
||||
};
|
||||
walkNamedTree(root, (node) => {
|
||||
if (node.type === 'use_declaration') {
|
||||
const names = splitRustUseDeclaration(node)
|
||||
.filter((match) => match['@import.kind']?.text !== 'wildcard')
|
||||
.flatMap((match) =>
|
||||
match['@import.name'] === undefined ? [] : [match['@import.name'].text],
|
||||
);
|
||||
add(ownerOf(node.parent), names, node, { kind: 'import', purpose: 'both', hoisted: true });
|
||||
} else if (node.type === 'let_declaration') {
|
||||
const pattern = node.childForFieldName('pattern');
|
||||
if (pattern !== null)
|
||||
add(ownerOf(node.parent), patternNames(pattern), node, {
|
||||
availableFrom: { startLine: node.endPosition.row + 1, startCol: node.endPosition.column },
|
||||
inactive: 'outer',
|
||||
});
|
||||
} else if (node.type === 'parameter') {
|
||||
const pattern = node.childForFieldName('pattern');
|
||||
if (pattern !== null) add(ownerOf(node.parent), patternNames(pattern), node);
|
||||
} else if (node.type === 'closure_expression') {
|
||||
const parameters = node.childForFieldName('parameters');
|
||||
for (const parameter of parameters?.namedChildren ?? []) {
|
||||
if (parameter.type !== 'parameter') add(node, patternNames(parameter), parameter);
|
||||
}
|
||||
} else if (node.type === 'for_expression') {
|
||||
const pattern = node.childForFieldName('pattern');
|
||||
if (pattern !== null) add(node.childForFieldName('body'), patternNames(pattern), pattern);
|
||||
} else if (node.type === 'match_arm') {
|
||||
const pattern = node.childForFieldName('pattern');
|
||||
if (pattern !== null) add(node, patternNames(pattern), pattern);
|
||||
} else if (node.type === 'let_condition') {
|
||||
const pattern = node.childForFieldName('pattern');
|
||||
let control = node.parent;
|
||||
while (control !== null && !['if_expression', 'while_expression'].includes(control.type))
|
||||
control = control.parent;
|
||||
if (pattern !== null && control !== null) {
|
||||
add(
|
||||
control.childForFieldName('consequence') ?? control.childForFieldName('body'),
|
||||
patternNames(pattern),
|
||||
pattern,
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
[
|
||||
'function_item',
|
||||
'const_item',
|
||||
'static_item',
|
||||
'struct_item',
|
||||
'enum_item',
|
||||
'union_item',
|
||||
'trait_item',
|
||||
'type_item',
|
||||
'mod_item',
|
||||
].includes(node.type)
|
||||
) {
|
||||
const name = node.childForFieldName('name');
|
||||
if (name !== null)
|
||||
add(ownerOf(node.parent), [name.text], node, {
|
||||
hoisted: true,
|
||||
purpose: ['function_item', 'const_item', 'static_item'].includes(node.type)
|
||||
? 'value'
|
||||
: ['trait_item', 'type_item'].includes(node.type)
|
||||
? 'type'
|
||||
: 'both',
|
||||
});
|
||||
}
|
||||
});
|
||||
return byScope;
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ const RUST_SCOPE_QUERY = `
|
|||
(block) @scope.block
|
||||
(if_expression) @scope.block
|
||||
(match_expression) @scope.block
|
||||
(match_arm) @scope.block
|
||||
(for_expression) @scope.block
|
||||
(while_expression) @scope.block
|
||||
(loop_expression) @scope.block
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import type { ParsedFile, Scope, ScopeId, TypeRef } from 'gitnexus-shared';
|
||||
import type { ParsedFile, Scope, TypeRef } from 'gitnexus-shared';
|
||||
import { lookupLexicalName } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { getRustParser } from './query.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import { parseSourceSafe, ParseTimeoutError } from '../../../tree-sitter/safe-parse.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { logger } from '../../../logger.js';
|
||||
import { lookupBindingsAt } from '../../scope-resolution/scope/walkers.js';
|
||||
import { lookupBindingsAt, lookupNameClaim } from '../../scope-resolution/scope/walkers.js';
|
||||
|
||||
/**
|
||||
* Populate type bindings for patterns and iterators that the tree-sitter
|
||||
|
|
@ -19,6 +20,57 @@ import { lookupBindingsAt } from '../../scope-resolution/scope/walkers.js';
|
|||
*/
|
||||
type RustTree = ReturnType<ReturnType<typeof getRustParser>['parse']>;
|
||||
|
||||
interface ProducerReturn {
|
||||
readonly rawType: string;
|
||||
readonly typeRef: TypeRef;
|
||||
}
|
||||
|
||||
function nodeRangeKey(node: SyntaxNode): string {
|
||||
return `${node.startPosition.row + 1}:${node.startPosition.column}-${node.endPosition.row + 1}:${node.endPosition.column}`;
|
||||
}
|
||||
|
||||
function indexScopeRanges(parsed: ParsedFile): ReadonlyMap<string, Scope> {
|
||||
return new Map(
|
||||
parsed.scopes.map((scope) => [
|
||||
`${scope.range.startLine}:${scope.range.startCol}-${scope.range.endLine}:${scope.range.endCol}`,
|
||||
scope,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function lexicalScopeForNode(
|
||||
node: SyntaxNode,
|
||||
scopeRanges: ReadonlyMap<string, Scope>,
|
||||
): Scope | undefined {
|
||||
let current: SyntaxNode | null = node;
|
||||
while (current !== null) {
|
||||
const scope = scopeRanges.get(nodeRangeKey(current));
|
||||
if (scope !== undefined) return scope;
|
||||
current = current.parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** null means claimed but unusable; undefined alone permits the legacy fallback. */
|
||||
function producerAt(
|
||||
func: SyntaxNode,
|
||||
scopeRanges: ReadonlyMap<string, Scope>,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
returnTypes: ReadonlyMap<string, ProducerReturn>,
|
||||
): ProducerReturn | null | undefined {
|
||||
const scope = lexicalScopeForNode(func, scopeRanges);
|
||||
if (scope === undefined || !indexes.scopeTree.has(scope.id)) return undefined;
|
||||
const claim = lookupNameClaim(scope.id, func.text, indexes, {
|
||||
position: { startLine: func.startPosition.row + 1, startCol: func.startPosition.column },
|
||||
purpose: 'value',
|
||||
});
|
||||
if (claim.status === 'absent') return undefined;
|
||||
if (claim.status === 'blocked') return null;
|
||||
const defs = new Map(claim.bindings.map((binding) => [binding.def.nodeId, binding.def]));
|
||||
if (defs.size !== 1) return null;
|
||||
return returnTypes.get(defs.keys().next().value!) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold parsed trees for reuse across both prepass loops only when the whole
|
||||
* Rust source fits this budget. Trees are much larger than their source, so a
|
||||
|
|
@ -72,6 +124,11 @@ export function populateRustRangeBindings(
|
|||
): void {
|
||||
const parser = getRustParser();
|
||||
const allReturnTypes = new Map<string, string>();
|
||||
const producerReturns = new Map<string, ProducerReturn>();
|
||||
const fieldsByDefinition = new Map<string, ReadonlyMap<string, TypeRef>>();
|
||||
const scopeRangesByFile = new Map(
|
||||
parsedFiles.map((parsed) => [parsed.filePath, indexScopeRanges(parsed)]),
|
||||
);
|
||||
const ambiguousReturnTypes = new Set<string>();
|
||||
const allFieldTypes = new Map<string, Map<string, string>>();
|
||||
const ambiguousFieldTypes = new Set<string>();
|
||||
|
|
@ -94,6 +151,8 @@ export function populateRustRangeBindings(
|
|||
for (const parsed of parsedFiles) {
|
||||
const tree = getOrParseTree(parser, parsed.filePath, ctx, treeStore);
|
||||
if (tree === null) continue;
|
||||
const scopeRanges = scopeRangesByFile.get(parsed.filePath)!;
|
||||
const scopeMap = new Map(parsed.scopes.map((scope) => [scope.id, scope]));
|
||||
|
||||
for (const fn of tree.rootNode.descendantsOfType('function_item')) {
|
||||
const nameNode = fn.childForFieldName('name');
|
||||
|
|
@ -111,6 +170,33 @@ export function populateRustRangeBindings(
|
|||
}
|
||||
// Full-generic record per defining file for import-disambiguated lookup.
|
||||
recordByFile(returnTypeByFile, parsed.filePath, name, retType.text);
|
||||
const declarationScope = scopeRanges.get(nodeRangeKey(fn));
|
||||
if (declarationScope !== undefined) {
|
||||
const declaredReturn =
|
||||
declarationScope.parent === null
|
||||
? undefined
|
||||
: scopeMap.get(declarationScope.parent)?.typeBindings.get(name);
|
||||
for (const def of declarationScope.ownedDefs) {
|
||||
if (def.type !== 'Function' && def.type !== 'Method') continue;
|
||||
producerReturns.set(def.nodeId, {
|
||||
rawType: retType.text,
|
||||
typeRef: {
|
||||
rawName:
|
||||
declaredReturn?.source === 'return-annotation'
|
||||
? declaredReturn.rawName
|
||||
: normalizeFieldType(retType.text),
|
||||
declaredSpelling: retType.text,
|
||||
declaredAtScope: declarationScope.id,
|
||||
lookupPosition: {
|
||||
startLine: retType.startPosition.row + 1,
|
||||
startCol: retType.startPosition.column,
|
||||
},
|
||||
lookupPurpose: 'type',
|
||||
source: 'annotation',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -119,11 +205,26 @@ export function populateRustRangeBindings(
|
|||
const body = structNode.childForFieldName('body');
|
||||
if (nameNode === null || body === null) continue;
|
||||
const fields = new Map<string, string>();
|
||||
const fieldRefs = new Map<string, TypeRef>();
|
||||
const declarationScope = scopeRanges.get(nodeRangeKey(structNode));
|
||||
for (const field of body.descendantsOfType('field_declaration')) {
|
||||
const fieldName = field.childForFieldName('name');
|
||||
const fieldType = field.childForFieldName('type');
|
||||
if (fieldName !== null && fieldType !== null) {
|
||||
fields.set(fieldName.text, normalizeFieldType(fieldType.text));
|
||||
if (declarationScope !== undefined) {
|
||||
fieldRefs.set(fieldName.text, {
|
||||
rawName: normalizeFieldType(fieldType.text),
|
||||
declaredSpelling: fieldType.text,
|
||||
declaredAtScope: declarationScope.id,
|
||||
lookupPosition: {
|
||||
startLine: fieldType.startPosition.row + 1,
|
||||
startCol: fieldType.startPosition.column,
|
||||
},
|
||||
lookupPurpose: 'type',
|
||||
source: 'annotation',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fields.size > 0) {
|
||||
|
|
@ -138,13 +239,15 @@ export function populateRustRangeBindings(
|
|||
}
|
||||
// Full-generic record per defining file for import-disambiguated lookup.
|
||||
recordByFile(fieldTypeByFile, parsed.filePath, name, fields);
|
||||
for (const def of declarationScope?.ownedDefs ?? []) {
|
||||
if (def.type === 'Struct') fieldsByDefinition.set(def.nodeId, fieldRefs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Publish per-type member bindings for the whole workspace before resolving
|
||||
// assignments. Otherwise an importer processed before its defining file can
|
||||
// miss a field or identity-method type solely because of file order.
|
||||
const scopeMap = new Map(parsed.scopes.map((scope) => [scope.id, scope]));
|
||||
processFieldTypeBindings(tree.rootNode, parsed, scopeMap);
|
||||
processIdentityMethodBindings(parsed);
|
||||
}
|
||||
|
|
@ -154,6 +257,7 @@ export function populateRustRangeBindings(
|
|||
if (tree === null) continue;
|
||||
|
||||
const scopeMap = new Map(parsed.scopes.map((s) => [s.id, s]));
|
||||
const scopeRanges = scopeRangesByFile.get(parsed.filePath)!;
|
||||
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
|
||||
if (moduleScope === undefined) continue;
|
||||
|
||||
|
|
@ -165,8 +269,10 @@ export function populateRustRangeBindings(
|
|||
allReturnTypes,
|
||||
indexes,
|
||||
returnTypeByFile,
|
||||
producerReturns,
|
||||
scopeRanges,
|
||||
);
|
||||
processPatternBindings(tree.rootNode, parsed, scopeMap, moduleScope);
|
||||
processPatternBindings(tree.rootNode, parsed, scopeMap, moduleScope, scopeRanges);
|
||||
processStructDestructuring(
|
||||
tree.rootNode,
|
||||
parsed,
|
||||
|
|
@ -175,6 +281,8 @@ export function populateRustRangeBindings(
|
|||
allFieldTypes,
|
||||
indexes,
|
||||
fieldTypeByFile,
|
||||
fieldsByDefinition,
|
||||
scopeRanges,
|
||||
);
|
||||
processPendingAssignments(
|
||||
tree.rootNode,
|
||||
|
|
@ -183,6 +291,9 @@ export function populateRustRangeBindings(
|
|||
scopeMap,
|
||||
moduleScope,
|
||||
allReturnTypes,
|
||||
indexes,
|
||||
producerReturns,
|
||||
scopeRanges,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -322,6 +433,8 @@ function processForLoops(
|
|||
allReturnTypes: ReadonlyMap<string, string>,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
returnTypeByFile: ReadonlyMap<string, Map<string, string>>,
|
||||
producerReturns: ReadonlyMap<string, ProducerReturn>,
|
||||
scopeRanges: ReadonlyMap<string, Scope>,
|
||||
): void {
|
||||
for (const forNode of root.descendantsOfType('for_expression')) {
|
||||
const patternNode = forNode.childForFieldName('pattern');
|
||||
|
|
@ -339,10 +452,13 @@ function processForLoops(
|
|||
allReturnTypes,
|
||||
indexes,
|
||||
returnTypeByFile,
|
||||
producerReturns,
|
||||
scopeRanges,
|
||||
);
|
||||
if (elementType === null) continue;
|
||||
|
||||
const targetScope = findEnclosingFunctionScope(forNode, scopeMap) ?? moduleScope;
|
||||
const targetScope =
|
||||
lexicalScopeForNode(forNode.childForFieldName('body') ?? forNode, scopeRanges) ?? moduleScope;
|
||||
injectTypeBinding(targetScope, varName, elementType);
|
||||
}
|
||||
}
|
||||
|
|
@ -352,6 +468,7 @@ function processPatternBindings(
|
|||
parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
scopeRanges: ReadonlyMap<string, Scope>,
|
||||
): void {
|
||||
for (const nodeType of ['let_condition', 'match_arm'] as const) {
|
||||
for (const node of root.descendantsOfType(nodeType)) {
|
||||
|
|
@ -362,12 +479,12 @@ function processPatternBindings(
|
|||
if (patternNode === null) continue;
|
||||
|
||||
if (patternNode.type === 'captured_pattern') {
|
||||
processCapturedPattern(patternNode, node, parsed, scopeMap, moduleScope);
|
||||
processCapturedPattern(patternNode, node, parsed, scopeMap, moduleScope, scopeRanges);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (patternNode.type === 'tuple_struct_pattern') {
|
||||
processTupleStructPattern(patternNode, node, parsed, scopeMap, moduleScope);
|
||||
processTupleStructPattern(patternNode, node, parsed, scopeMap, moduleScope, scopeRanges);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -377,8 +494,9 @@ function processCapturedPattern(
|
|||
patternNode: SyntaxNode,
|
||||
contextNode: SyntaxNode,
|
||||
_parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
_scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
scopeRanges: ReadonlyMap<string, Scope>,
|
||||
): void {
|
||||
const varNode = patternNode.namedChildren.find((c) => c.type === 'identifier');
|
||||
const structPatternNode = patternNode.namedChildren.find((c) => c.type === 'struct_pattern');
|
||||
|
|
@ -387,7 +505,7 @@ function processCapturedPattern(
|
|||
const typeName = structPatternNode.childForFieldName('type')?.text;
|
||||
if (typeName === undefined) return;
|
||||
|
||||
const targetScope = findEnclosingFunctionScope(contextNode, scopeMap) ?? moduleScope;
|
||||
const targetScope = patternBindingScope(contextNode, scopeRanges) ?? moduleScope;
|
||||
injectTypeBinding(targetScope, varNode.text, typeName);
|
||||
}
|
||||
|
||||
|
|
@ -397,6 +515,7 @@ function processTupleStructPattern(
|
|||
parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
scopeRanges: ReadonlyMap<string, Scope>,
|
||||
): void {
|
||||
const wrapperNode = patternNode.childForFieldName('type');
|
||||
if (wrapperNode === null) return;
|
||||
|
|
@ -424,7 +543,14 @@ function processTupleStructPattern(
|
|||
const sourceVarName = sourceVarNode.type === 'identifier' ? sourceVarNode.text : null;
|
||||
if (sourceVarName === null) return;
|
||||
|
||||
const sourceType = lookupTypeInScopes(sourceVarName, contextNode, parsed, scopeMap, moduleScope);
|
||||
const sourceType = lookupTypeInScopes(
|
||||
sourceVarName,
|
||||
contextNode,
|
||||
parsed,
|
||||
scopeMap,
|
||||
moduleScope,
|
||||
scopeRanges,
|
||||
);
|
||||
if (sourceType === null) return;
|
||||
|
||||
let resolvedType: string | null = null;
|
||||
|
|
@ -444,10 +570,26 @@ function processTupleStructPattern(
|
|||
|
||||
if (resolvedType === null) return;
|
||||
|
||||
const targetScope = findEnclosingFunctionScope(contextNode, scopeMap) ?? moduleScope;
|
||||
const targetScope = patternBindingScope(contextNode, scopeRanges) ?? moduleScope;
|
||||
injectTypeBinding(targetScope, varName, resolvedType);
|
||||
}
|
||||
|
||||
function patternBindingScope(
|
||||
contextNode: SyntaxNode,
|
||||
scopeRanges: ReadonlyMap<string, Scope>,
|
||||
): Scope | undefined {
|
||||
if (contextNode.type === 'match_arm') return lexicalScopeForNode(contextNode, scopeRanges);
|
||||
let control = contextNode.parent;
|
||||
while (
|
||||
control !== null &&
|
||||
control.type !== 'if_expression' &&
|
||||
control.type !== 'while_expression'
|
||||
)
|
||||
control = control.parent;
|
||||
const body = control?.childForFieldName('consequence') ?? control?.childForFieldName('body');
|
||||
return body == null ? undefined : lexicalScopeForNode(body, scopeRanges);
|
||||
}
|
||||
|
||||
function processStructDestructuring(
|
||||
root: SyntaxNode,
|
||||
parsed: ParsedFile,
|
||||
|
|
@ -456,6 +598,8 @@ function processStructDestructuring(
|
|||
allFieldTypes: ReadonlyMap<string, Map<string, string>>,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
fieldTypeByFile: ReadonlyMap<string, ReadonlyMap<string, Map<string, string>>>,
|
||||
fieldsByDefinition: ReadonlyMap<string, ReadonlyMap<string, TypeRef>>,
|
||||
scopeRanges: ReadonlyMap<string, Scope>,
|
||||
): void {
|
||||
for (const letNode of root.descendantsOfType('let_declaration')) {
|
||||
const patternNode = letNode.childForFieldName('pattern');
|
||||
|
|
@ -467,7 +611,22 @@ function processStructDestructuring(
|
|||
const valueNode = letNode.childForFieldName('value');
|
||||
if (valueNode === null) continue;
|
||||
|
||||
const targetScope = findEnclosingFunctionScope(letNode, scopeMap) ?? moduleScope;
|
||||
const targetScope = lexicalScopeForNode(letNode, scopeRanges) ?? moduleScope;
|
||||
const claim = indexes.scopeTree.has(targetScope.id)
|
||||
? lookupNameClaim(targetScope.id, typeName, indexes, {
|
||||
position: {
|
||||
startLine: patternNode.startPosition.row + 1,
|
||||
startCol: patternNode.startPosition.column,
|
||||
},
|
||||
purpose: 'type',
|
||||
})
|
||||
: undefined;
|
||||
const claimedDefs = new Set(claim?.bindings.map((binding) => binding.def.nodeId));
|
||||
const claimedFields =
|
||||
claimedDefs.size === 1
|
||||
? fieldsByDefinition.get(claimedDefs.values().next().value!)
|
||||
: undefined;
|
||||
if (claim !== undefined && claim.status !== 'absent' && claimedFields === undefined) continue;
|
||||
|
||||
for (const fieldNode of patternNode.namedChildren) {
|
||||
let fieldName: string | undefined;
|
||||
|
|
@ -477,6 +636,15 @@ function processStructDestructuring(
|
|||
fieldName = fieldNode.childForFieldName('name')?.text;
|
||||
}
|
||||
if (fieldName === undefined) continue;
|
||||
const fieldPattern = fieldNode.childForFieldName('pattern');
|
||||
const boundName = fieldPattern === null ? fieldName : extractVarName(fieldPattern);
|
||||
if (boundName === null) continue;
|
||||
|
||||
if (claim !== undefined && claim.status !== 'absent') {
|
||||
const fieldRef = claimedFields?.get(fieldName);
|
||||
if (fieldRef !== undefined) injectTypeBinding(targetScope, boundName, fieldRef);
|
||||
continue;
|
||||
}
|
||||
|
||||
let fieldType = lookupFieldType(typeName, fieldName, parsed, scopeMap, moduleScope);
|
||||
if (fieldType === null) {
|
||||
|
|
@ -489,7 +657,7 @@ function processStructDestructuring(
|
|||
fieldType = fields?.get(fieldName) ?? null;
|
||||
}
|
||||
if (fieldType !== null) {
|
||||
injectTypeBinding(targetScope, fieldName, fieldType);
|
||||
injectTypeBinding(targetScope, boundName, fieldType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -527,6 +695,9 @@ function processPendingAssignments(
|
|||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
allReturnTypes: ReadonlyMap<string, string>,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
producerReturns: ReadonlyMap<string, ProducerReturn>,
|
||||
scopeRanges: ReadonlyMap<string, Scope>,
|
||||
): void {
|
||||
for (let pass = 0; pass < 3; pass++) {
|
||||
for (const letNode of root.descendantsOfType('let_declaration')) {
|
||||
|
|
@ -535,16 +706,51 @@ function processPendingAssignments(
|
|||
const varName = extractVarName(patternNode);
|
||||
if (varName === null) continue;
|
||||
|
||||
const targetScope = findEnclosingFunctionScope(letNode, scopeMap) ?? moduleScope;
|
||||
if (targetScope.typeBindings.has(varName)) continue;
|
||||
|
||||
const valueNode = letNode.childForFieldName('value');
|
||||
if (valueNode === null) continue;
|
||||
|
||||
const bindingScope = lexicalScopeForNode(letNode, scopeRanges) ?? moduleScope;
|
||||
const func =
|
||||
valueNode.type === 'call_expression' ? valueNode.childForFieldName('function') : null;
|
||||
if (func?.type === 'identifier' && letNode.childForFieldName('type') === null) {
|
||||
const producer = producerAt(func, scopeRanges, indexes, producerReturns);
|
||||
if (producer !== undefined) {
|
||||
const bindings = bindingScope.typeBindings as Map<string, TypeRef>;
|
||||
if (producer === null) bindings.delete(varName);
|
||||
else
|
||||
bindings.set(varName, {
|
||||
...producer.typeRef,
|
||||
bindingRange: {
|
||||
startLine: letNode.startPosition.row + 1,
|
||||
startCol: letNode.startPosition.column,
|
||||
endLine: letNode.endPosition.row + 1,
|
||||
endCol: letNode.endPosition.column,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const targetScope = bindingScope;
|
||||
const existing = targetScope.typeBindings.get(varName);
|
||||
if (
|
||||
existing !== undefined &&
|
||||
existing.source !== 'constructor-inferred' &&
|
||||
existing.source !== 'assignment-inferred'
|
||||
)
|
||||
continue;
|
||||
|
||||
if (valueNode.type === 'identifier') {
|
||||
const rhsType = lookupTypeInScopes(valueNode.text, letNode, parsed, scopeMap, moduleScope);
|
||||
const rhsType = lookupTypeInScopes(
|
||||
valueNode.text,
|
||||
letNode,
|
||||
parsed,
|
||||
scopeMap,
|
||||
moduleScope,
|
||||
scopeRanges,
|
||||
);
|
||||
if (rhsType !== null) {
|
||||
injectTypeBinding(targetScope, varName, rhsType);
|
||||
injectTypeBinding(targetScope, varName, rhsType, true);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
|
@ -559,11 +765,12 @@ function processPendingAssignments(
|
|||
parsed,
|
||||
scopeMap,
|
||||
moduleScope,
|
||||
scopeRanges,
|
||||
);
|
||||
if (receiverType !== null) {
|
||||
const fieldType = findFieldTypeAcrossFiles(receiverType, field.text, allParsedFiles);
|
||||
if (fieldType !== null) {
|
||||
injectTypeBinding(targetScope, varName, fieldType);
|
||||
injectTypeBinding(targetScope, varName, fieldType, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -581,6 +788,7 @@ function processPendingAssignments(
|
|||
parsed,
|
||||
scopeMap,
|
||||
moduleScope,
|
||||
scopeRanges,
|
||||
);
|
||||
if (receiverType !== null) {
|
||||
const retType = findMethodReturnTypeAcrossFiles(
|
||||
|
|
@ -589,7 +797,7 @@ function processPendingAssignments(
|
|||
allParsedFiles,
|
||||
);
|
||||
if (retType !== null) {
|
||||
injectTypeBinding(targetScope, varName, retType);
|
||||
injectTypeBinding(targetScope, varName, retType, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -598,7 +806,7 @@ function processPendingAssignments(
|
|||
if (func !== null && func.type === 'identifier') {
|
||||
const rawReturn = allReturnTypes.get(func.text);
|
||||
if (rawReturn !== undefined) {
|
||||
injectTypeBinding(targetScope, varName, normalizeFieldType(rawReturn));
|
||||
injectTypeBinding(targetScope, varName, normalizeFieldType(rawReturn), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -614,14 +822,23 @@ function resolveIterableElementType(
|
|||
allReturnTypes: ReadonlyMap<string, string>,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
returnTypeByFile: ReadonlyMap<string, ReadonlyMap<string, string>>,
|
||||
): string | null {
|
||||
producerReturns: ReadonlyMap<string, ProducerReturn>,
|
||||
scopeRanges: ReadonlyMap<string, Scope>,
|
||||
): string | TypeRef | null {
|
||||
let iterableNode = valueNode;
|
||||
if (iterableNode.type === 'reference_expression') {
|
||||
iterableNode = iterableNode.firstNamedChild ?? iterableNode;
|
||||
}
|
||||
|
||||
if (iterableNode.type === 'identifier') {
|
||||
const rawType = lookupTypeInScopes(iterableNode.text, valueNode, parsed, scopeMap, moduleScope);
|
||||
const rawType = lookupTypeInScopes(
|
||||
iterableNode.text,
|
||||
valueNode,
|
||||
parsed,
|
||||
scopeMap,
|
||||
moduleScope,
|
||||
scopeRanges,
|
||||
);
|
||||
if (rawType !== null) return unwrapGeneric(rawType);
|
||||
}
|
||||
|
||||
|
|
@ -632,12 +849,29 @@ function resolveIterableElementType(
|
|||
if (func.type === 'field_expression') {
|
||||
const receiver = func.childForFieldName('value');
|
||||
if (receiver !== null && receiver.type === 'identifier') {
|
||||
const rawType = lookupTypeInScopes(receiver.text, valueNode, parsed, scopeMap, moduleScope);
|
||||
const rawType = lookupTypeInScopes(
|
||||
receiver.text,
|
||||
valueNode,
|
||||
parsed,
|
||||
scopeMap,
|
||||
moduleScope,
|
||||
scopeRanges,
|
||||
);
|
||||
if (rawType !== null) return unwrapGeneric(rawType);
|
||||
}
|
||||
}
|
||||
|
||||
if (func.type === 'identifier') {
|
||||
const producer = producerAt(func, scopeRanges, indexes, producerReturns);
|
||||
if (producer === null) return null;
|
||||
if (producer !== undefined) {
|
||||
const elementType = unwrapGeneric(producer.rawType);
|
||||
return {
|
||||
...producer.typeRef,
|
||||
rawName: normalizeFieldType(elementType),
|
||||
declaredSpelling: elementType,
|
||||
};
|
||||
}
|
||||
const crossFileReturn = allReturnTypes.get(func.text);
|
||||
if (crossFileReturn !== undefined) return unwrapGeneric(crossFileReturn);
|
||||
const rawReturn = lookupRawFunctionReturnType(func.text, valueNode);
|
||||
|
|
@ -733,20 +967,25 @@ function lookupRawParameterType(paramName: string, contextNode: SyntaxNode): str
|
|||
function lookupTypeInScopes(
|
||||
name: string,
|
||||
contextNode: SyntaxNode,
|
||||
parsed: ParsedFile,
|
||||
_parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
scopeRanges: ReadonlyMap<string, Scope>,
|
||||
): string | null {
|
||||
const fnScope = findEnclosingFunctionScope(contextNode, scopeMap);
|
||||
if (fnScope !== null) {
|
||||
const tb = fnScope.typeBindings.get(name);
|
||||
if (tb !== undefined) return tb.rawName;
|
||||
}
|
||||
|
||||
const mtb = moduleScope.typeBindings.get(name);
|
||||
if (mtb !== undefined) return mtb.rawName;
|
||||
|
||||
return null;
|
||||
const scope = lexicalScopeForNode(contextNode, scopeRanges) ?? moduleScope;
|
||||
const claim = lookupLexicalName(
|
||||
scope.id,
|
||||
name,
|
||||
{ scopes: { getScope: (id) => scopeMap.get(id) } },
|
||||
{
|
||||
position: {
|
||||
startLine: contextNode.startPosition.row + 1,
|
||||
startCol: contextNode.startPosition.column,
|
||||
},
|
||||
purpose: 'value',
|
||||
},
|
||||
);
|
||||
return claim.typeBinding?.rawName ?? null;
|
||||
}
|
||||
|
||||
function lookupReturnTypeInScopes(
|
||||
|
|
@ -851,34 +1090,29 @@ function extractVarName(node: SyntaxNode): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
function injectTypeBinding(scope: Scope, name: string, typeName: string): void {
|
||||
if (scope.typeBindings.has(name)) return;
|
||||
(scope.typeBindings as Map<string, TypeRef>).set(name, {
|
||||
rawName: typeName,
|
||||
declaredAtScope: scope.id,
|
||||
source: 'annotation',
|
||||
});
|
||||
}
|
||||
|
||||
function findEnclosingFunctionScope(
|
||||
node: SyntaxNode,
|
||||
scopeMap: ReadonlyMap<ScopeId, Scope>,
|
||||
): Scope | null {
|
||||
let current: SyntaxNode | null = node as SyntaxNode;
|
||||
while (current !== null) {
|
||||
if (current.type === 'function_item') {
|
||||
for (const scope of scopeMap.values()) {
|
||||
if (
|
||||
scope.kind === 'Function' &&
|
||||
scope.range.startLine === current.startPosition.row + 1 &&
|
||||
scope.range.startCol === current.startPosition.column
|
||||
) {
|
||||
return scope;
|
||||
function injectTypeBinding(
|
||||
scope: Scope,
|
||||
name: string,
|
||||
typeName: string | TypeRef,
|
||||
replaceInferred = false,
|
||||
): void {
|
||||
const existing = scope.typeBindings.get(name);
|
||||
if (
|
||||
existing !== undefined &&
|
||||
!(
|
||||
replaceInferred &&
|
||||
(existing.source === 'constructor-inferred' || existing.source === 'assignment-inferred')
|
||||
)
|
||||
)
|
||||
return;
|
||||
(scope.typeBindings as Map<string, TypeRef>).set(
|
||||
name,
|
||||
typeof typeName === 'string'
|
||||
? {
|
||||
rawName: typeName,
|
||||
declaredAtScope: scope.id,
|
||||
source: 'annotation',
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
: typeName,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,8 +160,8 @@ export const rustScopeResolver: ScopeResolver = {
|
|||
importEdgeReason: 'rust-scope: use',
|
||||
loadResolutionConfig: loadRustCargoTargets,
|
||||
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) =>
|
||||
resolveRustImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig),
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig, context) =>
|
||||
resolveRustImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig, context),
|
||||
|
||||
mergeBindings: (existing, incoming, scopeId) => rustMergeBindings(existing, incoming, scopeId),
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import { getTreeSitterBufferSize } from '../../constants.js';
|
|||
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
||||
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
|
||||
import { synthesizeCjsModuleExports } from './cjs-module-exports.js';
|
||||
import { synthesizeTsLocalImports } from './local-loaders.js';
|
||||
import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
|
||||
import {
|
||||
deriveDefaultExportHocName,
|
||||
|
|
@ -362,7 +363,9 @@ export function emitTsScopeCaptures(
|
|||
sourceText: string,
|
||||
filePath: string,
|
||||
cachedTree?: unknown,
|
||||
sourceMeta?: { scriptLanguage?: string },
|
||||
): readonly CaptureMatch[] {
|
||||
const grammarPath = sourceMeta?.scriptLanguage === 'tsx' ? `${filePath}.tsx` : filePath;
|
||||
// Reuse a pre-parsed Tree when the caller passes one via `cachedTree`; a
|
||||
// miss re-parses. (The cache is currently always empty — its only producer,
|
||||
// the sequential parser, was removed — so this re-parses in practice.) The
|
||||
|
|
@ -377,11 +380,11 @@ export function emitTsScopeCaptures(
|
|||
// fresh parse if they disagree (e.g. a worker-mode parse landed
|
||||
// with the wrong grammar pinned).
|
||||
let tree = cachedTree as ReturnType<ReturnType<typeof getTsParser>['parse']> | undefined;
|
||||
if (tree !== undefined && !tsCachedTreeMatchesGrammar(tree, filePath)) {
|
||||
if (tree !== undefined && !tsCachedTreeMatchesGrammar(tree, grammarPath)) {
|
||||
tree = undefined;
|
||||
}
|
||||
if (tree === undefined) {
|
||||
tree = parseSourceSafe(getTsParser(filePath), sourceText, undefined, {
|
||||
tree = parseSourceSafe(getTsParser(grammarPath), sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
recordCacheMiss();
|
||||
|
|
@ -389,7 +392,7 @@ export function emitTsScopeCaptures(
|
|||
recordCacheHit();
|
||||
}
|
||||
|
||||
const rawMatches = getTsScopeQuery(filePath).matches(tree.rootNode);
|
||||
const rawMatches = getTsScopeQuery(grammarPath).matches(tree.rootNode);
|
||||
// Export evidence, read once per file (see `ts-js-export-marker.ts`).
|
||||
const exportEvidence = collectEsmExportEvidence(tree.rootNode, filePath);
|
||||
const out: CaptureMatch[] = [];
|
||||
|
|
@ -733,7 +736,8 @@ export function emitTsScopeCaptures(
|
|||
// emitter: a `.ts` file in a CommonJS package uses the same forms, and
|
||||
// without this the default-export NODE was emitted with nothing declaring it
|
||||
// — the "found, zero callers" state this work exists to remove (#2729 F7).
|
||||
synthesizeCjsModuleExports(tree.rootNode, filePath, out);
|
||||
const validRequireCalls = synthesizeTsLocalImports(tree.rootNode, filePath, out);
|
||||
synthesizeCjsModuleExports(tree.rootNode, filePath, out, validRequireCalls);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
|
@ -791,7 +795,7 @@ function synthesizeTsInheritanceReferences(root: SyntaxNode, out: CaptureMatch[]
|
|||
for (const child of node.namedChildren) {
|
||||
if (child === null || child.type !== 'extends_type_clause') continue;
|
||||
for (const base of child.namedChildren) {
|
||||
emitTsInheritanceBase(base, out);
|
||||
emitTsInheritanceBase(base, out, 'type');
|
||||
}
|
||||
}
|
||||
continue;
|
||||
|
|
@ -819,11 +823,11 @@ function synthesizeTsInheritanceReferences(root: SyntaxNode, out: CaptureMatch[]
|
|||
// `extends Foo` / `extends Foo<T>` — the base is the `value:` field
|
||||
// (an identifier; generics live in a sibling `type_arguments`).
|
||||
const value = clause.childForFieldName('value') ?? clause.firstNamedChild;
|
||||
emitTsInheritanceBase(value, out);
|
||||
emitTsInheritanceBase(value, out, 'value');
|
||||
} else if (clause.type === 'implements_clause') {
|
||||
// `implements IFoo, IBar<T>` — each base type is a direct named child.
|
||||
for (const base of clause.namedChildren) {
|
||||
emitTsInheritanceBase(base, out);
|
||||
emitTsInheritanceBase(base, out, 'type');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -833,13 +837,18 @@ function synthesizeTsInheritanceReferences(root: SyntaxNode, out: CaptureMatch[]
|
|||
/** Emit one `@reference.inherits` match for a TS heritage base, normalizing
|
||||
* the lookup name to its bare simple identifier. No-ops on null / non-type
|
||||
* nodes or when the bare name can't be derived. */
|
||||
function emitTsInheritanceBase(base: SyntaxNode | null, out: CaptureMatch[]): void {
|
||||
function emitTsInheritanceBase(
|
||||
base: SyntaxNode | null,
|
||||
out: CaptureMatch[],
|
||||
purpose: 'value' | 'type',
|
||||
): void {
|
||||
if (base === null) return;
|
||||
const nameNode = terminalTsTypeNameNode(base);
|
||||
if (nameNode === null) return;
|
||||
out.push({
|
||||
'@reference.inherits': nodeToCapture('@reference.inherits', base),
|
||||
'@reference.name': nodeToCapture('@reference.name', nameNode),
|
||||
'@reference.lookup-purpose': syntheticCapture('@reference.lookup-purpose', base, purpose),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,11 @@ import { defaultExportNameCollides } from './cjs-export-assignment.js';
|
|||
* resolve through it, and synthesizing a second binding would re-create the
|
||||
* ambiguity the shadow guard exists to prevent.
|
||||
*/
|
||||
export function synthesizeCjsReExports(root: SyntaxNode, out: CaptureMatch[]): void {
|
||||
export function synthesizeCjsReExports(
|
||||
root: SyntaxNode,
|
||||
out: CaptureMatch[],
|
||||
validRequireCalls?: ReadonlyMap<number, ReadonlySet<string>>,
|
||||
): void {
|
||||
// Namespace and named bindings introduced by require(), by local name.
|
||||
const namespaceSources = new Map<string, { source: string; node: SyntaxNode }>();
|
||||
const namedSources = new Map<string, { source: string; name: string; node: SyntaxNode }>();
|
||||
|
|
@ -51,6 +55,8 @@ export function synthesizeCjsReExports(root: SyntaxNode, out: CaptureMatch[]): v
|
|||
const value = declarator.childForFieldName('value');
|
||||
if (value === null || value.type !== 'call_expression') continue;
|
||||
if (value.childForFieldName('function')?.text !== 'require') continue;
|
||||
if (validRequireCalls !== undefined && !validRequireCalls.has(value.id)) continue;
|
||||
const blockedNames = validRequireCalls?.get(value.id);
|
||||
const arg = value.childForFieldName('arguments')?.namedChild(0);
|
||||
if (arg === undefined || arg === null || arg.type !== 'string') continue;
|
||||
const source = arg.namedChild(0)?.text ?? arg.text.slice(1, -1);
|
||||
|
|
@ -58,15 +64,22 @@ export function synthesizeCjsReExports(root: SyntaxNode, out: CaptureMatch[]): v
|
|||
const nameNode = declarator.childForFieldName('name');
|
||||
if (nameNode === null) continue;
|
||||
if (nameNode.type === 'identifier') {
|
||||
if (blockedNames?.has(nameNode.text)) continue;
|
||||
namespaceSources.set(nameNode.text, { source, node: arg });
|
||||
} else if (nameNode.type === 'object_pattern') {
|
||||
for (const field of nameNode.namedChildren) {
|
||||
if (field.type === 'shorthand_property_identifier_pattern') {
|
||||
if (blockedNames?.has(field.text)) continue;
|
||||
namedSources.set(field.text, { source, name: field.text, node: arg });
|
||||
} else if (field.type === 'pair_pattern') {
|
||||
const key = field.childForFieldName('key');
|
||||
const local = field.childForFieldName('value');
|
||||
if (key !== null && local !== null && local.type === 'identifier')
|
||||
if (
|
||||
key !== null &&
|
||||
local !== null &&
|
||||
local.type === 'identifier' &&
|
||||
!blockedNames?.has(local.text)
|
||||
)
|
||||
namedSources.set(local.text, { source, name: key.text, node: arg });
|
||||
}
|
||||
}
|
||||
|
|
@ -190,7 +203,8 @@ export function synthesizeCjsModuleExports(
|
|||
root: SyntaxNode,
|
||||
filePath: string,
|
||||
out: CaptureMatch[],
|
||||
validRequireCalls?: ReadonlyMap<number, ReadonlySet<string>>,
|
||||
): void {
|
||||
synthesizeCjsReExports(root, out);
|
||||
synthesizeCjsReExports(root, out, validRequireCalls);
|
||||
synthesizeCjsDefaultExport(root, filePath, out);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,10 +39,9 @@
|
|||
* The TypeScript registry-primary path intentionally does NOT resolve
|
||||
* the following. Each is a conscious trade-off at migration time.
|
||||
*
|
||||
* 1. **Type-only import / export separation** — `import type { X }`
|
||||
* and `import { X }` produce the same `ParsedImport` shape today;
|
||||
* `def.type` on the resolved symbol is the only discriminator.
|
||||
* Parity with the legacy path is preserved. Tracking in #927.
|
||||
* 1. **Indirect local loaders** — literal require and immediately awaited
|
||||
* import bindings are supported. Computed sources, promise chains and
|
||||
* default/rest destructuring do not establish imported aliases.
|
||||
* 2. **Declaration merging for imports** — when `import { Foo }`
|
||||
* brings in a symbol that is BOTH a class and a namespace in the
|
||||
* source module, we currently surface a single binding per the
|
||||
|
|
|
|||
|
|
@ -15,7 +15,13 @@
|
|||
* `@type-binding.type`.
|
||||
*/
|
||||
|
||||
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
|
||||
import type {
|
||||
CaptureMatch,
|
||||
ParsedImport,
|
||||
ParsedTypeBinding,
|
||||
ScopeId,
|
||||
TypeRef,
|
||||
} from 'gitnexus-shared';
|
||||
|
||||
/** Shared empty result for the non-type-only path — see `typeOnly` below. */
|
||||
const NO_TYPE_ONLY: { typeOnly?: true } = Object.freeze({});
|
||||
|
|
@ -33,6 +39,8 @@ export function interpretTsImport(captures: CaptureMatch): ParsedImport | null {
|
|||
const nameCap = captures['@import.name'];
|
||||
const aliasCap = captures['@import.alias'];
|
||||
const sourceCap = captures['@import.source'];
|
||||
const owner = captures['@import.lookup-scope'];
|
||||
const lexicalOwner = owner ? { declaredAtScope: owner.text as ScopeId } : {};
|
||||
|
||||
const kind = kindCap?.text;
|
||||
if (kind === undefined) return null;
|
||||
|
|
@ -55,6 +63,7 @@ export function interpretTsImport(captures: CaptureMatch): ParsedImport | null {
|
|||
if (sourceCap === undefined || aliasCap === undefined) return null;
|
||||
return {
|
||||
kind: 'alias',
|
||||
...lexicalOwner,
|
||||
localName: aliasCap.text,
|
||||
importedName: 'default',
|
||||
alias: aliasCap.text,
|
||||
|
|
@ -67,6 +76,7 @@ export function interpretTsImport(captures: CaptureMatch): ParsedImport | null {
|
|||
if (sourceCap === undefined || nameCap === undefined) return null;
|
||||
return {
|
||||
kind: 'named',
|
||||
...lexicalOwner,
|
||||
localName: nameCap.text,
|
||||
importedName: nameCap.text,
|
||||
targetRaw: sourceCap.text,
|
||||
|
|
@ -80,6 +90,7 @@ export function interpretTsImport(captures: CaptureMatch): ParsedImport | null {
|
|||
}
|
||||
return {
|
||||
kind: 'alias',
|
||||
...lexicalOwner,
|
||||
localName: aliasCap.text,
|
||||
importedName: nameCap.text,
|
||||
alias: aliasCap.text,
|
||||
|
|
@ -92,6 +103,7 @@ export function interpretTsImport(captures: CaptureMatch): ParsedImport | null {
|
|||
if (sourceCap === undefined || aliasCap === undefined) return null;
|
||||
return {
|
||||
kind: 'namespace',
|
||||
...lexicalOwner,
|
||||
localName: aliasCap.text,
|
||||
importedName: sourceCap.text,
|
||||
targetRaw: sourceCap.text,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,602 @@
|
|||
/** Lexical names and statically named loaders shared by TS, JS, and Vue scripts. */
|
||||
import {
|
||||
makeScopeId,
|
||||
type Capture,
|
||||
type CaptureMatch,
|
||||
type NameClaim,
|
||||
type Range,
|
||||
type ScopeKind,
|
||||
} from 'gitnexus-shared';
|
||||
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
const FUNCTION_TYPES = new Set([
|
||||
'function_declaration',
|
||||
'generator_function_declaration',
|
||||
'function_signature',
|
||||
'function_expression',
|
||||
'generator_function',
|
||||
'arrow_function',
|
||||
'method_definition',
|
||||
'method_signature',
|
||||
'abstract_method_signature',
|
||||
]);
|
||||
const SCOPE_TAGS: Readonly<Record<string, ScopeKind>> = {
|
||||
'@scope.module': 'Module',
|
||||
'@scope.namespace': 'Namespace',
|
||||
'@scope.class': 'Class',
|
||||
'@scope.function': 'Function',
|
||||
'@scope.block': 'Block',
|
||||
'@scope.object': 'Object',
|
||||
};
|
||||
interface LexicalScope {
|
||||
readonly anchor: Capture;
|
||||
readonly kind: ScopeKind;
|
||||
readonly matchIndex: number;
|
||||
node?: SyntaxNode;
|
||||
parent?: LexicalScope;
|
||||
readonly claims: NameClaim[];
|
||||
readonly names: Set<string>;
|
||||
}
|
||||
interface LocatedNode {
|
||||
readonly node: SyntaxNode;
|
||||
readonly scope: LexicalScope;
|
||||
}
|
||||
const rangeKey = (range: Range) =>
|
||||
`${range.startLine}:${range.startCol}-${range.endLine}:${range.endCol}`;
|
||||
const scopeKey = (range: Range, kind: ScopeKind) => `${kind}:${rangeKey(range)}`;
|
||||
const rangeOf = (node: SyntaxNode): Range => {
|
||||
const start = node.startPosition;
|
||||
const end = node.endPosition;
|
||||
return {
|
||||
startLine: start.row + 1,
|
||||
startCol: start.column,
|
||||
endLine: end.row + 1,
|
||||
endCol: end.column,
|
||||
};
|
||||
};
|
||||
const atEnd = (node: SyntaxNode) => ({
|
||||
startLine: node.endPosition.row + 1,
|
||||
startCol: node.endPosition.column,
|
||||
});
|
||||
|
||||
/** Binding patterns, not arbitrary descendants: object keys and default RHSs do not bind. */
|
||||
function boundNames(node: SyntaxNode | null): SyntaxNode[] {
|
||||
if (node === null) return [];
|
||||
switch (node.type) {
|
||||
case 'identifier':
|
||||
case 'type_identifier':
|
||||
case 'shorthand_property_identifier_pattern':
|
||||
return [node];
|
||||
case 'required_parameter':
|
||||
case 'optional_parameter':
|
||||
return boundNames(node.childForFieldName('pattern'));
|
||||
case 'pair_pattern':
|
||||
return boundNames(node.childForFieldName('value'));
|
||||
case 'assignment_pattern':
|
||||
case 'object_assignment_pattern':
|
||||
return boundNames(node.childForFieldName('left'));
|
||||
case 'rest_pattern':
|
||||
return boundNames(node.firstNamedChild);
|
||||
case 'object_pattern':
|
||||
case 'array_pattern':
|
||||
case 'formal_parameters':
|
||||
return node.namedChildren.flatMap(boundNames);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function functionOwner(scope: LexicalScope): LexicalScope {
|
||||
let current = scope;
|
||||
while (
|
||||
current.parent &&
|
||||
current.kind !== 'Function' &&
|
||||
current.kind !== 'Module' &&
|
||||
current.kind !== 'Namespace'
|
||||
)
|
||||
current = current.parent;
|
||||
return current;
|
||||
}
|
||||
function bindingOwner(scope: LexicalScope, name: string): LexicalScope | undefined {
|
||||
for (let current: LexicalScope | undefined = scope; current; current = current.parent) {
|
||||
if (current.names.has(name)) return current;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
function extraBlock(node: SyntaxNode): boolean {
|
||||
return (
|
||||
node.type === 'catch_clause' ||
|
||||
node.type === 'switch_body' ||
|
||||
(node.type === 'for_in_statement' &&
|
||||
['let', 'const'].includes(node.childForFieldName('kind')?.text ?? '')) ||
|
||||
(node.type === 'for_statement' &&
|
||||
node.childForFieldName('initializer')?.type === 'lexical_declaration')
|
||||
);
|
||||
}
|
||||
function nodeScopeKind(node: SyntaxNode): ScopeKind | undefined {
|
||||
if (FUNCTION_TYPES.has(node.type)) return 'Function';
|
||||
switch (node.type) {
|
||||
case 'program':
|
||||
return 'Module';
|
||||
case 'internal_module':
|
||||
return 'Namespace';
|
||||
case 'class':
|
||||
case 'class_declaration':
|
||||
case 'abstract_class_declaration':
|
||||
case 'interface_declaration':
|
||||
case 'enum_declaration':
|
||||
case 'type_alias_declaration':
|
||||
return 'Class';
|
||||
case 'object':
|
||||
return 'Object';
|
||||
case 'statement_block':
|
||||
return 'Block';
|
||||
default:
|
||||
return extraBlock(node) ? 'Block' : undefined;
|
||||
}
|
||||
}
|
||||
/** A hoisted var initialized conditionally cannot prove a namespace value. */
|
||||
function conditionalVar(node: SyntaxNode, owner: LexicalScope): boolean {
|
||||
if (node.parent?.type !== 'variable_declaration') return false;
|
||||
for (
|
||||
let current = node.parent.parent;
|
||||
current && current.id !== owner.node?.id;
|
||||
current = current.parent
|
||||
) {
|
||||
if (
|
||||
[
|
||||
'if_statement',
|
||||
'switch_case',
|
||||
'switch_default',
|
||||
'for_statement',
|
||||
'for_in_statement',
|
||||
'while_statement',
|
||||
'do_statement',
|
||||
'catch_clause',
|
||||
'try_statement',
|
||||
].includes(current.type)
|
||||
)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add scope facts and imports in one AST pass. Returned identities and blocked
|
||||
* binding names let CommonJS forwarding reject shadowed loaders and written handles.
|
||||
* Computed sources and indirect promise/loader chains never become aliases.
|
||||
*/
|
||||
export function synthesizeTsLocalImports(
|
||||
root: SyntaxNode,
|
||||
filePath: string,
|
||||
out: CaptureMatch[],
|
||||
): ReadonlyMap<number, ReadonlySet<string>> {
|
||||
const scopes = new Map<string, LexicalScope>();
|
||||
const scopesAt = new Map<string, LexicalScope>();
|
||||
const calls: LocatedNode[] = [];
|
||||
const writes: LocatedNode[] = [];
|
||||
const bindingWrites: Array<LocatedNode & { readonly name: string }> = [];
|
||||
const uninitializedVarClaims = new Set<NameClaim>();
|
||||
const declarations: LocatedNode[] = [];
|
||||
const equalsImports: LocatedNode[] = [];
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
for (const [tag, kind] of Object.entries(SCOPE_TAGS)) {
|
||||
const anchor = out[i][tag];
|
||||
if (anchor)
|
||||
scopes.set(scopeKey(anchor.range, kind), {
|
||||
anchor,
|
||||
kind,
|
||||
matchIndex: i,
|
||||
claims: [],
|
||||
names: new Set(),
|
||||
});
|
||||
}
|
||||
}
|
||||
const module = scopes.get(scopeKey(rangeOf(root), 'Module'));
|
||||
if (!module) return new Map();
|
||||
|
||||
const add = (
|
||||
scope: LexicalScope,
|
||||
node: SyntaxNode,
|
||||
names: readonly SyntaxNode[],
|
||||
extra: Partial<NameClaim> = {},
|
||||
) => {
|
||||
for (const name of names) {
|
||||
if (extra.purpose !== 'type') scope.names.add(name.text);
|
||||
scope.claims.push({
|
||||
name: name.text,
|
||||
range: rangeOf(node),
|
||||
kind: 'binding',
|
||||
purpose: 'value',
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
};
|
||||
const pending: Array<{ node: SyntaxNode; parent: LexicalScope }> = [
|
||||
{ node: root, parent: module },
|
||||
];
|
||||
while (pending.length) {
|
||||
const { node, parent } = pending.pop()!;
|
||||
const range = rangeOf(node);
|
||||
const key = rangeKey(range);
|
||||
const kind = nodeScopeKind(node);
|
||||
let scope = kind ? scopes.get(scopeKey(range, kind)) : undefined;
|
||||
if (!scope && extraBlock(node)) {
|
||||
const anchor = nodeToCapture('@scope.block', node);
|
||||
scope = { anchor, kind: 'Block', matchIndex: out.length, claims: [], names: new Set() };
|
||||
scopes.set(scopeKey(range, 'Block'), scope);
|
||||
out.push({ '@scope.block': anchor });
|
||||
}
|
||||
if (scope && scope !== parent) scope.parent = parent;
|
||||
if (scope) scope.node = node;
|
||||
const current = scope ?? parent;
|
||||
if (node.type === 'import_statement') scopesAt.set(key, current);
|
||||
|
||||
if (FUNCTION_TYPES.has(node.type)) {
|
||||
// JSDoc parameter type facts are anchored on the enclosing function.
|
||||
add(
|
||||
current,
|
||||
node,
|
||||
boundNames(node.childForFieldName('parameters') ?? node.childForFieldName('parameter')),
|
||||
{ hoisted: true },
|
||||
);
|
||||
const name = node.childForFieldName('name');
|
||||
if (
|
||||
name &&
|
||||
['function_declaration', 'generator_function_declaration', 'function_signature'].includes(
|
||||
node.type,
|
||||
)
|
||||
) {
|
||||
add(parent, node, [name], { hoisted: true });
|
||||
} else if (name && ['function_expression', 'generator_function'].includes(node.type))
|
||||
add(current, node, [name], { hoisted: true });
|
||||
}
|
||||
if (node.type === 'variable_declarator') {
|
||||
const owner = node.parent?.type === 'variable_declaration' ? functionOwner(current) : current;
|
||||
// Declaration queries anchor values on the whole declaration (and JS
|
||||
// also captures its export wrapper). The claim must contain those exact
|
||||
// declaration ranges while activation still follows this initializer.
|
||||
const declaration = node.parent ?? node;
|
||||
const anchor =
|
||||
declaration.parent?.type === 'export_statement' ? declaration.parent : declaration;
|
||||
const uninitializedVar =
|
||||
node.parent?.type === 'variable_declaration' && node.childForFieldName('value') === null;
|
||||
const claimStart = owner.claims.length;
|
||||
add(
|
||||
owner,
|
||||
anchor,
|
||||
boundNames(node.childForFieldName('name')),
|
||||
uninitializedVar ? { hoisted: true } : { availableFrom: atEnd(node) },
|
||||
);
|
||||
if (uninitializedVar) {
|
||||
for (const claim of owner.claims.slice(claimStart)) uninitializedVarClaims.add(claim);
|
||||
}
|
||||
declarations.push({ node, scope: owner });
|
||||
} else if (node.type === 'catch_clause') {
|
||||
add(
|
||||
current,
|
||||
node.childForFieldName('parameter') ?? node,
|
||||
boundNames(node.childForFieldName('parameter')),
|
||||
);
|
||||
} else if (node.type === 'for_in_statement') {
|
||||
const kind = node.childForFieldName('kind')?.text;
|
||||
if (kind)
|
||||
add(
|
||||
kind === 'var' ? functionOwner(current) : current,
|
||||
node.childForFieldName('left') ?? node,
|
||||
boundNames(node.childForFieldName('left')),
|
||||
);
|
||||
} else if (node.type === 'class') {
|
||||
const name = node.childForFieldName('name');
|
||||
if (name) add(current, node, [name], { purpose: 'both', hoisted: true });
|
||||
} else if (node.type === 'class_declaration' || node.type === 'abstract_class_declaration') {
|
||||
const name = node.childForFieldName('name');
|
||||
if (name) {
|
||||
add(parent, node, [name], { availableFrom: atEnd(node) });
|
||||
add(parent, node, [name], { purpose: 'type', hoisted: true });
|
||||
}
|
||||
} else if (node.type === 'enum_declaration' || node.type === 'internal_module') {
|
||||
const name = node.childForFieldName('name');
|
||||
if (name)
|
||||
add(parent, node, [name], {
|
||||
purpose: 'both',
|
||||
availableFrom: {
|
||||
startLine: node.startPosition.row + 1,
|
||||
startCol: node.startPosition.column,
|
||||
},
|
||||
});
|
||||
} else if (node.type === 'interface_declaration' || node.type === 'type_alias_declaration') {
|
||||
const name = node.childForFieldName('name');
|
||||
if (name) add(scope ? parent : current, node, [name], { purpose: 'type', hoisted: true });
|
||||
}
|
||||
if (node.type === 'call_expression') calls.push({ node, scope: current });
|
||||
if (
|
||||
node.type === 'assignment_expression' ||
|
||||
node.type === 'augmented_assignment_expression' ||
|
||||
node.type === 'update_expression'
|
||||
)
|
||||
writes.push({ node, scope: current });
|
||||
if (
|
||||
node.type === 'import_statement' &&
|
||||
node.namedChildren.some((child) => child.type === 'import_require_clause')
|
||||
)
|
||||
equalsImports.push({ node, scope: current });
|
||||
for (let i = node.namedChildCount - 1; i >= 0; i--) {
|
||||
const child = node.namedChild(i);
|
||||
if (child) pending.push({ node: child, parent: current });
|
||||
}
|
||||
}
|
||||
|
||||
// Static imports establish loader identity before any require call is inspected.
|
||||
for (const match of out) {
|
||||
const anchor = match['@import.statement'];
|
||||
const kind = match['@import.kind']?.text;
|
||||
if (
|
||||
!anchor ||
|
||||
!kind ||
|
||||
kind.startsWith('reexport') ||
|
||||
kind === 'dynamic' ||
|
||||
kind === 'side-effect'
|
||||
)
|
||||
continue;
|
||||
const name = match['@import.alias']?.text ?? match['@import.name']?.text;
|
||||
const scope = scopesAt.get(rangeKey(anchor.range));
|
||||
if (!name || !scope) continue;
|
||||
scope.names.add(name);
|
||||
const claim: NameClaim = {
|
||||
name,
|
||||
range: anchor.range,
|
||||
kind: 'import',
|
||||
purpose: 'both',
|
||||
hoisted: true,
|
||||
};
|
||||
if (match['@import.type-only']) {
|
||||
scope.claims.push(
|
||||
{ ...claim, purpose: 'type' },
|
||||
{ ...claim, kind: 'blocked', purpose: 'value' },
|
||||
);
|
||||
} else scope.claims.push(claim);
|
||||
}
|
||||
for (const { node, scope } of equalsImports) {
|
||||
const clause = node.namedChildren.find((child) => child.type === 'import_require_clause')!;
|
||||
const name = clause.namedChildren.find((child) => child.type === 'identifier');
|
||||
if (name) scope.names.add(name.text);
|
||||
}
|
||||
|
||||
// An assignment to an actual local shadow does not mutate the ambient loader.
|
||||
// A write to the ambient loader makes its identity unknown for this file.
|
||||
let ambientLoaderWritten = false;
|
||||
for (const { node, scope } of writes) {
|
||||
const names = boundNames(node.childForFieldName('left') ?? node.childForFieldName('argument'));
|
||||
for (const name of names) {
|
||||
const owner = bindingOwner(scope, name.text);
|
||||
if (name.text === 'require' && !owner) ambientLoaderWritten = true;
|
||||
if (owner) bindingWrites.push({ node, scope: owner, name: name.text });
|
||||
}
|
||||
}
|
||||
|
||||
const validRequireCalls = new Map<number, Set<string>>();
|
||||
const declarationByValue = new Map(
|
||||
declarations.map((entry) => [entry.node.childForFieldName('value')?.id, entry]),
|
||||
);
|
||||
const emit = (
|
||||
at: SyntaxNode,
|
||||
sourceNode: SyntaxNode,
|
||||
source: string,
|
||||
kind: string,
|
||||
scope: LexicalScope,
|
||||
name?: SyntaxNode,
|
||||
imported?: string,
|
||||
available?: ReturnType<typeof atEnd>,
|
||||
typeOnly = false,
|
||||
) => {
|
||||
const match: Record<string, Capture> = {
|
||||
'@import.statement': nodeToCapture('@import.statement', at),
|
||||
'@import.kind': syntheticCapture('@import.kind', at, kind),
|
||||
'@import.source': syntheticCapture('@import.source', sourceNode, source),
|
||||
'@import.lookup-scope': syntheticCapture(
|
||||
'@import.lookup-scope',
|
||||
at,
|
||||
makeScopeId({ filePath, range: scope.anchor.range, kind: scope.kind }),
|
||||
),
|
||||
};
|
||||
if (typeOnly) match['@import.type-only'] = syntheticCapture('@import.type-only', at, 'true');
|
||||
if (name) {
|
||||
match['@import.alias'] = nodeToCapture('@import.alias', name);
|
||||
if (imported) match['@import.name'] = syntheticCapture('@import.name', name, imported);
|
||||
scope.names.add(name.text);
|
||||
// A loader initializer supplies the variable's value; its ordinary
|
||||
// declaration must not compete with the imported target at this site.
|
||||
for (let i = scope.claims.length - 1; i >= 0; i--) {
|
||||
const claim = scope.claims[i];
|
||||
if (
|
||||
claim.name === name.text &&
|
||||
claim.kind === 'binding' &&
|
||||
claim.availableFrom?.startLine === available?.startLine &&
|
||||
claim.availableFrom?.startCol === available?.startCol
|
||||
)
|
||||
scope.claims.splice(i, 1);
|
||||
}
|
||||
const claim: NameClaim = {
|
||||
name: name.text,
|
||||
range: match['@import.statement'].range,
|
||||
kind: 'import',
|
||||
purpose: 'both',
|
||||
...(available ? { availableFrom: available } : { hoisted: true }),
|
||||
};
|
||||
if (typeOnly)
|
||||
scope.claims.push(
|
||||
{ ...claim, purpose: 'type' },
|
||||
{ ...claim, kind: 'blocked', purpose: 'value' },
|
||||
);
|
||||
else scope.claims.push(claim);
|
||||
}
|
||||
out.push(match);
|
||||
};
|
||||
for (const { node, scope } of calls) {
|
||||
const fn = node.childForFieldName('function');
|
||||
const requireCall = fn?.type === 'identifier' && fn.text === 'require';
|
||||
const importCall = fn?.type === 'import';
|
||||
if (!requireCall && !importCall) continue;
|
||||
if (requireCall && (ambientLoaderWritten || bindingOwner(scope, 'require'))) continue;
|
||||
const args =
|
||||
node
|
||||
.childForFieldName('arguments')
|
||||
?.namedChildren.filter((child) => child.type !== 'comment') ?? [];
|
||||
const sourceNode = args[0];
|
||||
if (!sourceNode || sourceNode.type !== 'string' || (requireCall && args.length !== 1)) continue;
|
||||
const source = sourceNode.text.slice(1, -1);
|
||||
if (requireCall) validRequireCalls.set(node.id, new Set());
|
||||
const value = importCall && node.parent?.type === 'await_expression' ? node.parent : node;
|
||||
const declaration = declarationByValue.get(value.id);
|
||||
if (importCall && value === node) continue; // A promise is not a module namespace.
|
||||
if (!declaration) {
|
||||
if (requireCall) emit(node, sourceNode, source, 'side-effect', scope);
|
||||
continue;
|
||||
}
|
||||
const name = declaration.node.childForFieldName('name');
|
||||
const available = atEnd(declaration.node);
|
||||
if (name?.type === 'identifier')
|
||||
emit(node, sourceNode, source, 'namespace', declaration.scope, name, undefined, available);
|
||||
else if (name?.type === 'object_pattern') {
|
||||
for (const field of name.namedChildren) {
|
||||
if (field.type === 'shorthand_property_identifier_pattern') {
|
||||
emit(node, sourceNode, source, 'named', declaration.scope, field, field.text, available);
|
||||
} else if (field.type === 'pair_pattern') {
|
||||
const key = field.childForFieldName('key');
|
||||
const local = field.childForFieldName('value');
|
||||
if (
|
||||
key &&
|
||||
['property_identifier', 'identifier', 'string'].includes(key.type) &&
|
||||
local?.type === 'identifier'
|
||||
) {
|
||||
emit(
|
||||
node,
|
||||
sourceNode,
|
||||
source,
|
||||
'named-alias',
|
||||
declaration.scope,
|
||||
local,
|
||||
key.type === 'string' ? key.text.slice(1, -1) : key.text,
|
||||
available,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const { node, scope } of equalsImports) {
|
||||
const clause = node.namedChildren.find((child) => child.type === 'import_require_clause')!;
|
||||
const name = clause.namedChildren.find((child) => child.type === 'identifier');
|
||||
const source = clause.childForFieldName('source');
|
||||
const typeOnly = node.children.some((child) => child.type === 'type');
|
||||
if (
|
||||
scope.kind === 'Module' &&
|
||||
name &&
|
||||
source?.type === 'string' &&
|
||||
(typeOnly || (!ambientLoaderWritten && !bindingOwner(scope, 'require')))
|
||||
) {
|
||||
emit(
|
||||
node,
|
||||
source,
|
||||
source.text.slice(1, -1),
|
||||
'namespace',
|
||||
scope,
|
||||
name,
|
||||
undefined,
|
||||
undefined,
|
||||
typeOnly,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Writes invalidate an imported handle's module proof, including a var loader
|
||||
// that shares a name with a function declaration. Ordinary locals retain their
|
||||
// type and callable-value inference; an assignment does not erase that evidence.
|
||||
for (const { node, scope, name } of bindingWrites) {
|
||||
if (
|
||||
!scope.claims.some(
|
||||
(claim) => claim.name === name && claim.kind === 'import' && claim.purpose !== 'type',
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
scope.claims.push({
|
||||
name,
|
||||
range: rangeOf(node),
|
||||
kind: 'blocked',
|
||||
purpose: 'value',
|
||||
availableFrom: atEnd(node),
|
||||
});
|
||||
}
|
||||
for (const { node, scope } of declarations) {
|
||||
if (!conditionalVar(node, scope)) continue;
|
||||
const end = atEnd(node);
|
||||
const names = new Set(boundNames(node.childForFieldName('name')).map((name) => name.text));
|
||||
for (let i = 0; i < scope.claims.length; i++) {
|
||||
const claim = scope.claims[i];
|
||||
if (
|
||||
claim.kind === 'import' &&
|
||||
names.has(claim.name) &&
|
||||
claim.availableFrom?.startLine === end.startLine &&
|
||||
claim.availableFrom.startCol === end.startCol
|
||||
) {
|
||||
scope.claims[i] = { ...claim, kind: 'blocked' };
|
||||
}
|
||||
}
|
||||
}
|
||||
// Forwarding exports require a stable handle as well as a genuine loader.
|
||||
// Keep the original import facts for calls before a write; remove forwarding
|
||||
// proof only for each written handle, preserving unchanged destructured siblings.
|
||||
for (const { node, scope } of declarations) {
|
||||
const value = node.childForFieldName('value');
|
||||
const blockedNames = value && validRequireCalls.get(value.id);
|
||||
if (!blockedNames) continue;
|
||||
const names = new Set(boundNames(node.childForFieldName('name')).map((name) => name.text));
|
||||
for (const claim of scope.claims) {
|
||||
if (claim.kind === 'blocked' && names.has(claim.name)) blockedNames.add(claim.name);
|
||||
}
|
||||
}
|
||||
for (const scope of scopes.values()) {
|
||||
// `var x;` creates ownership but performs no store. A same-scope parameter,
|
||||
// function, or initialized declaration already owns that binding's value.
|
||||
const valueNames = new Set(
|
||||
scope.claims
|
||||
.filter(
|
||||
(claim) =>
|
||||
!uninitializedVarClaims.has(claim) &&
|
||||
claim.kind !== 'blocked' &&
|
||||
claim.purpose !== 'type',
|
||||
)
|
||||
.map((claim) => claim.name),
|
||||
);
|
||||
const claims = scope.claims.filter(
|
||||
(claim) => !uninitializedVarClaims.has(claim) || !valueNames.has(claim.name),
|
||||
);
|
||||
const selfName =
|
||||
scope.node?.type === 'class' ? scope.node.childForFieldName('name')?.text : undefined;
|
||||
out[scope.matchIndex] = {
|
||||
...out[scope.matchIndex],
|
||||
'@scope.name-claims': {
|
||||
...scope.anchor,
|
||||
name: '@scope.name-claims',
|
||||
text: JSON.stringify(claims),
|
||||
},
|
||||
'@scope.lookup-policy': {
|
||||
...scope.anchor,
|
||||
name: '@scope.lookup-policy',
|
||||
text: JSON.stringify({
|
||||
// An assignment can update a binding, but cannot declare a local.
|
||||
nameClaimsComplete: true,
|
||||
...(scope.kind === 'Function' ? { deferParentActivation: true } : {}),
|
||||
...(scope.kind === 'Class'
|
||||
? {
|
||||
skipFromChildren: true,
|
||||
...(selfName ? { visibleNamesFromChildren: [selfName] } : {}),
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return validRequireCalls;
|
||||
}
|
||||
|
|
@ -220,6 +220,12 @@ export const TYPESCRIPT_SCOPE_QUERY = `
|
|||
name: (identifier) @declaration.name
|
||||
value: (function_expression) @declaration.function))
|
||||
|
||||
;; Generator expressions have the same callable binding and scope anchor.
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (generator_function) @declaration.function))
|
||||
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
|
|
@ -230,6 +236,11 @@ export const TYPESCRIPT_SCOPE_QUERY = `
|
|||
name: (identifier) @declaration.name
|
||||
value: (function_expression) @declaration.function))
|
||||
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (generator_function) @declaration.function))
|
||||
|
||||
;; CJS property-assignment exports (#2723) — see the matching block in
|
||||
;; \`languages/javascript/query.ts\` for the rationale. Mirrored here because
|
||||
;; \`.ts\` files in a CommonJS package use the same form, and because the JS
|
||||
|
|
@ -1096,12 +1107,11 @@ export const TYPESCRIPT_SCOPE_QUERY = `
|
|||
function: (member_expression) @type-binding.type)) @type-binding.alias
|
||||
|
||||
;; Type bindings — for-of member-access iterable: \`for (const u of this.users)\`.
|
||||
;; Bind u to \`users\` (the attribute name); chain-follow resolves users
|
||||
;; via the enclosing class's field binding.
|
||||
;; Preserve the explicit receiver so class fields are resolved as members,
|
||||
;; without treating them as lexical names visible in method bodies.
|
||||
(for_in_statement
|
||||
left: (identifier) @type-binding.name
|
||||
right: (member_expression
|
||||
property: (property_identifier) @type-binding.type)) @type-binding.alias
|
||||
right: (member_expression) @type-binding.type) @type-binding.alias
|
||||
|
||||
;; Type bindings — class field annotation: \`private city: City\`.
|
||||
(public_field_definition
|
||||
|
|
@ -1639,17 +1649,17 @@ export function getTsScopeQuery(filePath?: string): Parser.Query {
|
|||
|
||||
/**
|
||||
* Validate that a cached `Tree` was produced by the grammar matching
|
||||
* `filePath` (TSX vs TypeScript). The runtime tree-sitter `Tree` exposes
|
||||
* `getLanguage()` (returning the grammar object the parser was bound
|
||||
* to); the .d.ts is incomplete, so we reach via a cast. Identity
|
||||
* `filePath` (TSX vs TypeScript). Native tree-sitter exposes `language`;
|
||||
* other Tree implementations expose `getLanguage()`. The .d.ts is
|
||||
* incomplete, so we reach via a cast. Identity
|
||||
* comparison against `TSX_GRAMMAR` / `TS_GRAMMAR` is exact: the same
|
||||
* module instance produces both. If `getLanguage` is unavailable for
|
||||
* module instance produces both. If neither grammar accessor is available for
|
||||
* any reason, return true to keep behavior backwards-compatible (the
|
||||
* original code never validated grammar at all).
|
||||
*/
|
||||
export function tsCachedTreeMatchesGrammar(tree: unknown, filePath: string): boolean {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const lang = (tree as any)?.getLanguage?.();
|
||||
const lang = (tree as any)?.getLanguage?.() ?? (tree as any)?.language;
|
||||
if (lang === undefined || lang === null) return true;
|
||||
return isTsxFile(filePath) ? lang === TSX_GRAMMAR : lang === TS_GRAMMAR;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,6 +87,8 @@ const typescriptScopeResolver: ScopeResolver = {
|
|||
language: SupportedLanguages.TypeScript,
|
||||
languageProvider: typescriptProvider,
|
||||
importEdgeReason: 'typescript-scope: import',
|
||||
importsBindAtLexicalScope: true,
|
||||
ownedMembersBindAtModuleScope: false,
|
||||
|
||||
resolveImportTarget: makeTsResolveImportTarget(),
|
||||
|
||||
|
|
@ -297,6 +299,8 @@ function hasLocalBindingInScopeChain(
|
|||
visited.add(cursor);
|
||||
const scope = indexes.scopeTree.getScope(cursor);
|
||||
if (!scope) return false;
|
||||
if (scope.nameClaims?.some((claim) => claim.name === name && claim.purpose !== 'type'))
|
||||
return true;
|
||||
|
||||
const localBindings = scope.bindings.get(name);
|
||||
if (
|
||||
|
|
@ -317,15 +321,6 @@ function hasLocalBindingInScopeChain(
|
|||
// belongs to callable space, so a same-file presence check here cannot
|
||||
// over-suppress a legitimate auto-import.
|
||||
//
|
||||
// Residual (known limitation): this catches parameters whose annotation the
|
||||
// TS scope query records as a type-binding (`p: Named`, generics, unions,
|
||||
// predefined, arrays). Function-typed params (`p: () => void`), untyped
|
||||
// params, destructured locals (`const { x } = …`), and catch-clause vars
|
||||
// are captured by NEITHER map — the scope query emits no `@declaration` /
|
||||
// `@type-binding` for them — so those shadow forms still leak an edge.
|
||||
// Closing that needs shared TS scope-query/extractor changes that alter call
|
||||
// resolution beyond Nuxt, so it is deferred to a follow-up rather than fixed
|
||||
// here.
|
||||
if (scope.filePath === filePath && scope.typeBindings.has(name)) {
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,26 @@ export function tsBindingScopeFor(
|
|||
return walkToScope(innermost, tree, 'Class');
|
||||
}
|
||||
|
||||
// JSDoc parameters are anchored on the whole function, unlike inline
|
||||
// parameter annotations. They still belong to its body, not its parent.
|
||||
if (decl['@type-binding.parameter'] !== undefined) {
|
||||
return walkToScope(innermost, tree, 'Function');
|
||||
}
|
||||
|
||||
// A for-of alias covers the whole loop. When that loop introduces a lexical
|
||||
// block, its element type belongs beside the loop variable's name claim.
|
||||
// Returning the owner explicitly prevents equal-range auto-hoisting.
|
||||
const alias = decl['@type-binding.alias'];
|
||||
if (
|
||||
alias !== undefined &&
|
||||
innermost.kind === 'Block' &&
|
||||
alias.range.startLine === innermost.range.startLine &&
|
||||
alias.range.startCol === innermost.range.startCol &&
|
||||
alias.range.endLine === innermost.range.endLine &&
|
||||
alias.range.endCol === innermost.range.endCol
|
||||
)
|
||||
return innermost.id;
|
||||
|
||||
// `this.p = new Outer()` binds the FIELD, not a method-local, so the binding
|
||||
// belongs on the class the way an annotated field's does — that is the only
|
||||
// place `typeOfMemberOnClass` reads. Left on the innermost scope it would sit
|
||||
|
|
@ -107,18 +127,11 @@ function isVarDeclaration(captureText: string): boolean {
|
|||
// ─── importOwningScope ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* TypeScript imports are syntactically top-level: `import_statement` is
|
||||
* legal only inside `program` (the module root). `namespace X { … }`
|
||||
* bodies CAN contain imports (`internal_module`), in which case the
|
||||
* import scopes to the namespace. Dynamic `import()` calls appear
|
||||
* inside any scope but their runtime effect is still a module-level
|
||||
* resolution — we attach the `ParsedImport` to the innermost Module /
|
||||
* Namespace scope so the binding is visible through the full subtree.
|
||||
*
|
||||
* Returning `null` delegates to the central default, which walks to
|
||||
* the nearest enclosing `Module`/`Namespace`. That matches our rule,
|
||||
* so we only override when we explicitly need a non-default scope
|
||||
* (we don't).
|
||||
* Static external imports belong to the module. Local require/await-import
|
||||
* captures carry an explicit declaredAtScope from their binding declaration:
|
||||
* var hoists to a function, while let/const remain in their lexical block.
|
||||
* The extractor honors that explicit owner before consulting this hook.
|
||||
* Returning null leaves other imports at their syntactic scope.
|
||||
*/
|
||||
export function tsImportOwningScope(
|
||||
_imp: ParsedImport,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* Vue language provider.
|
||||
*
|
||||
* Vue SFCs are preprocessed by extracting the <script> / <script setup>
|
||||
* block content, which is then parsed as TypeScript. This provider reuses
|
||||
* block content, which is then parsed as TypeScript or TSX. This provider reuses
|
||||
* nearly all TypeScript infrastructure — queries, type config, field
|
||||
* extraction, and named binding extraction.
|
||||
*
|
||||
|
|
@ -37,6 +37,7 @@ import {
|
|||
resolveTsImportTarget,
|
||||
} from './typescript/index.js';
|
||||
import { emitVueScopeCaptures } from './vue/captures.js';
|
||||
import { getTsParser } from './typescript/query.js';
|
||||
import { createTypeScriptCfgVisitor } from '../cfg/visitors/typescript.js';
|
||||
|
||||
const VUE_SPECIFIC_BUILT_INS = [
|
||||
|
|
@ -89,13 +90,14 @@ export const vueProvider = defineLanguage({
|
|||
variableExtractor: createVariableExtractor(typescriptVariableConfig),
|
||||
classExtractor: vueClassExtractor,
|
||||
builtInNames: VUE_BUILT_INS,
|
||||
// Vue SFC <script> blocks are extracted and parsed with the TypeScript
|
||||
// grammar (parse-worker GRAMMAR_BY_LANGUAGE[Vue] = TypeScript.typescript),
|
||||
// Vue SFC <script> blocks use the TypeScript or TSX grammar,
|
||||
// so the TS CFG visitor builds CFGs for the script's functions verbatim —
|
||||
// no Vue-specific visitor needed (#2195).
|
||||
cfgVisitor: createTypeScriptCfgVisitor(),
|
||||
// Scope-resolution pipeline hooks (RFC #909 Ring 3)
|
||||
emitScopeCaptures: emitVueScopeCaptures,
|
||||
selectEmbeddedGrammar: (lang) =>
|
||||
getTsParser(lang === 'jsx' || lang === 'tsx' ? 'embedded.tsx' : 'embedded.ts').getLanguage(),
|
||||
interpretImport: interpretTsImport,
|
||||
interpretTypeBinding: interpretTsTypeBinding,
|
||||
bindingScopeFor: tsBindingScopeFor,
|
||||
|
|
|
|||
|
|
@ -2,21 +2,17 @@
|
|||
* Vue SFC scope captures (RFC #909 Ring 3, issue #940).
|
||||
*
|
||||
* Extracts the `<script>` / `<script setup>` block from the SFC source
|
||||
* and delegates to `emitTsScopeCaptures`. The parse-worker builds the
|
||||
* cached tree from the extracted script content using the TypeScript
|
||||
* grammar (see `[SupportedLanguages.Vue]: TypeScript.typescript` in
|
||||
* `parse-worker.ts`), so passing that tree here keeps grammar identity
|
||||
* consistent and avoids a redundant re-parse.
|
||||
* and delegates to the script language's scope emitter. The parse worker's
|
||||
* structural tree uses TypeScript or TSX; explicit JS/JSX scripts are parsed with
|
||||
* their own scope grammar inside the worker.
|
||||
*
|
||||
* Template expressions are intentionally out-of-scope: component-
|
||||
* reference CALLS edges are already emitted by the legacy template
|
||||
* extractor in the parse worker and would be double-counted here.
|
||||
*
|
||||
* Position note: all capture positions are relative to the *extracted*
|
||||
* script block, not the full .vue file. This is consistent with the
|
||||
* cached tree and with how the scope model uses positions (only for
|
||||
* scope-containment walks within a single file), so no offset
|
||||
* translation is required for graph-edge correctness.
|
||||
* Lexical capture positions stay relative to the extracted script block,
|
||||
* matching the cached tree. Declaration graph-position metadata carries
|
||||
* the source row map used by the structure phase's graph-node positions.
|
||||
*/
|
||||
|
||||
import type { CaptureMatch } from 'gitnexus-shared';
|
||||
|
|
@ -24,21 +20,60 @@ import { extractVueScript } from '../../vue-sfc-extractor.js';
|
|||
import { emitTsScopeCaptures } from '../typescript/captures.js';
|
||||
import { emitJsScopeCaptures } from '../javascript/captures.js';
|
||||
|
||||
const DECLARATION_ANCHORS = [
|
||||
'@declaration.function',
|
||||
'@declaration.method',
|
||||
'@declaration.class',
|
||||
'@declaration.interface',
|
||||
'@declaration.enum',
|
||||
'@declaration.type_alias',
|
||||
'@declaration.namespace',
|
||||
'@declaration.property',
|
||||
'@declaration.variable',
|
||||
'@declaration.const',
|
||||
] as const;
|
||||
|
||||
/** Preserve lexical coordinates while exposing the structure phase's position. */
|
||||
function withGraphPositions(
|
||||
matches: readonly CaptureMatch[],
|
||||
lineOffset: number,
|
||||
sourceLineMap?: readonly number[],
|
||||
): readonly CaptureMatch[] {
|
||||
return matches.map((match) => {
|
||||
const anchor = DECLARATION_ANCHORS.map((name) => match[name]).find(
|
||||
(capture) => capture !== undefined,
|
||||
);
|
||||
if (anchor === undefined) return match;
|
||||
const startLine =
|
||||
(sourceLineMap?.[anchor.range.startLine - 1] ?? anchor.range.startLine - 1 + lineOffset) + 1;
|
||||
const startCol = anchor.range.startCol;
|
||||
return {
|
||||
...match,
|
||||
'@declaration.graph-position': {
|
||||
name: '@declaration.graph-position',
|
||||
text: '',
|
||||
range: { startLine, startCol, endLine: startLine, endCol: startCol },
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit scope captures for a Vue SFC.
|
||||
*
|
||||
* Handles three call-site shapes:
|
||||
*
|
||||
* 1. **Full SFC content** (sequential path, <15 files): `sourceText`
|
||||
* 1. **Full SFC content** (direct extraction): `sourceText`
|
||||
* contains the whole `.vue` file with `<template>`, `<script>`, etc.
|
||||
* `extractVueScript` extracts the script block and we delegate to
|
||||
* `emitTsScopeCaptures` or `emitJsScopeCaptures` based on `lang`.
|
||||
*
|
||||
* 2. **Already-extracted script content** (worker-mode path, ≥15 files):
|
||||
* 2. **Already-extracted script content** (worker path):
|
||||
* the parse worker calls `extractVueScript` itself before calling
|
||||
* `extractParsedFile`, so `sourceText` is already the bare script
|
||||
* text with no `<script>` tags. The caller marks this explicitly via
|
||||
* `sourceMeta.sourceKind === 'pre-extracted-script'`.
|
||||
* `sourceMeta.sourceKind === 'pre-extracted-script'` and preserves the
|
||||
* extracted language in `sourceMeta.scriptLanguage`.
|
||||
*
|
||||
* 3. **Supporting TS/JS files** included in Vue scope-resolution runs:
|
||||
* when `filePath` is not `.vue`, delegate straight to TypeScript captures.
|
||||
|
|
@ -49,7 +84,12 @@ export function emitVueScopeCaptures(
|
|||
sourceText: string,
|
||||
filePath: string,
|
||||
cachedTree?: unknown,
|
||||
sourceMeta?: { sourceKind?: 'full-file' | 'pre-extracted-script'; setupLang?: string },
|
||||
sourceMeta?: {
|
||||
sourceKind?: 'full-file' | 'pre-extracted-script';
|
||||
scriptLanguage?: string;
|
||||
lineOffset?: number;
|
||||
sourceLineMap?: readonly number[];
|
||||
},
|
||||
): readonly CaptureMatch[] {
|
||||
// Vue resolver may include supporting TS/JS files in the same run to
|
||||
// preserve cross-file import/type context for `.vue` callers. These are
|
||||
|
|
@ -59,9 +99,16 @@ export function emitVueScopeCaptures(
|
|||
}
|
||||
|
||||
if (sourceMeta?.sourceKind === 'pre-extracted-script') {
|
||||
// Worker-mode path: the parse worker always uses TypeScript grammar for
|
||||
// .vue files. Lang-based grammar selection is a sequential-path feature.
|
||||
return emitTsScopeCaptures(sourceText, filePath, cachedTree);
|
||||
const isJavaScript = sourceMeta.scriptLanguage === 'js' || sourceMeta.scriptLanguage === 'jsx';
|
||||
const emit = isJavaScript ? emitJsScopeCaptures : emitTsScopeCaptures;
|
||||
// The worker's structural tree uses a TypeScript grammar. Native Tree versions without
|
||||
// getLanguage() cannot prove grammar compatibility, so never give that
|
||||
// tree to the JavaScript query. This parse stays inside the worker.
|
||||
return withGraphPositions(
|
||||
emit(sourceText, filePath, isJavaScript ? undefined : cachedTree, sourceMeta),
|
||||
sourceMeta.lineOffset ?? 0,
|
||||
sourceMeta.sourceLineMap,
|
||||
);
|
||||
}
|
||||
|
||||
const extracted = extractVueScript(sourceText);
|
||||
|
|
@ -71,7 +118,19 @@ export function emitVueScopeCaptures(
|
|||
// Use TS grammar unless ALL blocks explicitly request JS/JSX.
|
||||
// Mixed-lang: TS handles JS natively; JS grammar chokes on TS syntax.
|
||||
if (extracted.lang === 'js' || extracted.lang === 'jsx') {
|
||||
return emitJsScopeCaptures(extracted.scriptContent, filePath, cachedTree);
|
||||
return withGraphPositions(
|
||||
emitJsScopeCaptures(extracted.scriptContent, filePath, cachedTree, {
|
||||
scriptLanguage: extracted.lang,
|
||||
}),
|
||||
extracted.lineOffset,
|
||||
extracted.sourceLineMap,
|
||||
);
|
||||
}
|
||||
return emitTsScopeCaptures(extracted.scriptContent, filePath, cachedTree);
|
||||
return withGraphPositions(
|
||||
emitTsScopeCaptures(extracted.scriptContent, filePath, cachedTree, {
|
||||
scriptLanguage: extracted.lang,
|
||||
}),
|
||||
extracted.lineOffset,
|
||||
extracted.sourceLineMap,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ const vueScopeResolver: ScopeResolver = {
|
|||
language: SupportedLanguages.Vue,
|
||||
languageProvider: vueProvider,
|
||||
importEdgeReason: 'vue-scope: import',
|
||||
importsBindAtLexicalScope: true,
|
||||
ownedMembersBindAtModuleScope: false,
|
||||
|
||||
resolveImportTarget: makeVueResolveImportTarget(),
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import type { Capture, CaptureMatch, NameClaim } from 'gitnexus-shared';
|
||||
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { getZigParser, getZigScopeQuery } from './query.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
|
|
@ -1172,8 +1172,12 @@ export function emitZigScopeCaptures(
|
|||
// `Counter` from `counter.zig` below. Aliases are collected in the same
|
||||
// pass so their plain-variable group is dropped (a local Const binding
|
||||
// would outrank the import binding it stands for).
|
||||
const importSources = new Map<string, SyntaxNode>();
|
||||
const importSources = new Map<
|
||||
number,
|
||||
Map<string, { readonly statement: SyntaxNode; readonly source: SyntaxNode }>
|
||||
>();
|
||||
const aliasDeclIds = new Set<number>();
|
||||
const aliasSources = new Map<number, SyntaxNode>();
|
||||
// The `@import(…)` string nodes a BINDING rule (or the keyword-less
|
||||
// side-effect rule) matched, by node id, plus their texts. The catch-all
|
||||
// `@import.inline` rule matches those same builtins again; the id set
|
||||
|
|
@ -1210,10 +1214,14 @@ export function emitZigScopeCaptures(
|
|||
importSource !== undefined &&
|
||||
byName.get('import.imported') === undefined &&
|
||||
importStmt !== undefined &&
|
||||
importStmt.parent?.type === 'source_file' &&
|
||||
isZigKeywordDeclaration(importStmt)
|
||||
) {
|
||||
importSources.set(importName.text, importSource);
|
||||
const owner = zigLexicalHost(importStmt);
|
||||
if (owner !== null) {
|
||||
let names = importSources.get(owner.id);
|
||||
if (names === undefined) importSources.set(owner.id, (names = new Map()));
|
||||
names.set(importName.text, { statement: importStmt, source: importSource });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const m of rawMatches) {
|
||||
|
|
@ -1227,47 +1235,12 @@ export function emitZigScopeCaptures(
|
|||
// answered with the first `work` in the file — `A.work` (PR #1432 review,
|
||||
// 8.4). Those stay Consts and are rewritten at their use sites instead
|
||||
// (`collectZigDeepAliases`).
|
||||
if (
|
||||
stmt !== undefined &&
|
||||
ns !== undefined &&
|
||||
importSources.has(ns.text) &&
|
||||
zigMemberChainOf(stmt)?.members.length === 1
|
||||
) {
|
||||
aliasDeclIds.add(stmt.id);
|
||||
}
|
||||
}
|
||||
// Function-local `@import` bindings, keyed per enclosing callable (PR #1432
|
||||
// review, 8.9). Finalization flattens every import of a file onto its
|
||||
// Module scope, so two sibling fns each binding `const m = @import(…)` to a
|
||||
// different file became ONE `m → [a.zig, b.zig]` namespace bucket and both
|
||||
// `m.Thing{}` sites took the first target. The binding and every use of the
|
||||
// name inside that fn are rewritten to `m$<fn>` — a spelling no Zig
|
||||
// identifier can take — so each fn's handle is its own bucket and resolves
|
||||
// through its own lexical import (`rewriteZigFunctionLocalImportNames`).
|
||||
const fnLocalImports = new Map<
|
||||
number,
|
||||
{ readonly fn: SyntaxNode; readonly names: Map<string, string> }
|
||||
>();
|
||||
for (const m of rawMatches) {
|
||||
const byName = new Map(m.captures.map((c) => [c.name, c.node] as const));
|
||||
const importName = byName.get('import.name');
|
||||
const importStmt = byName.get('import.statement');
|
||||
const importSource = byName.get('import.source');
|
||||
if (importName === undefined || importStmt === undefined || importSource === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (!isZigKeywordDeclaration(importStmt) || isZigTypePositionImport(importStmt, importSource)) {
|
||||
continue;
|
||||
}
|
||||
const fn = zigEnclosingFunction(importStmt);
|
||||
if (fn === null) continue;
|
||||
let entry = fnLocalImports.get(fn.id);
|
||||
if (entry === undefined) {
|
||||
entry = { fn, names: new Map() };
|
||||
fnLocalImports.set(fn.id, entry);
|
||||
}
|
||||
if (!entry.names.has(importName.text)) {
|
||||
entry.names.set(importName.text, zigFunctionLocalImportKey(importName.text, fn, _filePath));
|
||||
if (stmt !== undefined && ns !== undefined && zigMemberChainOf(stmt)?.members.length === 1) {
|
||||
const source = zigVisibleImportSource(stmt, ns.text, importSources);
|
||||
if (source !== undefined) {
|
||||
aliasDeclIds.add(stmt.id);
|
||||
aliasSources.set(stmt.id, source);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Deep member aliases — `const chosen = @import("lib.zig").B.work;`,
|
||||
|
|
@ -1275,7 +1248,7 @@ export function emitZigScopeCaptures(
|
|||
// #1432 review, 8.4): the owner path is kept and the alias's use sites are
|
||||
// rewritten to qualified references (`chosen()` → `lib.B` . `work`), which
|
||||
// the namespace chain walk resolves segment by segment.
|
||||
const deepAliases = collectZigDeepAliases(tree.rootNode, importSources, fnLocalImports);
|
||||
const deepAliases = collectZigDeepAliases(tree.rootNode, importSources);
|
||||
|
||||
// File-struct (top-level fields): the file IS a type named after the file.
|
||||
// Emit a Class scope over the whole file (nested under the Module scope —
|
||||
|
|
@ -1350,7 +1323,7 @@ export function emitZigScopeCaptures(
|
|||
if (claimedImportSourceIds.has(source.id)) continue;
|
||||
const sourceCapture = grouped['@import.source']!;
|
||||
if (isZigInlineImportReceiver(inlineImport) || deepAliases.inlineRoots.has(inlineImport.id)) {
|
||||
const key = `receiver:${source.text}`;
|
||||
const key = `receiver:${zigLexicalHost(inlineImport)?.id}:${source.text}`;
|
||||
if (importedSourceTexts.has(key)) continue;
|
||||
importedSourceTexts.add(key);
|
||||
out.push({
|
||||
|
|
@ -1385,7 +1358,7 @@ export function emitZigScopeCaptures(
|
|||
);
|
||||
const source = nodeMap['@import.source'];
|
||||
if (importRoot !== null && source !== undefined) {
|
||||
const key = `receiver:${source.text}`;
|
||||
const key = `receiver:${zigLexicalHost(importRoot)?.id}:${source.text}`;
|
||||
if (!importedSourceTexts.has(key)) {
|
||||
importedSourceTexts.add(key);
|
||||
out.push({
|
||||
|
|
@ -1404,7 +1377,7 @@ export function emitZigScopeCaptures(
|
|||
const aliasStmt = nodeMap['@alias.statement'];
|
||||
if (aliasStmt !== undefined) {
|
||||
if (!aliasDeclIds.has(aliasStmt.id)) continue;
|
||||
const source = importSources.get(nodeMap['@alias.namespace']!.text)!;
|
||||
const source = aliasSources.get(aliasStmt.id)!;
|
||||
out.push({
|
||||
'@import.statement': nodeToCapture('@import.statement', aliasStmt),
|
||||
'@import.name': nodeToCapture('@import.name', nodeMap['@alias.name']!),
|
||||
|
|
@ -1807,26 +1780,16 @@ export function emitZigScopeCaptures(
|
|||
),
|
||||
);
|
||||
|
||||
// Use-site rewrites, in this order: a deep alias's receiver may itself name
|
||||
// a fn-local import (`const m = @import(…); const w = m.B.work;`), and the
|
||||
// second pass rewrites that name inside the receiver text it just minted.
|
||||
// Keep the written receiver path for deep member aliases. Imported handles
|
||||
// retain their source spelling and bind in their actual lexical scope.
|
||||
rewriteZigDeepAliasReferences(out, deepAliases.aliases);
|
||||
rewriteZigFunctionLocalImportNames(out, fnLocalImports);
|
||||
addZigLexicalFacts(out, tree.rootNode);
|
||||
|
||||
return stampZigStaticGating(out, tree.rootNode);
|
||||
}
|
||||
|
||||
// ─── Use-site rewrites (8.4 / 8.9) and result-location sites (8.6) ────────────
|
||||
|
||||
/** The unique spelling a function-local import binding gets: `m$<callable>`
|
||||
* — `m$f_sib_a`, `m$Reflect$string`, `m$test$L12`. `$` cannot appear in a
|
||||
* Zig identifier, so the key collides with nothing the source declares;
|
||||
* every non-word character of the callable's qualified name becomes `$` so
|
||||
* the key stays a single receiver segment (a `.` would split it). */
|
||||
function zigFunctionLocalImportKey(name: string, fn: SyntaxNode, filePath: string): string {
|
||||
return `${name}$${zigCallableQualifiedName(fn, filePath).replace(/[^\w]/g, '$')}`;
|
||||
}
|
||||
|
||||
type ZigRange = Capture['range'];
|
||||
|
||||
/** The capture-side range of `node` (1-based lines, as `nodeToCapture`). */
|
||||
|
|
@ -1849,90 +1812,158 @@ function zigRangeWithin(inner: ZigRange, outer: ZigRange): boolean {
|
|||
return startsAfter && endsBefore;
|
||||
}
|
||||
|
||||
/** Replace every bare identifier token `name` in `text` — outside string
|
||||
* literals, and not the member of a `.name` access or the tail of a
|
||||
* `@builtin` — with `replacement`. Zig forbids shadowing, so inside the
|
||||
* region a rewrite applies to, every such token is the same binding. */
|
||||
function zigReplaceIdentifier(text: string, name: string, replacement: string): string {
|
||||
let out = '';
|
||||
let i = 0;
|
||||
let inString = false;
|
||||
while (i < text.length) {
|
||||
const ch = text[i]!;
|
||||
if (inString) {
|
||||
out += ch;
|
||||
if (ch === '\\') {
|
||||
out += text[i + 1] ?? '';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') inString = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = true;
|
||||
out += ch;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (/[A-Za-z_]/.test(ch)) {
|
||||
let j = i;
|
||||
while (j < text.length && /\w/.test(text[j]!)) j++;
|
||||
const word = text.slice(i, j);
|
||||
const prev = i > 0 ? text[i - 1] : '';
|
||||
out += word === name && prev !== '.' && prev !== '@' ? replacement : word;
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
out += ch;
|
||||
i++;
|
||||
/** Zig declarations belong to the nearest block, callable, or container. */
|
||||
function zigLexicalHost(node: SyntaxNode): SyntaxNode | null {
|
||||
let parent = node.parent;
|
||||
while (parent !== null) {
|
||||
if (
|
||||
parent.type === 'block' ||
|
||||
parent.type === 'source_file' ||
|
||||
isZigCallableNode(parent) ||
|
||||
ZIG_CONTAINER_TYPES.has(parent.type)
|
||||
)
|
||||
return parent;
|
||||
parent = parent.parent;
|
||||
}
|
||||
return out;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Every capture whose text can spell a receiver, a type, a bound name or a
|
||||
* callable-flow cell — the ones a fn-local import name can appear in. */
|
||||
const ZIG_NAME_BEARING_TAGS: readonly string[] = [
|
||||
'@import.name',
|
||||
'@reference.receiver',
|
||||
'@reference.name',
|
||||
'@type-binding.type',
|
||||
'@callable-flow.target-name',
|
||||
'@callable-flow.target-qualified-name',
|
||||
'@callable-flow.receiver',
|
||||
'@callable-flow.source',
|
||||
'@callable-flow.destination',
|
||||
'@callable-flow.callee',
|
||||
'@callable-flow.direct-callee-name',
|
||||
];
|
||||
type ZigImportSources = ReadonlyMap<
|
||||
number,
|
||||
ReadonlyMap<string, { readonly statement: SyntaxNode; readonly source: SyntaxNode }>
|
||||
>;
|
||||
|
||||
/** 8.9 — rewrite a fn-local import's binding and its uses to its unique key
|
||||
* (`zigFunctionLocalImportKey`), within that fn's range only. */
|
||||
function rewriteZigFunctionLocalImportNames(
|
||||
out: CaptureMatch[],
|
||||
fnLocalImports: ReadonlyMap<
|
||||
number,
|
||||
{ readonly fn: SyntaxNode; readonly names: Map<string, string> }
|
||||
>,
|
||||
): void {
|
||||
if (fnLocalImports.size === 0) return;
|
||||
for (const { fn, names } of fnLocalImports.values()) {
|
||||
const fnRange = zigNodeRange(fn);
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const group = out[i]!;
|
||||
let next: Record<string, Capture> | undefined;
|
||||
for (const tag of ZIG_NAME_BEARING_TAGS) {
|
||||
const cap = group[tag];
|
||||
if (cap === undefined || !zigRangeWithin(cap.range, fnRange)) continue;
|
||||
let text = cap.text;
|
||||
for (const [name, key] of names) text = zigReplaceIdentifier(text, name, key);
|
||||
if (text === cap.text) continue;
|
||||
next ??= { ...group };
|
||||
next[tag] = { ...cap, text };
|
||||
}
|
||||
if (next !== undefined) out[i] = next;
|
||||
/** Container names are unordered; block variables start after their initializer. */
|
||||
function zigVisibleImportSource(
|
||||
at: SyntaxNode,
|
||||
name: string,
|
||||
sources: ZigImportSources,
|
||||
): SyntaxNode | undefined {
|
||||
let owner = zigLexicalHost(at);
|
||||
while (owner !== null) {
|
||||
const binding = sources.get(owner.id)?.get(name);
|
||||
if (
|
||||
binding !== undefined &&
|
||||
(owner.type !== 'block' || binding.statement.endIndex <= at.startIndex)
|
||||
) {
|
||||
return binding.source;
|
||||
}
|
||||
owner = zigLexicalHost(owner);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function zigRangeKey(range: ZigRange): string {
|
||||
return `${range.startLine}:${range.startCol}:${range.endLine}:${range.endCol}`;
|
||||
}
|
||||
|
||||
/** Carry lexical ownership independently of whether the imported target exists. */
|
||||
function addZigLexicalFacts(out: CaptureMatch[], root: SyntaxNode): void {
|
||||
const nodes = new Map<string, SyntaxNode[]>();
|
||||
const declarations: SyntaxNode[] = [];
|
||||
const visit = (node: SyntaxNode): void => {
|
||||
const key = zigRangeKey(zigNodeRange(node));
|
||||
const peers = nodes.get(key);
|
||||
if (peers === undefined) nodes.set(key, [node]);
|
||||
else peers.push(node);
|
||||
if (node.type === 'variable_declaration' || node.type === 'parameter') declarations.push(node);
|
||||
for (const child of node.namedChildren) if (child !== null) visit(child);
|
||||
};
|
||||
visit(root);
|
||||
const claims = new Map<number, NameClaim[]>();
|
||||
const importedDeclarations = new Set<string>();
|
||||
const add = (
|
||||
owner: SyntaxNode,
|
||||
name: string,
|
||||
node: SyntaxNode,
|
||||
kind: NameClaim['kind'],
|
||||
): void => {
|
||||
if (name === '_') return;
|
||||
const range = zigNodeRange(node);
|
||||
let list = claims.get(owner.id);
|
||||
if (list === undefined) claims.set(owner.id, (list = []));
|
||||
if (
|
||||
list.some(
|
||||
(c) => c.name === name && c.kind === kind && zigRangeKey(c.range) === zigRangeKey(range),
|
||||
)
|
||||
)
|
||||
return;
|
||||
list.push({
|
||||
name,
|
||||
range,
|
||||
kind,
|
||||
...(owner.type === 'block' && node.type === 'variable_declaration'
|
||||
? {
|
||||
availableFrom: { startLine: range.endLine, startCol: range.endCol },
|
||||
inactive: 'outer' as const,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const group = out[i]!;
|
||||
const anchor = group['@import.statement'] ?? group['@import.wildcard'];
|
||||
if (anchor === undefined) continue;
|
||||
const node = nodes
|
||||
.get(zigRangeKey(anchor.range))
|
||||
?.find(
|
||||
(candidate) =>
|
||||
candidate.type === 'variable_declaration' ||
|
||||
candidate.type === 'builtin_function' ||
|
||||
candidate.type === 'using_namespace_declaration',
|
||||
);
|
||||
if (node === undefined) continue;
|
||||
const owner = zigLexicalHost(node);
|
||||
if (owner === null) continue;
|
||||
if (owner.type !== 'source_file') {
|
||||
out[i] = { ...group, '@import.lexical': { ...anchor, name: '@import.lexical', text: '' } };
|
||||
}
|
||||
const name = group['@import.name']?.text;
|
||||
if (name !== undefined) {
|
||||
importedDeclarations.add(zigRangeKey(anchor.range));
|
||||
add(owner, name, node, 'import');
|
||||
}
|
||||
}
|
||||
for (const node of declarations) {
|
||||
if (importedDeclarations.has(zigRangeKey(zigNodeRange(node)))) continue;
|
||||
if (node.type === 'variable_declaration' && !isZigKeywordDeclaration(node)) continue;
|
||||
const name = node.type === 'parameter' ? node.childForFieldName('name') : node.namedChild(0);
|
||||
const owner = zigLexicalHost(node);
|
||||
if (owner !== null && name?.type === 'identifier') {
|
||||
// A file's @This() alias names the synthetic Struct declaration, whose
|
||||
// range is the whole file. It must not claim a separate Const binding.
|
||||
const declaration = isZigFileThisAlias(node) && isZigFileStruct(root) ? root : node;
|
||||
add(owner, name.text, declaration, 'binding');
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const group = out[i]!;
|
||||
const scopeTag = ['@scope.module', '@scope.class', '@scope.function', '@scope.block'].find(
|
||||
(tag) => group[tag] !== undefined,
|
||||
);
|
||||
if (scopeTag === undefined) continue;
|
||||
const anchor = group[scopeTag]!;
|
||||
const node = nodes
|
||||
.get(zigRangeKey(anchor.range))
|
||||
?.find((candidate) =>
|
||||
scopeTag === '@scope.module'
|
||||
? candidate.type === 'source_file'
|
||||
: scopeTag === '@scope.class'
|
||||
? ZIG_CONTAINER_TYPES.has(candidate.type)
|
||||
: scopeTag === '@scope.function'
|
||||
? isZigCallableNode(candidate)
|
||||
: candidate.type === 'block',
|
||||
);
|
||||
// The synthetic file-struct Class shares its range with the Module.
|
||||
// File imports and their public forwarding names continue to belong to Module.
|
||||
if (node === undefined || (node.type === 'source_file' && scopeTag !== '@scope.module'))
|
||||
continue;
|
||||
const names = claims.get(node.id);
|
||||
if (names === undefined) continue;
|
||||
out[i] = {
|
||||
...group,
|
||||
'@scope.name-claims': { ...anchor, name: '@scope.name-claims', text: JSON.stringify(names) },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1968,18 +1999,13 @@ function zigMemberChainOf(
|
|||
return { root: cur, members };
|
||||
}
|
||||
|
||||
/** 8.4 — every deep member alias in the tree whose root is a module handle:
|
||||
* a file-level `@import` binding of this file, a fn-local one (its key is
|
||||
* applied by the later rewrite), or an inline `@import(…)`. Returns the
|
||||
/** 8.4 — every deep member alias in the tree whose root is a visible module
|
||||
* handle or an inline `@import(…)`. Returns the
|
||||
* aliases, the declaring nodes (so their import / alias groups are handled
|
||||
* as deep aliases) and the inline-import roots (bound as namespaces). */
|
||||
function collectZigDeepAliases(
|
||||
root: SyntaxNode,
|
||||
importSources: ReadonlyMap<string, SyntaxNode>,
|
||||
fnLocalImports: ReadonlyMap<
|
||||
number,
|
||||
{ readonly fn: SyntaxNode; readonly names: Map<string, string> }
|
||||
>,
|
||||
importSources: ZigImportSources,
|
||||
): {
|
||||
readonly aliases: readonly ZigDeepAlias[];
|
||||
readonly declIds: ReadonlySet<number>;
|
||||
|
|
@ -1992,13 +2018,11 @@ function collectZigDeepAliases(
|
|||
if (node.type === 'variable_declaration' && isZigKeywordDeclaration(node)) {
|
||||
const chain = zigMemberChainOf(node);
|
||||
if (chain !== undefined && chain.members.length >= 2) {
|
||||
const fn = zigEnclosingFunction(node);
|
||||
const isHandle =
|
||||
isZigImportBuiltin(chain.root) ||
|
||||
importSources.has(chain.root.text) ||
|
||||
(fn !== null && fnLocalImports.get(fn.id)?.names.has(chain.root.text) === true);
|
||||
zigVisibleImportSource(node, chain.root.text, importSources) !== undefined;
|
||||
if (isHandle) {
|
||||
const host = zigIdentityHost(node);
|
||||
const host = zigLexicalHost(node);
|
||||
const receiver = [chain.root.text, ...chain.members.slice(0, -1).map((m) => m.text)].join(
|
||||
'.',
|
||||
);
|
||||
|
|
@ -2006,7 +2030,18 @@ function collectZigDeepAliases(
|
|||
name: node.namedChild(0)!.text,
|
||||
receiver,
|
||||
member: chain.members[chain.members.length - 1]!.text,
|
||||
range: host === null || host.type === 'source_file' ? null : zigNodeRange(host),
|
||||
range:
|
||||
host === null || host.type === 'source_file'
|
||||
? null
|
||||
: {
|
||||
...zigNodeRange(host),
|
||||
...(host.type === 'block'
|
||||
? {
|
||||
startLine: node.endPosition.row + 1,
|
||||
startCol: node.endPosition.column,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
declIds.add(node.id);
|
||||
if (isZigImportBuiltin(chain.root)) inlineRoots.add(chain.root.id);
|
||||
|
|
|
|||
|
|
@ -37,12 +37,14 @@ export function interpretZigImport(captures: CaptureMatch): ParsedImport | null
|
|||
if (source === undefined) return null;
|
||||
const targetRaw = stripQuotes(source);
|
||||
if (targetRaw.length === 0) return null;
|
||||
const lexical =
|
||||
captures['@import.lexical'] !== undefined ? { bindsAtLexicalScope: true as const } : {};
|
||||
|
||||
// `pub usingnamespace @import("x.zig");` — every pub decl of the target
|
||||
// becomes a decl of this container. A wildcard, expanded by
|
||||
// `expandZigWildcardNames` in the scope resolver.
|
||||
if (captures['@import.wildcard'] !== undefined) {
|
||||
return { kind: 'wildcard', targetRaw };
|
||||
return { kind: 'wildcard', targetRaw, ...lexical };
|
||||
}
|
||||
if (captures['@import.side-effect'] !== undefined) {
|
||||
return { kind: 'side-effect', targetRaw };
|
||||
|
|
@ -63,7 +65,14 @@ export function interpretZigImport(captures: CaptureMatch): ParsedImport | null
|
|||
// still available (`isZigPublishingImport`).
|
||||
const republish = captures['@import.reexports'] !== undefined ? { reexportsName: true } : {};
|
||||
return imported === name
|
||||
? { kind: 'named', localName: name, importedName: imported, targetRaw, ...republish }
|
||||
? {
|
||||
kind: 'named',
|
||||
localName: name,
|
||||
importedName: imported,
|
||||
targetRaw,
|
||||
...republish,
|
||||
...lexical,
|
||||
}
|
||||
: {
|
||||
kind: 'alias',
|
||||
localName: name,
|
||||
|
|
@ -71,6 +80,7 @@ export function interpretZigImport(captures: CaptureMatch): ParsedImport | null
|
|||
alias: name,
|
||||
targetRaw,
|
||||
...republish,
|
||||
...lexical,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -79,6 +89,7 @@ export function interpretZigImport(captures: CaptureMatch): ParsedImport | null
|
|||
localName: name,
|
||||
importedName: zigModuleNameOf(targetRaw),
|
||||
targetRaw,
|
||||
...lexical,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -145,10 +145,12 @@ export function resolveReferenceSites(input: ResolveReferencesInput): ResolveRef
|
|||
const ref = buildReference(site, top);
|
||||
referencesEmitted++;
|
||||
|
||||
let bySource = bySourceScope.get(site.inScope);
|
||||
// Source grouping follows the graph caller; lookup has already used the
|
||||
// site's lexical environment, which may differ for a framework invocation.
|
||||
let bySource = bySourceScope.get(ref.fromScope);
|
||||
if (bySource === undefined) {
|
||||
bySource = [];
|
||||
bySourceScope.set(site.inScope, bySource);
|
||||
bySourceScope.set(ref.fromScope, bySource);
|
||||
}
|
||||
bySource.push(ref);
|
||||
|
||||
|
|
@ -208,16 +210,26 @@ function lookupForSite(
|
|||
macroRegistry: MacroRegistry,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): readonly Resolution[] {
|
||||
const lookupScope = site.lookupScope ?? site.inScope;
|
||||
const lookupOptions = {
|
||||
lookupPosition: site.atRange,
|
||||
lookupPurpose:
|
||||
site.lookupPurpose ??
|
||||
(site.kind === 'inherits' || site.kind === 'type-reference'
|
||||
? ('type' as const)
|
||||
: ('value' as const)),
|
||||
};
|
||||
switch (site.kind) {
|
||||
case 'call': {
|
||||
const opts: Parameters<MethodRegistry['lookup']>[2] = {
|
||||
...lookupOptions,
|
||||
...(site.arity !== undefined ? { callsite: { arity: site.arity } } : {}),
|
||||
...(site.explicitReceiver !== undefined ? { explicitReceiver: site.explicitReceiver } : {}),
|
||||
};
|
||||
return methodRegistry.lookup(site.name, site.inScope, opts);
|
||||
return methodRegistry.lookup(site.name, lookupScope, opts);
|
||||
}
|
||||
case 'inherits': {
|
||||
return classRegistry.lookup(site.name, site.inScope);
|
||||
return classRegistry.lookup(site.name, lookupScope, lookupOptions);
|
||||
}
|
||||
case 'type-reference': {
|
||||
// A TYPE PARAMETER SHADOWS A DECLARED TYPE OF THE SAME NAME (#2899).
|
||||
|
|
@ -258,17 +270,18 @@ function lookupForSite(
|
|||
// `typeParameters` is populated only by languages whose captures were
|
||||
// extended for it, so a POSITIVE match declines and an absent list changes
|
||||
// nothing — which is what keeps every unconverted language unchanged.
|
||||
if (bindsTypeParameter(site.inScope, site.name, scopes)) return [];
|
||||
return classRegistry.lookup(site.name, site.inScope);
|
||||
if (bindsTypeParameter(lookupScope, site.name, scopes)) return [];
|
||||
return classRegistry.lookup(site.name, lookupScope, lookupOptions);
|
||||
}
|
||||
case 'read':
|
||||
case 'write': {
|
||||
// Try field first; fall through to method then class so bare-name
|
||||
// reads of a function (e.g. `cb = save`) still resolve.
|
||||
const fieldOpts: Parameters<FieldRegistry['lookup']>[2] = {
|
||||
...lookupOptions,
|
||||
...(site.explicitReceiver !== undefined ? { explicitReceiver: site.explicitReceiver } : {}),
|
||||
};
|
||||
const fieldHits = fieldRegistry.lookup(site.name, site.inScope, fieldOpts);
|
||||
const fieldHits = fieldRegistry.lookup(site.name, lookupScope, fieldOpts);
|
||||
// A BARE IDENTIFIER is not a member access. With no receiver there is no
|
||||
// object whose `Property` this could be, so a hit on one is a false edge:
|
||||
// in JS/TS/Python/Ruby a field read needs `this.` / `self.` / `@`, and the
|
||||
|
|
@ -288,19 +301,19 @@ function lookupForSite(
|
|||
? fieldHits.filter((hit) => hit.def?.type !== 'Property')
|
||||
: fieldHits;
|
||||
if (receiverlessFieldHits.length > 0) return receiverlessFieldHits;
|
||||
const methodHits = methodRegistry.lookup(site.name, site.inScope);
|
||||
const methodHits = methodRegistry.lookup(site.name, lookupScope, lookupOptions);
|
||||
if (methodHits.length > 0) return methodHits;
|
||||
return classRegistry.lookup(site.name, site.inScope);
|
||||
return classRegistry.lookup(site.name, lookupScope, lookupOptions);
|
||||
}
|
||||
case 'import-use': {
|
||||
// Try class, method, then field. The lexical-hit Step 1 in
|
||||
// `lookupCore` handles the actual binding lookup; the choice of
|
||||
// registry only narrows `acceptedKinds`.
|
||||
const classHits = classRegistry.lookup(site.name, site.inScope);
|
||||
const classHits = classRegistry.lookup(site.name, lookupScope, lookupOptions);
|
||||
if (classHits.length > 0) return classHits;
|
||||
const methodHits = methodRegistry.lookup(site.name, site.inScope);
|
||||
const methodHits = methodRegistry.lookup(site.name, lookupScope, lookupOptions);
|
||||
if (methodHits.length > 0) return methodHits;
|
||||
return fieldRegistry.lookup(site.name, site.inScope);
|
||||
return fieldRegistry.lookup(site.name, lookupScope, lookupOptions);
|
||||
}
|
||||
case 'value-ref': {
|
||||
// Unreachable — filtered before lookup (post-finalize resolution in
|
||||
|
|
@ -310,7 +323,7 @@ function lookupForSite(
|
|||
case 'macro': {
|
||||
// Macro-only namespace: resolves against `Macro`-labeled defs, never
|
||||
// functions. No receiver, no arity — see `MacroRegistry`.
|
||||
return macroRegistry.lookup(site.name, site.inScope);
|
||||
return macroRegistry.lookup(site.name, lookupScope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -318,7 +331,7 @@ function lookupForSite(
|
|||
/** Compose a `Reference` record from a site + its top resolution. */
|
||||
function buildReference(site: ReferenceSite, top: Resolution): Reference {
|
||||
return {
|
||||
fromScope: site.inScope,
|
||||
fromScope: site.callerScope ?? site.inScope,
|
||||
toDef: top.def.nodeId,
|
||||
atRange: site.atRange,
|
||||
kind: site.kind,
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ export function extractParsedFile(
|
|||
cachedTree?: unknown,
|
||||
sourceKind: ScopeCaptureSourceKind = 'full-file',
|
||||
notebookSegments?: readonly NotebookLineSegment[],
|
||||
lineOffset?: number,
|
||||
scriptLanguage?: string,
|
||||
sourceLineMap?: readonly number[],
|
||||
): ParsedFile | undefined {
|
||||
if (provider.emitScopeCaptures === undefined) return undefined;
|
||||
if (sourceText.trim().length === 0) return undefined;
|
||||
|
|
@ -62,7 +65,10 @@ export function extractParsedFile(
|
|||
: sourceText;
|
||||
const captures = provider.emitScopeCaptures(parseText, filePath, cachedTree, {
|
||||
sourceKind,
|
||||
...(scriptLanguage !== undefined ? { scriptLanguage } : {}),
|
||||
...(notebookSegments ? { notebookSegments } : {}),
|
||||
...(lineOffset !== undefined ? { lineOffset } : {}),
|
||||
...(sourceLineMap !== undefined ? { sourceLineMap } : {}),
|
||||
});
|
||||
return extractScope(captures, filePath, provider);
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -100,7 +100,13 @@ import type {
|
|||
SymbolDefinition,
|
||||
TypeRef,
|
||||
} from 'gitnexus-shared';
|
||||
import { buildPositionIndex, buildScopeTree, canParentScope, makeScopeId } from 'gitnexus-shared';
|
||||
import {
|
||||
buildPositionIndex,
|
||||
buildScopeTree,
|
||||
canParentScope,
|
||||
lookupLexicalName,
|
||||
makeScopeId,
|
||||
} from 'gitnexus-shared';
|
||||
import type { LanguageProvider } from './language-provider.js';
|
||||
import { isValidReceiverChain } from './utils/receiver-chain-codec.js';
|
||||
import {
|
||||
|
|
@ -131,6 +137,7 @@ export type ScopeExtractorHooks = Pick<
|
|||
| 'scopeOwnsReceivers'
|
||||
| 'bindingScopeFor'
|
||||
| 'interpretImport'
|
||||
| 'importOwningScope'
|
||||
| 'importsExecuteWhereWritten'
|
||||
| 'interpretTypeBinding'
|
||||
| 'classifyCallForm'
|
||||
|
|
@ -178,6 +185,8 @@ export function extract(
|
|||
d.filePath,
|
||||
d.ownsReceivers,
|
||||
d.lexicalNames,
|
||||
d.nameClaims,
|
||||
d.lookupPolicy,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -382,6 +391,8 @@ interface ScopeDraft {
|
|||
readonly ownedDefs: SymbolDefinition[];
|
||||
readonly imports: ImportEdge[];
|
||||
readonly typeBindings: Map<string, TypeRef>;
|
||||
readonly nameClaims?: readonly import('gitnexus-shared').NameClaim[];
|
||||
readonly lookupPolicy?: import('gitnexus-shared').ScopeLookupPolicy;
|
||||
readonly lexicalNames?: ReadonlySet<string>;
|
||||
/** See `Scope.ownsReceivers` — set once at pass 1, never mutated. */
|
||||
readonly ownsReceivers?: ReadonlySet<string>;
|
||||
|
|
@ -441,6 +452,8 @@ function draftToScope(draft: ScopeDraft): Scope {
|
|||
imports: Object.freeze(draft.imports.slice()),
|
||||
typeBindings: new Map(draft.typeBindings),
|
||||
lexicalNames: draft.lexicalNames,
|
||||
nameClaims: draft.nameClaims,
|
||||
lookupPolicy: draft.lookupPolicy,
|
||||
ownsReceivers: draft.ownsReceivers,
|
||||
};
|
||||
}
|
||||
|
|
@ -519,6 +532,8 @@ function pass1BuildScopes(
|
|||
filePath,
|
||||
provider.scopeOwnsReceivers?.(cand.match),
|
||||
parseScopeLexicalNames(cand.match),
|
||||
parseJsonCapture(cand.match['@scope.name-claims']) as ScopeDraft['nameClaims'],
|
||||
parseJsonCapture(cand.match['@scope.lookup-policy']) as ScopeDraft['lookupPolicy'],
|
||||
),
|
||||
);
|
||||
stack.push(cand);
|
||||
|
|
@ -566,6 +581,8 @@ function makeDraft(
|
|||
filePath: string,
|
||||
ownsReceivers?: ReadonlySet<string>,
|
||||
lexicalNames?: ReadonlySet<string>,
|
||||
nameClaims?: ScopeDraft['nameClaims'],
|
||||
lookupPolicy?: ScopeDraft['lookupPolicy'],
|
||||
): ScopeDraft {
|
||||
return {
|
||||
id,
|
||||
|
|
@ -578,6 +595,8 @@ function makeDraft(
|
|||
imports: [],
|
||||
typeBindings: new Map(),
|
||||
lexicalNames,
|
||||
nameClaims,
|
||||
lookupPolicy,
|
||||
ownsReceivers,
|
||||
};
|
||||
}
|
||||
|
|
@ -706,7 +725,7 @@ function pass2AttachDeclarations(
|
|||
if (nameKey === undefined) continue;
|
||||
|
||||
const existing = bindingHost.bindings.get(nameKey) ?? [];
|
||||
existing.push({ def, origin: 'local' });
|
||||
existing.push({ def, origin: 'local', declarationRange: anchor.range });
|
||||
bindingHost.bindings.set(nameKey, existing);
|
||||
}
|
||||
}
|
||||
|
|
@ -757,11 +776,15 @@ function buildDefFromDeclarationMatch(
|
|||
// emits the marker, and both `true` and `false` are verdicts (see
|
||||
// `SymbolDefinition.isExported`). Absent stays absent.
|
||||
const isExported = parseBooleanCapture(match['@declaration.is-exported']);
|
||||
const graphPosition = match['@declaration.graph-position']?.range;
|
||||
|
||||
return {
|
||||
nodeId: makeDefId(filePath, anchor.range, type, nameCap.text),
|
||||
filePath,
|
||||
type,
|
||||
...(graphPosition !== undefined
|
||||
? { graphPosition: { startLine: graphPosition.startLine, startCol: graphPosition.startCol } }
|
||||
: {}),
|
||||
...(qualifiedName !== undefined ? { qualifiedName } : { qualifiedName: nameCap.text }),
|
||||
...(parameterCount !== undefined ? { parameterCount } : {}),
|
||||
...(requiredParameterCount !== undefined ? { requiredParameterCount } : {}),
|
||||
|
|
@ -1110,9 +1133,16 @@ function pass3CollectImports(
|
|||
);
|
||||
const deferred =
|
||||
positionCanDefer && inScopeId !== undefined && runsOnlyWhenCalled(scopeTree, inScopeId);
|
||||
const innermost = inScopeId === undefined ? undefined : scopeTree.getScope(inScopeId);
|
||||
const owner =
|
||||
parsed.declaredAtScope ??
|
||||
(innermost === undefined
|
||||
? undefined
|
||||
: (provider.importOwningScope?.(parsed, innermost, scopeTree) ?? inScopeId));
|
||||
parsedImports.push({
|
||||
...parsed,
|
||||
...(inScopeId !== undefined ? { declaredAtScope: inScopeId } : {}),
|
||||
atRange: parsed.atRange ?? anchor.range,
|
||||
...(owner !== undefined ? { declaredAtScope: owner } : {}),
|
||||
...(deferred ? { runsOnlyWhenCalled: true } : {}),
|
||||
});
|
||||
}
|
||||
|
|
@ -1195,13 +1225,25 @@ function pass4CollectTypeBindings(
|
|||
declaredSpelling === undefined
|
||||
? {
|
||||
rawName: parsed.rawTypeName,
|
||||
declaredAtScope: host.id,
|
||||
bindingRange: anchor.range,
|
||||
declaredAtScope: match['@type.lookup-scope']?.text ?? host.id,
|
||||
lookupPosition: { startLine: anchor.range.startLine, startCol: anchor.range.startCol },
|
||||
lookupPurpose:
|
||||
parsed.source === 'constructor-inferred' || parsed.source === 'assignment-inferred'
|
||||
? 'value'
|
||||
: 'type',
|
||||
source: parsed.source,
|
||||
}
|
||||
: {
|
||||
rawName: parsed.rawTypeName,
|
||||
declaredSpelling,
|
||||
declaredAtScope: host.id,
|
||||
bindingRange: anchor.range,
|
||||
declaredAtScope: match['@type.lookup-scope']?.text ?? host.id,
|
||||
lookupPosition: { startLine: anchor.range.startLine, startCol: anchor.range.startCol },
|
||||
lookupPurpose:
|
||||
parsed.source === 'constructor-inferred' || parsed.source === 'assignment-inferred'
|
||||
? 'value'
|
||||
: 'type',
|
||||
source: parsed.source,
|
||||
};
|
||||
// Prefer stronger sources when multiple matches fire for the same
|
||||
|
|
@ -1251,23 +1293,21 @@ function followChainedRef(start: TypeRef, draftById: ReadonlyMap<ScopeId, ScopeD
|
|||
// `QualifiedNameIndex` at resolution time — don't follow it here.
|
||||
if (current.rawName.includes('.')) return current;
|
||||
|
||||
// Look up the current rawName in the declaring scope and walk up
|
||||
// the chain until we hit a scope that has a binding for it.
|
||||
let scopeId: ScopeId | null = current.declaredAtScope;
|
||||
let next: TypeRef | undefined;
|
||||
while (scopeId !== null) {
|
||||
const scope = draftById.get(scopeId);
|
||||
if (scope === undefined) break;
|
||||
next = scope.typeBindings.get(current.rawName);
|
||||
if (next !== undefined) break;
|
||||
scopeId = scope.parent;
|
||||
}
|
||||
const claim = lookupLexicalName(
|
||||
current.declaredAtScope,
|
||||
current.rawName,
|
||||
{
|
||||
scopes: { getScope: (id) => draftById.get(id) },
|
||||
},
|
||||
{ position: current.lookupPosition, purpose: current.lookupPurpose },
|
||||
);
|
||||
const next = claim.typeBinding;
|
||||
|
||||
if (next === undefined) return current; // dead end — nothing to chain to
|
||||
if (next === current) return current; // self-ref
|
||||
if (visited.has(next.rawName)) return current; // cycle guard
|
||||
visited.add(next.rawName);
|
||||
current = next;
|
||||
current = { ...next, bindingRange: start.bindingRange };
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
|
@ -1384,7 +1424,18 @@ function pass5CollectReferences(
|
|||
const site: ReferenceSite = {
|
||||
name: nameCap.text,
|
||||
atRange: anchor.range,
|
||||
inScope: inScopeId,
|
||||
...(match['@reference.lookup-purpose']?.text === 'type' ||
|
||||
match['@reference.lookup-purpose']?.text === 'value'
|
||||
? { lookupPurpose: match['@reference.lookup-purpose'].text as 'type' | 'value' }
|
||||
: {}),
|
||||
inScope:
|
||||
kind === 'inherits' ? inScopeId : (match['@reference.lookup-scope']?.text ?? inScopeId),
|
||||
...(kind === 'inherits' && match['@reference.lookup-scope'] !== undefined
|
||||
? { lookupScope: match['@reference.lookup-scope'].text }
|
||||
: {}),
|
||||
...(match['@reference.caller-scope'] !== undefined
|
||||
? { callerScope: match['@reference.caller-scope'].text }
|
||||
: {}),
|
||||
kind,
|
||||
...(qualifiedCap?.text !== undefined && qualifiedCap.text.length > 0
|
||||
? { rawQualifiedName: qualifiedCap.text }
|
||||
|
|
@ -1866,6 +1917,7 @@ const KNOWN_SUB_TAGS: ReadonlySet<string> = new Set<string>([
|
|||
'@scope.lexical-names',
|
||||
'@declaration.name',
|
||||
'@declaration.qualified_name',
|
||||
'@declaration.graph-position',
|
||||
'@declaration.is-synthetic',
|
||||
'@declaration.global',
|
||||
'@declaration.lexical-method',
|
||||
|
|
|
|||
|
|
@ -280,6 +280,7 @@
|
|||
|
||||
import type { DecorationStripper } from '../scope/walkers.js';
|
||||
import type {
|
||||
NameLookupOptions,
|
||||
BindingRef,
|
||||
Callsite,
|
||||
ConstraintContext,
|
||||
|
|
@ -733,7 +734,9 @@ export interface ScopeResolver {
|
|||
) => void;
|
||||
|
||||
/**
|
||||
* Optional workspace-wide enrichment of extracted reference sites. Runs
|
||||
* Optional workspace-wide enrichment of extracted reference sites and their
|
||||
* lexical name claims. Providers replace affected ParsedFile/scope records
|
||||
* rather than mutating extraction snapshots or finalized indexes. Runs
|
||||
* after all files have been extracted and before reference finalization.
|
||||
* Use this when a per-file capture needs conservative facts from an
|
||||
* imported sibling (for example a compile-time branch constant).
|
||||
|
|
@ -1306,6 +1309,23 @@ export interface ScopeResolver {
|
|||
parsedFiles: readonly ParsedFile[],
|
||||
) => readonly SymbolDefinition[] | undefined;
|
||||
|
||||
/**
|
||||
* Ordinary lexical lookup paired with `resolveAdlCandidates`. The provider
|
||||
* preserves shared name-claim selection, then normalizes its declarations
|
||||
* and reports whether the selected tier blocks argument-dependent lookup.
|
||||
* Both hooks must be supplied to enable that additional candidate tier.
|
||||
*/
|
||||
readonly resolveOrdinaryCallables?: (
|
||||
startScope: ScopeId,
|
||||
name: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
options?: NameLookupOptions,
|
||||
) => {
|
||||
readonly callables: readonly SymbolDefinition[];
|
||||
readonly nonCallableFound: boolean;
|
||||
readonly blockScopeDeclFound: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Optional resolver for a module-qualified FREE call — a call written with
|
||||
* an explicit path but no value receiver (Rust `tools::dispatch(...)`, where
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ export function tryEmitEdge(
|
|||
nodeLookup: GraphNodeLookup,
|
||||
site: {
|
||||
readonly inScope: ScopeId;
|
||||
readonly callerScope?: ScopeId;
|
||||
readonly atRange: { startLine: number; startCol: number };
|
||||
readonly kind: string;
|
||||
/** See {@link isPhantomCalleeRead}. Set by the extractor from the
|
||||
|
|
@ -143,7 +144,12 @@ export function tryEmitEdge(
|
|||
// Inheritance edges are emitted directly by `preEmitInheritanceEdges` (which
|
||||
// owns the enclosing-class caller and the EXTENDS-vs-IMPLEMENTS type), so this
|
||||
// generic bridge derives caller + edge type purely from the site.
|
||||
const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup, site.atRange);
|
||||
const callerGraphId = resolveCallerGraphId(
|
||||
site.callerScope ?? site.inScope,
|
||||
scopes,
|
||||
nodeLookup,
|
||||
site.atRange,
|
||||
);
|
||||
const targetGraphId = resolveDefGraphId(targetDef.filePath, targetDef, nodeLookup);
|
||||
const edgeType = mapReferenceKindToEdgeType(site.kind as Reference['kind']);
|
||||
if (callerGraphId === undefined) return false;
|
||||
|
|
@ -214,6 +220,7 @@ export function tryEmitEdgeWithExplicitTargetId(
|
|||
nodeLookup: GraphNodeLookup,
|
||||
site: {
|
||||
readonly inScope: ScopeId;
|
||||
readonly callerScope?: ScopeId;
|
||||
readonly atRange: { startLine: number; startCol: number };
|
||||
readonly kind: string;
|
||||
/** See `ReferenceSite.staticGated`; copied onto the emitted edge. */
|
||||
|
|
@ -226,7 +233,12 @@ export function tryEmitEdgeWithExplicitTargetId(
|
|||
collapseByCallerTarget = false,
|
||||
calleeCapture?: CalleeIdCaptureCtx,
|
||||
): boolean {
|
||||
const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup, site.atRange);
|
||||
const callerGraphId = resolveCallerGraphId(
|
||||
site.callerScope ?? site.inScope,
|
||||
scopes,
|
||||
nodeLookup,
|
||||
site.atRange,
|
||||
);
|
||||
const edgeType = mapReferenceKindToEdgeType(site.kind as Reference['kind']);
|
||||
if (callerGraphId === undefined) return false;
|
||||
if (edgeType === undefined) return false;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,14 @@
|
|||
* migrate.
|
||||
*/
|
||||
|
||||
import type { NodeLabel, ParameterTypeClass, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type {
|
||||
NodeLabel,
|
||||
ParameterTypeClass,
|
||||
ScopeId,
|
||||
ScopeLookupPolicy,
|
||||
SourcePosition,
|
||||
SymbolDefinition,
|
||||
} from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { generateId } from '../../../../lib/utils.js';
|
||||
import {
|
||||
|
|
@ -50,6 +57,7 @@ const defGraphIdMemoKey = (
|
|||
filePath: string,
|
||||
def: {
|
||||
nodeId?: string;
|
||||
graphPosition?: SourcePosition;
|
||||
qualifiedName?: string;
|
||||
type?: NodeLabel;
|
||||
parameterTypes?: readonly string[];
|
||||
|
|
@ -60,7 +68,7 @@ const defGraphIdMemoKey = (
|
|||
namespacePrefix?: string;
|
||||
},
|
||||
): string =>
|
||||
`${filePath}\0${def.nodeId ?? ''}\0${def.type ?? ''}\0${def.qualifiedName ?? ''}\0${def.parameterCount ?? ''}\0${(def.parameterTypes ?? []).join(',')}\0${(def.parameterTypeClasses ?? []).join(',')}\0${def.namespacePrefix ?? ''}\0${(def.templateArguments ?? []).join(',')}\0${templateConstraintsIdTag(def.templateConstraints)}`;
|
||||
`${filePath}\0${def.nodeId ?? ''}\0${def.graphPosition?.startLine ?? ''}:${def.graphPosition?.startCol ?? ''}\0${def.type ?? ''}\0${def.qualifiedName ?? ''}\0${def.parameterCount ?? ''}\0${(def.parameterTypes ?? []).join(',')}\0${(def.parameterTypeClasses ?? []).join(',')}\0${def.namespacePrefix ?? ''}\0${(def.templateArguments ?? []).join(',')}\0${templateConstraintsIdTag(def.templateConstraints)}`;
|
||||
|
||||
/**
|
||||
* Labels that may legitimately ANCHOR a CALLS/ACCESSES edge as the
|
||||
|
|
@ -144,13 +152,17 @@ function scopeIsCallableBody(
|
|||
function pickCallerCallableDef(
|
||||
scope: {
|
||||
readonly id: ScopeId;
|
||||
readonly kind: string;
|
||||
readonly lookupPolicy?: ScopeLookupPolicy;
|
||||
readonly range: { startLine: number; startCol: number; endLine: number; endCol: number };
|
||||
readonly ownedDefs: readonly SymbolDefinition[];
|
||||
},
|
||||
scopes: ScopeResolutionIndexes,
|
||||
atRange?: { startLine: number; startCol: number },
|
||||
): { def: SymbolDefinition; fromChildScope: boolean } | undefined {
|
||||
if (atRange !== undefined) {
|
||||
// A provider can place default arguments and similar expressions in the
|
||||
// scope that evaluates them, outside their physical declaration container.
|
||||
if (atRange !== undefined && scope.lookupPolicy?.callerScopeIsAuthoritative !== true) {
|
||||
for (const childId of scopes.scopeTree.getChildren(scope.id)) {
|
||||
const child = scopes.scopeTree.getScope(childId);
|
||||
if (child === undefined) continue;
|
||||
|
|
@ -176,7 +188,15 @@ function pickCallerCallableDef(
|
|||
}
|
||||
}
|
||||
}
|
||||
const own = scope.ownedDefs.find(isCallableDef);
|
||||
// Owning a declaration is not the same as being its executable body: a
|
||||
// block may own a function prototype or a nested declaration while the
|
||||
// current statement still belongs to the surrounding callable.
|
||||
const own = scope.ownedDefs.find(
|
||||
(def) =>
|
||||
isCallableDef(def) &&
|
||||
(scope.kind === 'Function' ||
|
||||
(scope.kind === 'Block' && scopeIsCallableBody(scope.range, def))),
|
||||
);
|
||||
return own === undefined ? undefined : { def: own, fromChildScope: false };
|
||||
}
|
||||
|
||||
|
|
@ -247,6 +267,8 @@ export function resolveDefGraphId(
|
|||
def: {
|
||||
/** Scope-resolution def id — carries the declaration position (#2699). */
|
||||
nodeId?: string;
|
||||
/** Graph position when the provider parses an embedded source buffer. */
|
||||
graphPosition?: SourcePosition;
|
||||
qualifiedName?: string;
|
||||
type?: NodeLabel;
|
||||
parameterTypes?: readonly string[];
|
||||
|
|
@ -280,6 +302,7 @@ function resolveDefGraphIdUncached(
|
|||
filePath: string,
|
||||
def: {
|
||||
nodeId?: string;
|
||||
graphPosition?: SourcePosition;
|
||||
qualifiedName?: string;
|
||||
type?: NodeLabel;
|
||||
parameterTypes?: readonly string[];
|
||||
|
|
@ -309,7 +332,10 @@ function resolveDefGraphIdUncached(
|
|||
// AST nodes (outer wrapper vs inner callable), but the graph node's
|
||||
// `startLine` follows the initializer (#2735) so this join matches even
|
||||
// when the binding is split across lines.
|
||||
const definitionPosition = definitionIdPosition(def.nodeId, filePath);
|
||||
const definitionPosition =
|
||||
def.graphPosition !== undefined
|
||||
? { line: def.graphPosition.startLine, column: def.graphPosition.startCol }
|
||||
: definitionIdPosition(def.nodeId, filePath);
|
||||
const line = definitionPosition?.line;
|
||||
if (line !== undefined && isPositionQualifiedLocalLabel(def.type)) {
|
||||
const simple = simpleNameOf(qn);
|
||||
|
|
@ -369,8 +395,8 @@ function resolveDefGraphIdUncached(
|
|||
// edge is the correct failure direction for a graph whose consumers include
|
||||
// `impact`; a fabricated caller is not. Gated on `localNameKey` so this ONLY
|
||||
// fires where the collision is real — a file with no such local keeps its
|
||||
// previous fallback behaviour, which is what preserves legitimate anchor
|
||||
// differences such as a Vue SFC's `lineOffset`.
|
||||
// previous fallback behaviour for legitimate differences in query anchors.
|
||||
// Embedded source offsets are explicit graphPosition facts joined above.
|
||||
//
|
||||
// Multi-line closure bindings are NOT this case anymore (#2735): their graph
|
||||
// `startLine` follows the initializer, so the position key above hits.
|
||||
|
|
@ -591,7 +617,7 @@ export function resolveCallerGraphId(
|
|||
// function that does not make it. Fail closed instead.
|
||||
return undefined;
|
||||
}
|
||||
const classDef = scope.ownedDefs.find((d) => isCallerAnchorLabel(d.type));
|
||||
const classDef = scope.ownedDefs.find((d) => isCallerAnchorLabel(d.type) && !isCallableDef(d));
|
||||
if (classDef !== undefined) {
|
||||
const id = resolveDefGraphId(scope.filePath, classDef, nodeLookup);
|
||||
if (id !== undefined) return id;
|
||||
|
|
|
|||
|
|
@ -25,7 +25,11 @@ import {
|
|||
resolveDefGraphId,
|
||||
simpleQualifiedName,
|
||||
} from '../graph-bridge/ids.js';
|
||||
import { resolveInheritanceBaseInScope } from '../scope/walkers.js';
|
||||
import {
|
||||
resolveInheritanceBaseInScope,
|
||||
lookupNameClaim,
|
||||
findReceiverTypeBinding,
|
||||
} from '../scope/walkers.js';
|
||||
import { definitionIdPosition } from '../utils/definition-id.js';
|
||||
import { narrowOverloadCandidates } from './overload-narrowing.js';
|
||||
|
||||
|
|
@ -125,7 +129,9 @@ export function collectDeferredIndirectCollection(
|
|||
for (const site of parsed.callableFlowSites ?? []) {
|
||||
const operand = flowCellOperand(site);
|
||||
if (operand !== undefined) {
|
||||
flowCells.add(canonicalBindingKey(parsed.filePath, operand, scopes));
|
||||
flowCells.add(
|
||||
canonicalBindingKey(parsed.filePath, operand, scopes, site.kind !== 'store'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -229,6 +235,8 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
|
||||
const bindingKey = (filePath: string, operand: CallableFlowOperand): string =>
|
||||
canonicalBindingKey(filePath, operand, input.scopes);
|
||||
const destinationKey = (filePath: string, operand: CallableFlowOperand): string =>
|
||||
canonicalBindingKey(filePath, operand, input.scopes, true);
|
||||
// Lexical binding lookup is suppressed ONLY for cells bound by formal
|
||||
// facts: a parameter whose grammar emits no declaration binding must not
|
||||
// adopt a same-named outer function. Value cells (copy/alias/store/load
|
||||
|
|
@ -239,7 +247,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
const formalConstrainedBindings = new Set<string>();
|
||||
for (const fact of facts) {
|
||||
if (fact.site.kind === 'formal') {
|
||||
formalConstrainedBindings.add(bindingKey(fact.filePath, fact.site.binding));
|
||||
formalConstrainedBindings.add(destinationKey(fact.filePath, fact.site.binding));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -443,7 +451,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
// narrowing. No arbitrary first-overload choice is permitted.
|
||||
for (const fact of facts) {
|
||||
if (fact.site.kind !== 'seed') continue;
|
||||
const destination = bindingKey(fact.filePath, fact.site.destination);
|
||||
const destination = destinationKey(fact.filePath, fact.site.destination);
|
||||
const candidates = resolveSeedCandidates(
|
||||
fact.filePath,
|
||||
fact.site.destination.inScope,
|
||||
|
|
@ -503,7 +511,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
for (const fact of facts) {
|
||||
if (fact.site.kind !== 'address') continue;
|
||||
addAddress(
|
||||
bindingKey(fact.filePath, fact.site.destination),
|
||||
destinationKey(fact.filePath, fact.site.destination),
|
||||
bindingKey(fact.filePath, fact.site.source),
|
||||
`address:${fact.filePath}:${fact.site.source.name}`,
|
||||
);
|
||||
|
|
@ -527,9 +535,9 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
switch (site.kind) {
|
||||
case 'copy':
|
||||
case 'alias': {
|
||||
const source = bindingKey(fact.filePath, site.source);
|
||||
const destination = destinationKey(fact.filePath, site.destination);
|
||||
addWorkItem(() => {
|
||||
const source = bindingKey(fact.filePath, site.source);
|
||||
const destination = bindingKey(fact.filePath, site.destination);
|
||||
transferTargets(source, destination, context);
|
||||
transferAddresses(source, destination, context);
|
||||
if (site.kind === 'alias') {
|
||||
|
|
@ -540,8 +548,8 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
break;
|
||||
}
|
||||
case 'load': {
|
||||
const destination = destinationKey(fact.filePath, site.destination);
|
||||
addWorkItem(() => {
|
||||
const destination = bindingKey(fact.filePath, site.destination);
|
||||
const reached = reachedCells(fact.filePath, site.pointer);
|
||||
if (reached.overflow) {
|
||||
markTargetOverflow(destination, context);
|
||||
|
|
@ -656,7 +664,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
if (callOverflow) {
|
||||
for (const targetId of history ?? targetIds) {
|
||||
for (const formal of indexedFormals(targetId)) {
|
||||
const formalKey = bindingKey(formal.filePath, formal.site.binding);
|
||||
const formalKey = destinationKey(formal.filePath, formal.site.binding);
|
||||
markTargetOverflow(formalKey, `actual-formal-overflow:${callKey}`);
|
||||
markAddressOverflow(formalKey, `actual-formal-overflow:${callKey}`);
|
||||
}
|
||||
|
|
@ -669,7 +677,7 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
|
|||
const sourceTargets = operandTargets(fact.filePath, site.source);
|
||||
for (const targetId of targetIds) {
|
||||
for (const formal of indexedFormals(targetId)) {
|
||||
const formalKey = bindingKey(formal.filePath, formal.site.binding);
|
||||
const formalKey = destinationKey(formal.filePath, formal.site.binding);
|
||||
const context = `actual-formal:${callKey}:${site.parameterIndex}`;
|
||||
const contextualTargets = new Map(sourceTargets.targets);
|
||||
for (const target of resolveOperandCandidates(
|
||||
|
|
@ -1251,58 +1259,60 @@ function lexicalCallableLookup(
|
|||
scopes: ScopeResolutionIndexes,
|
||||
graphTargets: ReadonlyMap<string, Target>,
|
||||
): { readonly targets: Target[]; readonly shadowed: boolean } {
|
||||
let current: ScopeId | null = operand.inScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
while (current !== null && !visited.has(current)) {
|
||||
visited.add(current);
|
||||
const scope = scopes.scopeTree.getScope(current);
|
||||
if (scope === undefined) return { targets: [], shadowed: false };
|
||||
const refs = nearestScopeBindings(current, operand.name, scope.bindings, scopes);
|
||||
if (refs.length > 0) {
|
||||
const out: Target[] = [];
|
||||
for (const ref of refs) {
|
||||
const target = targetForDef(ref.def, graphTargets);
|
||||
if (target !== undefined) out.push(target);
|
||||
}
|
||||
return { targets: dedupeTargets(out), shadowed: true };
|
||||
}
|
||||
current = scope.parent;
|
||||
}
|
||||
return { targets: [], shadowed: false };
|
||||
}
|
||||
|
||||
function nearestScopeBindings(
|
||||
scopeId: ScopeId,
|
||||
name: string,
|
||||
local: ReadonlyMap<string, readonly { readonly def: SymbolDefinition }[]>,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): readonly { readonly def: SymbolDefinition }[] {
|
||||
const out: { readonly def: SymbolDefinition }[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (refs: readonly { readonly def: SymbolDefinition }[] | undefined): void => {
|
||||
for (const ref of refs ?? []) {
|
||||
if (seen.has(ref.def.nodeId)) continue;
|
||||
seen.add(ref.def.nodeId);
|
||||
out.push(ref);
|
||||
}
|
||||
};
|
||||
add(local.get(name));
|
||||
add(scopes.bindings.get(scopeId)?.get(name));
|
||||
add(scopes.bindingAugmentations.get(scopeId)?.get(name));
|
||||
if (out.length === 0 && scopes.scopeTree.getScope(scopeId)?.kind === 'Module') {
|
||||
add(scopes.workspaceFqnBindings.get(name));
|
||||
for (const namespace of scopes.accessibleNamespacesByScope.get(scopeId) ?? []) {
|
||||
add(scopes.namespaceFqnBindings.get(namespace)?.get(name));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
const claim = lookupNameClaim(operand.inScope, operand.name, scopes, {
|
||||
position: operand.atRange.startLine > 0 ? operand.atRange : undefined,
|
||||
purpose: 'value',
|
||||
});
|
||||
const targets = claim.bindings
|
||||
.map((binding) => targetForDef(binding.def, graphTargets))
|
||||
.filter((target): target is Target => target !== undefined);
|
||||
return { targets: dedupeTargets(targets), shadowed: claim.status !== 'absent' };
|
||||
}
|
||||
|
||||
function canonicalBindingKey(
|
||||
filePath: string,
|
||||
operand: CallableFlowOperand,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
destination = false,
|
||||
): string {
|
||||
let position = operand.atRange.startLine > 0 ? operand.atRange : undefined;
|
||||
if (destination && position !== undefined) {
|
||||
const destinationPosition = position;
|
||||
// A declaration writes its own cell even before its initializer finishes.
|
||||
// Only activate the declaration that contains this destination; an earlier
|
||||
// assignment must not be redirected to an unrelated later declaration.
|
||||
let owner: ScopeId | null = operand.inScope;
|
||||
const seen = new Set<ScopeId>();
|
||||
while (owner !== null && !seen.has(owner)) {
|
||||
seen.add(owner);
|
||||
const scope = scopes.scopeTree.getScope(owner);
|
||||
if (scope === undefined) break;
|
||||
const declaration = scope.nameClaims?.find(
|
||||
(claim) =>
|
||||
claim.name === operand.name &&
|
||||
(claim.range.startLine < destinationPosition.startLine ||
|
||||
(claim.range.startLine === destinationPosition.startLine &&
|
||||
claim.range.startCol <= destinationPosition.startCol)) &&
|
||||
(claim.range.endLine > destinationPosition.startLine ||
|
||||
(claim.range.endLine === destinationPosition.startLine &&
|
||||
claim.range.endCol >= destinationPosition.startCol)),
|
||||
);
|
||||
if (declaration !== undefined) {
|
||||
if (declaration.availableFrom !== undefined) {
|
||||
position = { ...operand.atRange, ...declaration.availableFrom };
|
||||
}
|
||||
break;
|
||||
}
|
||||
owner = scope.lookupPolicy?.parentScope ?? scope.parent;
|
||||
}
|
||||
}
|
||||
const claim = lookupNameClaim(operand.inScope, operand.name, scopes, {
|
||||
position,
|
||||
purpose: 'value',
|
||||
});
|
||||
if (claim.scope !== undefined) {
|
||||
return `${filePath}\0${claim.scope.id}\0${operand.name}`;
|
||||
}
|
||||
let current: ScopeId | null = operand.inScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
let enclosingFunction: ScopeId | undefined;
|
||||
|
|
@ -1311,9 +1321,6 @@ function canonicalBindingKey(
|
|||
const scope = scopes.scopeTree.getScope(current);
|
||||
if (scope === undefined) break;
|
||||
if (enclosingFunction === undefined && scope.kind === 'Function') enclosingFunction = current;
|
||||
if (nearestScopeBindings(current, operand.name, scope.bindings, scopes).length > 0) {
|
||||
return `${filePath}\0${current}\0${operand.name}`;
|
||||
}
|
||||
current = scope.parent;
|
||||
}
|
||||
// Some grammars emit parameter type-bindings but no declaration binding.
|
||||
|
|
@ -1539,17 +1546,10 @@ function receiverType(
|
|||
operand: CallableFlowOperand,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): string | undefined {
|
||||
let current: ScopeId | null = operand.inScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
while (current !== null && !visited.has(current)) {
|
||||
visited.add(current);
|
||||
const scope = scopes.scopeTree.getScope(current);
|
||||
if (scope === undefined) return undefined;
|
||||
const hit = scope.typeBindings.get(operand.name);
|
||||
if (hit !== undefined) return hit.rawName;
|
||||
current = scope.parent;
|
||||
}
|
||||
return scopes.workspaceTypeBindings.get(operand.name)?.rawName;
|
||||
return findReceiverTypeBinding(operand.inScope, operand.name, scopes, {
|
||||
position: operand.atRange.startLine > 0 ? operand.atRange : undefined,
|
||||
purpose: 'value',
|
||||
})?.rawName;
|
||||
}
|
||||
|
||||
function targetForDef(
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ import { decodeReceiverChain } from '../../utils/receiver-chain-codec.js';
|
|||
import type { DecorationStripper } from '../scope/walkers.js';
|
||||
import {
|
||||
findClassBindingInScope,
|
||||
lookupNameClaim,
|
||||
hasExplicitNameClaim,
|
||||
resolveClassBindingForName,
|
||||
findEnclosingClassDef,
|
||||
findExportedDef,
|
||||
|
|
@ -88,6 +90,11 @@ function parseMapTupleSentinel(text: string): { tupleIdx: number; rhs: string }
|
|||
type ReceiverTypeRecorder = (spelling: string, defId: string) => void;
|
||||
|
||||
interface ResolveCompoundReceiverOptions {
|
||||
readonly lookupPosition?: TypeRef['lookupPosition'];
|
||||
readonly namespaceTargetsAt?: (
|
||||
scope: ScopeId,
|
||||
position?: TypeRef['lookupPosition'],
|
||||
) => ReadonlyMap<string, readonly string[]>;
|
||||
/**
|
||||
* Optional sink for the DECLARED TYPE SPELLINGS this fold typed receiver
|
||||
* positions from (#2912). The fold returns a class, and a class has lost the
|
||||
|
|
@ -143,6 +150,7 @@ interface ResolveCompoundReceiverOptions {
|
|||
readonly resolveQualifiedClass?: (
|
||||
qualifiedName: string,
|
||||
inScope: ScopeId,
|
||||
position?: TypeRef['lookupPosition'],
|
||||
) => SymbolDefinition | undefined;
|
||||
/** Compact receiver chain for THIS site (`ReferenceSite.receiverChain`), when
|
||||
* the language's capture emitter produced one. Present ⇒ the structural fold
|
||||
|
|
@ -261,6 +269,7 @@ function resolveConstructionExpressionClass(
|
|||
inScope,
|
||||
scopes,
|
||||
options.namespaceSkipsEnclosingClasses,
|
||||
options.lookupPosition,
|
||||
)
|
||||
)
|
||||
return undefined;
|
||||
|
|
@ -275,11 +284,17 @@ function resolveConstructionExpressionClass(
|
|||
}
|
||||
}
|
||||
|
||||
const direct = findClassBindingInScope(inScope, calleeName, scopes);
|
||||
const direct = findClassBindingInScope(inScope, calleeName, scopes, undefined, {
|
||||
position: options.lookupPosition,
|
||||
purpose: 'value',
|
||||
});
|
||||
if (direct !== undefined && isClassLike(direct.type)) return direct;
|
||||
|
||||
if (baseName.length > 0 && baseName !== calleeName) {
|
||||
const viaBaseName = findClassBindingInScope(inScope, baseName, scopes);
|
||||
const viaBaseName = findClassBindingInScope(inScope, baseName, scopes, undefined, {
|
||||
position: options.lookupPosition,
|
||||
purpose: 'value',
|
||||
});
|
||||
if (viaBaseName !== undefined && isClassLike(viaBaseName.type)) return viaBaseName;
|
||||
}
|
||||
|
||||
|
|
@ -287,6 +302,12 @@ function resolveConstructionExpressionClass(
|
|||
// unambiguous qualified-name match, then fall back to the trailing simple
|
||||
// name the way receiver resolution does elsewhere (#2708).
|
||||
if (lastDot === -1) return undefined;
|
||||
const root = baseName.slice(0, baseName.indexOf('.'));
|
||||
const claim = lookupNameClaim(inScope, root, scopes, {
|
||||
position: options.lookupPosition,
|
||||
purpose: 'value',
|
||||
});
|
||||
if (hasExplicitNameClaim(claim, root)) return undefined;
|
||||
|
||||
const qualifiedIds = scopes.qualifiedNames.get(baseName);
|
||||
if (qualifiedIds.length === 1) {
|
||||
|
|
@ -295,7 +316,10 @@ function resolveConstructionExpressionClass(
|
|||
}
|
||||
const simpleName = baseName.slice(lastDot + 1);
|
||||
if (simpleName.length === 0) return undefined;
|
||||
const viaSimpleName = findClassBindingInScope(inScope, simpleName, scopes);
|
||||
const viaSimpleName = findClassBindingInScope(inScope, simpleName, scopes, undefined, {
|
||||
position: options.lookupPosition,
|
||||
purpose: 'value',
|
||||
});
|
||||
return viaSimpleName !== undefined && isClassLike(viaSimpleName.type) ? viaSimpleName : undefined;
|
||||
}
|
||||
|
||||
|
|
@ -339,6 +363,7 @@ interface FoldState {
|
|||
*/
|
||||
readonly declaredType?: string;
|
||||
readonly declaredAtScope?: ScopeId;
|
||||
readonly lookupPosition?: TypeRef['lookupPosition'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -407,6 +432,7 @@ function classOfDeclaredType(
|
|||
spelling,
|
||||
scopes,
|
||||
stripDecoration,
|
||||
{ position: typeRef.lookupPosition, purpose: typeRef.lookupPurpose ?? 'type' },
|
||||
);
|
||||
return noteReceiverType(recordReceiverType, spelling, def);
|
||||
}
|
||||
|
|
@ -451,7 +477,10 @@ function classOfReturnType(
|
|||
scopes: ScopeResolutionIndexes,
|
||||
record: ReceiverTypeRecorder | undefined,
|
||||
): SymbolDefinition | undefined {
|
||||
const def = findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes);
|
||||
const def = findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes, undefined, {
|
||||
position: retType.lookupPosition,
|
||||
purpose: retType.lookupPurpose ?? 'type',
|
||||
});
|
||||
if (def === undefined || record === undefined) return def;
|
||||
return noteReceiverType(record, erasedTypeApplication(retType) ?? retType.rawName, def);
|
||||
}
|
||||
|
|
@ -485,6 +514,7 @@ function typeOfMemberOnClass(
|
|||
def,
|
||||
declaredType: memberType.declaredSpelling ?? memberType.rawName,
|
||||
declaredAtScope: memberType.declaredAtScope,
|
||||
lookupPosition: memberType.lookupPosition,
|
||||
};
|
||||
}
|
||||
// Languages whose binding-scope hook hoists a method's return-type binding
|
||||
|
|
@ -580,7 +610,9 @@ export function foldReceiverChain(
|
|||
const needsBaseDeclaredType =
|
||||
baseDef === undefined || chain.steps.some((step) => step.kind === 'index');
|
||||
const baseBinding = needsBaseDeclaredType
|
||||
? findReceiverTypeBinding(inScope, chain.baseReceiverName, scopes)
|
||||
? findReceiverTypeBinding(inScope, chain.baseReceiverName, scopes, {
|
||||
position: options.lookupPosition,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// A base whose declared type names no class is NOT automatically a dead end:
|
||||
|
|
@ -594,6 +626,7 @@ export function foldReceiverChain(
|
|||
def: baseDef,
|
||||
declaredType: baseBinding?.declaredSpelling ?? baseBinding?.rawName,
|
||||
declaredAtScope: baseBinding?.declaredAtScope,
|
||||
lookupPosition: baseBinding?.lookupPosition ?? options.lookupPosition,
|
||||
};
|
||||
|
||||
for (const step of chain.steps) {
|
||||
|
|
@ -639,12 +672,14 @@ export function foldReceiverChain(
|
|||
element,
|
||||
scopes,
|
||||
options.stripTypePreservingDecoration,
|
||||
{ position: current.lookupPosition, purpose: 'type' },
|
||||
);
|
||||
if (elementClass === undefined) return undefined;
|
||||
current = {
|
||||
def: elementClass,
|
||||
declaredType: element,
|
||||
declaredAtScope: scopeForLookup,
|
||||
lookupPosition: current.lookupPosition,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
|
@ -748,6 +783,12 @@ export function resolveCompoundReceiverClass(
|
|||
if (depth > COMPOUND_RECEIVER_MAX_DEPTH) return undefined;
|
||||
const text = receiverText.trim();
|
||||
if (text.length === 0) return undefined;
|
||||
if (options.namespaceTargetsAt !== undefined) {
|
||||
options = {
|
||||
...options,
|
||||
namespaceTargets: options.namespaceTargetsAt(inScope, options.lookupPosition),
|
||||
};
|
||||
}
|
||||
const fieldFallback = options.fieldFallback ?? true;
|
||||
|
||||
// ── Structural fold, ahead of the text cascade ───────────────────
|
||||
|
|
@ -791,7 +832,10 @@ export function resolveCompoundReceiverClass(
|
|||
// resolution — the cast narrows the receiver's declared type, so
|
||||
// resolve to the CAST type, not the underlying expression's type.
|
||||
if (stripped.castType !== undefined) {
|
||||
const cls = findClassBindingInScope(inScope, stripped.castType, scopes);
|
||||
const cls = findClassBindingInScope(inScope, stripped.castType, scopes, undefined, {
|
||||
position: options.lookupPosition,
|
||||
purpose: 'value',
|
||||
});
|
||||
if (cls !== undefined) return cls;
|
||||
}
|
||||
}
|
||||
|
|
@ -805,11 +849,16 @@ export function resolveCompoundReceiverClass(
|
|||
if (!workingText.includes('.') && !workingText.includes('(')) {
|
||||
const mapTuple = parseMapTupleSentinel(workingText);
|
||||
if (mapTuple !== null) {
|
||||
const rhsTb = findReceiverTypeBinding(inScope, mapTuple.rhs, scopes);
|
||||
const rhsTb = findReceiverTypeBinding(inScope, mapTuple.rhs, scopes, {
|
||||
position: options.lookupPosition,
|
||||
});
|
||||
if (rhsTb === undefined) return undefined;
|
||||
const arg = extractShallowMapTypeArgByIndex(rhsTb.rawName, mapTuple.tupleIdx);
|
||||
if (arg === undefined) return undefined;
|
||||
return findClassBindingInScope(rhsTb.declaredAtScope, arg, scopes);
|
||||
return findClassBindingInScope(rhsTb.declaredAtScope, arg, scopes, undefined, {
|
||||
position: rhsTb.lookupPosition,
|
||||
purpose: rhsTb.lookupPurpose ?? 'type',
|
||||
});
|
||||
}
|
||||
|
||||
// A language may declare that `this` IS the enclosing class rather than a
|
||||
|
|
@ -826,18 +875,25 @@ export function resolveCompoundReceiverClass(
|
|||
if (enclosing !== undefined) return enclosing;
|
||||
}
|
||||
|
||||
const tb = findReceiverTypeBinding(inScope, workingText, scopes);
|
||||
const tb = findReceiverTypeBinding(inScope, workingText, scopes, {
|
||||
position: options.lookupPosition,
|
||||
});
|
||||
if (tb !== undefined) {
|
||||
// Map for-of: binding name is `user` but rawType is
|
||||
// `__MAP_TUPLE_i__:entries` (see captures.ts) — same extraction as
|
||||
// the literal-sentinel branch above.
|
||||
const boundMapTuple = parseMapTupleSentinel(tb.rawName);
|
||||
if (boundMapTuple !== null) {
|
||||
const rhsTb = findReceiverTypeBinding(inScope, boundMapTuple.rhs, scopes);
|
||||
const rhsTb = findReceiverTypeBinding(inScope, boundMapTuple.rhs, scopes, {
|
||||
position: options.lookupPosition,
|
||||
});
|
||||
if (rhsTb === undefined) return undefined;
|
||||
const arg = extractShallowMapTypeArgByIndex(rhsTb.rawName, boundMapTuple.tupleIdx);
|
||||
if (arg === undefined) return undefined;
|
||||
return findClassBindingInScope(rhsTb.declaredAtScope, arg, scopes);
|
||||
return findClassBindingInScope(rhsTb.declaredAtScope, arg, scopes, undefined, {
|
||||
position: rhsTb.lookupPosition,
|
||||
purpose: rhsTb.lookupPurpose ?? 'type',
|
||||
});
|
||||
}
|
||||
|
||||
const viaTb = classOfDeclaredType(
|
||||
|
|
@ -853,19 +909,19 @@ export function resolveCompoundReceiverClass(
|
|||
if (tb.rawName.includes('.') && !tb.rawName.includes('(')) {
|
||||
const dotted = resolveCompoundReceiverClass(
|
||||
tb.rawName,
|
||||
inScope,
|
||||
tb.declaredAtScope,
|
||||
scopes,
|
||||
index,
|
||||
options,
|
||||
{ ...options, lookupPosition: tb.lookupPosition, receiverChain: undefined },
|
||||
depth + 1,
|
||||
);
|
||||
if (dotted !== undefined) return dotted;
|
||||
const dottedCall = resolveCompoundReceiverClass(
|
||||
`${tb.rawName}()`,
|
||||
inScope,
|
||||
tb.declaredAtScope,
|
||||
scopes,
|
||||
index,
|
||||
options,
|
||||
{ ...options, lookupPosition: tb.lookupPosition, receiverChain: undefined },
|
||||
depth + 1,
|
||||
);
|
||||
if (dottedCall !== undefined) return dottedCall;
|
||||
|
|
@ -875,10 +931,10 @@ export function resolveCompoundReceiverClass(
|
|||
if (!tb.rawName.includes('.') && !tb.rawName.includes('(')) {
|
||||
const callAlias = resolveCompoundReceiverClass(
|
||||
`${tb.rawName}()`,
|
||||
inScope,
|
||||
tb.declaredAtScope,
|
||||
scopes,
|
||||
index,
|
||||
options,
|
||||
{ ...options, lookupPosition: tb.lookupPosition, receiverChain: undefined },
|
||||
depth + 1,
|
||||
);
|
||||
if (callAlias !== undefined) return callAlias;
|
||||
|
|
@ -892,10 +948,10 @@ export function resolveCompoundReceiverClass(
|
|||
if (tb.rawName.includes('.') && tb.rawName.includes('(')) {
|
||||
const compound = resolveCompoundReceiverClass(
|
||||
tb.rawName,
|
||||
inScope,
|
||||
tb.declaredAtScope,
|
||||
scopes,
|
||||
index,
|
||||
options,
|
||||
{ ...options, lookupPosition: tb.lookupPosition, receiverChain: undefined },
|
||||
depth + 1,
|
||||
);
|
||||
if (compound !== undefined) return compound;
|
||||
|
|
@ -906,7 +962,10 @@ export function resolveCompoundReceiverClass(
|
|||
// "try the class namespace instead". Only the structural fold opts in; the
|
||||
// cascade keeps its historical fallthrough so no existing edge moves.
|
||||
if (tb !== undefined && options.strictBaseBinding === true) return undefined;
|
||||
return findClassBindingInScope(inScope, workingText, scopes);
|
||||
return findClassBindingInScope(inScope, workingText, scopes, undefined, {
|
||||
position: options.lookupPosition,
|
||||
purpose: 'value',
|
||||
});
|
||||
}
|
||||
|
||||
// Trailing `()` — call expression. Strip it and resolve the function
|
||||
|
|
@ -932,9 +991,14 @@ export function resolveCompoundReceiverClass(
|
|||
// Free call `name()`. Look up function in scope, then its
|
||||
// return-type typeBinding (which lives in the function's
|
||||
// enclosing scope per the language's return-type hoist rule).
|
||||
const fnDef = findExportedDefByName(fnExpr, inScope, scopes, index);
|
||||
const fnDef = findExportedDefByName(fnExpr, inScope, scopes, index, {
|
||||
position: options.lookupPosition,
|
||||
purpose: 'value',
|
||||
});
|
||||
if (fnDef !== undefined) {
|
||||
const retType = findReceiverTypeBinding(inScope, fnExpr, scopes);
|
||||
const retType = findReceiverTypeBinding(inScope, fnExpr, scopes, {
|
||||
position: options.lookupPosition,
|
||||
});
|
||||
const viaReturn =
|
||||
retType === undefined
|
||||
? undefined
|
||||
|
|
@ -979,7 +1043,10 @@ export function resolveCompoundReceiverClass(
|
|||
const objIsClassConstant =
|
||||
!objExpr.includes('(') &&
|
||||
!objExpr.includes('.') &&
|
||||
findClassBindingInScope(inScope, objExpr, scopes)?.nodeId === objClass.nodeId;
|
||||
findClassBindingInScope(inScope, objExpr, scopes, undefined, {
|
||||
position: options.lookupPosition,
|
||||
purpose: 'value',
|
||||
})?.nodeId === objClass.nodeId;
|
||||
|
||||
// Selector-form construction — `Factory.new.do_work` (#2708). Gated on the
|
||||
// receiver naming the CLASS: `factory.new` is an ordinary call to a member
|
||||
|
|
@ -1086,17 +1153,24 @@ export function resolveCompoundReceiverClass(
|
|||
const prefix = parts.slice(0, -1).join('.');
|
||||
let prefixType: TypeRef | undefined;
|
||||
if (parts.length === 2) {
|
||||
prefixType = findReceiverTypeBinding(inScope, prefix, scopes);
|
||||
prefixType = findReceiverTypeBinding(inScope, prefix, scopes, {
|
||||
position: options.lookupPosition,
|
||||
});
|
||||
} else {
|
||||
// Recursive resolution: walk the prefix as a dotted class chain
|
||||
// to find its typeRef. We need the TypeRef (not the class def)
|
||||
// because the hook inspects the raw generic args (e.g.
|
||||
// `Dictionary<string, User>`).
|
||||
let cur = findReceiverTypeBinding(inScope, headInner, scopes);
|
||||
let cur = findReceiverTypeBinding(inScope, headInner, scopes, {
|
||||
position: options.lookupPosition,
|
||||
});
|
||||
for (let i = 1; i < parts.length - 1 && cur !== undefined; i++) {
|
||||
const segment = parts[i];
|
||||
if (segment === undefined) break;
|
||||
const cls = findClassBindingInScope(cur.declaredAtScope, cur.rawName, scopes);
|
||||
const cls = findClassBindingInScope(cur.declaredAtScope, cur.rawName, scopes, undefined, {
|
||||
position: cur.lookupPosition,
|
||||
purpose: cur.lookupPurpose ?? 'type',
|
||||
});
|
||||
if (cls === undefined) {
|
||||
cur = undefined;
|
||||
break;
|
||||
|
|
@ -1114,7 +1188,10 @@ export function resolveCompoundReceiverClass(
|
|||
// to widen an unmeasured surface.
|
||||
const elemName = options.elementTypeOf(prefixType.rawName, { kind: 'accessor', name: last });
|
||||
if (elemName !== undefined) {
|
||||
return findClassBindingInScope(prefixType.declaredAtScope, elemName, scopes);
|
||||
return findClassBindingInScope(prefixType.declaredAtScope, elemName, scopes, undefined, {
|
||||
position: prefixType.lookupPosition,
|
||||
purpose: prefixType.lookupPurpose ?? 'type',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1122,7 +1199,9 @@ export function resolveCompoundReceiverClass(
|
|||
const head = parts[0];
|
||||
if (head === undefined) return undefined;
|
||||
const headMemberName = stripCallParens(head);
|
||||
const headType = findReceiverTypeBinding(inScope, headMemberName, scopes);
|
||||
const headType = findReceiverTypeBinding(inScope, headMemberName, scopes, {
|
||||
position: options.lookupPosition,
|
||||
});
|
||||
// The typed arm reads a DECLARED TYPE and so goes through the grounded lookup
|
||||
// (see {@link classOfDeclaredType}); the untyped arm resolves the head NAME as
|
||||
// the source WROTE it — a static class receiver — which was never erased and
|
||||
|
|
@ -1137,7 +1216,10 @@ export function resolveCompoundReceiverClass(
|
|||
// `TypeRef` that was never reduced.
|
||||
let currentClass: SymbolDefinition | undefined = headType
|
||||
? classOfDeclaredType(headType, scopes, undefined, options.recordReceiverType)
|
||||
: findClassBindingInScope(inScope, headMemberName, scopes);
|
||||
: findClassBindingInScope(inScope, headMemberName, scopes, undefined, {
|
||||
position: options.lookupPosition,
|
||||
purpose: 'value',
|
||||
});
|
||||
// Whether the walk currently sits on the CLASS ITSELF rather than on a
|
||||
// value of that class. Seeded true only when the head resolved straight to
|
||||
// a class binding (`Factory.new…`); a head reached through a typeBinding
|
||||
|
|
@ -1221,7 +1303,7 @@ export function resolveCompoundReceiverClass(
|
|||
for (let k = parts.length; k >= 2; k--) {
|
||||
const prefix = parts.slice(0, k).join('.');
|
||||
if (prefix.includes('(')) continue;
|
||||
const seeded = options.resolveQualifiedClass(prefix, inScope);
|
||||
const seeded = options.resolveQualifiedClass(prefix, inScope, options.lookupPosition);
|
||||
if (seeded === undefined) continue;
|
||||
currentClass = seeded;
|
||||
currentIsClassConstant = true;
|
||||
|
|
@ -1499,7 +1581,10 @@ function unwrapMapValueToClass(
|
|||
): SymbolDefinition | undefined {
|
||||
const v = extractShallowMapTypeArgByIndex(memberType.rawName, 1);
|
||||
if (v === undefined) return undefined;
|
||||
return findClassBindingInScope(memberType.declaredAtScope, v, scopes);
|
||||
return findClassBindingInScope(memberType.declaredAtScope, v, scopes, undefined, {
|
||||
position: memberType.lookupPosition,
|
||||
purpose: memberType.lookupPurpose ?? 'type',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1519,10 +1604,18 @@ function resolveMapValueTypeNameFromPrefix(
|
|||
const head = parts[0];
|
||||
if (head === undefined) return undefined;
|
||||
const headMemberName = stripCallParens(head);
|
||||
const headType = findReceiverTypeBinding(inScope, headMemberName, scopes);
|
||||
const headType = findReceiverTypeBinding(inScope, headMemberName, scopes, {
|
||||
position: options.lookupPosition,
|
||||
});
|
||||
let currentClass: SymbolDefinition | undefined = headType
|
||||
? findClassBindingInScope(headType.declaredAtScope, headType.rawName, scopes)
|
||||
: findClassBindingInScope(inScope, headMemberName, scopes);
|
||||
? findClassBindingInScope(headType.declaredAtScope, headType.rawName, scopes, undefined, {
|
||||
position: headType.lookupPosition,
|
||||
purpose: headType.lookupPurpose ?? 'type',
|
||||
})
|
||||
: findClassBindingInScope(inScope, headMemberName, scopes, undefined, {
|
||||
position: options.lookupPosition,
|
||||
purpose: 'value',
|
||||
});
|
||||
if (
|
||||
currentClass === undefined &&
|
||||
headType !== undefined &&
|
||||
|
|
@ -1561,7 +1654,13 @@ function resolveMapValueTypeNameFromPrefix(
|
|||
}
|
||||
if (memberType === undefined) return undefined;
|
||||
lastMemberType = memberType;
|
||||
let nextClass = findClassBindingInScope(memberType.declaredAtScope, memberType.rawName, scopes);
|
||||
let nextClass = findClassBindingInScope(
|
||||
memberType.declaredAtScope,
|
||||
memberType.rawName,
|
||||
scopes,
|
||||
undefined,
|
||||
{ position: memberType.lookupPosition, purpose: memberType.lookupPurpose ?? 'type' },
|
||||
);
|
||||
if (nextClass === undefined) {
|
||||
const fromMap = unwrapMapValueToClass(memberType, scopes);
|
||||
if (fromMap !== undefined) nextClass = fromMap;
|
||||
|
|
|
|||
|
|
@ -40,10 +40,11 @@ import { GLOBAL_NAME_FALLBACK_REASON } from '../../../graph/edge-reasons.js';
|
|||
import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js';
|
||||
import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js';
|
||||
import {
|
||||
lookupNameClaim,
|
||||
hasExplicitNameClaim,
|
||||
findAllCallableBindingCandidatesInScope,
|
||||
findAllCallableBindingsInScope,
|
||||
findCallableBindingInScope,
|
||||
findCallableBindingsAndAdlBlocker,
|
||||
findClassBindingInScope,
|
||||
findEnclosingClassDef,
|
||||
isClassFileImportGrounded,
|
||||
|
|
@ -85,6 +86,7 @@ export function emitFreeCallFallback(
|
|||
readonly markConstructionSites?: boolean;
|
||||
readonly isFileLocalDef?: (def: SymbolDefinition) => boolean;
|
||||
readonly isCallableVisibleFromCaller?: ScopeResolver['isCallableVisibleFromCaller'];
|
||||
readonly resolveOrdinaryCallables?: ScopeResolver['resolveOrdinaryCallables'];
|
||||
readonly resolveAdlCandidates?: (
|
||||
site: {
|
||||
readonly name: string;
|
||||
|
|
@ -220,6 +222,36 @@ export function emitFreeCallFallback(
|
|||
if (site.kind !== 'call') continue;
|
||||
if (site.explicitReceiver !== undefined) continue;
|
||||
if (options.skipSites?.has(siteKey(parsed.filePath, site)) === true) continue;
|
||||
const lookupOptions = { position: site.atRange, purpose: 'value' as const };
|
||||
const nameClaim = lookupNameClaim(site.inScope, site.name, scopes, lookupOptions);
|
||||
const strictClaim = hasExplicitNameClaim(nameClaim, site.name);
|
||||
const blocksCall =
|
||||
strictClaim &&
|
||||
(nameClaim.status === 'blocked' ||
|
||||
(nameClaim.status === 'resolved' &&
|
||||
!nameClaim.bindings.some(
|
||||
(b) =>
|
||||
b.def.type === 'Function' ||
|
||||
b.def.type === 'Method' ||
|
||||
b.def.type === 'Constructor' ||
|
||||
b.def.type === 'Class' ||
|
||||
b.def.type === 'Struct' ||
|
||||
b.def.type === 'Record',
|
||||
)));
|
||||
if (blocksCall) {
|
||||
if (options.resolveAdlCandidates !== undefined) {
|
||||
recordSuppressedOutcome(options.recordResolutionOutcome, {
|
||||
phase: 'free-call-fallback',
|
||||
filePath: parsed.filePath,
|
||||
name: site.name,
|
||||
range: site.atRange,
|
||||
reason: 'adl-ordinary-lookup-blocked',
|
||||
candidates: [],
|
||||
});
|
||||
handledSites.add(siteKey(parsed.filePath, site));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Constructor form (`new User(...)`): resolve the class, then
|
||||
// emit CALLS to its explicit Constructor def (when present) or
|
||||
|
|
@ -248,14 +280,15 @@ export function emitFreeCallFallback(
|
|||
scopes,
|
||||
site.rawQualifiedName,
|
||||
undefined,
|
||||
{ uniqueQualifiedNameFallback: site.rawQualifiedName !== undefined },
|
||||
{ ...lookupOptions, uniqueQualifiedNameFallback: site.rawQualifiedName !== undefined },
|
||||
);
|
||||
if (classDef === undefined) {
|
||||
classDef =
|
||||
findClassBindingInScope(site.inScope, site.name, scopes, undefined, {
|
||||
...lookupOptions,
|
||||
uniqueQualifiedNameFallback: true,
|
||||
}) ??
|
||||
(options.allowGlobalFallback === true
|
||||
(options.allowGlobalFallback === true && !strictClaim
|
||||
? pickUniqueGlobalClass(site.name, globalClassesBySimpleName)
|
||||
: undefined);
|
||||
if (
|
||||
|
|
@ -305,7 +338,7 @@ export function emitFreeCallFallback(
|
|||
// the same name in a single class, choose the best match by
|
||||
// arity + argument types.
|
||||
let fnDefFromImplicitThis = false;
|
||||
if (fnDef === undefined) {
|
||||
if (fnDef === undefined && !strictClaim) {
|
||||
fnDef = pickImplicitThisOverload(site, scopes, workspaceIndex, model, {
|
||||
conversionRankFn: options.conversionRankFn,
|
||||
conversionOnlyArgTypePrefixes: options.conversionOnlyArgTypePrefixes,
|
||||
|
|
@ -321,7 +354,10 @@ export function emitFreeCallFallback(
|
|||
// by argument types (#1578). The first-match result is kept as a
|
||||
// fallback when narrowing is indeterminate.
|
||||
if (fnDef === undefined) {
|
||||
if (options.resolveAdlCandidates === undefined) {
|
||||
if (
|
||||
options.resolveAdlCandidates === undefined ||
|
||||
options.resolveOrdinaryCallables === undefined
|
||||
) {
|
||||
// Non-ADL path: first-match preserves scope-chain precedence
|
||||
// (local shadows import). When a conversion-rank function is
|
||||
// available AND the binding scope contains multiple overloads,
|
||||
|
|
@ -333,19 +369,21 @@ export function emitFreeCallFallback(
|
|||
byName = new Map();
|
||||
bindingCandidatesByScope.set(site.inScope, byName);
|
||||
}
|
||||
bindingCandidates = byName.get(site.name);
|
||||
const cacheName = `${site.name}:${site.atRange.startLine}:${site.atRange.startCol}`;
|
||||
bindingCandidates = byName.get(cacheName);
|
||||
if (bindingCandidates === undefined) {
|
||||
bindingCandidates = findAllCallableBindingCandidatesInScope(
|
||||
site.inScope,
|
||||
site.name,
|
||||
scopes,
|
||||
lookupOptions,
|
||||
);
|
||||
byName.set(site.name, bindingCandidates);
|
||||
byName.set(cacheName, bindingCandidates);
|
||||
}
|
||||
}
|
||||
let eligibleBindingCandidates: readonly CallableBindingCandidate[] | undefined;
|
||||
if (bindingCandidates === undefined) {
|
||||
fnDef = findCallableBindingInScope(site.inScope, site.name, scopes);
|
||||
fnDef = findCallableBindingInScope(site.inScope, site.name, scopes, lookupOptions);
|
||||
} else {
|
||||
eligibleBindingCandidates = bindingCandidates.filter((candidate) => {
|
||||
const def = candidate.def;
|
||||
|
|
@ -414,7 +452,7 @@ export function emitFreeCallFallback(
|
|||
) {
|
||||
const allCallables =
|
||||
eligibleBindingCandidates === undefined
|
||||
? findAllCallableBindingsInScope(site.inScope, site.name, scopes)
|
||||
? findAllCallableBindingsInScope(site.inScope, site.name, scopes, lookupOptions)
|
||||
: eligibleBindingCandidates.map((candidate) => candidate.def);
|
||||
if (allCallables.length > 1) {
|
||||
const narrowed = narrowOverloadCandidates(
|
||||
|
|
@ -457,14 +495,13 @@ export function emitFreeCallFallback(
|
|||
}
|
||||
}
|
||||
} else {
|
||||
// ADL path: ISO C++ `[basic.lookup.unqual]` §7 — ADL is suppressed
|
||||
// when ordinary lookup finds a non-function name or a block-scope
|
||||
// function declaration.
|
||||
// The provider owns declaration normalization and lexical barriers
|
||||
// for its argument-dependent candidate tier.
|
||||
const {
|
||||
callables: ordinary,
|
||||
nonCallableFound,
|
||||
blockScopeDeclFound,
|
||||
} = findCallableBindingsAndAdlBlocker(site.inScope, site.name, scopes);
|
||||
} = options.resolveOrdinaryCallables(site.inScope, site.name, scopes, lookupOptions);
|
||||
const adlSuppressed = nonCallableFound || blockScopeDeclFound;
|
||||
const adl = adlSuppressed
|
||||
? undefined
|
||||
|
|
@ -505,12 +542,13 @@ export function emitFreeCallFallback(
|
|||
// via the unified `OverloadNarrowingHookCtx`.
|
||||
const hasConstraints = ordinary.some((d) => d.templateConstraints !== undefined);
|
||||
const canNarrow = hasConstraints || options.conversionRankFn !== undefined;
|
||||
if (ordinary.length <= 1 || !canNarrow) {
|
||||
if (ordinary.length === 0 || !canNarrow) {
|
||||
fnDef = ordinary[0];
|
||||
} else {
|
||||
const narrowed = narrowOverloadCandidates(ordinary, site.arity, site.argumentTypes, {
|
||||
argumentTypeClasses: site.argumentTypeClasses,
|
||||
conversionRankFn: options.conversionRankFn,
|
||||
incompatibleConversionsAreFinal: blockScopeDeclFound,
|
||||
conversionOnlyArgTypePrefixes: options.conversionOnlyArgTypePrefixes,
|
||||
constraintCompatibility: options.constraintCompatibility,
|
||||
});
|
||||
|
|
@ -520,27 +558,21 @@ export function emitFreeCallFallback(
|
|||
handledSites.add(key);
|
||||
continue;
|
||||
} else {
|
||||
// >1 survivors: same-file → suppress (true overloads,
|
||||
// "degrade not lie" — no edge beats a wrong one, and
|
||||
// SFINAE-ambiguous calls land here). Cross-file →
|
||||
// first-match (shadowing semantics).
|
||||
const sameFile = narrowed.every((d) => d.filePath === narrowed[0]!.filePath);
|
||||
if (sameFile) {
|
||||
recordSuppressedOutcome(options.recordResolutionOutcome, {
|
||||
phase: 'free-call-fallback',
|
||||
filePath: parsed.filePath,
|
||||
name: site.name,
|
||||
range: site.atRange,
|
||||
reason: suppressionReasonForOverload(narrowed, site.arity, {
|
||||
conversionRankFn: options.conversionRankFn,
|
||||
argumentTypes: site.argumentTypes,
|
||||
}),
|
||||
candidates: narrowed,
|
||||
});
|
||||
handledSites.add(key);
|
||||
continue;
|
||||
}
|
||||
fnDef = ordinary[0];
|
||||
// These candidates share the selected lexical tier. Different
|
||||
// files do not establish precedence between imported overloads.
|
||||
recordSuppressedOutcome(options.recordResolutionOutcome, {
|
||||
phase: 'free-call-fallback',
|
||||
filePath: parsed.filePath,
|
||||
name: site.name,
|
||||
range: site.atRange,
|
||||
reason: suppressionReasonForOverload(narrowed, site.arity, {
|
||||
conversionRankFn: options.conversionRankFn,
|
||||
argumentTypes: site.argumentTypes,
|
||||
}),
|
||||
candidates: narrowed,
|
||||
});
|
||||
handledSites.add(key);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -602,7 +634,7 @@ export function emitFreeCallFallback(
|
|||
// visibility rules forbid (below), and every edge that survives is
|
||||
// emitted with `GLOBAL_NAME_FALLBACK_REASON` at 0.5 rather than
|
||||
// masquerading as `import-resolved` at 0.85 (see the emit site).
|
||||
if (fnDef === undefined && options.allowGlobalFallback === true) {
|
||||
if (fnDef === undefined && options.allowGlobalFallback === true && !strictClaim) {
|
||||
fnDef = pickUniqueGlobalCallable(
|
||||
site.name,
|
||||
model,
|
||||
|
|
@ -702,7 +734,12 @@ export function emitFreeCallFallback(
|
|||
handledSites.add(siteKey(parsed.filePath, site));
|
||||
continue;
|
||||
}
|
||||
const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup, site.atRange);
|
||||
const callerGraphId = resolveCallerGraphId(
|
||||
site.callerScope ?? site.inScope,
|
||||
scopes,
|
||||
nodeLookup,
|
||||
site.atRange,
|
||||
);
|
||||
if (callerGraphId === undefined) continue;
|
||||
const tgtGraphId = resolveDefGraphId(fnDef.filePath, fnDef, nodeLookup);
|
||||
if (tgtGraphId === undefined) continue;
|
||||
|
|
|
|||
|
|
@ -39,16 +39,72 @@
|
|||
* scope-resolution generalization plan.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, ScopeId, TypeRef } from 'gitnexus-shared';
|
||||
import type { ParsedFile, Scope, ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import type { WorkspaceResolutionIndex } from '../workspace-index.js';
|
||||
import { definitionIdPosition } from '../utils/definition-id.js';
|
||||
import {
|
||||
lookupBindingsAt,
|
||||
lookupNameClaim,
|
||||
findReceiverTypeBinding,
|
||||
namesAtScope,
|
||||
moduleScopeIdOf,
|
||||
namespaceTypeBindingFor,
|
||||
} from '../scope/walkers.js';
|
||||
|
||||
/** Definition identity → its own type fact; built once, facts read lazily after propagation. */
|
||||
interface TypeFactOwner {
|
||||
readonly scope: Scope;
|
||||
readonly name: string;
|
||||
}
|
||||
const typeFactOwners = new WeakMap<ScopeResolutionIndexes, Map<string, TypeFactOwner>>();
|
||||
function producerTypeRef(defId: string, scopes: ScopeResolutionIndexes): TypeRef | undefined {
|
||||
let owners = typeFactOwners.get(scopes);
|
||||
if (owners === undefined) {
|
||||
owners = new Map();
|
||||
const returnsByDeclaration = new Map<string, TypeFactOwner | null>();
|
||||
const definitions = new Map<string, SymbolDefinition>();
|
||||
for (const scope of scopes.scopeTree.byId?.values() ?? []) {
|
||||
for (const def of scope.ownedDefs ?? []) definitions.set(def.nodeId, def);
|
||||
for (const [name, ref] of scope.typeBindings) {
|
||||
// Some providers hoist method return annotations to the module. Join
|
||||
// those facts by their declaration anchor, never by an enclosing name.
|
||||
if (
|
||||
ref.source !== 'return-annotation' ||
|
||||
ref.bindingRange === undefined ||
|
||||
ref.declaredAtScope !== scope.id
|
||||
)
|
||||
continue;
|
||||
const key = `${scope.filePath}\0${ref.bindingRange.startLine}:${ref.bindingRange.startCol}`;
|
||||
returnsByDeclaration.set(key, returnsByDeclaration.has(key) ? null : { scope, name });
|
||||
}
|
||||
for (const [name, bindings] of scope.bindings) {
|
||||
for (const binding of bindings) {
|
||||
if (binding.origin !== 'local') continue;
|
||||
definitions.set(binding.def.nodeId, binding.def);
|
||||
const ref = scope.typeBindings.get(name);
|
||||
if (
|
||||
ref !== undefined &&
|
||||
!(ref.source === 'return-annotation' && ref.bindingRange !== undefined)
|
||||
)
|
||||
owners.set(binding.def.nodeId, { scope, name });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const def of definitions.values()) {
|
||||
const position = definitionIdPosition(def.nodeId, def.filePath);
|
||||
if (position === undefined) continue;
|
||||
const owner = returnsByDeclaration.get(
|
||||
`${def.filePath}\0${position.line}:${position.column}`,
|
||||
);
|
||||
if (owner !== undefined && owner !== null) owners.set(def.nodeId, owner);
|
||||
}
|
||||
typeFactOwners.set(scopes, owners);
|
||||
}
|
||||
const owner = owners.get(defId);
|
||||
return owner?.scope.typeBindings.get(owner.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Max chain depth for the post-finalize re-follow. Effective end-to-end
|
||||
* depth is roughly 2× this number, because chain-following runs once
|
||||
|
|
@ -76,16 +132,28 @@ export function followChainPostFinalize(
|
|||
const moduleScopeId = moduleScopeIdOf(fromScopeId, scopes);
|
||||
for (let depth = 0; depth < RECHAIN_MAX_DEPTH; depth++) {
|
||||
if (current.rawName.includes('.')) return current;
|
||||
let scopeId: ScopeId | null = fromScopeId;
|
||||
let next: TypeRef | undefined;
|
||||
while (scopeId !== null) {
|
||||
const scope = scopes.scopeTree.getScope(scopeId);
|
||||
if (scope === undefined) break;
|
||||
next = scope.typeBindings.get(current.rawName);
|
||||
if (next !== undefined && next !== current) break;
|
||||
next = undefined;
|
||||
scopeId = scope.parent;
|
||||
const lookupScope = current.declaredAtScope ?? fromScopeId;
|
||||
const claim = lookupNameClaim(lookupScope, current.rawName, scopes, {
|
||||
position: current.lookupPosition,
|
||||
purpose: current.lookupPurpose,
|
||||
});
|
||||
let next = claim.typeBinding;
|
||||
if (next === undefined || claim.bindings.some((binding) => binding.origin !== 'local')) {
|
||||
const candidates = new Map<string, TypeRef>();
|
||||
for (const binding of claim.bindings) {
|
||||
const producer = producerTypeRef(binding.def.nodeId, scopes);
|
||||
if (producer !== undefined) candidates.set(binding.def.nodeId, producer);
|
||||
}
|
||||
if (candidates.size > 1) return current;
|
||||
if (candidates.size === 1) next = candidates.values().next().value;
|
||||
}
|
||||
if (claim.status !== 'absent' && next === undefined) return current;
|
||||
if (claim.status === 'absent')
|
||||
next = findReceiverTypeBinding(lookupScope, current.rawName, scopes, {
|
||||
position: current.lookupPosition,
|
||||
purpose: current.lookupPurpose,
|
||||
});
|
||||
if (next === current) next = undefined;
|
||||
// Scope-independent fallbacks (#1871), mirroring findReceiverTypeBinding's
|
||||
// precedence: named namespaces accessible from this file
|
||||
// (`namespaceTypeBindings`, gated by accessibility) first — they lived in the
|
||||
|
|
@ -93,18 +161,18 @@ export function followChainPostFinalize(
|
|||
// global/default namespace (`workspaceTypeBindings`, visible everywhere).
|
||||
// Both live in shared channels rather than each Scope.typeBindings to avoid
|
||||
// the O(files × names) blow-up.
|
||||
if (next === undefined) {
|
||||
if (next === undefined && claim.status === 'absent') {
|
||||
const nsHit = namespaceTypeBindingFor(moduleScopeId, current.rawName, scopes);
|
||||
if (nsHit !== undefined && nsHit !== current) next = nsHit;
|
||||
}
|
||||
if (next === undefined) {
|
||||
if (next === undefined && claim.status === 'absent') {
|
||||
const ws = scopes.workspaceTypeBindings?.get(current.rawName);
|
||||
if (ws !== undefined && ws !== current) next = ws;
|
||||
}
|
||||
if (next === undefined) return current;
|
||||
if (visited.has(next.rawName)) return current;
|
||||
visited.add(next.rawName);
|
||||
current = next;
|
||||
current = { ...next, bindingRange: start.bindingRange };
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,9 @@ export interface OverloadNarrowingHookCtx {
|
|||
/** Conversion-rank scoring fallback (step 4b). Engages when the
|
||||
* exact-type filter rejects every candidate. */
|
||||
readonly conversionRankFn?: ConversionRankFn;
|
||||
/** Reject an empty conversion set when all argument and parameter slots are
|
||||
* known. Used for block-scope declarations that prohibit ADL recovery. */
|
||||
readonly incompatibleConversionsAreFinal?: boolean;
|
||||
/** Per-language argument-type prefixes whose conversion-rank failures
|
||||
* should suppress genuinely ambiguous multi-overload sets instead of
|
||||
* falling back to arity-only candidates. */
|
||||
|
|
@ -171,8 +174,8 @@ export function narrowOverloadCandidates(
|
|||
// pairwise dominance: F1 beats F2 only when F1 is not worse for
|
||||
// every arg and better for at least one. Non-dominated candidates
|
||||
// are returned; multiple survivors are genuinely ambiguous. When
|
||||
// ranking also yields empty, fall through to the arity-filtered
|
||||
// `candidates` set — matches pre-#1606 behavior.
|
||||
// ranking also yields empty, retain the legacy arity fallback unless
|
||||
// the caller requires a definitive conversion verdict with known slots.
|
||||
const ranked = rankByConversion(
|
||||
candidates,
|
||||
argTypes,
|
||||
|
|
@ -181,6 +184,21 @@ export function narrowOverloadCandidates(
|
|||
);
|
||||
if (ranked.length > 0) result = ranked;
|
||||
else if (
|
||||
hookCtx.incompatibleConversionsAreFinal === true &&
|
||||
argTypes.every((type) => type !== '') &&
|
||||
candidates.every((candidate) => {
|
||||
const params = candidate.parameterTypes;
|
||||
return (
|
||||
params !== undefined &&
|
||||
argTypes.every((_, index) => {
|
||||
const type = parameterTypeAt(params, index);
|
||||
return type !== undefined && type !== '';
|
||||
})
|
||||
);
|
||||
})
|
||||
) {
|
||||
result = [];
|
||||
} else if (
|
||||
candidates.length > 1 &&
|
||||
hasConversionOnlyArgType(argTypes, hookCtx.conversionOnlyArgTypePrefixes)
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -159,7 +159,10 @@ export function resolveValueRefTarget(
|
|||
): SymbolDefinition | undefined {
|
||||
const receiverName = site.explicitReceiver?.name;
|
||||
if (receiverName === undefined) {
|
||||
return findCallableBindingInScope(site.inScope, site.name, scopes);
|
||||
return findCallableBindingInScope(site.inScope, site.name, scopes, {
|
||||
position: site.atRange,
|
||||
purpose: 'value',
|
||||
});
|
||||
}
|
||||
// NAMESPACE FIRST, and the order is load-bearing. `findClassBindingInScope`
|
||||
// does not stop at the scope chain: when its `isClassLike` walk misses — and a
|
||||
|
|
@ -205,7 +208,10 @@ export function resolveValueRefTarget(
|
|||
return viaNamespace === 'owned' ? undefined : viaNamespace;
|
||||
}
|
||||
|
||||
const owner = findClassBindingInScope(site.inScope, receiverName, scopes);
|
||||
const owner = findClassBindingInScope(site.inScope, receiverName, scopes, undefined, {
|
||||
position: site.atRange,
|
||||
purpose: 'value',
|
||||
});
|
||||
if (owner !== undefined) {
|
||||
// The container lookup is a CLASS-ONLY walk: `walkScopeChain` filters by
|
||||
// `isClassLike`, so it steps over a nearer binding that is a value and keeps
|
||||
|
|
@ -285,6 +291,7 @@ function findNamespaceValueRefTarget(
|
|||
collectNamespaceTargets({ moduleScope: moduleScopeId }, scopes, {
|
||||
...namespaceOptions,
|
||||
inScope: site.inScope,
|
||||
position: site.atRange,
|
||||
}).get(receiverName) ?? [];
|
||||
if (targetFiles.length === 0) return undefined;
|
||||
if (
|
||||
|
|
@ -293,6 +300,7 @@ function findNamespaceValueRefTarget(
|
|||
site.inScope,
|
||||
scopes,
|
||||
namespaceOptions?.skipEnclosingClasses,
|
||||
site.atRange,
|
||||
)
|
||||
)
|
||||
return undefined;
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ import type { SemanticModel } from '../../model/semantic-model.js';
|
|||
import type { ScopeResolver } from '../contract/scope-resolver.js';
|
||||
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
|
||||
import type { WorkspaceResolutionIndex } from '../workspace-index.js';
|
||||
import { collectNamespaceTargets } from '../scope/namespace-targets.js';
|
||||
import { createNamespaceTargetCache } from '../scope/namespace-target-cache.js';
|
||||
import {
|
||||
bindsTypeParameter,
|
||||
findClassBindingInScope,
|
||||
|
|
@ -83,6 +83,8 @@ import {
|
|||
findExportedDefIncludingImportedNames,
|
||||
findOwnedMember,
|
||||
findReceiverTypeBinding,
|
||||
lookupNameClaim,
|
||||
hasExplicitNameClaim,
|
||||
findValueBindingInScope,
|
||||
isClassLike,
|
||||
isNamespaceNameShadowed,
|
||||
|
|
@ -495,6 +497,7 @@ export function emitReceiverBoundCalls(
|
|||
prefix: string,
|
||||
inScope: ScopeId,
|
||||
namespaceTargets: ReadonlyMap<string, readonly string[]>,
|
||||
position?: TypeRef['lookupPosition'],
|
||||
): ChainCursor | undefined => {
|
||||
const segments = splitTopLevelDots(prefix);
|
||||
if (segments.length === 0) return undefined;
|
||||
|
|
@ -507,7 +510,15 @@ export function emitReceiverBoundCalls(
|
|||
const key = segments.slice(0, k).join('.');
|
||||
const files = namespaceTargets.get(key);
|
||||
if (files === undefined) continue;
|
||||
if (isNamespaceNameShadowed(key, inScope, scopes, provider.namespaceSkipsEnclosingClasses))
|
||||
if (
|
||||
isNamespaceNameShadowed(
|
||||
key,
|
||||
inScope,
|
||||
scopes,
|
||||
provider.namespaceSkipsEnclosingClasses,
|
||||
position,
|
||||
)
|
||||
)
|
||||
return undefined;
|
||||
cursor = { files };
|
||||
rest = segments.slice(k);
|
||||
|
|
@ -548,6 +559,7 @@ export function emitReceiverBoundCalls(
|
|||
receiverName: string,
|
||||
inScope: ScopeId,
|
||||
namespaceTargets: ReadonlyMap<string, readonly string[]>,
|
||||
position?: TypeRef['lookupPosition'],
|
||||
): SymbolDefinition | undefined => {
|
||||
const dot = walkChains ? lastTopLevelDot(receiverName) : receiverName.lastIndexOf('.');
|
||||
if (dot <= 0 || dot === receiverName.length - 1) return undefined;
|
||||
|
|
@ -555,7 +567,7 @@ export function emitReceiverBoundCalls(
|
|||
const tail = receiverName.slice(dot + 1);
|
||||
if (tail.includes('(') || tail.includes('[')) return undefined;
|
||||
if (walkChains) {
|
||||
const cursor = resolveNamespaceChain(head, inScope, namespaceTargets);
|
||||
const cursor = resolveNamespaceChain(head, inScope, namespaceTargets, position);
|
||||
if (cursor === undefined) return undefined;
|
||||
return 'classDef' in cursor
|
||||
? findNestedClass(cursor.classDef, tail)
|
||||
|
|
@ -564,7 +576,13 @@ export function emitReceiverBoundCalls(
|
|||
const files = namespaceTargets.get(head);
|
||||
if (
|
||||
files === undefined ||
|
||||
isNamespaceNameShadowed(head, inScope, scopes, provider.namespaceSkipsEnclosingClasses)
|
||||
isNamespaceNameShadowed(
|
||||
head,
|
||||
inScope,
|
||||
scopes,
|
||||
provider.namespaceSkipsEnclosingClasses,
|
||||
position,
|
||||
)
|
||||
)
|
||||
return undefined;
|
||||
return uniqueClassAcross(files, tail);
|
||||
|
|
@ -724,7 +742,12 @@ export function emitReceiverBoundCalls(
|
|||
): boolean | 'suppress' => {
|
||||
const predicate = provider.resolveMissingReceiverMembersFromSubtypes;
|
||||
if (predicate === undefined) return false;
|
||||
const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup, site.atRange);
|
||||
const callerGraphId = resolveCallerGraphId(
|
||||
site.callerScope ?? site.inScope,
|
||||
scopes,
|
||||
nodeLookup,
|
||||
site.atRange,
|
||||
);
|
||||
const callerIsStatic =
|
||||
callerGraphId === undefined ? undefined : graph.getNode(callerGraphId)?.properties.isStatic;
|
||||
const receiverBindingGraphId = resolveCallerGraphId(
|
||||
|
|
@ -1042,30 +1065,48 @@ export function emitReceiverBoundCalls(
|
|||
};
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
const namespaceContext = (inScope?: ScopeId) => {
|
||||
const namespaceTargets = collectNamespaceTargets(parsed, scopes, {
|
||||
const namespaceCache = createNamespaceTargetCache(
|
||||
parsed,
|
||||
scopes,
|
||||
{
|
||||
receiverPaths: provider.namespaceReceiverPaths,
|
||||
bindingIdentity: provider.namespaceBindingIdentity,
|
||||
skipEnclosingClasses: provider.namespaceSkipsEnclosingClasses,
|
||||
moduleFileExists: (filePath) => index.moduleScopeByFile.has(filePath),
|
||||
inScope,
|
||||
});
|
||||
},
|
||||
provider.importsBindAtLexicalScope === true,
|
||||
);
|
||||
const namespaceTargetsAt = namespaceCache.at;
|
||||
const namespaceContext = (inScope?: ScopeId, position?: TypeRef['lookupPosition']) => {
|
||||
const namespaceTargets = namespaceTargetsAt(inScope, position);
|
||||
return {
|
||||
namespaceTargets,
|
||||
fileCompoundOpts: {
|
||||
...compoundOpts,
|
||||
namespaceTargets,
|
||||
namespaceTargetsAt,
|
||||
lookupPosition: position,
|
||||
...(walkChains
|
||||
? {
|
||||
resolveQualifiedClass: (qualifiedName: string, scopeId: ScopeId) =>
|
||||
resolveNamespaceQualifiedClass(qualifiedName, scopeId, namespaceTargets),
|
||||
resolveQualifiedClass: (
|
||||
qualifiedName: string,
|
||||
scopeId: ScopeId,
|
||||
lookupPosition?: TypeRef['lookupPosition'],
|
||||
) =>
|
||||
resolveNamespaceQualifiedClass(
|
||||
qualifiedName,
|
||||
scopeId,
|
||||
lookupPosition === undefined
|
||||
? namespaceTargets
|
||||
: namespaceTargetsAt(scopeId, lookupPosition),
|
||||
lookupPosition,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
};
|
||||
const fileNamespaces = namespaceContext();
|
||||
const namespacesByScope = new Map<ScopeId, ReturnType<typeof namespaceContext>>();
|
||||
// Per-file resolved-callee-id capture context (#2227 U2). Built once per
|
||||
// file; `undefined` when the sink is absent (pdg off) so the `tryEmitEdge`
|
||||
// capture is a no-op and emission stays byte-identical (R4).
|
||||
|
|
@ -1078,15 +1119,9 @@ export function emitReceiverBoundCalls(
|
|||
if (site.kind !== 'call' && site.kind !== 'read' && site.kind !== 'write') continue;
|
||||
if (site.explicitReceiver === undefined) continue;
|
||||
|
||||
let namespaces = fileNamespaces;
|
||||
if (provider.importsBindAtLexicalScope === true) {
|
||||
let scoped = namespacesByScope.get(site.inScope);
|
||||
if (scoped === undefined) {
|
||||
scoped = namespaceContext(site.inScope);
|
||||
namespacesByScope.set(site.inScope, scoped);
|
||||
}
|
||||
namespaces = scoped;
|
||||
}
|
||||
const namespaces = namespaceCache.requiresLexicalLookup
|
||||
? namespaceContext(site.inScope, site.atRange)
|
||||
: fileNamespaces;
|
||||
const { namespaceTargets, fileCompoundOpts } = namespaces;
|
||||
|
||||
const receiverName = site.explicitReceiver.name;
|
||||
|
|
@ -1100,7 +1135,7 @@ export function emitReceiverBoundCalls(
|
|||
const baseTypeRef =
|
||||
baseName === undefined
|
||||
? undefined
|
||||
: findReceiverTypeBinding(site.inScope, baseName, scopes);
|
||||
: findReceiverTypeBinding(site.inScope, baseName, scopes, { position: site.atRange });
|
||||
if (baseTypeRef !== undefined && provider.suppressReceiverLookup(baseTypeRef)) {
|
||||
options.recordResolutionOutcome?.({
|
||||
kind: 'suppressed',
|
||||
|
|
@ -1594,6 +1629,7 @@ export function emitReceiverBoundCalls(
|
|||
site.inScope,
|
||||
scopes,
|
||||
provider.namespaceSkipsEnclosingClasses,
|
||||
site.atRange,
|
||||
)
|
||||
? namespaceCandidates
|
||||
: undefined;
|
||||
|
|
@ -1601,7 +1637,12 @@ export function emitReceiverBoundCalls(
|
|||
// no handle of this file, but its segments reach a module (see
|
||||
// `resolveNamespaceChain`). A prefix that ends in a CLASS is Case 2's.
|
||||
if (targetFiles === undefined && walkChains && lastTopLevelDot(receiverName) > 0) {
|
||||
const cursor = resolveNamespaceChain(receiverName, site.inScope, namespaceTargets);
|
||||
const cursor = resolveNamespaceChain(
|
||||
receiverName,
|
||||
site.inScope,
|
||||
namespaceTargets,
|
||||
site.atRange,
|
||||
);
|
||||
if (cursor !== undefined && 'files' in cursor) targetFiles = cursor.files;
|
||||
}
|
||||
if (targetFiles !== undefined && provider.resolveQualifiedReceiverMember === undefined) {
|
||||
|
|
@ -1719,8 +1760,11 @@ export function emitReceiverBoundCalls(
|
|||
// opted in. Only a bare tail is walked here; `ns.Type.field.m()` is the
|
||||
// compound resolver's shape.
|
||||
const classDef =
|
||||
findClassBindingInScope(site.inScope, receiverName, scopes) ??
|
||||
resolveNamespaceQualifiedClass(receiverName, site.inScope, namespaceTargets);
|
||||
findClassBindingInScope(site.inScope, receiverName, scopes, undefined, {
|
||||
position: site.atRange,
|
||||
purpose: 'value',
|
||||
}) ??
|
||||
resolveNamespaceQualifiedClass(receiverName, site.inScope, namespaceTargets, site.atRange);
|
||||
if (classDef !== undefined) {
|
||||
const chain = [classDef.nodeId, ...scopes.methodDispatch.mroFor(classDef.nodeId)];
|
||||
let memberDef: SymbolDefinition | undefined;
|
||||
|
|
@ -1841,7 +1885,9 @@ export function emitReceiverBoundCalls(
|
|||
}
|
||||
|
||||
// ── Case 3: dotted typeBinding (`u: models.User`) ────────────
|
||||
const typeRef = findReceiverTypeBinding(site.inScope, receiverName, scopes);
|
||||
const typeRef = findReceiverTypeBinding(site.inScope, receiverName, scopes, {
|
||||
position: site.atRange,
|
||||
});
|
||||
if (typeRef !== undefined && typeRef.rawName.includes('.')) {
|
||||
const [nsName, ...classNameParts] = typeRef.rawName.split('.');
|
||||
const className = classNameParts.join('.');
|
||||
|
|
@ -1849,7 +1895,12 @@ export function emitReceiverBoundCalls(
|
|||
// (`x: mod.Outer.Inner`, `t: hub.sub.Thing`); the candidate list then
|
||||
// has one entry or none. Without it: the historical one-hop split.
|
||||
const chainDef3 = walkChains
|
||||
? resolveNamespaceQualifiedClass(typeRef.rawName, site.inScope, namespaceTargets)
|
||||
? resolveNamespaceQualifiedClass(
|
||||
typeRef.rawName,
|
||||
typeRef.declaredAtScope,
|
||||
namespaceTargetsAt(typeRef.declaredAtScope, typeRef.lookupPosition),
|
||||
typeRef.lookupPosition,
|
||||
)
|
||||
: undefined;
|
||||
const targetFiles3 = walkChains
|
||||
? chainDef3 === undefined
|
||||
|
|
@ -1954,7 +2005,7 @@ export function emitReceiverBoundCalls(
|
|||
typeRef.declaredAtScope,
|
||||
scopes,
|
||||
index,
|
||||
fileCompoundOpts,
|
||||
{ ...fileCompoundOpts, lookupPosition: typeRef.lookupPosition },
|
||||
);
|
||||
if (resolved === undefined && !typeRef.rawName.includes('(')) {
|
||||
resolved = resolveCompoundReceiverTyped(
|
||||
|
|
@ -1962,7 +2013,7 @@ export function emitReceiverBoundCalls(
|
|||
typeRef.declaredAtScope,
|
||||
scopes,
|
||||
index,
|
||||
fileCompoundOpts,
|
||||
{ ...fileCompoundOpts, lookupPosition: typeRef.lookupPosition },
|
||||
);
|
||||
}
|
||||
const ownerDef = resolved?.def;
|
||||
|
|
@ -2095,9 +2146,11 @@ export function emitReceiverBoundCalls(
|
|||
// declares under that base name — see {@link erasedTypeApplication}.
|
||||
const typeApplication = erasedTypeApplication(typeRef);
|
||||
let ownerDef = resolveClassBindingForName(
|
||||
site.inScope,
|
||||
typeRef.declaredAtScope,
|
||||
typeApplication ?? typeRef.rawName,
|
||||
scopes,
|
||||
undefined,
|
||||
{ position: typeRef.lookupPosition, purpose: typeRef.lookupPurpose ?? 'type' },
|
||||
);
|
||||
// `findClassBindingInScope(..., typeRef.rawName)` only works when
|
||||
// rawName is itself a class symbol reachable through scope bindings.
|
||||
|
|
@ -2113,7 +2166,15 @@ export function emitReceiverBoundCalls(
|
|||
// MANUFACTURED by erasing type arguments, where the file may never
|
||||
// have named it at all. The lookup above already answered that case on
|
||||
// grounds; re-asking it here without any would undo them.
|
||||
if (ownerDef === undefined && typeApplication === undefined) {
|
||||
const typeClaim = lookupNameClaim(typeRef.declaredAtScope, typeRef.rawName, scopes, {
|
||||
position: typeRef.lookupPosition,
|
||||
purpose: typeRef.lookupPurpose ?? 'type',
|
||||
});
|
||||
if (
|
||||
ownerDef === undefined &&
|
||||
typeApplication === undefined &&
|
||||
!hasExplicitNameClaim(typeClaim, typeRef.rawName)
|
||||
) {
|
||||
const qnameIds = scopes.qualifiedNames.get(typeRef.rawName);
|
||||
if (qnameIds.length === 1) {
|
||||
const qdef = scopes.defs.get(qnameIds[0]!);
|
||||
|
|
@ -2740,7 +2801,13 @@ export function emitReceiverBoundCalls(
|
|||
const headClass =
|
||||
staticMemberReceiver === undefined
|
||||
? undefined
|
||||
: findClassBindingInScope(site.inScope, staticMemberReceiver.headName, scopes);
|
||||
: findClassBindingInScope(
|
||||
site.inScope,
|
||||
staticMemberReceiver.headName,
|
||||
scopes,
|
||||
undefined,
|
||||
{ position: site.atRange, purpose: 'value' },
|
||||
);
|
||||
// The head must be the CLASS ITSELF, not a value that happens to
|
||||
// share its name — the same `currentIsClassConstant` test the text
|
||||
// cascade makes before it treats a head as a class constant. A head
|
||||
|
|
@ -2749,7 +2816,9 @@ export function emitReceiverBoundCalls(
|
|||
if (
|
||||
staticMemberReceiver !== undefined &&
|
||||
headClass !== undefined &&
|
||||
findReceiverTypeBinding(site.inScope, staticMemberReceiver.headName, scopes) === undefined
|
||||
findReceiverTypeBinding(site.inScope, staticMemberReceiver.headName, scopes, {
|
||||
position: site.atRange,
|
||||
}) === undefined
|
||||
) {
|
||||
// MRO walk, so a class-level member declared on an ancestor is
|
||||
// reachable through a subclass name where the language allows it.
|
||||
|
|
|
|||
|
|
@ -284,7 +284,9 @@ function buildParameterProducers(
|
|||
|
||||
// Resolve the callee from the CALL SITE, so the formal is looked up in the
|
||||
// file that actually declares the function rather than the one calling it.
|
||||
const calleeDef = findCallableBindingInScope(flow.source.inScope, callee, indexes);
|
||||
const calleeDef = findCallableBindingInScope(flow.source.inScope, callee, indexes, {
|
||||
position: flow.source.atRange,
|
||||
});
|
||||
if (calleeDef?.filePath === undefined) continue;
|
||||
|
||||
const binding = formals.get(
|
||||
|
|
@ -358,7 +360,9 @@ export function emitReturnShapeMemberAccesses(
|
|||
// The receiver's binding names the PRODUCER, not a class. That is the
|
||||
// whole point: `formatSpikeAlert` is a function, and before R3-4 there
|
||||
// was nothing named after it to look a member up on.
|
||||
const typeRef = findReceiverTypeBinding(site.inScope, receiver, indexes);
|
||||
const typeRef = findReceiverTypeBinding(site.inScope, receiver, indexes, {
|
||||
position: site.atRange,
|
||||
});
|
||||
let producerRef = typeRef?.rawName;
|
||||
|
||||
// W2-2. A receiver with no binding of its own may still be a PARAMETER
|
||||
|
|
@ -411,7 +415,12 @@ export function emitReturnShapeMemberAccesses(
|
|||
// resolve into another language with its members
|
||||
// legitimately in that same file. File equality passes
|
||||
// there; only the language restriction closes it.
|
||||
const producerDef = findCallableBindingInScope(site.inScope, producerRef, indexes);
|
||||
const producerDef = findCallableBindingInScope(
|
||||
typeRef?.declaredAtScope ?? site.inScope,
|
||||
producerRef,
|
||||
indexes,
|
||||
{ position: typeRef?.lookupPosition ?? site.atRange },
|
||||
);
|
||||
let producerFile = producerDef?.filePath;
|
||||
|
||||
// MEMBER-CALL PRODUCERS (W2-1). Tried only where the callable lookup above
|
||||
|
|
@ -476,7 +485,12 @@ export function emitReturnShapeMemberAccesses(
|
|||
}
|
||||
const target = owned[0]!;
|
||||
|
||||
const callerGraphId = resolveCallerGraphId(site.inScope, indexes, nodeLookup, site.atRange);
|
||||
const callerGraphId = resolveCallerGraphId(
|
||||
site.callerScope ?? site.inScope,
|
||||
indexes,
|
||||
nodeLookup,
|
||||
site.atRange,
|
||||
);
|
||||
if (callerGraphId === undefined) continue;
|
||||
if (callerGraphId === target.id) continue;
|
||||
|
||||
|
|
|
|||
|
|
@ -491,7 +491,12 @@ export function emitUniqueNamePropertyAccesses(
|
|||
|
||||
// Resolved BEFORE narrowing: the enclosing callable is evidence the
|
||||
// ranking needs, not just the edge's source.
|
||||
const callerGraphId = resolveCallerGraphId(site.inScope, indexes, nodeLookup, site.atRange);
|
||||
const callerGraphId = resolveCallerGraphId(
|
||||
site.callerScope ?? site.inScope,
|
||||
indexes,
|
||||
nodeLookup,
|
||||
site.atRange,
|
||||
);
|
||||
if (callerGraphId === undefined) continue;
|
||||
|
||||
const choice = narrowToSingleCandidate(
|
||||
|
|
|
|||
|
|
@ -413,6 +413,9 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
|||
let scopeFilePaths: Set<string>;
|
||||
let contents: Map<string, string>;
|
||||
if (provider.collectScopeContextPaths !== undefined) {
|
||||
// Context hooks consume worker facts too. Load primary records before
|
||||
// the hook, or a disk-backed run needlessly re-extracts those files.
|
||||
await loadStoreFor(new Set(primaryFilePaths));
|
||||
// Context-expanding providers (e.g. Vue) need every primary file's
|
||||
// source up front for the closure hook, so load it all.
|
||||
const entryFileContents = await readFileContents(ctx.repoPath, primaryFilePaths);
|
||||
|
|
@ -427,7 +430,7 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
|||
const extraPaths = [...scopeFilePaths].filter((p) => !entryFileContents.has(p));
|
||||
const extraContents = await readFileContents(ctx.repoPath, extraPaths);
|
||||
contents = new Map([...entryFileContents, ...extraContents]);
|
||||
await loadStoreFor(scopeFilePaths);
|
||||
await loadStoreFor(new Set(extraPaths));
|
||||
} else {
|
||||
scopeFilePaths = new Set(primaryFilePaths);
|
||||
await loadStoreFor(scopeFilePaths);
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
* Plan: `docs/plans/2026-04-20-001-refactor-emit-pipeline-generalization-plan.md`.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, RegistryProviders } from 'gitnexus-shared';
|
||||
import type { ParsedFile, RegistryProviders, Scope } from 'gitnexus-shared';
|
||||
import type { TypeRef } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import { generateId } from '../../../../lib/utils.js';
|
||||
|
|
@ -258,11 +258,12 @@ function preEmitInheritanceEdges(
|
|||
if (callerClass === undefined) continue;
|
||||
|
||||
const targetDef = resolveInheritanceBaseInScope(
|
||||
site.inScope,
|
||||
site.lookupScope ?? site.inScope,
|
||||
site.name,
|
||||
scopes,
|
||||
site.rawQualifiedName,
|
||||
callerClass,
|
||||
{ position: site.atRange, purpose: site.lookupPurpose ?? 'type' },
|
||||
);
|
||||
if (targetDef === undefined || targetDef.nodeId === callerClass.nodeId) {
|
||||
// Static lookup can mistake the current declaration for an earlier
|
||||
|
|
@ -1237,6 +1238,7 @@ export function runScopeResolution(
|
|||
implicitThisWalksMro: provider.implicitThisWalksMro === true,
|
||||
isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller,
|
||||
resolveAdlCandidates: provider.resolveAdlCandidates,
|
||||
resolveOrdinaryCallables: provider.resolveOrdinaryCallables,
|
||||
resolveQualifiedFreeCall: provider.resolveQualifiedFreeCall,
|
||||
conversionRankFn: provider.conversionRankFn,
|
||||
conversionOnlyArgTypePrefixes: provider.conversionOnlyArgTypePrefixes,
|
||||
|
|
@ -1698,6 +1700,22 @@ export function runScopeResolution(
|
|||
// entirely when the language has no registered model.
|
||||
if (taintSpec !== undefined) {
|
||||
const t1 = PROF ? performance.now() : 0;
|
||||
// The disk seal strips ParsedFile.scopes. Restore only this file's
|
||||
// lexical context for both taint consumers, then release it with this
|
||||
// iteration. Passing the stripped file silently blocks every sink;
|
||||
// an imports-only fallback would instead lose local shadow barriers.
|
||||
let taintParsed = pf;
|
||||
if (pf.scopes.length === 0) {
|
||||
const scopes: Scope[] = [];
|
||||
const pending = [pf.moduleScope];
|
||||
for (let i = 0; i < pending.length; i++) {
|
||||
const scope = indexes.scopeTree.getScope(pending[i]);
|
||||
if (scope === undefined) continue;
|
||||
scopes.push(scope);
|
||||
for (const child of indexes.scopeTree.getChildren(scope.id)) pending.push(child);
|
||||
}
|
||||
taintParsed = { ...pf, scopes };
|
||||
}
|
||||
const taint = emitFileTaint(
|
||||
pdgTarget,
|
||||
wellFormed,
|
||||
|
|
@ -1706,6 +1724,7 @@ export function runScopeResolution(
|
|||
taintLimits,
|
||||
(message) => logger.warn(message), // unconditional — R4/R6
|
||||
rdSolve,
|
||||
taintParsed,
|
||||
);
|
||||
if (PROF) taintMs += performance.now() - t1;
|
||||
taintTotals.analyzed += taint.functionsAnalyzed;
|
||||
|
|
@ -1740,6 +1759,7 @@ export function runScopeResolution(
|
|||
? taintLimits.maxFacts
|
||||
: DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
|
||||
rdSolve,
|
||||
taintParsed,
|
||||
);
|
||||
harvestedSummaries.push(...harvest.summaries);
|
||||
summaryUnresolved += harvest.unresolved;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
import type { BindingRef, ParsedFile, ScopeId, SourcePosition } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { collectNamespaceTargets, type NamespaceTargetOptions } from './namespace-targets.js';
|
||||
|
||||
const compare = (a: SourcePosition, b: SourcePosition): number =>
|
||||
a.startLine - b.startLine || a.startCol - b.startCol;
|
||||
|
||||
/** Per-file namespace snapshots change only when a claim or binding activates.
|
||||
* Keep only the target maps here: receiver type lookup still needs each site's
|
||||
* exact position, even when its namespace snapshot is shared with other sites.
|
||||
*/
|
||||
export function createNamespaceTargetCache(
|
||||
parsed: Pick<ParsedFile, 'moduleScope' | 'scopes'>,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
options: Omit<NamespaceTargetOptions, 'inScope' | 'position'>,
|
||||
importsBindAtLexicalScope: boolean,
|
||||
) {
|
||||
const boundaries: SourcePosition[] = [];
|
||||
const scopeIds = new Set<ScopeId>();
|
||||
const importNames = new Set<string>();
|
||||
const accessibleNamespaces = new Set<string>();
|
||||
const tree = scopes.scopeTree;
|
||||
let complete = typeof tree?.getChildren === 'function';
|
||||
let requiresLexicalLookup = importsBindAtLexicalScope;
|
||||
const collectBindings = (bindings: ReadonlyMap<string, readonly BindingRef[]> | undefined) => {
|
||||
if (bindings === undefined) return;
|
||||
for (const refs of bindings.values()) {
|
||||
for (const ref of refs)
|
||||
if (ref.availableFrom !== undefined) boundaries.push(ref.availableFrom);
|
||||
}
|
||||
};
|
||||
|
||||
// Sealed disk-backed ParsedFiles have no scopes left in their arrays. Walk
|
||||
// the index using point lookups, never scopeTree.byId (unsupported on disk).
|
||||
const pending = [parsed.moduleScope, ...parsed.scopes.map((scope) => scope.id)];
|
||||
while (pending.length > 0) {
|
||||
const id = pending.pop()!;
|
||||
if (scopeIds.has(id)) continue;
|
||||
scopeIds.add(id);
|
||||
const scope = tree?.getScope(id);
|
||||
if (scope === undefined) {
|
||||
complete = false;
|
||||
continue;
|
||||
}
|
||||
if (scope.nameClaims !== undefined) requiresLexicalLookup = true;
|
||||
for (const claim of scope.nameClaims ?? []) {
|
||||
if (claim.availableFrom !== undefined) boundaries.push(claim.availableFrom);
|
||||
}
|
||||
collectBindings(scope.bindings);
|
||||
collectBindings(scopes.bindings?.get(id));
|
||||
collectBindings(scopes.bindingAugmentations?.get(id));
|
||||
for (const edge of scopes.imports?.get(id) ?? scope.imports) importNames.add(edge.localName);
|
||||
for (const namespace of scopes.accessibleNamespacesByScope?.get(id) ?? []) {
|
||||
accessibleNamespaces.add(namespace);
|
||||
}
|
||||
if (typeof tree.getChildren === 'function') pending.push(...tree.getChildren(id));
|
||||
if (scope.parent !== null) pending.push(scope.parent);
|
||||
if (scope.lookupPolicy?.parentScope !== undefined) pending.push(scope.lookupPolicy.parentScope);
|
||||
}
|
||||
// These shared binding channels can also shadow an import. Inspect only the
|
||||
// imported names, rather than scanning the workspace for every source file.
|
||||
for (const name of importNames) {
|
||||
for (const ref of scopes.workspaceFqnBindings?.get(name) ?? []) {
|
||||
if (ref.availableFrom !== undefined) boundaries.push(ref.availableFrom);
|
||||
}
|
||||
for (const namespace of accessibleNamespaces) {
|
||||
for (const ref of scopes.namespaceFqnBindings?.get(namespace)?.get(name) ?? []) {
|
||||
if (ref.availableFrom !== undefined) boundaries.push(ref.availableFrom);
|
||||
}
|
||||
}
|
||||
}
|
||||
boundaries.sort(compare);
|
||||
const activations = boundaries.filter(
|
||||
(point, i) => i === 0 || compare(point, boundaries[i - 1]!) !== 0,
|
||||
);
|
||||
const byScope = new Map<ScopeId, Map<number | string, Map<string, string[]>>>();
|
||||
|
||||
const at = (inScope = parsed.moduleScope, position?: SourcePosition): Map<string, string[]> => {
|
||||
let bucket: number | string = -1; // An unspecified position sees every activation.
|
||||
if (position !== undefined) {
|
||||
if (!complete || !scopeIds.has(inScope)) {
|
||||
// Partial/legacy index views cannot prove that two positions have the
|
||||
// same active claims. Retain exact-position caching in that case.
|
||||
bucket = `${position.startLine}:${position.startCol}`;
|
||||
} else {
|
||||
let lo = 0;
|
||||
let hi = activations.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (compare(activations[mid]!, position) <= 0) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
bucket = lo;
|
||||
}
|
||||
}
|
||||
let snapshots = byScope.get(inScope);
|
||||
if (snapshots === undefined) {
|
||||
snapshots = new Map();
|
||||
byScope.set(inScope, snapshots);
|
||||
}
|
||||
let targets = snapshots.get(bucket);
|
||||
if (targets === undefined) {
|
||||
targets = collectNamespaceTargets(parsed, scopes, { ...options, inScope, position });
|
||||
snapshots.set(bucket, targets);
|
||||
}
|
||||
return targets;
|
||||
};
|
||||
return { at, requiresLexicalLookup: requiresLexicalLookup || !complete };
|
||||
}
|
||||
|
|
@ -34,11 +34,13 @@
|
|||
* paths that refer to that same namespace object; sibling scopes are invisible.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, ScopeId } from 'gitnexus-shared';
|
||||
import type { ParsedFile, ScopeId, SourcePosition, ImportEdge } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import type { ScopeResolver } from '../contract/scope-resolver.js';
|
||||
import { lookupNameClaim } from './walkers.js';
|
||||
|
||||
export interface NamespaceTargetOptions {
|
||||
readonly position?: SourcePosition;
|
||||
/** Reference scope for providers that bind imports lexically. */
|
||||
readonly inScope?: ScopeId;
|
||||
/** `ScopeResolver.namespaceReceiverPaths` for the file's language. Absent
|
||||
|
|
@ -71,7 +73,7 @@ export function collectNamespaceTargets(
|
|||
scopeIds.push(scopeId);
|
||||
}
|
||||
if (scopeId === parsed.moduleScope) break;
|
||||
scopeId = scope?.parent ?? null;
|
||||
scopeId = scope?.lookupPolicy?.parentScope ?? scope?.parent ?? null;
|
||||
}
|
||||
|
||||
const addTarget = (key: string, targetFile: string): void => {
|
||||
|
|
@ -85,9 +87,54 @@ export function collectNamespaceTargets(
|
|||
|
||||
const moduleFileExists = options?.moduleFileExists ?? ((): boolean => false);
|
||||
|
||||
const selectionCache = new Map<string, ReturnType<typeof lookupNameClaim>>();
|
||||
const initialScope = options?.inScope ?? parsed.moduleScope;
|
||||
const useClaims = scopes.scopeTree?.getScope(initialScope)?.bindings !== undefined;
|
||||
const edgeVisible = (edge: ImportEdge): boolean => {
|
||||
if (!useClaims) return true; // compatibility with legacy import-only index views
|
||||
let claim = selectionCache.get(edge.localName);
|
||||
if (claim === undefined) {
|
||||
claim = lookupNameClaim(initialScope, edge.localName, scopes, {
|
||||
position: options?.position,
|
||||
purpose: 'value',
|
||||
skipEnclosingClasses: options?.skipEnclosingClasses,
|
||||
});
|
||||
selectionCache.set(edge.localName, claim);
|
||||
}
|
||||
if (claim.imports.includes(edge)) return true;
|
||||
// A provider may prove that a nearer import and an outer dotted path denote
|
||||
// the same namespace object. Keep that path without admitting other names.
|
||||
if (
|
||||
claim.status !== 'resolved' ||
|
||||
options?.bindingIdentity === undefined ||
|
||||
edge.targetFile === null ||
|
||||
edge.kind !== 'namespace'
|
||||
)
|
||||
return false;
|
||||
const identity = options.bindingIdentity({
|
||||
localName: edge.localName,
|
||||
importPath: edge.targetExportedName,
|
||||
targetFile: edge.targetFile,
|
||||
explicitAlias: edge.explicitAlias,
|
||||
});
|
||||
return (
|
||||
identity !== undefined &&
|
||||
claim.imports.some(
|
||||
(candidate) =>
|
||||
candidate.kind === 'namespace' &&
|
||||
candidate.targetFile !== null &&
|
||||
options.bindingIdentity!({
|
||||
localName: candidate.localName,
|
||||
importPath: candidate.targetExportedName,
|
||||
targetFile: candidate.targetFile,
|
||||
explicitAlias: candidate.explicitAlias,
|
||||
}) === identity,
|
||||
)
|
||||
);
|
||||
};
|
||||
const claimedNames = new Map<string, string | undefined>();
|
||||
for (const id of scopeIds) {
|
||||
const edges = scopes.imports.get(id) ?? [];
|
||||
const edges = (scopes.imports.get(id) ?? []).filter(edgeVisible);
|
||||
const namesHere = new Map<string, string | undefined>();
|
||||
const conflictingNames = new Set<string>();
|
||||
const candidates = edges.map((edge) => {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,10 @@
|
|||
* as-is for TypeScript, Java, Kotlin, Ruby, etc.
|
||||
*/
|
||||
|
||||
import { lookupLexicalName, nameClaimsFor } from 'gitnexus-shared';
|
||||
import type {
|
||||
NameLookupOptions,
|
||||
NameClaimResult,
|
||||
BindingRef,
|
||||
ParsedFile,
|
||||
Scope,
|
||||
|
|
@ -45,6 +48,95 @@ import { definitionIdPosition } from '../utils/definition-id.js';
|
|||
|
||||
const EMPTY_BINDINGS: readonly BindingRef[] = Object.freeze([]);
|
||||
|
||||
interface IndexedNameLookupOptions extends NameLookupOptions {
|
||||
/** Add same-scope candidates beside legacy local bindings. Explicit name
|
||||
* claims still select their own candidates through the shared resolver. */
|
||||
readonly expandLocalBindings?: (
|
||||
scope: Scope,
|
||||
local: readonly BindingRef[],
|
||||
indexed: readonly BindingRef[],
|
||||
) => readonly BindingRef[];
|
||||
}
|
||||
|
||||
/** Resolve ownership before any target-kind filtering or global fallback. */
|
||||
export function lookupNameClaim(
|
||||
startScope: ScopeId,
|
||||
name: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
options: IndexedNameLookupOptions = {},
|
||||
): NameClaimResult {
|
||||
return lookupLexicalName(
|
||||
startScope,
|
||||
name,
|
||||
{
|
||||
scopes: scopes.scopeTree,
|
||||
importsAt: (scope) => scopes.imports?.get(scope.id) ?? scope.imports,
|
||||
bindingsAt: (scope, spelling) => {
|
||||
const local = scope.bindings.get(spelling) ?? [];
|
||||
const augmented = scopes.bindingAugmentations.get(scope.id)?.get(spelling) ?? [];
|
||||
// Unrelated claims must not change the legacy local binding tier.
|
||||
// Providers may add candidates that share that selected tier.
|
||||
if (local.length > 0 && nameClaimsFor(scope, spelling).length === 0) {
|
||||
const additional =
|
||||
options.expandLocalBindings?.(
|
||||
scope,
|
||||
local,
|
||||
scopes.bindings.get(scope.id)?.get(spelling) ?? EMPTY_BINDINGS,
|
||||
) ?? EMPTY_BINDINGS;
|
||||
return [
|
||||
...local,
|
||||
...additional,
|
||||
...augmented.filter(
|
||||
(binding) =>
|
||||
binding.declarationRange !== undefined || binding.availableFrom !== undefined,
|
||||
),
|
||||
];
|
||||
}
|
||||
return [
|
||||
...local,
|
||||
...(scopes.bindings.get(scope.id)?.get(spelling) ?? []),
|
||||
...augmented,
|
||||
...(collectNamespaceFqnBindings(scope.id, spelling, scopes) ?? []),
|
||||
...(scopes.workspaceFqnBindings?.get(spelling) ?? []),
|
||||
];
|
||||
},
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/** Provider claims are strict; legacy records retain their historical kind namespaces. */
|
||||
export function hasExplicitNameClaim(claim: NameClaimResult, name: string): boolean {
|
||||
return (
|
||||
claim.claims.length > 0 ||
|
||||
claim.scope?.lexicalNames?.has(name) === true ||
|
||||
claim.imports.some((edge) => edge.linkStatus === 'unresolved')
|
||||
);
|
||||
}
|
||||
|
||||
function lookupMatchingNameClaim(
|
||||
startScope: ScopeId,
|
||||
name: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
predicate: (def: SymbolDefinition) => boolean,
|
||||
options?: NameLookupOptions,
|
||||
): NameClaimResult {
|
||||
let current: ScopeId | null = startScope;
|
||||
const seen = new Set<ScopeId>();
|
||||
while (current !== null && !seen.has(current)) {
|
||||
seen.add(current);
|
||||
const claim = lookupNameClaim(current, name, scopes, options);
|
||||
if (
|
||||
claim.status === 'absent' ||
|
||||
hasExplicitNameClaim(claim, name) ||
|
||||
claim.bindings.some((b) => predicate(b.def))
|
||||
)
|
||||
return claim;
|
||||
current = claim.scope?.lookupPolicy?.parentScope ?? claim.scope?.parent ?? null;
|
||||
}
|
||||
return { status: 'absent', bindings: [], imports: [], claims: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up binding refs at `scopeId` for `name`, consulting both the
|
||||
* finalize-owned `bindings` channel and the post-finalize
|
||||
|
|
@ -301,25 +393,32 @@ export function isReceiverOwnedButUnbound(
|
|||
* caller, suppressing a resolution costs a missing edge, while trusting a
|
||||
* corrupt scope chain costs a wrong one.
|
||||
*
|
||||
* Reads `scope.bindings` DIRECTLY rather than through `lookupBindingsAt`, and
|
||||
* that is deliberate — the opposite of the fix #2745 applied to Rust's
|
||||
* `headBoundLocally`. There the question was "is this name bound at all?", so
|
||||
* missing the finalized/augmented import channels lost real bindings. Here the
|
||||
* question is "does something LOCAL shadow the import?", and the import's own
|
||||
* finalized binding is the one thing that must NOT count: routing this through
|
||||
* `lookupBindingsAt` would find the namespace import shadowing itself and
|
||||
* suppress every namespace receiver in the workspace. Locals, parameters and
|
||||
* lexical names all live in the scope's own tables, which is exactly the set
|
||||
* this walk wants.
|
||||
* Provider claims first select the owning declaration and its active import
|
||||
* provenance, so a local loader does not shadow its own namespace handle.
|
||||
* Without an explicit claim, the compatibility walk inspects local scope
|
||||
* tables directly. Finalized import bindings must not count as local shadows
|
||||
* in that fallback, or a namespace import would suppress itself.
|
||||
*/
|
||||
export function isNamespaceNameShadowed(
|
||||
namespaceName: string,
|
||||
inScope: ScopeId,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
skipEnclosingClasses = false,
|
||||
position?: NameLookupOptions['position'],
|
||||
): boolean {
|
||||
const firstDot = namespaceName.indexOf('.');
|
||||
const rootName = firstDot === -1 ? namespaceName : namespaceName.slice(0, firstDot);
|
||||
const claim = lookupNameClaim(inScope, rootName, scopes, {
|
||||
position,
|
||||
purpose: 'value',
|
||||
skipEnclosingClasses,
|
||||
});
|
||||
if (hasExplicitNameClaim(claim, rootName)) {
|
||||
return !claim.imports.some(
|
||||
(edge) =>
|
||||
edge.kind === 'namespace' && edge.targetFile !== null && edge.linkStatus !== 'unresolved',
|
||||
);
|
||||
}
|
||||
let currentId: ScopeId | null = inScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
while (currentId !== null) {
|
||||
|
|
@ -438,21 +537,11 @@ export function findReceiverTypeBinding(
|
|||
startScope: ScopeId,
|
||||
receiverName: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
options?: NameLookupOptions,
|
||||
): TypeRef | undefined {
|
||||
let currentId: ScopeId | null = startScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
let moduleScopeId: ScopeId | null = null;
|
||||
while (currentId !== null) {
|
||||
if (visited.has(currentId)) return undefined;
|
||||
visited.add(currentId);
|
||||
const scope = scopes.scopeTree.getScope(currentId);
|
||||
if (scope === undefined) return undefined;
|
||||
const typeRef = scope.typeBindings.get(receiverName);
|
||||
if (typeRef !== undefined) return typeRef;
|
||||
if (scope.ownsReceivers?.has(receiverName) === true) return undefined;
|
||||
if (scope.kind === 'Module') moduleScopeId = currentId;
|
||||
currentId = scope.parent;
|
||||
}
|
||||
const claim = lookupNameClaim(startScope, receiverName, scopes, options);
|
||||
if (claim.status !== 'absent') return claim.typeBinding;
|
||||
const moduleScopeId = moduleScopeIdOf(startScope, scopes);
|
||||
// Fallback 1 — named namespaces accessible from this file (own + `using`d),
|
||||
// gated by `accessibleNamespacesByScope`. Consulted BEFORE the global channel
|
||||
// so a more-specific named binding wins, matching the pre-#1871 order where
|
||||
|
|
@ -543,12 +632,14 @@ export function findAllClassBindingsInScope(
|
|||
startScope: ScopeId,
|
||||
name: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
options?: NameLookupOptions,
|
||||
): readonly SymbolDefinition[] {
|
||||
return classBindingsVisibleFrom(
|
||||
lexicalClassBindingsInScope(startScope, name, scopes),
|
||||
name,
|
||||
scopes,
|
||||
);
|
||||
const lookup = { purpose: 'type' as const, ...options };
|
||||
const lexical = lexicalClassBindingsInScope(startScope, name, scopes, lookup);
|
||||
if (lexical.length > 0) return lexical;
|
||||
const claim = lookupNameClaim(startScope, name, scopes, lookup);
|
||||
if (hasExplicitNameClaim(claim, name)) return [];
|
||||
return classBindingsVisibleFrom(lexical, name, scopes);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -787,7 +878,7 @@ function soleBoundBaseName(bound: string): string | undefined {
|
|||
return base.length === 0 ? undefined : base;
|
||||
}
|
||||
|
||||
export type ClassBindingLookup = {
|
||||
export type ClassBindingLookup = NameLookupOptions & {
|
||||
/**
|
||||
* When false, skip the workspace-unique QualifiedNameIndex hit (and the
|
||||
* dotted-tail variant). Default true — Go inheritance still needs that
|
||||
|
|
@ -835,8 +926,41 @@ export function findClassBindingInScope(
|
|||
);
|
||||
}
|
||||
|
||||
const local = walkScopeChain(startScope, receiverName, scopes, (def) => isClassLike(def.type));
|
||||
const claim = lookupMatchingNameClaim(
|
||||
startScope,
|
||||
receiverName,
|
||||
scopes,
|
||||
(def) => isClassLike(def.type),
|
||||
{ purpose: 'type', ...lookup },
|
||||
);
|
||||
const local = claim.bindings.find((binding) => isClassLike(binding.def.type))?.def;
|
||||
if (local !== undefined) return local;
|
||||
if (claim.status !== 'absent') return undefined;
|
||||
const rootName = receiverName.split(/[.:]/, 1)[0]!;
|
||||
if (rootName !== receiverName) {
|
||||
const rootClaim = lookupNameClaim(startScope, rootName, scopes, { purpose: 'type', ...lookup });
|
||||
if (rootClaim.status !== 'absent') {
|
||||
// A selected namespace import licenses types only from its target files.
|
||||
// A non-namespace or unresolved owner still blocks the global name index.
|
||||
const targetFiles = new Set(
|
||||
rootClaim.imports.flatMap((edge) =>
|
||||
edge.kind === 'namespace' && edge.targetFile !== null && edge.linkStatus !== 'unresolved'
|
||||
? [edge.targetFile]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
const candidates = new Map<string, SymbolDefinition>();
|
||||
const memberName = receiverName.slice(rootName.length).replace(/^[.:]+/, '');
|
||||
for (const spelling of [receiverName, memberName]) {
|
||||
for (const id of scopes.qualifiedNames.get(spelling)) {
|
||||
const def = scopes.defs.get(id);
|
||||
if (def !== undefined && isClassLike(def.type) && targetFiles.has(def.filePath))
|
||||
candidates.set(def.nodeId, def);
|
||||
}
|
||||
}
|
||||
return candidates.size === 1 ? candidates.values().next().value : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for languages (Go) where namespace-style imports don't
|
||||
// create scope bindings: resolve via QualifiedNameIndex. Only fires
|
||||
|
|
@ -873,7 +997,7 @@ export function findClassBindingInScope(
|
|||
const stripped = stripDecoration(current);
|
||||
if (stripped === undefined || stripped === current || stripped.length === 0) break;
|
||||
current = stripped;
|
||||
const candidates = findAllClassBindingsInScope(startScope, current, scopes);
|
||||
const candidates = findAllClassBindingsInScope(startScope, current, scopes, lookup);
|
||||
// Exactly one, or decline. Two same-named classes reachable from here mean
|
||||
// the decoration was carrying the only disambiguating information, and
|
||||
// picking the nearest would mint a confident wrong edge — the failure this
|
||||
|
|
@ -982,8 +1106,12 @@ function lexicalClassBindingsInScope(
|
|||
startScope: ScopeId,
|
||||
name: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
options?: NameLookupOptions,
|
||||
): readonly SymbolDefinition[] {
|
||||
return findAllBindingsInScope(startScope, name, scopes, (def) => isClassLike(def.type));
|
||||
return findAllBindingsInScope(startScope, name, scopes, (def) => isClassLike(def.type), {
|
||||
purpose: 'type',
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1160,9 +1288,12 @@ export function resolveClassBindingForName(
|
|||
* strip is what suppresses its fallback.
|
||||
*/
|
||||
stripDecoration?: DecorationStripper,
|
||||
lookup?: ClassBindingLookup,
|
||||
): SymbolDefinition | undefined {
|
||||
const direct = findClassBindingInScope(scopeId, rawClassName, scopes, stripDecoration);
|
||||
const direct = findClassBindingInScope(scopeId, rawClassName, scopes, stripDecoration, lookup);
|
||||
if (direct !== undefined) return direct;
|
||||
const claim = lookupNameClaim(scopeId, rawClassName, scopes, { purpose: 'type', ...lookup });
|
||||
if (hasExplicitNameClaim(claim, rawClassName)) return undefined;
|
||||
|
||||
// NO object-type-ALIAS fallback here, and that is a decision rather than an
|
||||
// omission. This function carried one before #2833 moved it out of
|
||||
|
|
@ -1186,7 +1317,9 @@ export function resolveClassBindingForName(
|
|||
// here" AND ground (1) of the erasure rule below, and the two asked for it
|
||||
// separately, bottoming out in the same walk for a third of the cost of every
|
||||
// lookup whose declared type carries type arguments.
|
||||
const lexical = lexicalClassBindingsInScope(scopeId, baseName, scopes);
|
||||
const lexical = lexicalClassBindingsInScope(scopeId, baseName, scopes, lookup);
|
||||
const baseClaim = lookupNameClaim(scopeId, baseName, scopes, { purpose: 'type', ...lookup });
|
||||
if (lexical.length === 0 && hasExplicitNameClaim(baseClaim, baseName)) return undefined;
|
||||
|
||||
const wantedArgs = extractTemplateArguments(rawClassName)?.map(normalizeTemplateArgToken);
|
||||
if (wantedArgs !== undefined && wantedArgs.length > 0) {
|
||||
|
|
@ -1316,6 +1449,12 @@ export function resolveInheritanceBaseInScope(
|
|||
enclosingClassDef?: SymbolDefinition,
|
||||
lookup?: ClassBindingLookup,
|
||||
): SymbolDefinition | undefined {
|
||||
const claim = lookupNameClaim(startScope, baseName, scopes, { purpose: 'type', ...lookup });
|
||||
if (
|
||||
hasExplicitNameClaim(claim, baseName) &&
|
||||
!claim.bindings.some((binding) => isClassLike(binding.def.type))
|
||||
)
|
||||
return undefined;
|
||||
// #1982: when the source wrote a qualified base (`Other::Inner`), resolve it
|
||||
// against the full-path QualifiedNameIndex FIRST, so a same-tail nested base
|
||||
// binds to the matching sibling instead of the first-inserted one that the
|
||||
|
|
@ -1670,38 +1809,10 @@ function walkScopeChain(
|
|||
name: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
predicate: (def: SymbolDefinition) => boolean,
|
||||
options?: NameLookupOptions,
|
||||
): SymbolDefinition | undefined {
|
||||
let currentId: ScopeId | null = startScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
while (currentId !== null) {
|
||||
if (visited.has(currentId)) return undefined;
|
||||
visited.add(currentId);
|
||||
const scope = scopes.scopeTree.getScope(currentId);
|
||||
if (scope === undefined) return undefined;
|
||||
|
||||
// `Object` scopes (object/record literal bodies) are a hoist
|
||||
// boundary only -- their members are reachable via property access,
|
||||
// never bare identifiers, so they contribute nothing to lookup
|
||||
// (#2545/#2551). Still traverse past to the parent.
|
||||
if (scope.kind !== 'Object') {
|
||||
// Local first: a `const x` in this scope shadows any imported `x`.
|
||||
const localBindings = scope.bindings.get(name);
|
||||
if (localBindings !== undefined) {
|
||||
for (const b of localBindings) {
|
||||
if (predicate(b.def)) return b.def;
|
||||
}
|
||||
}
|
||||
|
||||
// Then imported/augmented bindings — only consulted when no local match.
|
||||
const importedBindings = lookupBindingsAt(currentId, name, scopes);
|
||||
for (const b of importedBindings) {
|
||||
if (predicate(b.def)) return b.def;
|
||||
}
|
||||
}
|
||||
|
||||
currentId = scope.parent;
|
||||
}
|
||||
return undefined;
|
||||
const claim = lookupMatchingNameClaim(startScope, name, scopes, predicate, options);
|
||||
return claim.bindings.find((binding) => predicate(binding.def))?.def;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1718,8 +1829,9 @@ export function findCallableBindingInScope(
|
|||
startScope: ScopeId,
|
||||
callableName: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
options?: NameLookupOptions,
|
||||
): SymbolDefinition | undefined {
|
||||
return findAllCallableBindingsInScope(startScope, callableName, scopes)[0];
|
||||
return findAllCallableBindingsInScope(startScope, callableName, scopes, options)[0];
|
||||
}
|
||||
|
||||
export interface CallableBindingCandidate {
|
||||
|
|
@ -1758,31 +1870,16 @@ export function findAllCallableBindingCandidatesInScope(
|
|||
startScope: ScopeId,
|
||||
callableName: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
options?: NameLookupOptions,
|
||||
): readonly CallableBindingCandidate[] {
|
||||
let currentId: ScopeId | null = startScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
while (currentId !== null) {
|
||||
if (visited.has(currentId)) return [];
|
||||
visited.add(currentId);
|
||||
const scope = scopes.scopeTree.getScope(currentId);
|
||||
if (scope === undefined) return [];
|
||||
|
||||
if (scope.kind !== 'Object') {
|
||||
const lexical = collectCallableBindingCandidates([scope.bindings.get(callableName)]);
|
||||
if (lexical.length > 0) return lexical;
|
||||
|
||||
const candidates = collectCallableBindingCandidates([
|
||||
scopes.bindings.get(currentId)?.get(callableName),
|
||||
scopes.bindingAugmentations.get(currentId)?.get(callableName),
|
||||
collectNamespaceFqnBindings(currentId, callableName, scopes),
|
||||
scopes.workspaceFqnBindings?.get(callableName),
|
||||
]);
|
||||
if (candidates.length > 0) return candidates;
|
||||
}
|
||||
|
||||
currentId = scope.parent;
|
||||
}
|
||||
return [];
|
||||
const claim = lookupMatchingNameClaim(
|
||||
startScope,
|
||||
callableName,
|
||||
scopes,
|
||||
(def) => def.type === 'Function' || def.type === 'Method' || def.type === 'Constructor',
|
||||
options,
|
||||
);
|
||||
return collectCallableBindingCandidates([claim.bindings]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1815,134 +1912,34 @@ function findAllBindingsInScope(
|
|||
name: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
predicate: (def: SymbolDefinition) => boolean,
|
||||
options?: NameLookupOptions,
|
||||
): readonly SymbolDefinition[] {
|
||||
let currentId: ScopeId | null = startScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
while (currentId !== null) {
|
||||
if (visited.has(currentId)) return [];
|
||||
visited.add(currentId);
|
||||
const scope = scopes.scopeTree.getScope(currentId);
|
||||
if (scope === undefined) return [];
|
||||
|
||||
// `Object` scopes are a hoist boundary only -- see walkScopeChain's
|
||||
// comment (#2545/#2551). Skip lookup here, still traverse to parent.
|
||||
if (scope.kind !== 'Object') {
|
||||
const out: SymbolDefinition[] = [];
|
||||
const seen = new Set<string>();
|
||||
const push = (def: SymbolDefinition): void => {
|
||||
if (!predicate(def)) return;
|
||||
if (seen.has(def.nodeId)) return;
|
||||
seen.add(def.nodeId);
|
||||
out.push(def);
|
||||
};
|
||||
|
||||
// Local first: a binding in this scope shadows an imported one.
|
||||
for (const b of scope.bindings.get(name) ?? []) push(b.def);
|
||||
for (const b of lookupBindingsAt(currentId, name, scopes)) push(b.def);
|
||||
|
||||
if (out.length > 0) return out;
|
||||
}
|
||||
currentId = scope.parent;
|
||||
}
|
||||
return [];
|
||||
const claim = lookupMatchingNameClaim(startScope, name, scopes, predicate, options);
|
||||
const seen = new Set<string>();
|
||||
return claim.bindings
|
||||
.map((binding) => binding.def)
|
||||
.filter((def) => {
|
||||
if (!predicate(def) || seen.has(def.nodeId)) return false;
|
||||
seen.add(def.nodeId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function findAllCallableBindingsInScope(
|
||||
startScope: ScopeId,
|
||||
callableName: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
options?: NameLookupOptions,
|
||||
): readonly SymbolDefinition[] {
|
||||
return findAllBindingsInScope(
|
||||
startScope,
|
||||
callableName,
|
||||
scopes,
|
||||
(def) => def.type === 'Function' || def.type === 'Method' || def.type === 'Constructor',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO C++ `[basic.lookup.unqual]` §7: ADL is suppressed when ordinary
|
||||
* unqualified lookup finds:
|
||||
* - a name that is NOT a function or function template, OR
|
||||
* - a block-scope function declaration that is NOT a using-declaration.
|
||||
*
|
||||
* Combined walker that stops at the **nearest scope** where `name` has any
|
||||
* binding (callable or non-callable) and returns:
|
||||
* - `callables`: Function/Method/Constructor defs found at that scope
|
||||
* - `nonCallableFound`: a non-function binding was present (variable, class, etc.)
|
||||
* - `blockScopeDeclFound`: a callable was found at a Function or Block scope
|
||||
* (block-scope function declaration that blocks ADL)
|
||||
*
|
||||
* One pass, one stop — no divergence between callable collection and blocker
|
||||
* detection.
|
||||
*/
|
||||
export function findCallableBindingsAndAdlBlocker(
|
||||
startScope: ScopeId,
|
||||
name: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): {
|
||||
callables: readonly SymbolDefinition[];
|
||||
nonCallableFound: boolean;
|
||||
blockScopeDeclFound: boolean;
|
||||
} {
|
||||
let currentId: ScopeId | null = startScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
while (currentId !== null) {
|
||||
if (visited.has(currentId))
|
||||
return { callables: [], nonCallableFound: false, blockScopeDeclFound: false };
|
||||
visited.add(currentId);
|
||||
const scope = scopes.scopeTree.getScope(currentId);
|
||||
if (scope === undefined)
|
||||
return { callables: [], nonCallableFound: false, blockScopeDeclFound: false };
|
||||
|
||||
const callables: SymbolDefinition[] = [];
|
||||
const seen = new Set<string>();
|
||||
let nonCallableFound = false;
|
||||
let anyBinding = false;
|
||||
|
||||
const process = (def: SymbolDefinition): void => {
|
||||
anyBinding = true;
|
||||
if (def.type === 'Function' || def.type === 'Method' || def.type === 'Constructor') {
|
||||
if (!seen.has(def.nodeId)) {
|
||||
seen.add(def.nodeId);
|
||||
callables.push(def);
|
||||
}
|
||||
} else {
|
||||
nonCallableFound = true;
|
||||
}
|
||||
};
|
||||
|
||||
// `Object` scopes are a hoist boundary only (#2545/#2551) -- never
|
||||
// reached by C++'s ADL path in practice (no language reusing this
|
||||
// function emits `@scope.object`), guarded for consistency with the
|
||||
// other scope-chain walkers in this file.
|
||||
if (scope.kind !== 'Object') {
|
||||
const localBindings = scope.bindings.get(name);
|
||||
if (localBindings !== undefined) {
|
||||
for (const b of localBindings) {
|
||||
process(b.def);
|
||||
}
|
||||
}
|
||||
|
||||
const importedBindings = lookupBindingsAt(currentId, name, scopes);
|
||||
for (const b of importedBindings) {
|
||||
process(b.def);
|
||||
}
|
||||
}
|
||||
|
||||
if (anyBinding) {
|
||||
// ISO C++: a block-scope function declaration (Function or Block scope)
|
||||
// that is NOT a using-declaration blocks ADL. If we found callables at
|
||||
// a function/block scope, ADL must be suppressed.
|
||||
const blockScopeDeclFound =
|
||||
callables.length > 0 && (scope.kind === 'Function' || scope.kind === 'Block');
|
||||
return { callables, nonCallableFound, blockScopeDeclFound };
|
||||
}
|
||||
currentId = scope.parent;
|
||||
}
|
||||
return { callables: [], nonCallableFound: false, blockScopeDeclFound: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate `ownerId` on every def structurally owned by a Class
|
||||
* scope — methods (defs in Function scopes whose parent is Class)
|
||||
|
|
@ -2172,29 +2169,20 @@ export function findExportedDefByName(
|
|||
inScope: ScopeId,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
index: WorkspaceResolutionIndex,
|
||||
options?: NameLookupOptions,
|
||||
): SymbolDefinition | undefined {
|
||||
let currentId: ScopeId | null = inScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
while (currentId !== null) {
|
||||
if (visited.has(currentId)) break;
|
||||
visited.add(currentId);
|
||||
const scope = scopes.scopeTree.getScope(currentId);
|
||||
if (scope === undefined) break;
|
||||
// `Object` scopes are a hoist boundary only (#2545/#2551).
|
||||
if (scope.kind !== 'Object') {
|
||||
const local = scope.bindings.get(name);
|
||||
if (local !== undefined) {
|
||||
for (const b of local) {
|
||||
if (b.def.type === 'Function' || b.def.type === 'Method') return b.def;
|
||||
}
|
||||
}
|
||||
const finalized = lookupBindingsAt(currentId, name, scopes);
|
||||
for (const b of finalized) {
|
||||
if (b.def.type === 'Function' || b.def.type === 'Method') return b.def;
|
||||
}
|
||||
}
|
||||
currentId = scope.parent;
|
||||
}
|
||||
const claim = lookupMatchingNameClaim(
|
||||
inScope,
|
||||
name,
|
||||
scopes,
|
||||
(def) => def.type === 'Function' || def.type === 'Method',
|
||||
options,
|
||||
);
|
||||
const callable = claim.bindings.find(
|
||||
(binding) => binding.def.type === 'Function' || binding.def.type === 'Method',
|
||||
);
|
||||
if (callable !== undefined) return callable.def;
|
||||
if (hasExplicitNameClaim(claim, name)) return undefined;
|
||||
// Workspace-wide fallback: the first locally-declared callable binding
|
||||
// matching `name` across every file's Module scope (first-seen-by-file wins;
|
||||
// `origin === 'local'`, callable types Function/Method/Constructor). This is
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@
|
|||
* language (R4) — never dropped on the floor (the M2 lesson).
|
||||
*/
|
||||
|
||||
import type { ParsedImport } from 'gitnexus-shared';
|
||||
import type { ParsedFile, ParsedImport } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../../graph/types.js';
|
||||
import { generateId } from '../../../lib/utils.js';
|
||||
import {
|
||||
|
|
@ -165,6 +165,7 @@ export function emitFileTaint(
|
|||
// The zero-match fast path below still skips the solve entirely; only MATCHED
|
||||
// functions request it, hitting the cache the call-summary harvest warmed.
|
||||
solve: ReachingDefsSolver = computeReachingDefs,
|
||||
parsed?: ParsedFile,
|
||||
): TaintEmitResult {
|
||||
const result: TaintEmitResult = {
|
||||
functionsAnalyzed: 0,
|
||||
|
|
@ -179,8 +180,8 @@ export function emitFileTaint(
|
|||
droppedExamples: [],
|
||||
};
|
||||
|
||||
// Imports are a FILE-level fact — build the index once, not per function.
|
||||
const importIndex = buildTaintImportIndex(parsedImports);
|
||||
// Build lexical import provenance once per file, not per function.
|
||||
const importIndex = buildTaintImportIndex(parsedImports, parsed);
|
||||
const maxFindingsPerFunction =
|
||||
limits?.maxFindingsPerFunction ?? DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION;
|
||||
const maxHops = limits?.maxHops ?? DEFAULT_PDG_MAX_TAINT_HOPS;
|
||||
|
|
|
|||
|
|
@ -11,69 +11,24 @@
|
|||
* (taint/site-safety.ts) first — this module dereferences binding/site
|
||||
* indices without re-validating them.
|
||||
*
|
||||
* ## Callee resolution precedence (bare and member-rooted calls)
|
||||
* A ParsedFile-backed index resolves each call's root through the same lexical
|
||||
* ownership policy as graph resolution. Only the selected import contributes
|
||||
* module provenance, including external imports whose targets are not indexed.
|
||||
* Ordinary declarations, inactive imports, and ambiguous provenance suppress
|
||||
* module/global matching. The provider's validated loader facts are authoritative;
|
||||
* a syntactic requireArg alone cannot establish module identity on this path.
|
||||
*
|
||||
* 1. ESM import join — the callee root's local name is resolved through the
|
||||
* {@link TaintImportIndex} built from `parsedImports` (`named`/`alias`
|
||||
* members, `namespace`/default-import module handles); `import { exec as
|
||||
* run } from 'child_process'` makes `run(c)` resolve to
|
||||
* `child_process.exec`, and `import * as cp …` makes `cp.exec(c)` resolve
|
||||
* the same way.
|
||||
* 2. require-literal join — a binding whose in-function defining site carries
|
||||
* `requireArg` resolves like a namespace handle (`const cp =
|
||||
* require('child_process'); cp.exec(c)`). A BARE call of a require-joined
|
||||
* binding is matched under BOTH interpretations, `<module>.default` (the
|
||||
* module/default export invoked directly) and `<module>.<localName>`
|
||||
* (non-renamed destructured require — the harvest attaches `resultDefs`
|
||||
* to destructured bindings without recording the property path, and the
|
||||
* binding name IS the member name in the non-renamed case).
|
||||
* 3. Bare-name fallback — TRUE GLOBALS only (`global: true` entries: `eval`,
|
||||
* `new Function`, `encodeURIComponent`), and only when the name is neither
|
||||
* import-bound nor shadowed. Conventional receiver names (`req`/`request`
|
||||
* member-read sources, `res.send`, `.query`/`.execute`) are matched
|
||||
* name-based by their own mechanisms, never via the global fallback.
|
||||
* Imports-only callers retain the legacy function-binding/require-literal join.
|
||||
* That compatibility path cannot distinguish sibling/block imports or module
|
||||
* declarations, and cannot resolve renamed or module-level loader aliases.
|
||||
*
|
||||
* ## Shadowing rule (exact)
|
||||
*
|
||||
* A name is treated as function-local — blocking import/global resolution —
|
||||
* iff the function's binding table contains a NON-`synthetic` entry with that
|
||||
* name (an in-function `function exec(){}` / `const exec = …`). Synthetic
|
||||
* bindings (kind `module`, `synthetic: true`) are imports, true globals, or
|
||||
* enclosing-scope captures and do not shadow. Member-call roots use the
|
||||
* harvested `receiver` binding index directly (no name scan).
|
||||
*
|
||||
* ## Documented resolution gaps (direction stated, per plan KTD10)
|
||||
*
|
||||
* - MODULE-LEVEL `const cp = require('child_process')`: the binding is
|
||||
* synthetic inside the function, produces no `ParsedImport`, and its
|
||||
* defining site lives outside the function's harvested sites — the
|
||||
* require join cannot see it. Module-mechanism sinks miss (FN) and
|
||||
* sanitizers don't kill (FP noise — never a false kill, the safe
|
||||
* direction). Only in-function requires resolve.
|
||||
* - RENAMED destructured require (`const { exec: run } = require(…)`):
|
||||
* the dual interpretation resolves `run` to `child_process.run` — no
|
||||
* match (FN). Non-renamed destructures resolve exactly.
|
||||
* - CONSERVATIVE shadow scan for bare calls: ANY non-synthetic binding of
|
||||
* the callee name anywhere in the function blocks import/global
|
||||
* resolution, even when the shadow is block-scoped elsewhere and the call
|
||||
* site actually sees the import (FN; rare; safe for sanitizers).
|
||||
* - MODULE-LEVEL user declarations are indistinguishable from imports in
|
||||
* the binding table (both synthetic). ESM forbids a module-level
|
||||
* declaration colliding with an import name, so the import join is
|
||||
* authoritative when an import exists; a module-level user function
|
||||
* shadowing a TRUE GLOBAL (e.g. a local `encodeURIComponent`) is not
|
||||
* detectable and would still match (pathological; accepted).
|
||||
* - Handle COPIES (`const c2 = cp; c2.exec(…)`) are not followed — joins
|
||||
* are one level deep (binding → import/require), never through
|
||||
* assignments (FN).
|
||||
* - `this.`/`super.`-rooted and call-rooted callee chains have no
|
||||
* resolvable root: only the syntactic `anyReceiver`/`receivers`
|
||||
* mechanisms can match them.
|
||||
* - `reexport`/`wildcard`/`dynamic-*`/`side-effect` imports introduce no
|
||||
* matcher-visible local binding and are skipped by the index.
|
||||
* Conventional receiver rules remain syntactic. Handle copies are not followed;
|
||||
* call-rooted chains have no resolvable import root. Reexports, wildcards,
|
||||
* dynamic dependencies, and side-effect imports provide no local provenance.
|
||||
*/
|
||||
|
||||
import type { ParsedImport } from 'gitnexus-shared';
|
||||
import { buildPositionIndex, lookupLexicalName } from 'gitnexus-shared';
|
||||
import type { ImportEdge, ParsedFile, ParsedImport, ScopeId } from 'gitnexus-shared';
|
||||
import type { FunctionCfg, SiteRecord } from '../cfg/types.js';
|
||||
import type {
|
||||
TaintCallResultSourceEntry,
|
||||
|
|
@ -102,8 +57,20 @@ export interface TaintImportBinding {
|
|||
readonly targetIncludesMember?: boolean;
|
||||
}
|
||||
|
||||
/** Local name → import provenance for one file. Build once per file (U4). */
|
||||
export type TaintImportIndex = ReadonlyMap<string, TaintImportBinding>;
|
||||
interface TaintNameResolution {
|
||||
readonly binding?: TaintImportBinding;
|
||||
/** Only an absent lexical name can use a registered true-global rule. */
|
||||
readonly globalRoot: boolean;
|
||||
}
|
||||
|
||||
/** Import provenance for one file; imports-only maps remain source-compatible. */
|
||||
export interface TaintImportIndex extends ReadonlyMap<string, TaintImportBinding> {
|
||||
readonly resolveAt?: (
|
||||
name: string,
|
||||
filePath: string,
|
||||
at: SiteRecord['at'],
|
||||
) => TaintNameResolution;
|
||||
}
|
||||
|
||||
/** A member-read site matched as a taint source. */
|
||||
export interface MatchedSourceRead {
|
||||
|
|
@ -181,31 +148,102 @@ const stripNodeScheme = (specifier: string): string =>
|
|||
const isCallResultSource = (entry: TaintSourceEntry): entry is TaintCallResultSourceEntry =>
|
||||
entry.type === 'call-result';
|
||||
|
||||
/** Convert only runtime imports that introduce a local module/member name. */
|
||||
function importBinding(imp: ParsedImport): TaintImportBinding | undefined {
|
||||
if (imp.kind !== 'named' && imp.kind !== 'alias' && imp.kind !== 'namespace') return undefined;
|
||||
if (imp.typeOnly === true) return undefined;
|
||||
const module = stripNodeScheme(imp.targetRaw);
|
||||
if (imp.kind === 'namespace' || imp.importedName === 'default') return { module };
|
||||
return {
|
||||
module,
|
||||
member: imp.importedName,
|
||||
...(imp.targetIncludesImportedName === true ? { targetIncludesMember: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the local-name → module/member index from a file's `parsedImports`.
|
||||
* Only `named`/`alias`/`namespace` kinds bind matcher-visible local names;
|
||||
* `importedName === 'default'` collapses to a module handle.
|
||||
* Build once per file. With parse context, exact call positions select lexical
|
||||
* import provenance; without it, preserve the imports-only compatibility API.
|
||||
*/
|
||||
export function buildTaintImportIndex(imports: readonly ParsedImport[]): TaintImportIndex {
|
||||
export function buildTaintImportIndex(
|
||||
imports: readonly ParsedImport[],
|
||||
parsed?: ParsedFile,
|
||||
): TaintImportIndex {
|
||||
const index = new Map<string, TaintImportBinding>();
|
||||
for (const imp of imports) {
|
||||
if (imp.kind === 'named' || imp.kind === 'alias') {
|
||||
const module = stripNodeScheme(imp.targetRaw);
|
||||
index.set(
|
||||
imp.localName,
|
||||
imp.importedName === 'default'
|
||||
? { module }
|
||||
: {
|
||||
module,
|
||||
member: imp.importedName,
|
||||
...(imp.targetIncludesImportedName === true ? { targetIncludesMember: true } : {}),
|
||||
},
|
||||
);
|
||||
} else if (imp.kind === 'namespace') {
|
||||
index.set(imp.localName, { module: stripNodeScheme(imp.targetRaw) });
|
||||
}
|
||||
const binding = importBinding(imp);
|
||||
if (binding !== undefined && 'localName' in imp) index.set(imp.localName, binding);
|
||||
}
|
||||
return index;
|
||||
if (parsed === undefined) return index;
|
||||
|
||||
const scopes = new Map(parsed.scopes.map((scope) => [scope.id, scope]));
|
||||
const positions = buildPositionIndex(parsed.scopes);
|
||||
const byScope = new Map<ScopeId, ImportEdge[]>();
|
||||
const provenance = new Map<ImportEdge, TaintImportBinding>();
|
||||
for (const imp of imports) {
|
||||
if (imp.kind !== 'named' && imp.kind !== 'alias' && imp.kind !== 'namespace') continue;
|
||||
// Keep erased imports in ownership selection, but never in runtime provenance.
|
||||
const edge: ImportEdge = {
|
||||
localName: imp.localName,
|
||||
kind: imp.kind,
|
||||
targetFile: null,
|
||||
targetExportedName: imp.importedName,
|
||||
atRange: imp.atRange,
|
||||
typeOnly: imp.typeOnly,
|
||||
linkStatus: 'unresolved',
|
||||
};
|
||||
const owner = imp.declaredAtScope ?? parsed.moduleScope;
|
||||
const bucket = byScope.get(owner);
|
||||
if (bucket === undefined) byScope.set(owner, [edge]);
|
||||
else bucket.push(edge);
|
||||
const binding = importBinding(imp);
|
||||
if (binding !== undefined) provenance.set(edge, binding);
|
||||
}
|
||||
|
||||
// Semantic anchors can differ from physical containment (for example, a
|
||||
// declaration's default arguments). Reuse the provider's reference facts.
|
||||
const referenceScopes = new Map<string, ScopeId | null>();
|
||||
for (const reference of parsed.referenceSites) {
|
||||
if (reference.kind !== 'call') continue;
|
||||
const key = `${reference.atRange.startLine}:${reference.atRange.startCol}`;
|
||||
const scope = reference.lookupScope ?? reference.inScope;
|
||||
const prior = referenceScopes.get(key);
|
||||
referenceScopes.set(key, prior === undefined || prior === scope ? scope : null);
|
||||
}
|
||||
const unresolved: TaintNameResolution = { globalRoot: false };
|
||||
const resolveAt: NonNullable<TaintImportIndex['resolveAt']> = (name, filePath, at) => {
|
||||
if (at === undefined || filePath !== parsed.filePath) return unresolved;
|
||||
const [startLine, startCol] = at;
|
||||
const anchor = referenceScopes.get(`${startLine}:${startCol}`);
|
||||
if (anchor === null) return unresolved;
|
||||
const scope = anchor ?? positions.atPosition(filePath, startLine, startCol);
|
||||
if (scope === undefined) return unresolved;
|
||||
const selected = lookupLexicalName(
|
||||
scope,
|
||||
name,
|
||||
{
|
||||
scopes: { getScope: (id) => scopes.get(id) },
|
||||
importsAt: (owner) => byScope.get(owner.id) ?? [],
|
||||
},
|
||||
{ position: { startLine, startCol }, purpose: 'value' },
|
||||
);
|
||||
if (selected.status === 'absent') return { globalRoot: true };
|
||||
let binding: TaintImportBinding | undefined;
|
||||
for (const edge of selected.imports) {
|
||||
const candidate = provenance.get(edge);
|
||||
if (candidate === undefined) return unresolved;
|
||||
if (
|
||||
binding !== undefined &&
|
||||
(binding.module !== candidate.module ||
|
||||
binding.member !== candidate.member ||
|
||||
binding.targetIncludesMember !== candidate.targetIncludesMember)
|
||||
)
|
||||
return unresolved;
|
||||
binding = candidate;
|
||||
}
|
||||
return binding === undefined ? unresolved : { binding, globalRoot: false };
|
||||
};
|
||||
return Object.assign(index, { resolveAt });
|
||||
}
|
||||
|
||||
/** Internal: a callee's resolution — canonical dotted names + syntactic path. */
|
||||
|
|
@ -233,7 +271,7 @@ export function matchFunctionSites(
|
|||
// shadow scan + bare-call require-join lookup.
|
||||
const nonSyntheticByName = new Map<string, number[]>();
|
||||
bindings.forEach((b, i) => {
|
||||
if (b.synthetic === true) return;
|
||||
if (imports.resolveAt !== undefined || b.synthetic === true) return;
|
||||
const list = nonSyntheticByName.get(b.name);
|
||||
if (list) list.push(i);
|
||||
else nonSyntheticByName.set(b.name, [i]);
|
||||
|
|
@ -244,7 +282,7 @@ export function matchFunctionSites(
|
|||
// either way could fabricate a sanitizer kill).
|
||||
const requireByBinding = new Map<number, string>();
|
||||
const conflicted = new Set<number>();
|
||||
for (const block of cfg.blocks) {
|
||||
for (const block of imports.resolveAt === undefined ? cfg.blocks : []) {
|
||||
for (const stmt of block.statements ?? []) {
|
||||
for (const site of stmt.sites ?? []) {
|
||||
if (site.requireArg === undefined || site.resultDefs === undefined) continue;
|
||||
|
|
@ -274,7 +312,25 @@ export function matchFunctionSites(
|
|||
? [imp.module]
|
||||
: [imp.module, imp.member];
|
||||
|
||||
if (site.receiver !== undefined) {
|
||||
if (imports.resolveAt !== undefined) {
|
||||
// Never revive a blocked/unknown lexical claim via the legacy loader join.
|
||||
if (site.receiver !== undefined || path.length === 1) {
|
||||
const resolved = imports.resolveAt(root, cfg.filePath, site.at);
|
||||
const imp = resolved.binding;
|
||||
if (imp !== undefined) {
|
||||
canonical.push(
|
||||
path.length > 1
|
||||
? [...canonicalBase(imp), ...rest].join('.')
|
||||
: imp.member === undefined
|
||||
? `${imp.module}.default`
|
||||
: imp.targetIncludesMember === true
|
||||
? imp.module
|
||||
: `${imp.module}.${imp.member}`,
|
||||
);
|
||||
}
|
||||
globalRoot = resolved.globalRoot;
|
||||
}
|
||||
} else if (site.receiver !== undefined) {
|
||||
// Member chain with an identifier root — origin known by binding index.
|
||||
const rb = bindings[site.receiver];
|
||||
if (rb.synthetic === true) {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
* function. Rare in practice; the alternative (cross-wired summaries) is unsound.
|
||||
*/
|
||||
|
||||
import type { ParsedImport, GraphNode } from 'gitnexus-shared';
|
||||
import type { ParsedFile, ParsedImport, GraphNode } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../../graph/types.js';
|
||||
import { computeReachingDefs, type ReachingDefsSolver } from '../cfg/reaching-defs.js';
|
||||
import { DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION } from '../cfg/emit.js';
|
||||
|
|
@ -112,8 +112,9 @@ export function harvestFileSummaries(
|
|||
maxFacts: number = DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
|
||||
// U12: shared per-file memoized solver (harvest/taint bucket — no maxBlockVisits).
|
||||
solve: ReachingDefsSolver = computeReachingDefs,
|
||||
parsed?: ParsedFile,
|
||||
): FileSummaryResult {
|
||||
const importIndex = buildTaintImportIndex(parsedImports);
|
||||
const importIndex = buildTaintImportIndex(parsedImports, parsed);
|
||||
const summaries: FunctionSummary[] = [];
|
||||
let unresolved = 0;
|
||||
let gaps = 0;
|
||||
|
|
|
|||
|
|
@ -241,7 +241,9 @@ const NESTING_BOUNDARIES: ReadonlySet<string> = new Set([
|
|||
// of the namespace/ambient module, not of the file.
|
||||
'internal_module',
|
||||
'module',
|
||||
'ambient_declaration',
|
||||
// `ambient_declaration` only wraps `declare`: an exported class/function
|
||||
// under it is still a file export. Ambient module bodies remain bounded by
|
||||
// their `module`/`internal_module` and `statement_block` nodes above.
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -10,9 +10,10 @@
|
|||
export interface VueScriptExtraction {
|
||||
/** Extracted script content (TypeScript/JavaScript) */
|
||||
scriptContent: string;
|
||||
/** 1-based line number in the .vue file where the script content starts
|
||||
* (used as offset from tree-sitter's 0-based row). */
|
||||
/** Zero-based source row of the first extracted script block. */
|
||||
lineOffset: number;
|
||||
/** Original zero-based source row for every row of the combined script. */
|
||||
sourceLineMap: readonly number[];
|
||||
/** true if at least one block is <script setup> */
|
||||
isSetup: boolean;
|
||||
/** Value of the `lang` attribute on the extracted script block (e.g. "ts", "js", "tsx", "jsx", or "" for default). */
|
||||
|
|
@ -232,8 +233,7 @@ function parseScriptBlock(
|
|||
const isSetup = attrs != null && /\bsetup\b/.test(attrs);
|
||||
const langMatch = attrs?.match(/\blang\s*=\s*["']([^"']+)["']/);
|
||||
const lang = langMatch ? langMatch[1] : '';
|
||||
// +1 for the newline after the opening <script...> tag
|
||||
const lineOffset = countNewlines(precedingText) + 1;
|
||||
const lineOffset = countNewlines(precedingText);
|
||||
|
||||
return { content, lineOffset, isSetup, lang };
|
||||
}
|
||||
|
|
@ -252,7 +252,7 @@ export function extractVueScript(vueContent: string): VueScriptExtraction | null
|
|||
// Reset lastIndex for reuse of the global regex
|
||||
SCRIPT_RE.lastIndex = 0;
|
||||
while ((match = SCRIPT_RE.exec(vueContent)) !== null) {
|
||||
const precedingText = vueContent.slice(0, match.index + match[0].indexOf(match[2]));
|
||||
const precedingText = vueContent.slice(0, match.index + match[0].indexOf('>') + 1);
|
||||
blocks.push(parseScriptBlock(match[1], match[2], precedingText));
|
||||
}
|
||||
|
||||
|
|
@ -264,14 +264,27 @@ export function extractVueScript(vueContent: string): VueScriptExtraction | null
|
|||
const ordered = [...nonSetupBlocks, ...setupBlocks];
|
||||
const combinedContent = ordered.map((b) => b.content).join('\n');
|
||||
const lineOffset = ordered[0].lineOffset;
|
||||
const sourceLineMap = ordered.flatMap((block) =>
|
||||
block.content.split('\n').map((_, row) => block.lineOffset + row),
|
||||
);
|
||||
// Only use JavaScript grammar when ALL blocks are explicitly lang="js"
|
||||
// or lang="jsx". If any block omits lang or uses ts/tsx, TypeScript wins.
|
||||
const allBlocksJs = blocks.length > 0 && blocks.every((b) => b.lang === 'js' || b.lang === 'jsx');
|
||||
const lang = allBlocksJs ? 'js' : '';
|
||||
const hasJsx = blocks.some((block) => block.lang === 'jsx' || block.lang === 'tsx');
|
||||
const lang = allBlocksJs
|
||||
? hasJsx
|
||||
? 'jsx'
|
||||
: 'js'
|
||||
: hasJsx
|
||||
? 'tsx'
|
||||
: blocks.some((block) => block.lang === 'ts')
|
||||
? 'ts'
|
||||
: '';
|
||||
|
||||
return {
|
||||
scriptContent: combinedContent,
|
||||
lineOffset,
|
||||
sourceLineMap,
|
||||
isSetup: blocks.some((b) => b.isSetup),
|
||||
lang,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1628,17 +1628,23 @@ const processFileGroup = (
|
|||
// Vue SFC preprocessing: extract <script> block content
|
||||
let parseContent = file.content;
|
||||
let scopeSourceKind: ScopeCaptureSourceKind = 'full-file';
|
||||
let scriptLanguage: string | undefined;
|
||||
let lineOffset = 0;
|
||||
let sourceLineMap: readonly number[] | undefined;
|
||||
let isVueSetup = false;
|
||||
let notebookSegments: readonly NotebookLineSegment[] | undefined;
|
||||
const mapRow = (row: number): number =>
|
||||
notebookSegments ? mapExtractLine(row, notebookSegments) : row + lineOffset;
|
||||
notebookSegments
|
||||
? mapExtractLine(row, notebookSegments)
|
||||
: (sourceLineMap?.[row] ?? row + lineOffset);
|
||||
if (language === SupportedLanguages.Vue) {
|
||||
const extracted = extractVueScript(file.content);
|
||||
if (!extracted) continue; // skip .vue files with no script block
|
||||
parseContent = extracted.scriptContent;
|
||||
scopeSourceKind = 'pre-extracted-script';
|
||||
lineOffset = extracted.lineOffset;
|
||||
sourceLineMap = extracted.sourceLineMap;
|
||||
scriptLanguage = extracted.lang;
|
||||
isVueSetup = extracted.isSetup;
|
||||
} else if (language === SupportedLanguages.Python && isNotebookPath(file.path)) {
|
||||
const extracted = extractNotebookPython(file.content);
|
||||
|
|
@ -1653,6 +1659,22 @@ const processFileGroup = (
|
|||
parseContent =
|
||||
getProvider(language).preprocessSource?.(parseContent, file.path) ?? parseContent;
|
||||
|
||||
const embeddedGrammar =
|
||||
scriptLanguage !== undefined
|
||||
? getProvider(language).selectEmbeddedGrammar?.(scriptLanguage)
|
||||
: undefined;
|
||||
if (embeddedGrammar && parser.getLanguage() !== embeddedGrammar) {
|
||||
parser.setLanguage(embeddedGrammar);
|
||||
let queries = compiledQueries.get(embeddedGrammar);
|
||||
if (!queries) {
|
||||
queries = new Map();
|
||||
compiledQueries.set(embeddedGrammar, queries);
|
||||
}
|
||||
const cached = queries.get(queryString);
|
||||
query = cached ?? new Parser.Query(embeddedGrammar, queryString);
|
||||
if (!cached) queries.set(queryString, query);
|
||||
}
|
||||
|
||||
clearCaches(); // Reset memoization before each new file
|
||||
|
||||
let tree;
|
||||
|
|
@ -1720,6 +1742,9 @@ const processFileGroup = (
|
|||
tree,
|
||||
scopeSourceKind,
|
||||
notebookSegments,
|
||||
lineOffset,
|
||||
scriptLanguage,
|
||||
sourceLineMap,
|
||||
);
|
||||
if (scopeExtractionFailed) (result.scopeExtractionFailures ??= []).push(file.path);
|
||||
if (parsedFile !== undefined) {
|
||||
|
|
@ -1766,7 +1791,8 @@ const processFileGroup = (
|
|||
// `lineOffset` in the file — shift the CFG into file coordinates so
|
||||
// it joins its graph node and BasicBlock lines map to source.
|
||||
lineOffset,
|
||||
notebookSegments ? mapRow : undefined,
|
||||
notebookSegments || sourceLineMap ? mapRow : undefined,
|
||||
sourceLineMap ? 'parse-buffer' : 'source',
|
||||
);
|
||||
if (cfgs.length) withChannels = { ...withChannels, cfgSideChannel: cfgs };
|
||||
// Surface per-function CFG skips per-language (#2195): merged + logged
|
||||
|
|
|
|||
|
|
@ -845,7 +845,18 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
|
|||
// v133 (#3505): route(path) builders emit only chained verb registrations
|
||||
// with semantic handler arguments. Warm v132 caches can retain phantom GET
|
||||
// routes for bare builders and lose the actual methods of chained handlers.
|
||||
const SCHEMA_BUMP = 133;
|
||||
// v134: lexical local-import ownership, activation, lookup positions and
|
||||
// provider side-channel facts now survive worker transport and durable reuse.
|
||||
// Explicit JS/JSX embedded scripts retain their provider capture semantics.
|
||||
// Invalidate both stores together: v133 can replay file-wide import ownership
|
||||
// or TypeScript captures for an explicitly JavaScript embedded script.
|
||||
// v135: review fixes retain unambiguous conditional Python imports and ordinary
|
||||
// mutable TS/JS type facts, and preserve unchanged destructured CJS re-exports.
|
||||
// v134 stores contain the old blocked claims and must be extracted again.
|
||||
// v136: Python parameter annotations retain enclosing lookup ownership; Vue
|
||||
// JSX/TSX captures and multi-block declarations retain their source grammar
|
||||
// and original graph positions. Both stores must re-extract v135 captures.
|
||||
const SCHEMA_BUMP = 136;
|
||||
const GITNEXUS_PKG_VERSION = (() => {
|
||||
try {
|
||||
// package.json sits at gitnexus/package.json — two levels up from
|
||||
|
|
@ -1054,7 +1065,7 @@ export const computeChunkHash = (
|
|||
// default-cap runs share a key. The emit-time edge cap is deliberately
|
||||
// absent — see the PdgCacheKey doc comment.
|
||||
//
|
||||
// NAMESPACE VERSION (`pdg:5`): bumped when the worker-emitted
|
||||
// NAMESPACE VERSION (`pdg:6`): bumped when the worker-emitted
|
||||
// `cfgSideChannel` SHAPE changes for pdg-mode runs only — pdg:1→2 in #2083
|
||||
// M3 U1 (TsHarvester emits taint `sites` on StatementFacts); pdg:2→3 in the
|
||||
// #2227 follow-up U1 (every C-family / TS harvester now stamps the call-site
|
||||
|
|
@ -1074,7 +1085,9 @@ export const computeChunkHash = (
|
|||
// byte-identical, so non-pdg users pay nothing. Deliberately NOT a SCHEMA_BUMP —
|
||||
// that gates the whole cache version and would force a full cold re-parse on
|
||||
// EVERY user (the M1 bump comment above records that cost).
|
||||
const ns = `pdg:5;maxFn=${opts.maxFunctionLines ?? 'def'}`;
|
||||
// pdg:5→6 preserves parse-buffer call anchors when embedded graph lines are
|
||||
// remapped, restoring the exact-position resolved-callee join (#3532).
|
||||
const ns = `pdg:6;maxFn=${opts.maxFunctionLines ?? 'def'}`;
|
||||
return sha256Hex(`${ns}\n${joined}`);
|
||||
};
|
||||
|
||||
|
|
|
|||
1
gitnexus/test/fixtures/lang-resolution/rust-traits/src/impls/mod.rs
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/rust-traits/src/impls/mod.rs
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod button;
|
||||
5
gitnexus/test/fixtures/lang-resolution/rust-traits/src/traits/mod.rs
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/rust-traits/src/traits/mod.rs
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
mod drawable;
|
||||
mod clickable;
|
||||
|
||||
pub use self::drawable::Drawable;
|
||||
pub use self::clickable::Clickable;
|
||||
|
|
@ -2,7 +2,9 @@ import { User } from './models/User';
|
|||
import { Repo } from './models/Repo';
|
||||
|
||||
class UserService {
|
||||
processUsers(users: User[]) {
|
||||
constructor(private users: User[]) {}
|
||||
|
||||
processUsers(users: Repo[]) {
|
||||
for (const user of this.users) {
|
||||
user.save();
|
||||
}
|
||||
|
|
@ -10,9 +12,20 @@ class UserService {
|
|||
}
|
||||
|
||||
class RepoService {
|
||||
processRepos(repos: Repo[]) {
|
||||
constructor(private repos: Repo[]) {}
|
||||
|
||||
processRepos(repos: User[]) {
|
||||
for (const repo of this.repos) {
|
||||
repo.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A same-named parameter does not establish a field on this receiver.
|
||||
class MissingFieldService {
|
||||
processMissingUsers(users: User[]) {
|
||||
for (const user of this.users) {
|
||||
user.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,3 +7,8 @@ function main(): void {
|
|||
const repo = new Repo();
|
||||
repo.persist();
|
||||
}
|
||||
|
||||
function typed(user: User, repo: Repo): void {
|
||||
user.save();
|
||||
repo.persist();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -572,8 +572,8 @@
|
|||
"digest": "19b650be99256aa211356edc5a3dde83aff21e5d763e2c079322a24045bf69c9"
|
||||
},
|
||||
"rust-match-unwrap/src/main.rs": {
|
||||
"captureGroups": 28,
|
||||
"digest": "938f98b69f632575c614f683154aa86bc1fe2827511d4891c7c944e59ffd0e11"
|
||||
"captureGroups": 30,
|
||||
"digest": "6db3ca733b3c227e1e22926368bfac420f684718ab3b05dbb9cf74c61b0ad438"
|
||||
},
|
||||
"rust-match-unwrap/src/repo.rs": {
|
||||
"captureGroups": 9,
|
||||
|
|
@ -771,6 +771,10 @@
|
|||
"captureGroups": 36,
|
||||
"digest": "30b0e4e92385e7f7eb83ad97e78ed65b0eced81bbcb20bf52a846924c75ab5bb"
|
||||
},
|
||||
"rust-traits/src/impls/mod.rs": {
|
||||
"captureGroups": 3,
|
||||
"digest": "80661b0e06de288bf9b460be28857b93c3895699e276ac37cda9ca9612320b90"
|
||||
},
|
||||
"rust-traits/src/main.rs": {
|
||||
"captureGroups": 13,
|
||||
"digest": "0f78c887c5a94ea2b03e38d2060046253b1e09f096356b5ae21cf9c6992ed83a"
|
||||
|
|
@ -783,6 +787,10 @@
|
|||
"captureGroups": 9,
|
||||
"digest": "cee5091f041038722f1f012394a75ba4e16870d05b2dafa37371200e785198f0"
|
||||
},
|
||||
"rust-traits/src/traits/mod.rs": {
|
||||
"captureGroups": 7,
|
||||
"digest": "9978bbdbc0e14bdddd6855da1b94de0aa39eaae5e63f29ebb663e55779c564ab"
|
||||
},
|
||||
"rust-union/lib.rs": {
|
||||
"captureGroups": 10,
|
||||
"digest": "e2a6fb9eab259b8c7104f1530b96b8c1f42ab32fe1d71d6bdca04d68263507f2"
|
||||
|
|
|
|||
|
|
@ -140,6 +140,39 @@ describe('collectFunctionCfgs — lineOffset → file coordinates (#2195 P1, Vue
|
|||
const omitted = collectFunctionCfgs(tsRoot(code), visitor(), 'g.ts').cfgs;
|
||||
expect(JSON.stringify(withZero)).toBe(JSON.stringify(omitted));
|
||||
});
|
||||
|
||||
it.each(['source', 'parse-buffer'] as const)(
|
||||
'remaps graph lines while retaining %s call-site coordinates',
|
||||
(siteCoordinates) => {
|
||||
const code = 'function caller() { return service.run(helper()); }';
|
||||
const base = collectFunctionCfgs(tsRoot(code), visitor(), 'embedded').cfgs[0];
|
||||
const mapped = collectFunctionCfgs(
|
||||
tsRoot(code),
|
||||
visitor(),
|
||||
'embedded',
|
||||
0,
|
||||
0,
|
||||
(row) => row + 10,
|
||||
siteCoordinates,
|
||||
).cfgs[0];
|
||||
const sites = (cfg: FunctionCfg) =>
|
||||
cfg.blocks.flatMap((block) =>
|
||||
(block.statements ?? []).flatMap((statement) => statement.sites ?? []),
|
||||
);
|
||||
const anchors = sites(base)
|
||||
.filter((site) => site.at !== undefined)
|
||||
.map((site) => site.at!);
|
||||
expect(anchors).toHaveLength(2);
|
||||
expect(mapped.functionStartLine).toBe(base.functionStartLine + 10);
|
||||
expect(
|
||||
sites(mapped)
|
||||
.filter((site) => site.at !== undefined)
|
||||
.map((site) => site.at),
|
||||
).toEqual(
|
||||
anchors.map(([line, column]) => [siteCoordinates === 'source' ? line + 10 : line, column]),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('U4 — AC2: every BasicBlock is reachable from its function ENTRY', () => {
|
||||
|
|
|
|||
|
|
@ -353,7 +353,7 @@ describe('#2083 M3 U1 — pdg chunk-key namespace version (flag-off keys untouch
|
|||
);
|
||||
});
|
||||
|
||||
it('pdg-mode keys CHANGED from prior namespaces AND pin the current pdg:5 (FU-C BindingEntry.formalIndex)', () => {
|
||||
it('pdg-mode keys CHANGED from prior namespaces AND pin the current pdg:6 (embedded call anchors)', () => {
|
||||
// The pdg namespace bumps whenever the worker `cfgSideChannel` SHAPE changes:
|
||||
// U1 added `SiteRecord.at` (pdg:2→3), U4 added the Rust struct-literal
|
||||
// `kind:'new'` site (pdg:3→4), and the FU-C call-summary soundness fix added
|
||||
|
|
@ -361,8 +361,9 @@ describe('#2083 M3 U1 — pdg chunk-key namespace version (flag-off keys untouch
|
|||
// the enclosing formal position, not the flattened binding ordinal. A stale
|
||||
// prior shard lacks the new field, so the call-summary harvest would route to
|
||||
// its conservative empty-summary fallback on a warm cache. Assert prior chunks
|
||||
// are NOT served, and PIN the current pdg:5 namespace so an accidental revert
|
||||
// are NOT served, and PIN the current pdg:6 namespace so an accidental revert
|
||||
// of the token re-introduces the stale-shape bug.
|
||||
// pdg:5→6 restores embedded parse-buffer call anchors for the callee-id join.
|
||||
const joined = 'a.ts:h1\nb.ts:h2';
|
||||
const keyOf = (token: string) =>
|
||||
createHash('sha256')
|
||||
|
|
@ -372,6 +373,7 @@ describe('#2083 M3 U1 — pdg chunk-key namespace version (flag-off keys untouch
|
|||
expect(current).not.toBe(keyOf('pdg:2'));
|
||||
expect(current).not.toBe(keyOf('pdg:3'));
|
||||
expect(current).not.toBe(keyOf('pdg:4'));
|
||||
expect(current).toBe(keyOf('pdg:5'));
|
||||
expect(current).not.toBe(keyOf('pdg:5'));
|
||||
expect(current).toBe(keyOf('pdg:6'));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -393,7 +393,7 @@ describe('function-local value identity resolves through to emitted edges (#2736
|
|||
range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 },
|
||||
filePath,
|
||||
bindings: new Map(),
|
||||
ownedDefs: [caller, topLevel],
|
||||
ownedDefs: [topLevel],
|
||||
imports: [],
|
||||
typeBindings: new Map(),
|
||||
};
|
||||
|
|
@ -404,7 +404,7 @@ describe('function-local value identity resolves through to emitted edges (#2736
|
|||
range: { startLine: 3, startCol: 0, endLine: 6, endCol: 1 },
|
||||
filePath,
|
||||
bindings: new Map(),
|
||||
ownedDefs: [local],
|
||||
ownedDefs: [caller, local],
|
||||
imports: [],
|
||||
typeBindings: new Map(),
|
||||
};
|
||||
|
|
|
|||
303
gitnexus/test/integration/local-import-worker-parity.test.ts
Normal file
303
gitnexus/test/integration/local-import-worker-parity.test.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { finalize, SupportedLanguages, type ParsedFile } from 'gitnexus-shared';
|
||||
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
||||
import { getProviderForFile } from '../../src/core/ingestion/languages/index.js';
|
||||
import { runChunkedParseAndResolve } from '../../src/core/ingestion/pipeline-phases/parse-impl.js';
|
||||
import { extractParsedFile } from '../../src/core/ingestion/scope-extractor-bridge.js';
|
||||
import { describeGrammarPresence, optionalGrammarGate } from '../helpers/optional-grammar.js';
|
||||
|
||||
const zig = optionalGrammarGate(SupportedLanguages.Zig);
|
||||
describeGrammarPresence(zig);
|
||||
|
||||
const loaderSource = `function allowed() {
|
||||
const ns = require('./target');
|
||||
const { run: renamed } = require('./target');
|
||||
ns.run(); renamed();
|
||||
}
|
||||
function blocked(require) { const ns = require('./forbidden'); ns.run(); }
|
||||
function ordered() { ns.run(); const ns = require('./target'); ns.run(); }
|
||||
function hoisted() { { var ns = require('./target'); } ns.run(); }
|
||||
async function dynamic() { const ns = await import('./target'); ns.run(); }
|
||||
const Named = class Hidden { method() { return Hidden; } };
|
||||
class Parent {}
|
||||
class Child extends Parent {}
|
||||
`;
|
||||
const fixtures: Record<string, string> = {
|
||||
'depends.py': `from fastapi import Depends
|
||||
def dependency(): return 1
|
||||
def handler(value=Depends(dependency)): return value
|
||||
`,
|
||||
'python.py': `from target import run
|
||||
def allowed():
|
||||
from target import run
|
||||
return run()
|
||||
def blocked():
|
||||
run()
|
||||
from target import run
|
||||
def redirected():
|
||||
global run
|
||||
from target import run
|
||||
return run()
|
||||
def enclosing():
|
||||
from target import run
|
||||
class Nested:
|
||||
before = run()
|
||||
from other import run
|
||||
after = run()
|
||||
def method(self): return run()
|
||||
return [run() for value in values]
|
||||
class Parent: pass
|
||||
class Child(Parent): pass
|
||||
`,
|
||||
'typescript.ts': `${loaderSource}\nfunction typed(value: Child): Parent { return value; }`,
|
||||
'typescript.tsx': `${loaderSource}\nconst element = <Child />;`,
|
||||
'javascript.js': loaderSource,
|
||||
'javascript.jsx': `${loaderSource}\nconst element = <Child />;`,
|
||||
'vue-js.vue': `<template>\n<div />\n</template>\n<script lang="js">\n${loaderSource}\n</script>`,
|
||||
'vue-jsx.vue': `<template>\n<div />\n</template>\n<script lang="jsx">\n${loaderSource}\nconst element = <Child />;\n</script>`,
|
||||
'vue-ts.vue': `<template>\n<div />\n</template>\n<script setup lang="ts">\n${loaderSource}\nfunction typed(value: Child): Parent { return value; }\n</script>`,
|
||||
'rust.rs': `mod target;
|
||||
fn allowed() { run(); use crate::target::run; }
|
||||
fn blocked() { use crate::missing::run; run(); }
|
||||
fn typed(value: Item) { use crate::target::Item; value.run(); }
|
||||
fn sibling() { run(); }
|
||||
`,
|
||||
'cpp.cpp': `#include "target.hpp"
|
||||
int allowed() { using target::run; return run(); }
|
||||
int blocked() { using missing::run; return run(); }
|
||||
int directive() { using namespace target; return run(); }
|
||||
int typed() { using target::Item; Item value; return value.run(); }
|
||||
`,
|
||||
...(zig.available
|
||||
? {
|
||||
'zig.zig': `pub fn allowed() void { const ns = @import("target.zig"); ns.run(); }
|
||||
pub fn blocked() void { ns.run(); const ns = @import("target.zig"); }
|
||||
pub fn siblings() void { { const ns = @import("a.zig"); ns.run(); } { const ns = @import("b.zig"); ns.run(); } }
|
||||
`,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
// Maps/Sets are part of the shared Scope contract. Their entries, and every
|
||||
// provider fact, must contain only data: never a tree-sitter node or callback.
|
||||
function assertPlainFacts(value: unknown): void {
|
||||
if (value === null || value === undefined || typeof value !== 'object') {
|
||||
expect(['function', 'symbol']).not.toContain(typeof value);
|
||||
return;
|
||||
}
|
||||
if (value instanceof Map) {
|
||||
for (const [key, item] of value) {
|
||||
assertPlainFacts(key);
|
||||
assertPlainFacts(item);
|
||||
}
|
||||
} else if (value instanceof Set || Array.isArray(value)) {
|
||||
for (const item of value) assertPlainFacts(item);
|
||||
} else {
|
||||
expect([Object.prototype, null]).toContain(Object.getPrototypeOf(value));
|
||||
for (const item of Object.values(value)) assertPlainFacts(item);
|
||||
}
|
||||
}
|
||||
|
||||
describe('local-import facts across the real parse worker', () => {
|
||||
let tempDir: string;
|
||||
let output: Awaited<ReturnType<typeof runChunkedParseAndResolve>>;
|
||||
let workerFiles: Map<string, ParsedFile>;
|
||||
let directFiles: Map<string, ParsedFile>;
|
||||
let witness: Array<{
|
||||
type: string;
|
||||
files?: string[];
|
||||
fileCount?: number;
|
||||
parsedPaths?: string[];
|
||||
}>;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-local-import-worker-'));
|
||||
const repoDir = path.join(tempDir, 'repo');
|
||||
fs.mkdirSync(repoDir);
|
||||
const marker = path.join(tempDir, 'worker.jsonl');
|
||||
const worker = path.join(tempDir, 'observed-worker.mjs');
|
||||
const realWorker = new URL(
|
||||
'../../dist/core/ingestion/workers/parse-worker.js',
|
||||
import.meta.url,
|
||||
);
|
||||
expect(fs.existsSync(realWorker), 'build gitnexus before real-worker parity tests').toBe(true);
|
||||
fs.writeFileSync(
|
||||
worker,
|
||||
`
|
||||
import fs from 'node:fs';
|
||||
import { parentPort, threadId } from 'node:worker_threads';
|
||||
const record = (entry) => fs.appendFileSync(${JSON.stringify(marker)}, JSON.stringify(entry) + '\\n');
|
||||
record({ type: 'boot', threadId });
|
||||
parentPort.on('message', (message) => {
|
||||
if (message.type === 'sub-batch') record({ type: 'dispatch', files: message.files.map(file => file.path) });
|
||||
});
|
||||
const send = parentPort.postMessage.bind(parentPort);
|
||||
parentPort.postMessage = (message, ...rest) => {
|
||||
if (message.type === 'result') record({ type: 'result', fileCount: message.data.fileCount,
|
||||
parsedPaths: message.data.parsedFiles.map(file => file.filePath) });
|
||||
return send(message, ...rest);
|
||||
};
|
||||
await import(${JSON.stringify(realWorker.href)});
|
||||
`,
|
||||
);
|
||||
directFiles = new Map();
|
||||
for (const [filePath, source] of Object.entries(fixtures)) {
|
||||
fs.writeFileSync(path.join(repoDir, filePath), source);
|
||||
const provider = getProviderForFile(filePath)!;
|
||||
const parsed = extractParsedFile(provider, source, filePath);
|
||||
expect(parsed, `direct extraction for ${filePath}`).toBeDefined();
|
||||
const captureSideChannel = provider.collectCaptureSideChannel?.(filePath);
|
||||
directFiles.set(
|
||||
filePath,
|
||||
captureSideChannel === undefined ? parsed! : { ...parsed!, captureSideChannel },
|
||||
);
|
||||
}
|
||||
const paths = Object.keys(fixtures);
|
||||
output = await runChunkedParseAndResolve(
|
||||
createKnowledgeGraph(),
|
||||
paths.map((filePath) => ({
|
||||
path: filePath,
|
||||
size: fs.statSync(path.join(repoDir, filePath)).size,
|
||||
})),
|
||||
paths,
|
||||
paths.length,
|
||||
repoDir,
|
||||
Date.now(),
|
||||
() => {},
|
||||
{ workerUrlForTest: pathToFileURL(worker), workerPoolSize: 1 },
|
||||
);
|
||||
workerFiles = new Map(output.parsedFiles.map((file) => [file.filePath, file]));
|
||||
witness = fs
|
||||
.readFileSync(marker, 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => JSON.parse(line));
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
// runChunkedParseAndResolve owns the pool's native-safe drain handshake.
|
||||
if (tempDir)
|
||||
fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
});
|
||||
|
||||
it('witnesses actual worker dispatch and returned ParsedFiles without a fallback', () => {
|
||||
expect(output.usedWorkerPool).toBe(true);
|
||||
expect(output.reparsedFileCount).toBe(Object.keys(fixtures).length);
|
||||
expect(output.parseCacheHitFileCount).toBe(0);
|
||||
expect(output.scopeExtractionFailures).toEqual([]);
|
||||
expect(witness.some((entry) => entry.type === 'boot')).toBe(true);
|
||||
expect(witness.flatMap((entry) => entry.files ?? []).sort()).toEqual(
|
||||
Object.keys(fixtures).sort(),
|
||||
);
|
||||
expect(witness.flatMap((entry) => entry.parsedPaths ?? []).sort()).toEqual(
|
||||
Object.keys(fixtures).sort(),
|
||||
);
|
||||
expect([...workerFiles.keys()].sort()).toEqual(Object.keys(fixtures).sort());
|
||||
});
|
||||
|
||||
it.each(Object.keys(fixtures))('preserves complete direct provider facts for %s', (filePath) => {
|
||||
const direct = directFiles.get(filePath)!;
|
||||
const transported = workerFiles.get(filePath)!;
|
||||
expect(direct.scopes.some((scope) => (scope.nameClaims?.length ?? 0) > 0)).toBe(true);
|
||||
expect(transported).toEqual(direct);
|
||||
assertPlainFacts(transported);
|
||||
});
|
||||
|
||||
it('retains the deferred Depends caller independently of its lexical lookup scope', () => {
|
||||
const parsed = workerFiles.get('depends.py')!;
|
||||
const site = parsed.referenceSites.find(
|
||||
(reference) =>
|
||||
reference.kind === 'call' &&
|
||||
reference.name === 'dependency' &&
|
||||
reference.callerScope !== undefined,
|
||||
);
|
||||
expect(site).toBeDefined();
|
||||
expect(site!.inScope).toBe(parsed.moduleScope);
|
||||
expect(site!.callerScope).not.toBe(site!.inScope);
|
||||
expect(parsed.scopes.find((scope) => scope.id === site!.callerScope)?.kind).toBe('Function');
|
||||
});
|
||||
|
||||
it('derives identical finalized ImportEdge positions from transported facts', () => {
|
||||
const target = extractParsedFile(
|
||||
getProviderForFile('target.py')!,
|
||||
'def run(): return 1\n',
|
||||
'target.py',
|
||||
)!;
|
||||
const link = (source: ParsedFile) =>
|
||||
finalize(
|
||||
{
|
||||
files: [source, target],
|
||||
workspaceIndex: undefined,
|
||||
},
|
||||
{
|
||||
importsBindAtLexicalScope: true,
|
||||
resolveImportTarget: (raw) => (raw === 'target' ? target.filePath : null),
|
||||
expandsWildcardTo: () => [],
|
||||
mergeBindings: (existing, incoming) => [...existing, ...incoming],
|
||||
},
|
||||
);
|
||||
const direct = link(directFiles.get('python.py')!);
|
||||
const transported = link(workerFiles.get('python.py')!);
|
||||
expect(transported.imports).toEqual(direct.imports);
|
||||
const edges = [...transported.imports.values()].flat();
|
||||
expect(edges.length).toBeGreaterThan(0);
|
||||
expect(edges.every((edge) => edge.atRange !== undefined)).toBe(true);
|
||||
expect(edges.some((edge) => edge.targetFile === 'target.py' && edge.localName === 'run')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('exercises ownership, activation, semantic positions, and C++ side-channel data', () => {
|
||||
const files = [...workerFiles.values()];
|
||||
const scopes = files.flatMap((file) => file.scopes);
|
||||
const claims = scopes.flatMap((scope) => scope.nameClaims ?? []);
|
||||
expect(claims.some((claim) => claim.availableFrom !== undefined)).toBe(true);
|
||||
expect(claims.some((claim) => claim.redirect !== undefined)).toBe(true);
|
||||
expect(claims.some((claim) => claim.inactive !== undefined)).toBe(true);
|
||||
expect(claims.some((claim) => claim.hoisted === true)).toBe(true);
|
||||
expect(claims.some((claim) => claim.merge === true)).toBe(true);
|
||||
const policies = scopes.flatMap((scope) => (scope.lookupPolicy ? [scope.lookupPolicy] : []));
|
||||
for (const key of [
|
||||
'skipFromChildren',
|
||||
'deferParentActivation',
|
||||
'parentScope',
|
||||
'visibleNamesFromChildren',
|
||||
'callerScopeIsAuthoritative',
|
||||
'nameClaimsComplete',
|
||||
] as const) {
|
||||
expect(
|
||||
policies.some((policy) => policy[key] !== undefined),
|
||||
key,
|
||||
).toBe(true);
|
||||
}
|
||||
const bindings = scopes.flatMap((scope) => [...scope.bindings.values()].flat());
|
||||
expect(bindings.some((binding) => binding.declarationRange !== undefined)).toBe(true);
|
||||
// BindingRef.availableFrom is synthesized by the C++ resolution hook,
|
||||
// after transport; the persistence graph assertions cover its activation.
|
||||
const types = scopes.flatMap((scope) => [...scope.typeBindings.values()]);
|
||||
for (const key of ['lookupPosition', 'lookupPurpose', 'bindingRange'] as const) {
|
||||
expect(
|
||||
types.some((type) => type[key] !== undefined),
|
||||
key,
|
||||
).toBe(true);
|
||||
}
|
||||
const imports = files.flatMap((file) => file.parsedImports);
|
||||
expect(imports.some((imp) => imp.atRange !== undefined)).toBe(true);
|
||||
expect(imports.some((imp) => imp.bindsAtLexicalScope === true)).toBe(true);
|
||||
expect(
|
||||
files.flatMap((file) => file.referenceSites).some((site) => site.lookupScope !== undefined),
|
||||
).toBe(true);
|
||||
expect(
|
||||
workerFiles.get('vue-ts.vue')!.localDefs.some((def) => def.graphPosition !== undefined),
|
||||
).toBe(true);
|
||||
expect(workerFiles.get('cpp.cpp')!.captureSideChannel).toEqual(
|
||||
expect.objectContaining({
|
||||
usingDeclarations: expect.arrayContaining([expect.any(Object)]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue