mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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:
parent
7d500390b9
commit
c30833fad3
19 changed files with 1163 additions and 48 deletions
|
|
@ -127,6 +127,7 @@ export { CLASS_KINDS, METHOD_KINDS, FIELD_KINDS } from './scope-resolution/regis
|
|||
export type {
|
||||
RegistryContext,
|
||||
RegistryProviders,
|
||||
OwnedMembersByOwnerLookup,
|
||||
OwnerScopedContributor,
|
||||
ArityVerdict,
|
||||
ConstraintContext,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
45
gitnexus/src/core/ingestion/model/owned-members-lookup.ts
Normal file
45
gitnexus/src/core/ingestion/model/owned-members-lookup.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
3
gitnexus/test/fixtures/lang-resolution/typescript-ambient-base-class/src/Derived.ts
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/typescript-ambient-base-class/src/Derived.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { AmbientBase } from './ambient';
|
||||
|
||||
export class Derived extends AmbientBase {}
|
||||
7
gitnexus/test/fixtures/lang-resolution/typescript-ambient-base-class/src/ambient.ts
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/typescript-ambient-base-class/src/ambient.ts
vendored
Normal 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;
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/typescript-ambient-base-class/src/app.ts
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/typescript-ambient-base-class/src/app.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { Derived } from './Derived';
|
||||
|
||||
export function run(): void {
|
||||
const d = new Derived();
|
||||
d.ambientMethod();
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
388
gitnexus/test/unit/model/owned-members-lookup.test.ts
Normal file
388
gitnexus/test/unit/model/owned-members-lookup.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
178
gitnexus/test/unit/scope-resolution/resolve-references.test.ts
Normal file
178
gitnexus/test/unit/scope-resolution/resolve-references.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue