mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
* fix(csharp): eliminate O(S·D) BindingRef OOM in namespace siblings Types declared in the C# global (default) namespace are visible from every file, so the previous per-scope augmentation materialized O(scopes × defs) BindingRefs — on large Unity solutions (tens of thousands of global types) this caused severe slowness and OOM. Route global-namespace types through a single workspace-level binding channel (workspaceFqnBindings, consulted by lookupBindingsAt) for O(D) memory. Also fix quadratic costs in the non-global path: append defs in place instead of copying (was O(D²) per bucket), pre-index the first scope per file (was O(S²·D)), and seed de-dup sets instead of repeated .some scans. Add csharp-pipeline-benchmark.test.ts (mirrors the PHP benchmark) with spread and concentrated-global-namespace scenarios to track elapsedMs, peakHeapMB, nodeCount, and edgeCount. Post-fix runs show linear scaling and stable heap. Co-authored-by: Cursor <cursoragent@cursor.com> * perf(csharp): scanner fallback for namespace siblings on the worker path Worker threads can't return tree-sitter Trees across MessageChannels, so the cross-phase tree cache is empty for worker-parsed files. The C# same-namespace pass (populateCsharpNamespaceSiblings -> extractFileStructure) then re-parsed every file with tree-sitter to find namespace / using-static nodes — effectively parsing a large solution a second time during scope resolution. Add a line-scanner fallback (extractCsharpStructureViaScanner) used only when no cached Tree is available, mirroring PHP's fix for issue #1741. It extracts the same namespaces / usingStaticPaths the AST walk produces for the common line-anchored forms (file-scoped + block namespaces, plain / global / aliased `using static`). The AST walk stays authoritative on the sequential / warm-cache path. Micro-benchmark over 3000 synthetic files: scanner is ~188x faster than parse+walk (0.001 vs 0.251 ms/file) with identical output on the parity spot-check; real-world files are larger, so the worker-path saving is bigger. Adds csharp-namespace-extraction.test.ts (12 cases) covering all declaration forms plus negative cases (using var, plain using, comments). Co-authored-by: Cursor <cursoragent@cursor.com> * chore(autofix): apply prettier + eslint fixes via /autofix command * fix(csharp): cover global-namespace workspaceFqnBindings path + doc + using-static perf Addresses the production-readiness review of the namespace-siblings OOM fix. - Add a unit test proving global-(default-)namespace C# types route to indexes.workspaceFqnBindings (one entry per simple name) with ZERO bindingAugmentations — pinning the O(D) invariant behind the #1871 Unity-scale OOM fix and guarding against a revert to per-scope O(scopes x defs) augmentation. (The csharp-hooks mock now supplies workspaceFqnBindings, which the global fast path reads directly.) - Correct the workspaceFqnBindings doc comment: it is shared by PHP (backslash-FQN keys) and C# (global-namespace simple-name keys); the two key formats are disjoint. - Pre-index parsedFiles by path before the `using static` member-injection loop, replacing an O(files) find-per-import with an O(1) Map lookup. Verified: tsc --noEmit clean; csharp-hooks + csharp-namespace-extraction suites pass (38 tests); prettier clean; eslint 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(csharp): apply PR-review polish to namespace-siblings (tests, types, docs) Addresses the multi-agent code review of this PR — the concrete, defensible findings. Two items intentionally deferred (below). - namespace-siblings.ts: couple the augmentation bucket + its de-dup set into one nullable lifecycle, removing the seen!/bucketArr! non-null assertions (identical runtime, still lazy). - validate-bindings-immutability.ts: extend the dev-mode immutability validator to the third channel (workspaceFqnBindings) + a test; complete the validator test mock with workspaceFqnBindings. - walkers.ts: document that namesAtScope deliberately excludes the scope-independent workspaceFqnBindings channel (enumerating workspace names at every scope would flood per-scope callers; lookupBindingsAt still consults it when resolving a specific name). - scope-resolution-indexes.ts: reframe the workspaceFqnBindings doc to describe the key-format contract language-neutrally (examples, not language branching). - csharp-hooks.test.ts: assert workspace entries carry origin:'namespace'; add a partial-class test (same simple name, distinct nodeIds across global files → both kept); rename the stale "parses" cache-miss test to "scans". - csharp-pipeline-benchmark.test.ts: clearTimeout the Promise.race budget timer (dangling handle when the pipeline won the race). - csharp.test.ts: correct the #1066 comment — extractFileStructure no longer re-parses on cache miss (line scanner); only emitCsharpScopeCaptures re-parses. Deferred (surfaced, not applied): (1) worker-path scanner mis-reads namespace/using-static inside block comments and verbatim/raw strings — an explicitly documented trade-off mirroring the PHP scanner; hardening it to track comment/string state is a separate decision. (2) workspaceFqnBindings is read via an `as Map` cast; a type-safe mutable handle from finalize-orchestrator is a cross-module contract change. Verified: tsc --noEmit clean; 49 unit tests pass (incl. 3 new); prettier clean; eslint 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(csharp): harden worker-path scanner + localize workspace-map cast Addresses the two deferred PR-review findings plus the remaining test gap. #1 — Worker-path scanner false positives: the line scanner now tracks block- comment and string state across lines (advanceCsScanState), so a `namespace` / `using static` keyword at the start of a line inside a block comment, verbatim string (@"..."), or raw string literal ("""...""") is no longer mistaken for a declaration on the worker cache-miss path. It matches only at code-state line starts. 5 new scanner tests cover the block-comment / raw / verbatim cases. #4 — workspaceFqnBindings type safety: the ReadonlyMap->Map cast is localized to one documented line, and global-namespace writes go through a new getWorkspaceBucket helper (mirroring getAugmentationBucket) rather than an inline `.set()` at the mutation site. #2 — lookupBindingsAt workspace-channel coverage: walkers-augmentations.test.ts now exercises the third (workspace) channel: workspace-only, append-after- finalized/augmented, and dedup-loses-to-finalized/augmented precedence. #5 — OOM CI guard: the deterministic O(D) invariant (zero per-scope augmentation for global types) is already asserted by the always-on csharp-hooks unit tests added earlier; the scale/time benchmark stays appropriately opt-in (skipIf). Verified: tsc --noEmit clean; 69 unit tests (4 suites) + 210 C# integration resolver tests pass; prettier clean; eslint 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(csharp): replace remaining O(A) .some dedup scans with seeded Sets The using-static member-injection loop and the cross-namespace import loop both de-duped via `bucketArr.some((b) => b.def.nodeId === ...)` — O(A) per item. Both now use a per-file `Map<simpleName, Set<nodeId>>`, seeded lazily from the augmentation bucket (capturing entries from earlier passes), matching the global and named-namespace paths. Same dedup semantics, O(1) amortized. Verified: tsc --noEmit clean; csharp-hooks unit (27) + C# integration resolver (210) tests pass; prettier + eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
471 lines
18 KiB
TypeScript
471 lines
18 KiB
TypeScript
/**
|
|
* Unit 3 coverage for C# simple hooks.
|
|
*
|
|
* Exercises the small-surface hooks that mirror Python's simple-hooks:
|
|
* `bindingScopeFor`, `importOwningScope`, `receiverBinding`. Each hook
|
|
* is tiny, but the tests pin the delegation semantics so refactors
|
|
* don't silently re-route bindings.
|
|
*
|
|
* `isSuperReceiver` lives on the ScopeResolver contract (Unit 6) rather
|
|
* than the LanguageProvider, so it isn't exercised here.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
csharpBindingScopeFor,
|
|
csharpImportOwningScope,
|
|
csharpReceiverBinding,
|
|
} from '../../../../src/core/ingestion/languages/csharp/simple-hooks.js';
|
|
import { csharpMergeBindings } from '../../../../src/core/ingestion/languages/csharp/merge-bindings.js';
|
|
import { csharpArityCompatibility } from '../../../../src/core/ingestion/languages/csharp/arity.js';
|
|
import { populateCsharpNamespaceSiblings } from '../../../../src/core/ingestion/languages/csharp/namespace-siblings.js';
|
|
import type {
|
|
BindingRef,
|
|
Callsite,
|
|
CaptureMatch,
|
|
ParsedFile,
|
|
ParsedImport,
|
|
Scope,
|
|
ScopeId,
|
|
ScopeTree,
|
|
SymbolDefinition,
|
|
TypeRef,
|
|
} from 'gitnexus-shared';
|
|
import type { ScopeResolutionIndexes } from '../../../../src/core/ingestion/model/scope-resolution-indexes.js';
|
|
|
|
function fakeScope(
|
|
kind: Scope['kind'],
|
|
id = 's1',
|
|
typeBindings = new Map<string, TypeRef>(),
|
|
): Scope {
|
|
return {
|
|
id,
|
|
kind,
|
|
parentId: null,
|
|
childrenIds: [],
|
|
bindings: new Map(),
|
|
typeBindings,
|
|
} as unknown as Scope;
|
|
}
|
|
|
|
const fakeTree = {} as ScopeTree;
|
|
const fakeCapture = {} as CaptureMatch;
|
|
const fakeImport: ParsedImport = {
|
|
kind: 'namespace',
|
|
localName: 'System',
|
|
importedName: 'System',
|
|
targetRaw: 'System',
|
|
};
|
|
|
|
describe('csharpBindingScopeFor', () => {
|
|
it('delegates to innermost for method-body declarations', () => {
|
|
const fn = fakeScope('Function');
|
|
expect(csharpBindingScopeFor(fakeCapture, fn, fakeTree)).toBe(null);
|
|
});
|
|
|
|
it('delegates to innermost for namespace-body class declarations', () => {
|
|
const ns = fakeScope('Namespace');
|
|
expect(csharpBindingScopeFor(fakeCapture, ns, fakeTree)).toBe(null);
|
|
});
|
|
});
|
|
|
|
describe('csharpImportOwningScope', () => {
|
|
it('binds `using` inside a namespace to the namespace scope', () => {
|
|
const ns = fakeScope('Namespace', 'ns-1');
|
|
expect(csharpImportOwningScope(fakeImport, ns, fakeTree)).toBe('ns-1');
|
|
});
|
|
|
|
it('delegates file-level `using` to the module default', () => {
|
|
const mod = fakeScope('Module');
|
|
expect(csharpImportOwningScope(fakeImport, mod, fakeTree)).toBe(null);
|
|
});
|
|
|
|
it('attaches `using` inside a function scope to that function', () => {
|
|
// Not legal C# at the source level, but defensive — Unit 7 parity
|
|
// gate flags any regression.
|
|
const fn = fakeScope('Function', 'fn-1');
|
|
expect(csharpImportOwningScope(fakeImport, fn, fakeTree)).toBe('fn-1');
|
|
});
|
|
});
|
|
|
|
describe('csharpMergeBindings — shadowing precedence', () => {
|
|
const def = (nodeId: string): SymbolDefinition =>
|
|
({ nodeId, filePath: 't.cs', type: 'Function' }) as SymbolDefinition;
|
|
const binding = (origin: BindingRef['origin'], nodeId: string): BindingRef =>
|
|
({ def: def(nodeId), origin }) as BindingRef;
|
|
|
|
it('local declaration shadows `using` import', () => {
|
|
const local = binding('local', 'L');
|
|
const imp = binding('import', 'I');
|
|
expect(csharpMergeBindings([imp, local])).toEqual([local]);
|
|
});
|
|
|
|
it('explicit `using` shadows `using static` (wildcard)', () => {
|
|
const imp = binding('import', 'I');
|
|
const wc = binding('wildcard', 'W');
|
|
expect(csharpMergeBindings([wc, imp])).toEqual([imp]);
|
|
});
|
|
|
|
it('local shadows both `using` and `using static`', () => {
|
|
const local = binding('local', 'L');
|
|
const imp = binding('import', 'I');
|
|
const wc = binding('wildcard', 'W');
|
|
expect(csharpMergeBindings([wc, imp, local])).toEqual([local]);
|
|
});
|
|
|
|
it('keeps overload siblings at the same tier', () => {
|
|
const a = binding('local', 'A');
|
|
const b = binding('local', 'B');
|
|
expect(csharpMergeBindings([a, b])).toEqual([a, b]);
|
|
});
|
|
|
|
it('dedupes same-nodeId bindings', () => {
|
|
const a = binding('local', 'A');
|
|
const a2 = binding('local', 'A');
|
|
expect(csharpMergeBindings([a, a2])).toHaveLength(1);
|
|
});
|
|
|
|
it('namespace and reexport tie with explicit import (same tier)', () => {
|
|
const ns = binding('namespace', 'N');
|
|
const re = binding('reexport', 'R');
|
|
const imp = binding('import', 'I');
|
|
expect(csharpMergeBindings([ns, re, imp])).toHaveLength(3);
|
|
});
|
|
|
|
it('empty in → empty out', () => {
|
|
expect(csharpMergeBindings([])).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('csharpArityCompatibility', () => {
|
|
const callsite = (arity: number): Callsite => ({ arity });
|
|
const def = (o: Partial<SymbolDefinition> = {}): SymbolDefinition =>
|
|
({ nodeId: 'd1', filePath: 't.cs', type: 'Function', ...o }) as SymbolDefinition;
|
|
|
|
it('unknown when both parameter counts are missing', () => {
|
|
expect(csharpArityCompatibility(def(), callsite(2))).toBe('unknown');
|
|
});
|
|
|
|
it('compatible inside [required, total]', () => {
|
|
expect(
|
|
csharpArityCompatibility(def({ parameterCount: 3, requiredParameterCount: 1 }), callsite(2)),
|
|
).toBe('compatible');
|
|
});
|
|
|
|
it('incompatible below required', () => {
|
|
expect(
|
|
csharpArityCompatibility(def({ parameterCount: 3, requiredParameterCount: 2 }), callsite(1)),
|
|
).toBe('incompatible');
|
|
});
|
|
|
|
it('incompatible above max without variadic', () => {
|
|
expect(
|
|
csharpArityCompatibility(def({ parameterCount: 2, requiredParameterCount: 0 }), callsite(5)),
|
|
).toBe('incompatible');
|
|
});
|
|
|
|
it('compatible above declared params when def has `params` variadic', () => {
|
|
expect(
|
|
csharpArityCompatibility(
|
|
def({ parameterCount: undefined, requiredParameterCount: 0, parameterTypes: ['params'] }),
|
|
callsite(7),
|
|
),
|
|
).toBe('compatible');
|
|
});
|
|
|
|
it('compatible above declared params when variadic token prefixes', () => {
|
|
expect(
|
|
csharpArityCompatibility(
|
|
def({
|
|
parameterCount: undefined,
|
|
requiredParameterCount: 1,
|
|
parameterTypes: ['string', 'params int[]'],
|
|
}),
|
|
callsite(4),
|
|
),
|
|
).toBe('compatible');
|
|
});
|
|
|
|
it('unknown for negative arity (defensive)', () => {
|
|
expect(
|
|
csharpArityCompatibility(def({ parameterCount: 3, requiredParameterCount: 1 }), callsite(-1)),
|
|
).toBe('unknown');
|
|
});
|
|
});
|
|
|
|
describe('populateCsharpNamespaceSiblings', () => {
|
|
const classDef = (nodeId: string, filePath: string, qualifiedName: string): SymbolDefinition =>
|
|
({ nodeId, filePath, qualifiedName, type: 'Class' }) as SymbolDefinition;
|
|
|
|
const scope = (
|
|
id: string,
|
|
kind: Scope['kind'],
|
|
filePath: string,
|
|
parent: ScopeId | null = null,
|
|
ownedDefs: readonly SymbolDefinition[] = [],
|
|
): Scope =>
|
|
({
|
|
id: id as ScopeId,
|
|
kind,
|
|
parent,
|
|
filePath,
|
|
range: { startLine: 1, startColumn: 0, endLine: 10, endColumn: 0 },
|
|
bindings: new Map(),
|
|
imports: [],
|
|
ownedDefs,
|
|
typeBindings: new Map(),
|
|
}) as unknown as Scope;
|
|
|
|
it('writes namespace siblings to the augmentation channel without touching frozen finalized bindings', () => {
|
|
// Verifies the post-finalize binding-augmentation contract for the
|
|
// C# namespace-siblings hook (per ScopeResolver I8 + the
|
|
// `bindingAugmentations` doc on `ScopeResolutionIndexes`):
|
|
// * `indexes.bindings` (the finalize output) stays frozen and
|
|
// its inner `BindingRef[]` arrays are NEVER mutated by the
|
|
// hook — proven here by passing a frozen bucket and asserting
|
|
// it survives unchanged.
|
|
// * Cross-file siblings are appended to
|
|
// `indexes.bindingAugmentations`, the dedicated mutable
|
|
// append-only buffer.
|
|
// * Walkers downstream (`lookupBindingsAt`) merge the two layers
|
|
// transparently — covered by walkers-augmentations.test.ts.
|
|
// Reproduces the pre-architecture `Cannot add property N, object
|
|
// is not extensible` crash by carrying a pre-frozen `BindingRef[]`
|
|
// through `indexes.bindings`. End-to-end coverage is in the
|
|
// `csharp-large-cache-miss-resolution` fixture.
|
|
const existing = classDef('def:external.B', 'external.cs', 'Other.B');
|
|
const sibling = classDef('def:b.B', 'b.cs', 'Demo.B');
|
|
const moduleA = scope('scope:a:module', 'Module', 'a.cs');
|
|
const moduleB = scope('scope:b:module', 'Module', 'b.cs');
|
|
const classB = scope('scope:b:class', 'Class', 'b.cs', moduleB.id, [sibling]);
|
|
const parsedFiles: ParsedFile[] = [
|
|
{
|
|
filePath: 'a.cs',
|
|
moduleScope: moduleA.id,
|
|
scopes: Object.freeze([moduleA]),
|
|
parsedImports: Object.freeze([]),
|
|
localDefs: Object.freeze([]),
|
|
referenceSites: Object.freeze([]),
|
|
} as ParsedFile,
|
|
{
|
|
filePath: 'b.cs',
|
|
moduleScope: moduleB.id,
|
|
scopes: Object.freeze([moduleB, classB]),
|
|
parsedImports: Object.freeze([]),
|
|
localDefs: Object.freeze([sibling]),
|
|
referenceSites: Object.freeze([]),
|
|
} as ParsedFile,
|
|
];
|
|
const frozenBucket = Object.freeze([{ def: existing, origin: 'import' } as BindingRef]);
|
|
const bindings = new Map<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>([
|
|
[moduleA.id, new Map<string, readonly BindingRef[]>([['B', frozenBucket]])],
|
|
]);
|
|
const bindingAugmentations = new Map<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>();
|
|
|
|
populateCsharpNamespaceSiblings(
|
|
parsedFiles,
|
|
{ bindings, bindingAugmentations } as unknown as ScopeResolutionIndexes,
|
|
{
|
|
fileContents: new Map([
|
|
['a.cs', 'namespace Demo;\nclass A { }\n'],
|
|
['b.cs', 'namespace Demo;\nclass B { }\n'],
|
|
]),
|
|
},
|
|
);
|
|
|
|
const finalized = bindings.get(moduleA.id)?.get('B') ?? [];
|
|
expect(finalized).toBe(frozenBucket);
|
|
expect(finalized.map((b) => b.def.nodeId)).toEqual(['def:external.B']);
|
|
expect(Object.isFrozen(finalized)).toBe(true);
|
|
|
|
const augmented = bindingAugmentations.get(moduleA.id)?.get('B') ?? [];
|
|
expect(augmented.map((b) => b.def.nodeId)).toEqual(['def:b.B']);
|
|
expect(Object.isFrozen(augmented)).toBe(false);
|
|
});
|
|
|
|
it('scans (no re-parse) UTF-8-heavy cache-miss files before namespace sibling injection', () => {
|
|
const sibling = classDef('def:b.B', 'b.cs', 'Demo.B');
|
|
const moduleA = scope('scope:a:module', 'Module', 'a.cs');
|
|
const moduleB = scope('scope:b:module', 'Module', 'b.cs');
|
|
const classB = scope('scope:b:class', 'Class', 'b.cs', moduleB.id, [sibling]);
|
|
const parsedFiles: ParsedFile[] = [
|
|
{
|
|
filePath: 'a.cs',
|
|
moduleScope: moduleA.id,
|
|
scopes: Object.freeze([moduleA]),
|
|
parsedImports: Object.freeze([]),
|
|
localDefs: Object.freeze([]),
|
|
referenceSites: Object.freeze([]),
|
|
} as ParsedFile,
|
|
{
|
|
filePath: 'b.cs',
|
|
moduleScope: moduleB.id,
|
|
scopes: Object.freeze([moduleB, classB]),
|
|
parsedImports: Object.freeze([]),
|
|
localDefs: Object.freeze([sibling]),
|
|
referenceSites: Object.freeze([]),
|
|
} as ParsedFile,
|
|
];
|
|
const bindingAugmentations = new Map<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>();
|
|
const padding = '漢'.repeat(190_000);
|
|
|
|
populateCsharpNamespaceSiblings(
|
|
parsedFiles,
|
|
{ bindings: new Map(), bindingAugmentations } as unknown as ScopeResolutionIndexes,
|
|
{
|
|
fileContents: new Map([
|
|
['a.cs', `namespace Demo;\n// ${padding}\nclass A { }\n`],
|
|
['b.cs', `namespace Demo;\n// ${padding}\nclass B { }\n`],
|
|
]),
|
|
},
|
|
);
|
|
|
|
expect(bindingAugmentations.get(moduleA.id)?.get('B')?.[0]?.def.nodeId).toBe('def:b.B');
|
|
});
|
|
|
|
it('routes global-namespace types to workspaceFqnBindings, not per-scope augmentations (#1871 OOM guard)', () => {
|
|
// Types declared with NO `namespace` (the global/default namespace) are
|
|
// visible from every C# file, so the hook writes ONE workspace-level entry
|
|
// per simple name instead of O(scopes x defs) per-scope augmentations —
|
|
// the fix for the #1871 Unity-scale OOM. This pins both halves of that
|
|
// contract: global types are reachable via `workspaceFqnBindings`, and the
|
|
// per-scope augmentation channel stays empty for them.
|
|
//
|
|
// Note: the mock MUST supply `workspaceFqnBindings` — the global fast path
|
|
// reads `indexes.workspaceFqnBindings` directly, so omitting it (as the
|
|
// other tests in this suite do) would throw.
|
|
const defA = classDef('def:a.A', 'a.cs', 'A'); // simple name => global namespace
|
|
const defB = classDef('def:b.B', 'b.cs', 'B');
|
|
const moduleA = scope('scope:a:module', 'Module', 'a.cs');
|
|
const classA = scope('scope:a:class', 'Class', 'a.cs', moduleA.id, [defA]);
|
|
const moduleB = scope('scope:b:module', 'Module', 'b.cs');
|
|
const classB = scope('scope:b:class', 'Class', 'b.cs', moduleB.id, [defB]);
|
|
const parsedFiles: ParsedFile[] = [
|
|
{
|
|
filePath: 'a.cs',
|
|
moduleScope: moduleA.id,
|
|
scopes: Object.freeze([moduleA, classA]),
|
|
parsedImports: Object.freeze([]),
|
|
localDefs: Object.freeze([defA]),
|
|
referenceSites: Object.freeze([]),
|
|
} as ParsedFile,
|
|
{
|
|
filePath: 'b.cs',
|
|
moduleScope: moduleB.id,
|
|
scopes: Object.freeze([moduleB, classB]),
|
|
parsedImports: Object.freeze([]),
|
|
localDefs: Object.freeze([defB]),
|
|
referenceSites: Object.freeze([]),
|
|
} as ParsedFile,
|
|
];
|
|
const bindingAugmentations = new Map<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>();
|
|
const workspaceFqnBindings = new Map<string, readonly BindingRef[]>();
|
|
|
|
populateCsharpNamespaceSiblings(
|
|
parsedFiles,
|
|
{
|
|
bindings: new Map(),
|
|
bindingAugmentations,
|
|
workspaceFqnBindings,
|
|
} as unknown as ScopeResolutionIndexes,
|
|
{
|
|
fileContents: new Map([
|
|
['a.cs', 'class A { }\n'], // no `namespace` => global
|
|
['b.cs', 'class B { }\n'],
|
|
]),
|
|
},
|
|
);
|
|
|
|
// Global types are reachable workspace-wide via simple-name keys.
|
|
expect(workspaceFqnBindings.get('A')?.map((b) => b.def.nodeId)).toEqual(['def:a.A']);
|
|
expect(workspaceFqnBindings.get('B')?.map((b) => b.def.nodeId)).toEqual(['def:b.B']);
|
|
// O(D) invariant: one entry per unique simple name, never scopes x defs.
|
|
expect(workspaceFqnBindings.size).toBe(2);
|
|
// The whole point of the fast path: no per-scope augmentation explosion.
|
|
expect(bindingAugmentations.size).toBe(0);
|
|
// Workspace entries carry the cross-file `namespace` origin (so shadowing
|
|
// precedence in lookupBindingsAt orders them after local/finalized).
|
|
expect(workspaceFqnBindings.get('A')?.[0]?.origin).toBe('namespace');
|
|
});
|
|
|
|
it('keeps every declaration of a repeated global simple name (partial classes across files)', () => {
|
|
// Two global-namespace files each declare `Foo` (a partial class split
|
|
// across files => distinct nodeIds, same simple name). Both must survive
|
|
// in the workspace channel — the fast path de-dups by nodeId, not by name,
|
|
// so partial-class members from both files stay resolvable.
|
|
const foo1 = classDef('def:foo1.Foo', 'foo1.cs', 'Foo');
|
|
const foo2 = classDef('def:foo2.Foo', 'foo2.cs', 'Foo');
|
|
const moduleA = scope('scope:foo1:module', 'Module', 'foo1.cs');
|
|
const classA = scope('scope:foo1:class', 'Class', 'foo1.cs', moduleA.id, [foo1]);
|
|
const moduleB = scope('scope:foo2:module', 'Module', 'foo2.cs');
|
|
const classB = scope('scope:foo2:class', 'Class', 'foo2.cs', moduleB.id, [foo2]);
|
|
const parsedFiles: ParsedFile[] = [
|
|
{
|
|
filePath: 'foo1.cs',
|
|
moduleScope: moduleA.id,
|
|
scopes: Object.freeze([moduleA, classA]),
|
|
parsedImports: Object.freeze([]),
|
|
localDefs: Object.freeze([foo1]),
|
|
referenceSites: Object.freeze([]),
|
|
} as ParsedFile,
|
|
{
|
|
filePath: 'foo2.cs',
|
|
moduleScope: moduleB.id,
|
|
scopes: Object.freeze([moduleB, classB]),
|
|
parsedImports: Object.freeze([]),
|
|
localDefs: Object.freeze([foo2]),
|
|
referenceSites: Object.freeze([]),
|
|
} as ParsedFile,
|
|
];
|
|
const bindingAugmentations = new Map<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>();
|
|
const workspaceFqnBindings = new Map<string, readonly BindingRef[]>();
|
|
|
|
populateCsharpNamespaceSiblings(
|
|
parsedFiles,
|
|
{
|
|
bindings: new Map(),
|
|
bindingAugmentations,
|
|
workspaceFqnBindings,
|
|
} as unknown as ScopeResolutionIndexes,
|
|
{
|
|
fileContents: new Map([
|
|
['foo1.cs', 'class Foo { }\n'],
|
|
['foo2.cs', 'class Foo { }\n'],
|
|
]),
|
|
},
|
|
);
|
|
|
|
// Both partial declarations are kept (de-dup is by nodeId, not name).
|
|
expect(
|
|
workspaceFqnBindings
|
|
.get('Foo')
|
|
?.map((b) => b.def.nodeId)
|
|
.sort(),
|
|
).toEqual(['def:foo1.Foo', 'def:foo2.Foo']);
|
|
expect(bindingAugmentations.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('csharpReceiverBinding', () => {
|
|
it('returns the `this` type binding for an instance method scope', () => {
|
|
const binding: TypeRef = { rawName: 'User', source: 'self' } as unknown as TypeRef;
|
|
const fn = fakeScope('Function', 'm-1', new Map([['this', binding]]));
|
|
expect(csharpReceiverBinding(fn)).toBe(binding);
|
|
});
|
|
|
|
it('falls back to `base` when `this` is absent', () => {
|
|
const binding: TypeRef = { rawName: 'Parent', source: 'self' } as unknown as TypeRef;
|
|
const fn = fakeScope('Function', 'm-1', new Map([['base', binding]]));
|
|
expect(csharpReceiverBinding(fn)).toBe(binding);
|
|
});
|
|
|
|
it('returns null for a static method (no synthesized `this`/`base`)', () => {
|
|
const fn = fakeScope('Function', 'm-1');
|
|
expect(csharpReceiverBinding(fn)).toBe(null);
|
|
});
|
|
|
|
it('returns null for non-Function scopes', () => {
|
|
expect(csharpReceiverBinding(fakeScope('Class'))).toBe(null);
|
|
expect(csharpReceiverBinding(fakeScope('Module'))).toBe(null);
|
|
});
|
|
});
|