perf(scope-resolution): use owner-keyed lookup for Step 2 member resolution (#1657)

* perf(scope-resolution): use owner-keyed lookup for Step 2 member resolution (#1656)

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(scope-resolution): index Const/Static in FieldRegistry for Step 2 lookup

Extend FieldRegistry to hold multiple defs per (owner, name), reconcile Const and Static into the owner-keyed index, and wire lookupAllByOwner through the production hook so Step 2 does not drop field kinds the registry never indexed. Pass explicitReceiver on read/write reference sites and document undefined-vs-empty hook semantics for defs fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>

* perf(scope-resolution): centralize O(1) owned-member hook and guard hot path

Extract lookupOwnedMembersByOwner for the production Step 2 hook so merges stay O(1) per registry with no defs.byId scan. Add a perf-contract unit test that throws if byId.values runs when the hook is wired. Reuse a frozen empty sentinel on double miss to avoid per-probe allocations.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: drop unused buildFieldRegistry import

* chore(scope-resolution): apply ce-code-review safe_auto fixes

- Drop unreachable return + unused values() capture in perf-contract trap (Finding #7)
- Type lookupOwnedMembersByOwner ownerDefId as DefId (Finding #9)
- Add Static-kind Step 2 lookup test mirroring the Const case (Finding #11)

* docs(field-registry): document lookupFieldByOwner first-wins semantics

Audit of all 6 production callers (call-processor.ts:2279, walkers.ts:535,
receiver-bound-calls.ts:380+730, type-env.ts:627+631) confirms none depends
on last-wins precedence — all treat the return as a generic 'field with
this name owned by this class'. Clarify the JSDoc to surface the semantic
change introduced when FieldRegistry moved from last-wins to append-order
storage (ce-code-review finding #2).

* test(scope-resolution): extend Step 2 perf contract to implicit-self, MRO, field paths

Adds three sibling tests under the Step 2 perf contract describe block, each
asserting defs.byId.values() does NOT execute when ownedMembersByOwner is wired:

- implicit-self receiver via typeBindings.self (no explicitReceiver branch)
- 2-level MRO chain (Child extends Parent, save resolves on Parent at depth 1)
- FieldRegistry read via Step 2 (property lookup, separate registry path)

Pins the perf invariant on every distinct entry into walkReceiverTypeBinding
so a regression bypassing the hook on any sub-path now fails CI immediately
(ce-code-review finding #8).

* test(resolve-references): cover arity-overload filtering via resolveReferenceSites

Pins the orchestration-layer wiring of providers.arityCompatibility:
hook returns [save(arity 1), save(arity 2)], referenceSite.arity = 1,
arityCompatibility verdicts 'compatible'/'incompatible' by parameterCount,
exactly one reference emitted with toDef = the arity-1 overload.

registries.test.ts already covered arity at the buildMethodRegistry level;
this adds the missing entry-point check that resolveReferenceSites threads
providers correctly through to lookupCore.Step5 (ce-code-review finding #10).

* test(resolve-references): add hook-on vs hook-off parity test

Runs resolveReferenceSites twice on the same fixture (Parent.save method
hit + Child.name field hit, Child extends Parent MRO chain) — once with
ownedMembersByOwner wired to a synthetic registry, once with the hook
absent so collectOwnedMembers takes the defs.byId fallback. Asserts:

- stats are identical (sitesProcessed / referencesEmitted / unresolved)
- referenceIndex.bySourceScope entries have equal length
- toDef sets are equal
- each per-site reference (including evidence and depth) is .toEqual

Locks the semantic-parity claim in code while both paths still exist.
Will be removed alongside the fallback in finding #1 (ce-code-review #3).

* test(typescript): probe Step 2 MRO walk against ambient (declare class) base

Adds typescript-ambient-base-class fixture with an export declare class
AmbientBase + Derived extends AmbientBase and a call site d.ambientMethod().
Integration assertions:

- Both classes are detected
- EXTENDS edge Derived → AmbientBase emitted
- CALLS edge to ambient.ts:ambientMethod resolved via MRO walk

Probes the ce-code-review #6 concern that ambient-only owners (whose
bodies are never parsed) might be silently skipped by Step 2 after the
owner-keyed lookup change. Result: the call resolves correctly — the
method signature inside the declare class body still flows through
reconcileOwnership into model.methods, so the hook returns the right
ancestor hits. Residual risk is empirically closed.

* feat(scope-resolution): route nested types via owner-keyed TypeRegistry

Closes the Step 2 contract footgun where 'hook returns [] = authoritative
miss' silently dropped any owned def whose NodeLabel was outside the
method/field if-chain in reconcileOwnership.

- TypeRegistry: add nestedByOwner Map + lookupAllByOwner(owner, simple)
  + registerByOwner(owner, simple, def). Mirrors MethodRegistry/
  FieldRegistry shape; cleared with the rest on cascade clear.
- reconcileOwnership: route class-like NodeLabels (Class/Interface/Enum/
  Struct/Union/Trait/TypeAlias/Typedef/Record/Delegate/Annotation/
  Template/Namespace) via types.registerByOwner. New nestedTypesRegistered
  stat. Idempotent skip via nodeId match.
- validateOwnershipParity: extend the I9 invariant check to nested types.
- lookupOwnedMembersByOwner: merge methods + fields + nested-type hits;
  short-circuit when any one source contributes the full result.

Unblocks future receiver-MRO registries that need to resolve 'Outer.Inner'
through the receiver's type-binding chain (ce-code-review finding #5a).

* refactor(scope-resolution): make ownedMembersByOwner required; delete byId fallback

Per ce-code-review finding #1, the optional-hook design encoded a silent
O(|defs|) perf cliff into the type system: any RegistryContext built
without the hook regressed Step 2 to scanning every def per probe with
no warning. Production wires the hook unconditionally; the fallback was
exercised only by tests.

- RegistryContext.ownedMembersByOwner: required, returns readonly
  SymbolDefinition[] (no | undefined). Implementations MUST return [] on
  authoritative miss.
- collectOwnedMembers in lookup-core.ts collapses to a one-line forward
  to the hook; the defs.byId.values() scan and simpleNameOf helper are
  deleted (simpleNameOf had no other consumers).
- ResolveReferencesInput.ownedMembersByOwner: required to match.
- Tests: drop three fallback-path tests (registries Const fallback,
  resolveReferenceSites no-hook fallback, resolveReferenceSites Const-
  undefined fallback) and the hook-vs-fallback parity test added by
  finding #3. makeCtx in registries.test.ts now defaults to a real
  owner-keyed scan over the test fixture defs so tests that don't care
  about the hook keep working.

* perf(free-call-fallback): cache global callables by simple name once per pass

pickUniqueGlobalCallable scanned scopes.defs.byId.values() on every
free-call fallback site. After PR #1656 fixed Step 2, this scan became
the dominant remaining O(|defs|) hot path on large repos (ce-code-review
finding #4).

- buildGlobalCallableIndex builds a Map<simpleName, SymbolDefinition[]>
  over scopes.defs once at the top of emitFreeCallFallback. Same filter
  the per-site scan applied: Function / Method / Constructor, keyed by
  the last .-segment of qualifiedName.
- pickUniqueGlobalCallable consumes the prebuilt index via O(1) Map.get
  instead of iterating every def. Per-site complexity drops from
  O(|defs|) to O(|defs with this simple name|).
- Cost: O(|defs|) once per pass instead of O(|defs| * |free-call sites|).

Subsequent narrowing (arity, conversion-rank) and the model-side fallback
(model.symbols.lookupCallableByName + model.methods.lookupMethodByName)
are unchanged.

* chore(autofix): apply prettier + eslint fixes via /autofix command

* ci: trigger build

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Gergo Magyar <abhigyan1.patwari@gmail.com>
This commit is contained in:
Anton Fedotov 2026-05-18 15:14:27 +03:00 committed by GitHub
parent 7d500390b9
commit c30833fad3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1163 additions and 48 deletions

View file

@ -127,6 +127,7 @@ export { CLASS_KINDS, METHOD_KINDS, FIELD_KINDS } from './scope-resolution/regis
export type {
RegistryContext,
RegistryProviders,
OwnedMembersByOwnerLookup,
OwnerScopedContributor,
ArityVerdict,
ConstraintContext,

View file

@ -93,6 +93,19 @@ export interface OwnerScopedContributor {
byName(name: string): readonly SymbolDefinition[];
}
/**
* Required owner-keyed lookup hook for Step 2 receiver/MRO member walks.
* Production callers wire this to the SemanticModel's authoritative
* method/field/nested-type registries so each `(ownerDefId, memberName)`
* probe is O(1). Implementations MUST return `[]` on an indexed miss
* Step 2 treats `[]` as authoritative and does not consult `defs` for a
* fallback scan.
*/
export type OwnedMembersByOwnerLookup = (
ownerDefId: DefId,
memberName: string,
) => readonly SymbolDefinition[];
// ─── Top-level context threaded through every lookup ───────────────────────
export interface RegistryContext {
@ -100,6 +113,7 @@ export interface RegistryContext {
readonly defs: DefIndex;
readonly qualifiedNames: QualifiedNameIndex;
readonly moduleScopes: ModuleScopeIndex;
readonly ownedMembersByOwner: OwnedMembersByOwnerLookup;
/**
* Method-dispatch index; required for method/field registries that
* honor `useReceiverTypeBinding`. Omit for class-only lookups.

View file

@ -27,8 +27,10 @@
* is true, resolve the receiver's type at `startScope` (from
* `scope.typeBindings`), then walk the MRO via
* `MethodDispatchIndex.mroFor(ownerDefId)`. Membership per owner comes
* through `RegistryContext.methodDispatch` + owner lookups into
* `scope.ownedDefs`; each hit records a raw signal with the owner's
* through an optional `RegistryContext.ownedMembersByOwner` hook when
* supplied (`undefined` fall back to `defs.byId`; `[]` indexed
* miss), otherwise via the compatibility fallback scan over
* `defs.byId`; each hit records a raw signal with the owner's
* MRO depth.
*
* **Step 3 Owner-scoped contributor.** When
@ -263,13 +265,14 @@ function walkReceiverTypeBinding(
// Walk the owner itself at depth 0, then its MRO chain.
const walk: DefId[] = [ownerDefId, ...ctx.methodDispatch.mroFor(ownerDefId)];
for (let mroDepth = 0; mroDepth < walk.length; mroDepth++) {
const currentOwnerId = walk[mroDepth]!;
let mroDepth = 0;
for (const currentOwnerId of walk) {
const members = collectOwnedMembers(currentOwnerId, name, ctx);
for (const def of members) {
if (!acceptedKinds.has(def.type)) continue;
recordTypeBindingHit(perCandidate, def, mroDepth, ownerDefId);
}
mroDepth++;
}
}
@ -333,23 +336,7 @@ function collectOwnedMembers(
memberName: string,
ctx: RegistryContext,
): readonly SymbolDefinition[] {
// An owner's members are defs whose `ownerId === ownerDefId` and whose
// simple name matches `memberName`. We iterate `defs.byId` — O(D) per
// call today. A future by-owner index would make this O(K); tracked as
// a follow-up optimization before Ring 3 flips go production.
const out: SymbolDefinition[] = [];
for (const def of ctx.defs.byId.values()) {
if (def.ownerId !== ownerDefId) continue;
if (simpleNameOf(def) !== memberName) continue;
out.push(def);
}
return out;
}
function simpleNameOf(def: SymbolDefinition): string | undefined {
if (def.qualifiedName === undefined || def.qualifiedName.length === 0) return undefined;
const dot = def.qualifiedName.lastIndexOf('.');
return dot === -1 ? def.qualifiedName : def.qualifiedName.slice(dot + 1);
return ctx.ownedMembersByOwner(ownerDefId, memberName);
}
function recordTypeBindingHit(

View file

@ -2,18 +2,35 @@
* Field Registry
*
* Owner-scoped field/property index extracted from SymbolTable.
* Stores Property symbols keyed by `ownerNodeId\0fieldName` for O(1) lookup.
* Stores Property / Variable / Const / Static symbols keyed by
* `ownerNodeId\0fieldName` for O(1) lookup. Supports multiple defs
* under the same (owner, name) e.g. legacy Property plus a
* scope-resolution Variable reconciliation entry.
*/
import type { SymbolDefinition } from 'gitnexus-shared';
const EMPTY: readonly SymbolDefinition[] = Object.freeze([]);
// ---------------------------------------------------------------------------
// Public read-only interface
// ---------------------------------------------------------------------------
export interface FieldRegistry {
/** Look up a field/property by its owning class nodeId and field name. */
/**
* First field registered under `(ownerNodeId, fieldName)`, if any.
* Registration order is first-wins: when a Property and a Variable share
* an `(owner, simpleName)` key, the earlier `register(...)` call's def is
* returned. Prefer `lookupAllByOwner` when overloads or duplicate-kind
* entries under the same name must all be visible.
*/
lookupFieldByOwner(ownerNodeId: string, fieldName: string): SymbolDefinition | undefined;
/**
* Every field registered under `(ownerNodeId, fieldName)` in registration
* order. Returns `[]` on miss.
*/
lookupAllByOwner(ownerNodeId: string, fieldName: string): readonly SymbolDefinition[];
}
// ---------------------------------------------------------------------------
@ -21,7 +38,7 @@ export interface FieldRegistry {
// ---------------------------------------------------------------------------
export interface MutableFieldRegistry extends FieldRegistry {
/** Register a field/property under its owner. */
/** Register a field under its owner. Appends when the key already exists. */
register(ownerNodeId: string, fieldName: string, def: SymbolDefinition): void;
/** Clear all entries. */
clear(): void;
@ -32,22 +49,36 @@ export interface MutableFieldRegistry extends FieldRegistry {
// ---------------------------------------------------------------------------
export const createFieldRegistry = (): MutableFieldRegistry => {
const fieldByOwner = new Map<string, SymbolDefinition>();
const fieldByOwner = new Map<string, SymbolDefinition[]>();
const lookupAllByOwner = (
ownerNodeId: string,
fieldName: string,
): readonly SymbolDefinition[] => {
return fieldByOwner.get(`${ownerNodeId}\0${fieldName}`) ?? EMPTY;
};
const lookupFieldByOwner = (
ownerNodeId: string,
fieldName: string,
): SymbolDefinition | undefined => {
return fieldByOwner.get(`${ownerNodeId}\0${fieldName}`);
const pool = lookupAllByOwner(ownerNodeId, fieldName);
return pool.length === 0 ? undefined : pool[0];
};
const register = (ownerNodeId: string, fieldName: string, def: SymbolDefinition): void => {
fieldByOwner.set(`${ownerNodeId}\0${fieldName}`, def);
const key = `${ownerNodeId}\0${fieldName}`;
const existing = fieldByOwner.get(key);
if (existing) {
existing.push(def);
} else {
fieldByOwner.set(key, [def]);
}
};
const clear = (): void => {
fieldByOwner.clear();
};
return { lookupFieldByOwner, register, clear };
return { lookupFieldByOwner, lookupAllByOwner, register, clear };
};

View file

@ -0,0 +1,45 @@
/**
* Owner-keyed member lookup for Step 2 (RFC #909 / PR #1656).
*
* Merges MethodRegistry + FieldRegistry hits for `(ownerDefId, memberName)`
* in O(1) map time per registry no `defs.byId` scan. Callers that omit
* this helper and leave `ownedMembersByOwner` unset fall back to an O(|defs|)
* compatibility scan inside `lookupCore.collectOwnedMembers`.
*/
import type { DefId, SymbolDefinition } from 'gitnexus-shared';
import type { SemanticModel } from './semantic-model.js';
const EMPTY: readonly SymbolDefinition[] = Object.freeze([]);
/**
* Production hook for `RegistryContext.ownedMembersByOwner`.
* Returns `[]` on miss (authoritative indexed empty) never `undefined`.
*
* Merges hits from all three owner-keyed registries (methods, fields,
* nested types) under the same `(ownerDefId, memberName)` key. The
* caller's `acceptedKinds` filter in `lookupCore` picks the right subset.
*/
export function lookupOwnedMembersByOwner(
model: Pick<SemanticModel, 'methods' | 'fields' | 'types'>,
ownerDefId: DefId,
memberName: string,
): readonly SymbolDefinition[] {
const methods = model.methods.lookupAllByOwner(ownerDefId, memberName);
const fields = model.fields.lookupAllByOwner(ownerDefId, memberName);
const nestedTypes = model.types.lookupAllByOwner(ownerDefId, memberName);
const methodCount = methods.length;
const fieldCount = fields.length;
const typeCount = nestedTypes.length;
const total = methodCount + fieldCount + typeCount;
if (total === 0) return EMPTY;
if (methodCount === total) return methods;
if (fieldCount === total) return fields;
if (typeCount === total) return nestedTypes;
const merged = new Array<SymbolDefinition>(total);
let i = 0;
for (let j = 0; j < methodCount; j++) merged[i++] = methods[j]!;
for (let j = 0; j < fieldCount; j++) merged[i++] = fields[j]!;
for (let j = 0; j < typeCount; j++) merged[i++] = nestedTypes[j]!;
return merged;
}

View file

@ -8,6 +8,8 @@
import type { SymbolDefinition } from 'gitnexus-shared';
const EMPTY: readonly SymbolDefinition[] = Object.freeze([]);
// ---------------------------------------------------------------------------
// Public read-only interface
// ---------------------------------------------------------------------------
@ -35,6 +37,14 @@ export interface TypeRegistry {
* Returned array is a view into the live index do not mutate.
*/
lookupImplByName(name: string): readonly SymbolDefinition[];
/**
* Look up nested-type defs registered under `(ownerNodeId, simpleName)`
* in registration order. Returns `[]` on miss. Used by Step 2 Receiver/MRO
* resolution when the receiver's owner declares nested classes/structs/
* enums/typedefs/etc. that the caller's `acceptedKinds` includes.
*/
lookupAllByOwner(ownerNodeId: string, simpleName: string): readonly SymbolDefinition[];
}
// ---------------------------------------------------------------------------
@ -46,6 +56,8 @@ export interface MutableTypeRegistry extends TypeRegistry {
registerClass(name: string, qualifiedName: string, def: SymbolDefinition): void;
/** Register a Rust Impl block by name. */
registerImpl(name: string, def: SymbolDefinition): void;
/** Register a nested type under its owner. Appends when the key already exists. */
registerByOwner(ownerNodeId: string, simpleName: string, def: SymbolDefinition): void;
/** Clear all entries. */
clear(): void;
}
@ -58,6 +70,7 @@ export const createTypeRegistry = (): MutableTypeRegistry => {
const classByName = new Map<string, SymbolDefinition[]>();
const classByQualifiedName = new Map<string, SymbolDefinition[]>();
const implByName = new Map<string, SymbolDefinition[]>();
const nestedByOwner = new Map<string, SymbolDefinition[]>();
const lookupClassByName = (name: string): SymbolDefinition[] => {
return classByName.get(name) ?? [];
@ -71,6 +84,13 @@ export const createTypeRegistry = (): MutableTypeRegistry => {
return implByName.get(name) ?? [];
};
const lookupAllByOwner = (
ownerNodeId: string,
simpleName: string,
): readonly SymbolDefinition[] => {
return nestedByOwner.get(`${ownerNodeId}\0${simpleName}`) ?? EMPTY;
};
const registerClass = (name: string, qualifiedName: string, def: SymbolDefinition): void => {
const existing = classByName.get(name);
if (existing) {
@ -96,18 +116,35 @@ export const createTypeRegistry = (): MutableTypeRegistry => {
}
};
const registerByOwner = (
ownerNodeId: string,
simpleName: string,
def: SymbolDefinition,
): void => {
const key = `${ownerNodeId}\0${simpleName}`;
const existing = nestedByOwner.get(key);
if (existing) {
existing.push(def);
} else {
nestedByOwner.set(key, [def]);
}
};
const clear = (): void => {
classByName.clear();
classByQualifiedName.clear();
implByName.clear();
nestedByOwner.clear();
};
return {
lookupClassByName,
lookupClassByQualifiedName,
lookupImplByName,
lookupAllByOwner,
registerClass,
registerImpl,
registerByOwner,
clear,
};
};

View file

@ -64,6 +64,8 @@ export interface ResolveReferencesInput {
readonly scopes: ScopeResolutionIndexes;
/** Provider hooks consumed by the registries (e.g. `arityCompatibility`). */
readonly providers?: RegistryProviders;
/** Required owner-keyed member lookup used by Step 2 receiver/MRO walks. */
readonly ownedMembersByOwner: RegistryContext['ownedMembersByOwner'];
}
export interface ResolveStats {
@ -92,6 +94,7 @@ export function resolveReferenceSites(input: ResolveReferencesInput): ResolveRef
defs: scopes.defs,
qualifiedNames: scopes.qualifiedNames,
moduleScopes: scopes.moduleScopes,
ownedMembersByOwner: input.ownedMembersByOwner,
methodDispatch: scopes.methodDispatch,
providers,
};
@ -191,7 +194,10 @@ function lookupForSite(
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 fieldHits = fieldRegistry.lookup(site.name, site.inScope);
const fieldOpts: Parameters<FieldRegistry['lookup']>[2] = {
...(site.explicitReceiver !== undefined ? { explicitReceiver: site.explicitReceiver } : {}),
};
const fieldHits = fieldRegistry.lookup(site.name, site.inScope, fieldOpts);
if (fieldHits.length > 0) return fieldHits;
const methodHits = methodRegistry.lookup(site.name, site.inScope);
if (methodHits.length > 0) return methodHits;

View file

@ -78,6 +78,12 @@ export function emitFreeCallFallback(
let emitted = 0;
const seen = new Set<string>();
// Build an O(1) simple-name -> callable defs index over scopes.defs once
// per pass so pickUniqueGlobalCallable doesn't re-scan defs.byId.values()
// per call site. Same name + callable-kind filter that the previous scan
// applied (see pickUniqueGlobalCallable JSDoc). Cost: O(|defs|) once.
const globalCallablesBySimpleName = buildGlobalCallableIndex(scopes);
for (const parsed of parsedFiles) {
for (const site of parsed.referenceSites) {
if (site.kind !== 'call') continue;
@ -254,7 +260,7 @@ export function emitFreeCallFallback(
fnDef = pickUniqueGlobalCallable(
site.name,
model,
scopes,
globalCallablesBySimpleName,
parsed.filePath,
options.isFileLocalDef,
site.arity,
@ -299,10 +305,35 @@ export function emitFreeCallFallback(
return emitted;
}
/**
* Build a `simpleName -> callable defs` index from `scopes.defs` once per
* pass. Mirrors the filter the old per-site scan applied: Function /
* Method / Constructor, keyed by the last `.`-segment of `qualifiedName`
* (falling back to the qualifiedName itself when undotted). Used by
* `pickUniqueGlobalCallable` so every free-call fallback site is O(1)
* instead of O(|defs|).
*/
function buildGlobalCallableIndex(
scopes: ScopeResolutionIndexes,
): ReadonlyMap<string, readonly SymbolDefinition[]> {
const out = new Map<string, SymbolDefinition[]>();
for (const def of scopes.defs.byId.values()) {
if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') continue;
const qualified = def.qualifiedName;
if (qualified === undefined || qualified.length === 0) continue;
const dot = qualified.lastIndexOf('.');
const simple = dot === -1 ? qualified : qualified.slice(dot + 1);
const bucket = out.get(simple);
if (bucket) bucket.push(def);
else out.set(simple, [def]);
}
return out;
}
function pickUniqueGlobalCallable(
name: string,
model: SemanticModel,
scopes: ScopeResolutionIndexes,
globalCallablesBySimpleName: ReadonlyMap<string, readonly SymbolDefinition[]>,
callerFilePath: string,
isFileLocalDef?: (def: SymbolDefinition) => boolean,
callArity?: number,
@ -312,10 +343,7 @@ function pickUniqueGlobalCallable(
): SymbolDefinition | undefined {
const scopeDefs: SymbolDefinition[] = [];
const scopeSeen = new Set<string>();
for (const def of scopes.defs.byId.values()) {
const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName;
if (simple !== name) continue;
if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') continue;
for (const def of globalCallablesBySimpleName.get(name) ?? []) {
// Skip file-local defs (e.g. C `static` functions) that live in a
// different file from the caller — they are logically invisible.
if (isFileLocalDef !== undefined && def.filePath !== callerFilePath && isFileLocalDef(def)) {

View file

@ -18,8 +18,8 @@
* undefined `ownerId` is reachable via either:
* - `model.methods.lookupAllByOwner(ownerId, simpleName)` if the
* def is a Method / Function / Constructor, OR
* - `model.fields.lookupFieldByOwner(ownerId, simpleName)` if the
* def is a Property / Variable.
* - `model.fields.lookupAllByOwner(ownerId, simpleName)` if the
* def is a Property / Variable / Const / Static.
*
* This invariant is the foundation of Contract Invariant I9
* (`contract/scope-resolver.ts`): scope-resolution passes MUST read
@ -45,11 +45,29 @@ import type { ParsedFile } from 'gitnexus-shared';
import type { MutableSemanticModel, SemanticModel } from '../../model/semantic-model.js';
import { simpleQualifiedName } from '../graph-bridge/ids.js';
const NESTED_TYPE_KINDS = new Set<string>([
'Class',
'Interface',
'Enum',
'Struct',
'Union',
'Trait',
'TypeAlias',
'Typedef',
'Record',
'Delegate',
'Annotation',
'Template',
'Namespace',
]);
export interface ReconcileStats {
/** Method/Function/Constructor defs registered into MethodRegistry. */
readonly methodsRegistered: number;
/** Property/Variable defs registered into FieldRegistry. */
readonly fieldsRegistered: number;
/** Class-like nested type defs registered into TypeRegistry by owner. */
readonly nestedTypesRegistered: number;
/** Defs already present (idempotent skip). */
readonly skippedAlreadyPresent: number;
}
@ -60,6 +78,7 @@ export function reconcileOwnership(
): ReconcileStats {
let methodsRegistered = 0;
let fieldsRegistered = 0;
let nestedTypesRegistered = 0;
let skippedAlreadyPresent = 0;
for (const parsed of parsedFiles) {
@ -77,19 +96,32 @@ export function reconcileOwnership(
}
model.methods.register(ownerId, simple, def);
methodsRegistered++;
} else if (def.type === 'Property' || def.type === 'Variable') {
const existing = model.fields.lookupFieldByOwner(ownerId, simple);
if (existing !== undefined && existing.nodeId === def.nodeId) {
} else if (
def.type === 'Property' ||
def.type === 'Variable' ||
def.type === 'Const' ||
def.type === 'Static'
) {
const existing = model.fields.lookupAllByOwner(ownerId, simple);
if (existing.some((e) => e.nodeId === def.nodeId)) {
skippedAlreadyPresent++;
continue;
}
model.fields.register(ownerId, simple, def);
fieldsRegistered++;
} else if (NESTED_TYPE_KINDS.has(def.type)) {
const existing = model.types.lookupAllByOwner(ownerId, simple);
if (existing.some((e) => e.nodeId === def.nodeId)) {
skippedAlreadyPresent++;
continue;
}
model.types.registerByOwner(ownerId, simple, def);
nestedTypesRegistered++;
}
}
}
return { methodsRegistered, fieldsRegistered, skippedAlreadyPresent };
return { methodsRegistered, fieldsRegistered, nestedTypesRegistered, skippedAlreadyPresent };
}
/**
@ -131,15 +163,29 @@ export function validateOwnershipParity(
);
mismatches++;
}
} else if (def.type === 'Property' || def.type === 'Variable') {
const found = model.fields.lookupFieldByOwner(ownerId, simple);
if (found === undefined || found.nodeId !== def.nodeId) {
} else if (
def.type === 'Property' ||
def.type === 'Variable' ||
def.type === 'Const' ||
def.type === 'Static'
) {
const found = model.fields.lookupAllByOwner(ownerId, simple);
if (!found.some((d) => d.nodeId === def.nodeId)) {
onWarn(
`semantic-model parity: ${def.type} ${def.nodeId} (${parsed.filePath}) ` +
`owned by ${ownerId} as "${simple}" not in FieldRegistry`,
);
mismatches++;
}
} else if (NESTED_TYPE_KINDS.has(def.type)) {
const found = model.types.lookupAllByOwner(ownerId, simple);
if (!found.some((d) => d.nodeId === def.nodeId)) {
onWarn(
`semantic-model parity: ${def.type} ${def.nodeId} (${parsed.filePath}) ` +
`owned by ${ownerId} as "${simple}" not in TypeRegistry owner index`,
);
mismatches++;
}
}
}
}

View file

@ -25,6 +25,7 @@
import type { ParsedFile, RegistryProviders } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../../graph/types.js';
import { lookupOwnedMembersByOwner } from '../../model/owned-members-lookup.js';
import type { MutableSemanticModel, SemanticModel } from '../../model/semantic-model.js';
import { reconcileOwnership, validateOwnershipParity } from './reconcile-ownership.js';
import { validateBindingsImmutability } from './validate-bindings-immutability.js';
@ -342,6 +343,8 @@ export function runScopeResolution(
const { referenceIndex, stats: resolveStats } = resolveReferenceSites({
scopes: indexes,
providers: registryProviders,
ownedMembersByOwner: (ownerDefId, memberName) =>
lookupOwnedMembersByOwner(readonlyModel, ownerDefId, memberName),
});
const tResolve = PROF ? process.hrtime.bigint() : 0n;

View file

@ -0,0 +1,3 @@
import { AmbientBase } from './ambient';
export class Derived extends AmbientBase {}

View file

@ -0,0 +1,7 @@
// Ambient base class — simulates a .d.ts-declared external/library type
// whose body is never seen by the analyzer. Probes whether Step 2 MRO
// lookup can still resolve inherited members on owners that reconcile-
// ownership skipped because they have no parsed body.
export declare class AmbientBase {
ambientMethod(): string;
}

View file

@ -0,0 +1,6 @@
import { Derived } from './Derived';
export function run(): void {
const d = new Derived();
d.ambientMethod();
}

View file

@ -2683,6 +2683,44 @@ describe('TypeScript Child extends Parent — inherited method resolution (SM-9)
});
});
// ---------------------------------------------------------------------------
// PR #1657 finding #6: ambient base class — Step 2 MRO ancestor whose body
// is never parsed (declare class). Probes whether the owner-keyed lookup
// can still resolve inherited members on owners that reconcile-ownership
// skipped because they have no parsed body.
// ---------------------------------------------------------------------------
describe('TypeScript Derived extends declare class AmbientBase — ambient MRO ancestor', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'typescript-ambient-base-class'),
() => {},
);
}, 60000);
it('detects AmbientBase and Derived classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('AmbientBase');
expect(classes).toContain('Derived');
});
it('emits EXTENDS edge: Derived → AmbientBase', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(edgeSet(extends_)).toContain('Derived → AmbientBase');
});
it('resolves d.ambientMethod() to AmbientBase.ambientMethod via MRO walk', () => {
const calls = getRelationships(result, 'CALLS');
const ambientCall = calls.find(
(c) => c.target === 'ambientMethod' && c.targetFilePath.includes('ambient.ts'),
);
expect(ambientCall).toBeDefined();
expect(ambientCall!.source).toBe('run');
});
});
// ---------------------------------------------------------------------------
// PR #1050: tsconfig path alias resolution under registry-primary path
// (Adversarial review Finding 1 — `@/services/user` must resolve via tsconfig

View file

@ -41,15 +41,16 @@ describe('FieldRegistry', () => {
expect(reg.lookupFieldByOwner('class:Order', 'name')?.nodeId).toBe('prop:Order.name');
});
it('last-wins on duplicate (ownerNodeId, fieldName) — registry is flat, not an overload list', () => {
it('accumulates multiple defs under the same (ownerNodeId, fieldName)', () => {
const reg = createFieldRegistry();
const first = makeDef({ nodeId: 'prop:User.name#first' });
const second = makeDef({ nodeId: 'prop:User.name#second' });
const first = makeDef({ nodeId: 'prop:User.name#first', type: 'Property' });
const second = makeDef({ nodeId: 'def:User.name#var', type: 'Variable' });
reg.register('class:User', 'name', first);
reg.register('class:User', 'name', second);
expect(reg.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name#second');
expect(reg.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name#first');
expect(reg.lookupAllByOwner('class:User', 'name')).toEqual([first, second]);
});
it('clear() empties the registry', () => {

View file

@ -0,0 +1,388 @@
/**
* Step 2 owner-keyed lookup correctness and perf contract (PR #1656).
*/
import { describe, it, expect } from 'vitest';
import type { DefIndex, SymbolDefinition } from 'gitnexus-shared';
import {
buildFieldRegistry,
buildMethodRegistry,
EvidenceWeights,
buildScopeTree,
buildQualifiedNameIndex,
buildModuleScopeIndex,
buildMethodDispatchIndex,
type RegistryContext,
type Scope,
type ScopeId,
type TypeRef,
} from 'gitnexus-shared';
import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js';
import { lookupOwnedMembersByOwner } from '../../../src/core/ingestion/model/owned-members-lookup.js';
const mkDef = (overrides: Partial<SymbolDefinition> & { nodeId: string }): SymbolDefinition => ({
nodeId: overrides.nodeId,
filePath: overrides.filePath ?? 'x.ts',
type: overrides.type ?? 'Class',
...overrides,
});
const typeRef = (rawName: string, declaredAtScope: ScopeId): TypeRef => ({
rawName,
declaredAtScope,
source: 'parameter-annotation',
});
describe('lookupOwnedMembersByOwner', () => {
it('returns methods only, fields only, or both without allocating on single-hit paths', () => {
const model = createSemanticModel();
const save = mkDef({
nodeId: 'def:User.save',
type: 'Method',
qualifiedName: 'User.save',
ownerId: 'def:User',
});
const name = mkDef({
nodeId: 'def:User.name',
type: 'Property',
qualifiedName: 'User.name',
ownerId: 'def:User',
});
model.methods.register('def:User', 'save', save);
model.fields.register('def:User', 'name', name);
const methodsOnly = lookupOwnedMembersByOwner(model, 'def:User', 'save');
expect(methodsOnly).toEqual([save]);
const fieldsOnly = lookupOwnedMembersByOwner(model, 'def:User', 'name');
expect(fieldsOnly).toEqual([name]);
const both = lookupOwnedMembersByOwner(model, 'def:User', 'save');
expect(both).toEqual([save]);
});
it('merges method and field hits under the same (owner, name)', () => {
const model = createSemanticModel();
const prop = mkDef({
nodeId: 'prop:User.id',
type: 'Property',
qualifiedName: 'User.id',
ownerId: 'def:User',
});
const variable = mkDef({
nodeId: 'def:User.id',
type: 'Variable',
qualifiedName: 'User.id',
ownerId: 'def:User',
});
model.fields.register('def:User', 'id', prop);
model.fields.register('def:User', 'id', variable);
expect(lookupOwnedMembersByOwner(model, 'def:User', 'id')).toEqual([prop, variable]);
});
it('returns nested-type hits when registered under (owner, simpleName)', () => {
const model = createSemanticModel();
const inner = mkDef({
nodeId: 'def:Outer.Inner',
type: 'Class',
qualifiedName: 'Outer.Inner',
ownerId: 'def:Outer',
});
model.types.registerByOwner('def:Outer', 'Inner', inner);
expect(lookupOwnedMembersByOwner(model, 'def:Outer', 'Inner')).toEqual([inner]);
});
it('merges methods + fields + nested-type hits under the same (owner, name)', () => {
const model = createSemanticModel();
const method = mkDef({
nodeId: 'def:Outer.x#method',
type: 'Method',
qualifiedName: 'Outer.x',
ownerId: 'def:Outer',
});
const field = mkDef({
nodeId: 'def:Outer.x#field',
type: 'Property',
qualifiedName: 'Outer.x',
ownerId: 'def:Outer',
});
const nested = mkDef({
nodeId: 'def:Outer.x#class',
type: 'Class',
qualifiedName: 'Outer.x',
ownerId: 'def:Outer',
});
model.methods.register('def:Outer', 'x', method);
model.fields.register('def:Outer', 'x', field);
model.types.registerByOwner('def:Outer', 'x', nested);
expect(lookupOwnedMembersByOwner(model, 'def:Outer', 'x')).toEqual([method, field, nested]);
});
});
describe('Step 2 perf contract', () => {
it('does not scan defs.byId when ownedMembersByOwner is wired', () => {
const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' });
const saveMethod = mkDef({
nodeId: 'def:User.save',
type: 'Method',
qualifiedName: 'User.save',
ownerId: 'def:User',
});
const trapById = new Map<string, SymbolDefinition>([
[userClass.nodeId, userClass],
[saveMethod.nodeId, saveMethod],
]);
trapById.values = () => {
throw new Error('defs.byId.values() must not run when ownedMembersByOwner is provided');
};
const defs: DefIndex = {
byId: trapById,
size: trapById.size,
get: (id) => trapById.get(id),
has: (id) => trapById.has(id),
};
const callScope: Scope = {
id: 'scope:call',
parent: null,
kind: 'Module',
range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 },
filePath: 'x.ts',
bindings: new Map(),
ownedDefs: [],
imports: [],
typeBindings: new Map([['user', typeRef('User', 'scope:call')]]),
};
const model = createSemanticModel();
model.methods.register('def:User', 'save', saveMethod);
const ctx: RegistryContext = {
scopes: buildScopeTree([callScope]),
defs,
qualifiedNames: buildQualifiedNameIndex([userClass, saveMethod]),
moduleScopes: buildModuleScopeIndex([]),
methodDispatch: buildMethodDispatchIndex({
owners: ['def:User'],
computeMro: () => [],
implementsOf: () => [],
}),
ownedMembersByOwner: (ownerDefId, memberName) =>
lookupOwnedMembersByOwner(model, ownerDefId, memberName),
providers: {},
};
const results = buildMethodRegistry(ctx).lookup('save', 'scope:call', {
explicitReceiver: { name: 'user' },
});
expect(results).toHaveLength(1);
expect(results[0]!.def).toBe(saveMethod);
expect(results[0]!.evidence.find((e) => e.kind === 'type-binding')?.weight).toBe(
EvidenceWeights.typeBindingByMroDepth[0],
);
});
it('does not scan defs.byId for implicit-self receiver (no explicitReceiver)', () => {
const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' });
const saveMethod = mkDef({
nodeId: 'def:User.save',
type: 'Method',
qualifiedName: 'User.save',
ownerId: 'def:User',
});
const trapById = new Map<string, SymbolDefinition>([
[userClass.nodeId, userClass],
[saveMethod.nodeId, saveMethod],
]);
trapById.values = () => {
throw new Error('defs.byId.values() must not run when ownedMembersByOwner is provided');
};
const defs: DefIndex = {
byId: trapById,
size: trapById.size,
get: (id) => trapById.get(id),
has: (id) => trapById.has(id),
};
const moduleScope: Scope = {
id: 'scope:module',
parent: null,
kind: 'Module',
range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 },
filePath: 'x.ts',
bindings: new Map(),
ownedDefs: [],
imports: [],
typeBindings: new Map(),
};
const callScope: Scope = {
id: 'scope:method-body',
parent: 'scope:module',
kind: 'Method',
range: { startLine: 2, startCol: 0, endLine: 99, endCol: 0 },
filePath: 'x.ts',
bindings: new Map(),
ownedDefs: [],
imports: [],
typeBindings: new Map([['self', typeRef('User', 'scope:method-body')]]),
};
const model = createSemanticModel();
model.methods.register('def:User', 'save', saveMethod);
const ctx: RegistryContext = {
scopes: buildScopeTree([moduleScope, callScope]),
defs,
qualifiedNames: buildQualifiedNameIndex([userClass, saveMethod]),
moduleScopes: buildModuleScopeIndex([moduleScope]),
methodDispatch: buildMethodDispatchIndex({
owners: ['def:User'],
computeMro: () => [],
implementsOf: () => [],
}),
ownedMembersByOwner: (ownerDefId, memberName) =>
lookupOwnedMembersByOwner(model, ownerDefId, memberName),
providers: {},
};
const results = buildMethodRegistry(ctx).lookup('save', 'scope:method-body', {
explicitReceiver: { name: 'self' },
});
expect(results).toHaveLength(1);
expect(results[0]!.def).toBe(saveMethod);
});
it('does not scan defs.byId when walking a 2-level MRO chain', () => {
const parentClass = mkDef({ nodeId: 'def:Parent', type: 'Class', qualifiedName: 'Parent' });
const childClass = mkDef({ nodeId: 'def:Child', type: 'Class', qualifiedName: 'Child' });
const parentSave = mkDef({
nodeId: 'def:Parent.save',
type: 'Method',
qualifiedName: 'Parent.save',
ownerId: 'def:Parent',
});
const trapById = new Map<string, SymbolDefinition>([
[parentClass.nodeId, parentClass],
[childClass.nodeId, childClass],
[parentSave.nodeId, parentSave],
]);
trapById.values = () => {
throw new Error('defs.byId.values() must not run when ownedMembersByOwner is provided');
};
const defs: DefIndex = {
byId: trapById,
size: trapById.size,
get: (id) => trapById.get(id),
has: (id) => trapById.has(id),
};
const callScope: Scope = {
id: 'scope:call',
parent: null,
kind: 'Module',
range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 },
filePath: 'x.ts',
bindings: new Map(),
ownedDefs: [],
imports: [],
typeBindings: new Map([['c', typeRef('Child', 'scope:call')]]),
};
const model = createSemanticModel();
model.methods.register('def:Parent', 'save', parentSave);
const ctx: RegistryContext = {
scopes: buildScopeTree([callScope]),
defs,
qualifiedNames: buildQualifiedNameIndex([parentClass, childClass, parentSave]),
moduleScopes: buildModuleScopeIndex([]),
methodDispatch: buildMethodDispatchIndex({
owners: ['def:Child', 'def:Parent'],
computeMro: (id) => (id === 'def:Child' ? ['def:Parent'] : []),
implementsOf: () => [],
}),
ownedMembersByOwner: (ownerDefId, memberName) =>
lookupOwnedMembersByOwner(model, ownerDefId, memberName),
providers: {},
};
const results = buildMethodRegistry(ctx).lookup('save', 'scope:call', {
explicitReceiver: { name: 'c' },
});
expect(results).toHaveLength(1);
expect(results[0]!.def).toBe(parentSave);
expect(results[0]!.evidence.find((e) => e.kind === 'type-binding')?.weight).toBe(
EvidenceWeights.typeBindingByMroDepth[1],
);
});
it('does not scan defs.byId for FieldRegistry reads via Step 2', () => {
const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' });
const nameField = mkDef({
nodeId: 'def:User.name',
type: 'Property',
qualifiedName: 'User.name',
ownerId: 'def:User',
});
const trapById = new Map<string, SymbolDefinition>([
[userClass.nodeId, userClass],
[nameField.nodeId, nameField],
]);
trapById.values = () => {
throw new Error('defs.byId.values() must not run when ownedMembersByOwner is provided');
};
const defs: DefIndex = {
byId: trapById,
size: trapById.size,
get: (id) => trapById.get(id),
has: (id) => trapById.has(id),
};
const callScope: Scope = {
id: 'scope:call',
parent: null,
kind: 'Module',
range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 },
filePath: 'x.ts',
bindings: new Map(),
ownedDefs: [],
imports: [],
typeBindings: new Map([['user', typeRef('User', 'scope:call')]]),
};
const model = createSemanticModel();
model.fields.register('def:User', 'name', nameField);
const ctx: RegistryContext = {
scopes: buildScopeTree([callScope]),
defs,
qualifiedNames: buildQualifiedNameIndex([userClass, nameField]),
moduleScopes: buildModuleScopeIndex([]),
methodDispatch: buildMethodDispatchIndex({
owners: ['def:User'],
computeMro: () => [],
implementsOf: () => [],
}),
ownedMembersByOwner: (ownerDefId, memberName) =>
lookupOwnedMembersByOwner(model, ownerDefId, memberName),
providers: {},
};
const results = buildFieldRegistry(ctx).lookup('name', 'scope:call', {
explicitReceiver: { name: 'user' },
});
expect(results).toHaveLength(1);
expect(results[0]!.def).toBe(nameField);
});
});

View file

@ -111,6 +111,54 @@ describe('reconcileOwnership', () => {
expect(model.fields.lookupFieldByOwner('def:User', 'tag')).toBe(attr);
});
it('registers Const and Static owned members into FieldRegistry', () => {
const model = createSemanticModel();
const maxConst = mkProperty({
nodeId: 'def:User.MAX',
filePath: 'models.py',
name: 'MAX',
ownerId: 'def:User',
type: 'Const',
});
const counter = mkProperty({
nodeId: 'def:User.counter',
filePath: 'models.py',
name: 'counter',
ownerId: 'def:User',
type: 'Static',
});
const file = mkFile('models.py', [maxConst, counter]);
const stats = reconcileOwnership([file], model);
expect(stats.fieldsRegistered).toBe(2);
expect(model.fields.lookupAllByOwner('def:User', 'MAX')).toEqual([maxConst]);
expect(model.fields.lookupAllByOwner('def:User', 'counter')).toEqual([counter]);
});
it('keeps distinct field-kind defs that share (ownerId, simpleName)', () => {
const model = createSemanticModel();
const legacyProp = mkProperty({
nodeId: 'prop:User.name',
filePath: 'models.py',
name: 'name',
ownerId: 'def:User',
type: 'Property',
});
const reconciledVar = mkProperty({
nodeId: 'def:User.name',
filePath: 'models.py',
name: 'name',
ownerId: 'def:User',
type: 'Variable',
});
const file = mkFile('models.py', [legacyProp, reconciledVar]);
reconcileOwnership([file], model);
expect(model.fields.lookupAllByOwner('def:User', 'name')).toEqual([legacyProp, reconciledVar]);
});
it('skips defs without ownerId (top-level functions)', () => {
const model = createSemanticModel();
const topLevel = mkMethod({
@ -164,6 +212,61 @@ describe('reconcileOwnership', () => {
expect(model.methods.lookupAllByOwner('def:User', 'save')).toHaveLength(1);
});
it('registers nested class-like types (Class/Enum/Interface) into TypeRegistry by owner', () => {
const model = createSemanticModel();
const inner: SymbolDefinition = {
nodeId: 'def:Outer.Inner',
filePath: 'm.ts',
type: 'Class',
qualifiedName: 'Outer.Inner',
ownerId: 'def:Outer',
};
const status: SymbolDefinition = {
nodeId: 'def:Outer.Status',
filePath: 'm.ts',
type: 'Enum',
qualifiedName: 'Outer.Status',
ownerId: 'def:Outer',
};
const visitor: SymbolDefinition = {
nodeId: 'def:Outer.Visitor',
filePath: 'm.ts',
type: 'Interface',
qualifiedName: 'Outer.Visitor',
ownerId: 'def:Outer',
};
const file = mkFile('m.ts', [inner, status, visitor]);
const stats = reconcileOwnership([file], model);
expect(stats.nestedTypesRegistered).toBe(3);
expect(stats.methodsRegistered).toBe(0);
expect(stats.fieldsRegistered).toBe(0);
expect(model.types.lookupAllByOwner('def:Outer', 'Inner')).toEqual([inner]);
expect(model.types.lookupAllByOwner('def:Outer', 'Status')).toEqual([status]);
expect(model.types.lookupAllByOwner('def:Outer', 'Visitor')).toEqual([visitor]);
});
it('is idempotent for nested type registration', () => {
const model = createSemanticModel();
const inner: SymbolDefinition = {
nodeId: 'def:Outer.Inner',
filePath: 'm.ts',
type: 'Class',
qualifiedName: 'Outer.Inner',
ownerId: 'def:Outer',
};
const file = mkFile('m.ts', [inner]);
const first = reconcileOwnership([file], model);
const second = reconcileOwnership([file], model);
expect(first.nestedTypesRegistered).toBe(1);
expect(second.nestedTypesRegistered).toBe(0);
expect(second.skippedAlreadyPresent).toBe(1);
expect(model.types.lookupAllByOwner('def:Outer', 'Inner')).toHaveLength(1);
});
it('registers multiple overloads under the same (owner, name)', () => {
const model = createSemanticModel();
const log1 = mkMethod({

View file

@ -100,6 +100,7 @@ function makeCtx(
opts: {
mro?: Record<string, readonly string[]>;
implsByInterface?: Record<string, readonly string[]>;
ownedMembersByOwner?: RegistryContext['ownedMembersByOwner'];
arity?: (
callsite: { arity: number },
def: SymbolDefinition,
@ -125,11 +126,25 @@ function makeCtx(
return out;
},
});
// Default hook: scan supplied defs by (ownerId, simpleName) — the same
// semantics the byId fallback used to provide. Tests that need a custom
// hook override via opts.ownedMembersByOwner.
const defaultOwnedMembersByOwner = (ownerDefId: string, memberName: string) => {
const out: SymbolDefinition[] = [];
for (const def of defs) {
if (def.ownerId !== ownerDefId) continue;
const dot = def.qualifiedName?.lastIndexOf('.') ?? -1;
const simple = dot === -1 ? def.qualifiedName : def.qualifiedName?.slice(dot + 1);
if (simple === memberName) out.push(def);
}
return out;
};
return {
scopes: buildScopeTree(scopes),
defs: defIndex,
qualifiedNames: qualifiedNameIndex,
moduleScopes,
ownedMembersByOwner: opts.ownedMembersByOwner ?? defaultOwnedMembersByOwner,
methodDispatch,
providers: opts.arity !== undefined ? { arityCompatibility: opts.arity } : {},
};
@ -573,6 +588,184 @@ describe('Step 3: owner-scoped contributor', () => {
// ─── Step 2: type-binding / MRO walk ───────────────────────────────────────
describe('Step 2: type-binding + MRO walk', () => {
it('uses ownedMembersByOwner before falling back to defs scans', () => {
const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' });
const saveMethod = mkDef({
nodeId: 'def:User.save',
type: 'Method',
qualifiedName: 'User.save',
ownerId: 'def:User',
});
const callScope = mkScope({
id: 'scope:call',
parent: null,
typeBindings: { user: typeRef('User', 'scope:call') },
});
const ctx = makeCtx([callScope], [userClass], {
ownedMembersByOwner: (ownerDefId, memberName) =>
ownerDefId === 'def:User' && memberName === 'save' ? [saveMethod] : [],
});
const results = buildMethodRegistry(ctx).lookup('save', 'scope:call', {
explicitReceiver: { name: 'user' },
});
expect(results).toHaveLength(1);
expect(results[0]!.def).toBe(saveMethod);
expect(evidenceOfKind(results[0]!, 'type-binding')?.weight).toBe(
EvidenceWeights.typeBindingByMroDepth[0],
);
});
it('keeps hook-provided overloads available for arity filtering', () => {
const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' });
const saveOne = mkDef({
nodeId: 'def:User.save1',
type: 'Method',
qualifiedName: 'User.save',
ownerId: 'def:User',
parameterCount: 1,
});
const saveTwo = mkDef({
nodeId: 'def:User.save2',
type: 'Method',
qualifiedName: 'User.save',
ownerId: 'def:User',
parameterCount: 2,
});
const callScope = mkScope({
id: 'scope:call',
parent: null,
typeBindings: { user: typeRef('User', 'scope:call') },
});
const ctx = makeCtx([callScope], [userClass], {
ownedMembersByOwner: (ownerDefId, memberName) =>
ownerDefId === 'def:User' && memberName === 'save' ? [saveTwo, saveOne] : [],
arity: (callsite, def) =>
(def.parameterCount ?? 0) === callsite.arity ? 'compatible' : 'incompatible',
});
const results = buildMethodRegistry(ctx).lookup('save', 'scope:call', {
explicitReceiver: { name: 'user' },
callsite: { arity: 1 },
});
expect(results).toHaveLength(1);
expect(results[0]!.def).toBe(saveOne);
});
it('resolves field members from ownedMembersByOwner through accepted-kind filtering', () => {
const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' });
const nameField = mkDef({
nodeId: 'def:User.name',
type: 'Property',
qualifiedName: 'User.name',
ownerId: 'def:User',
});
const readScope = mkScope({
id: 'scope:read',
parent: null,
typeBindings: { user: typeRef('User', 'scope:read') },
});
const ctx = makeCtx([readScope], [userClass], {
ownedMembersByOwner: (ownerDefId, memberName) =>
ownerDefId === 'def:User' && memberName === 'name' ? [nameField] : [],
});
const results = buildFieldRegistry(ctx).lookup('name', 'scope:read', {
explicitReceiver: { name: 'user' },
});
expect(results).toHaveLength(1);
expect(results[0]!.def).toBe(nameField);
});
it('resolves Const members from ownedMembersByOwner through accepted-kind filtering', () => {
const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' });
const maxConst = mkDef({
nodeId: 'def:User.MAX',
type: 'Const',
qualifiedName: 'User.MAX',
ownerId: 'def:User',
});
const readScope = mkScope({
id: 'scope:read',
parent: null,
typeBindings: { user: typeRef('User', 'scope:read') },
});
const ctx = makeCtx([readScope], [userClass], {
ownedMembersByOwner: (ownerDefId, memberName) =>
ownerDefId === 'def:User' && memberName === 'MAX' ? [maxConst] : [],
});
const results = buildFieldRegistry(ctx).lookup('MAX', 'scope:read', {
explicitReceiver: { name: 'user' },
});
expect(results).toHaveLength(1);
expect(results[0]!.def).toBe(maxConst);
});
it('resolves Static members from ownedMembersByOwner through accepted-kind filtering', () => {
const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' });
const counterStatic = mkDef({
nodeId: 'def:User.counter',
type: 'Static',
qualifiedName: 'User.counter',
ownerId: 'def:User',
});
const readScope = mkScope({
id: 'scope:read',
parent: null,
typeBindings: { user: typeRef('User', 'scope:read') },
});
const ctx = makeCtx([readScope], [userClass], {
ownedMembersByOwner: (ownerDefId, memberName) =>
ownerDefId === 'def:User' && memberName === 'counter' ? [counterStatic] : [],
});
const results = buildFieldRegistry(ctx).lookup('counter', 'scope:read', {
explicitReceiver: { name: 'user' },
});
expect(results).toHaveLength(1);
expect(results[0]!.def).toBe(counterStatic);
});
it('returns every hook-provided field kind that shares (owner, name)', () => {
const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' });
const legacyProp = mkDef({
nodeId: 'prop:User.name',
type: 'Property',
qualifiedName: 'User.name',
ownerId: 'def:User',
});
const reconciledVar = mkDef({
nodeId: 'def:User.name',
type: 'Variable',
qualifiedName: 'User.name',
ownerId: 'def:User',
});
const readScope = mkScope({
id: 'scope:read',
parent: null,
typeBindings: { user: typeRef('User', 'scope:read') },
});
const ctx = makeCtx([readScope], [userClass], {
ownedMembersByOwner: (ownerDefId, memberName) =>
ownerDefId === 'def:User' && memberName === 'name' ? [legacyProp, reconciledVar] : [],
});
const results = buildFieldRegistry(ctx).lookup('name', 'scope:read', {
explicitReceiver: { name: 'user' },
});
expect(results).toHaveLength(2);
expect(results.map((r) => r.def.nodeId).sort()).toEqual(
[legacyProp.nodeId, reconciledVar.nodeId].sort(),
);
});
it('emits type-binding evidence with MRO-depth-decayed weight (explicit receiver)', () => {
const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' });
const saveMethod = mkDef({

View file

@ -0,0 +1,178 @@
import { describe, expect, it } from 'vitest';
import {
buildDefIndex,
buildMethodDispatchIndex,
buildModuleScopeIndex,
buildQualifiedNameIndex,
buildScopeTree,
type BindingRef,
type Range,
type ReferenceSite,
type Scope,
type ScopeId,
type SymbolDefinition,
type TypeRef,
} from 'gitnexus-shared';
import { resolveReferenceSites } from '../../../src/core/ingestion/resolve-references.js';
import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js';
const range = (sl = 1, sc = 0, el = 100, ec = 0): Range => ({
startLine: sl,
startCol: sc,
endLine: el,
endCol: ec,
});
const mkDef = (overrides: Partial<SymbolDefinition> & { nodeId: string }): SymbolDefinition => ({
nodeId: overrides.nodeId,
filePath: overrides.filePath ?? 'x.ts',
type: overrides.type ?? 'Class',
...overrides,
});
const mkScope = (input: {
id: ScopeId;
parent: ScopeId | null;
kind?: Scope['kind'];
filePath?: string;
range?: Range;
bindings?: Record<string, readonly BindingRef[]>;
typeBindings?: Record<string, TypeRef>;
ownedDefs?: readonly SymbolDefinition[];
}): Scope => ({
id: input.id,
parent: input.parent,
kind: input.kind ?? 'Module',
filePath: input.filePath ?? 'x.ts',
range: input.range ?? range(),
bindings: new Map(Object.entries(input.bindings ?? {})),
imports: [],
typeBindings: new Map(Object.entries(input.typeBindings ?? {})),
ownedDefs: input.ownedDefs ?? [],
});
const typeRef = (rawName: string, declaredAtScope: ScopeId): TypeRef => ({
rawName,
declaredAtScope,
source: 'parameter-annotation',
});
function makeIndexes(
scopes: Scope[],
defs: SymbolDefinition[],
referenceSites: readonly ReferenceSite[],
mro: Record<string, readonly string[]> = {},
): ScopeResolutionIndexes {
return {
scopeTree: buildScopeTree(scopes),
defs: buildDefIndex(defs),
qualifiedNames: buildQualifiedNameIndex(defs),
moduleScopes: buildModuleScopeIndex(
scopes
.filter((scope) => scope.kind === 'Module')
.map((scope) => ({ filePath: scope.filePath, moduleScopeId: scope.id })),
),
methodDispatch: buildMethodDispatchIndex({
owners: Array.from(new Set(defs.map((def) => def.nodeId))),
computeMro: (owner) => mro[owner] ?? [],
implementsOf: () => [],
}),
imports: new Map(),
bindings: new Map(),
bindingAugmentations: new Map(),
referenceSites,
sccs: [],
stats: {
totalFiles: 0,
totalEdges: 0,
linkedEdges: 0,
unresolvedEdges: 0,
sccCount: 0,
largestSccSize: 0,
},
};
}
describe('resolveReferenceSites', () => {
it('uses ownedMembersByOwner to resolve a hook-provided receiver member', () => {
const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' });
const saveMethod = mkDef({
nodeId: 'def:User.save',
type: 'Method',
qualifiedName: 'User.save',
ownerId: 'def:User',
});
const scope = mkScope({
id: 'scope:call',
parent: null,
typeBindings: { user: typeRef('User', 'scope:call') },
});
const referenceSite: ReferenceSite = {
name: 'save',
atRange: range(5, 2, 5, 6),
inScope: 'scope:call',
kind: 'call',
explicitReceiver: { name: 'user' },
arity: 0,
};
const indexes = makeIndexes([scope], [userClass], [referenceSite]);
const result = resolveReferenceSites({
scopes: indexes,
ownedMembersByOwner: (ownerDefId, memberName) =>
ownerDefId === 'def:User' && memberName === 'save' ? [saveMethod] : [],
});
expect(result.stats).toEqual({ sitesProcessed: 1, referencesEmitted: 1, unresolved: 0 });
expect(result.referenceIndex.bySourceScope.get('scope:call')).toHaveLength(1);
expect(result.referenceIndex.bySourceScope.get('scope:call')?.[0]?.toDef).toBe('def:User.save');
});
it('threads providers.arityCompatibility through to filter hook-provided overloads', () => {
const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' });
const saveOne = mkDef({
nodeId: 'def:User.save#1',
type: 'Method',
qualifiedName: 'User.save',
ownerId: 'def:User',
parameterCount: 1,
});
const saveTwo = mkDef({
nodeId: 'def:User.save#2',
type: 'Method',
qualifiedName: 'User.save',
ownerId: 'def:User',
parameterCount: 2,
});
const scope = mkScope({
id: 'scope:call',
parent: null,
typeBindings: { user: typeRef('User', 'scope:call') },
});
const referenceSite: ReferenceSite = {
name: 'save',
atRange: range(5, 2, 5, 6),
inScope: 'scope:call',
kind: 'call',
explicitReceiver: { name: 'user' },
arity: 1,
};
const indexes = makeIndexes([scope], [userClass], [referenceSite]);
const result = resolveReferenceSites({
scopes: indexes,
ownedMembersByOwner: (ownerDefId, memberName) =>
ownerDefId === 'def:User' && memberName === 'save' ? [saveOne, saveTwo] : [],
providers: {
arityCompatibility: (callsite, def) =>
def.parameterCount === callsite.arity ? 'compatible' : 'incompatible',
},
});
expect(result.stats).toEqual({ sitesProcessed: 1, referencesEmitted: 1, unresolved: 0 });
expect(result.referenceIndex.bySourceScope.get('scope:call')).toHaveLength(1);
expect(result.referenceIndex.bySourceScope.get('scope:call')?.[0]?.toDef).toBe(
'def:User.save#1',
);
});
});