mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-14 23:22:54 +00:00
* 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>
178 lines
5.4 KiB
TypeScript
178 lines
5.4 KiB
TypeScript
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',
|
|
);
|
|
});
|
|
});
|