mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-08 03:08:13 +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>
230 lines
9.2 KiB
TypeScript
230 lines
9.2 KiB
TypeScript
/**
|
|
* Unit coverage for `lookupBindingsAt` — the dual-source binding
|
|
* lookup primitive used by every walker that needs cross-file
|
|
* visibility (Step 2 of the binding-augmentation-channel refactor).
|
|
*
|
|
* These tests pin the contract exhaustively: precedence (finalized
|
|
* first), dedup (by `def.nodeId`), empty-array semantics, and the
|
|
* shared-empty-frozen-array identity for misses. Every other walker
|
|
* test in this directory delegates to `lookupBindingsAt` after the
|
|
* refactor, so a regression here surfaces quickly.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
findCallableBindingInScope,
|
|
findClassBindingInScope,
|
|
findExportedDefByName,
|
|
lookupBindingsAt,
|
|
} from '../../../src/core/ingestion/scope-resolution/scope/walkers.js';
|
|
import type { BindingRef, Scope, ScopeId, ScopeTree, SymbolDefinition } from 'gitnexus-shared';
|
|
import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js';
|
|
import type { WorkspaceResolutionIndex } from '../../../src/core/ingestion/scope-resolution/workspace-index.js';
|
|
|
|
const SCOPE = 'scope:m' as ScopeId;
|
|
|
|
const def = (nodeId: string): SymbolDefinition =>
|
|
({ nodeId, filePath: 'm.ts', type: 'Function' }) as SymbolDefinition;
|
|
|
|
const ref = (nodeId: string, origin: BindingRef['origin'] = 'local'): BindingRef =>
|
|
({ def: def(nodeId), origin }) as BindingRef;
|
|
|
|
function indexesWith({
|
|
finalized,
|
|
augmented,
|
|
workspace,
|
|
}: {
|
|
finalized?: readonly BindingRef[];
|
|
augmented?: readonly BindingRef[];
|
|
workspace?: readonly BindingRef[];
|
|
}): ScopeResolutionIndexes {
|
|
const bindings = new Map<ScopeId, Map<string, readonly BindingRef[]>>();
|
|
if (finalized !== undefined) {
|
|
Object.freeze(finalized as BindingRef[]);
|
|
bindings.set(SCOPE, new Map([['name', finalized]]));
|
|
}
|
|
const bindingAugmentations = new Map<ScopeId, Map<string, readonly BindingRef[]>>();
|
|
if (augmented !== undefined) bindingAugmentations.set(SCOPE, new Map([['name', augmented]]));
|
|
const workspaceFqnBindings = new Map<string, readonly BindingRef[]>();
|
|
if (workspace !== undefined) workspaceFqnBindings.set('name', workspace);
|
|
return {
|
|
bindings,
|
|
bindingAugmentations,
|
|
workspaceFqnBindings,
|
|
} as unknown as ScopeResolutionIndexes;
|
|
}
|
|
|
|
function scope(id: ScopeId, bindings = new Map<string, readonly BindingRef[]>()): Scope {
|
|
return {
|
|
id,
|
|
kind: 'Module',
|
|
parent: null,
|
|
filePath: 'm.ts',
|
|
range: { startLine: 1, startColumn: 0, endLine: 1, endColumn: 0 },
|
|
bindings,
|
|
imports: [],
|
|
ownedDefs: [],
|
|
typeBindings: new Map(),
|
|
} as unknown as Scope;
|
|
}
|
|
|
|
function indexesForScopeLookup(
|
|
moduleScope: Scope,
|
|
augmented: Map<string, readonly BindingRef[]>,
|
|
): ScopeResolutionIndexes {
|
|
const scopeTree = {
|
|
getScope: (id: ScopeId) => (id === moduleScope.id ? moduleScope : undefined),
|
|
} as unknown as ScopeTree;
|
|
return {
|
|
scopeTree,
|
|
bindings: new Map(),
|
|
bindingAugmentations: new Map([[moduleScope.id, augmented]]),
|
|
} as unknown as ScopeResolutionIndexes;
|
|
}
|
|
|
|
describe('lookupBindingsAt', () => {
|
|
it('returns the finalized bucket when augmentations are absent', () => {
|
|
const finalized = [ref('A'), ref('B')];
|
|
const out = lookupBindingsAt(SCOPE, 'name', indexesWith({ finalized }));
|
|
expect(out).toEqual(finalized);
|
|
// Identity preserved when only one channel populates — no allocation.
|
|
expect(out).toBe(finalized);
|
|
});
|
|
|
|
it('returns the augmented bucket when finalized is absent', () => {
|
|
const augmented = [ref('X', 'namespace')];
|
|
const out = lookupBindingsAt(SCOPE, 'name', indexesWith({ augmented }));
|
|
expect(out).toEqual(augmented);
|
|
expect(out).toBe(augmented);
|
|
});
|
|
|
|
it('concatenates with finalized first when both populate disjoint nodeIds', () => {
|
|
const finalized = [ref('A', 'import'), ref('B', 'import')];
|
|
const augmented = [ref('C', 'namespace'), ref('D', 'namespace')];
|
|
const out = lookupBindingsAt(SCOPE, 'name', indexesWith({ finalized, augmented }));
|
|
expect(out.map((b) => b.def.nodeId)).toEqual(['A', 'B', 'C', 'D']);
|
|
});
|
|
|
|
it('dedupes augmented entries that share a nodeId with finalized (finalized wins)', () => {
|
|
const finalized = [ref('A', 'import'), ref('B', 'import')];
|
|
const augmented = [ref('A', 'namespace'), ref('C', 'namespace')];
|
|
const out = lookupBindingsAt(SCOPE, 'name', indexesWith({ finalized, augmented }));
|
|
expect(out.map((b) => b.def.nodeId)).toEqual(['A', 'B', 'C']);
|
|
expect(out.find((b) => b.def.nodeId === 'A')!.origin).toBe('import');
|
|
});
|
|
|
|
// Third channel: workspaceFqnBindings (scope-independent — global-namespace
|
|
// C# types / PHP FQNs). Consulted LAST, after finalized + augmented.
|
|
it('returns the workspace bucket when it is the only channel', () => {
|
|
const workspace = [ref('W', 'namespace')];
|
|
const out = lookupBindingsAt(SCOPE, 'name', indexesWith({ workspace }));
|
|
expect(out).toEqual(workspace);
|
|
expect(out).toBe(workspace); // identity preserved when only one channel populates
|
|
});
|
|
|
|
it('appends workspace entries after finalized and augmented', () => {
|
|
const finalized = [ref('A', 'import')];
|
|
const augmented = [ref('B', 'namespace')];
|
|
const workspace = [ref('C', 'namespace')];
|
|
const out = lookupBindingsAt(SCOPE, 'name', indexesWith({ finalized, augmented, workspace }));
|
|
expect(out.map((b) => b.def.nodeId)).toEqual(['A', 'B', 'C']);
|
|
});
|
|
|
|
it('dedupes workspace entries already present in finalized/augmented (workspace loses)', () => {
|
|
const finalized = [ref('A', 'import')];
|
|
const augmented = [ref('B', 'namespace')];
|
|
const workspace = [ref('A', 'namespace'), ref('B', 'namespace'), ref('C', 'namespace')];
|
|
const out = lookupBindingsAt(SCOPE, 'name', indexesWith({ finalized, augmented, workspace }));
|
|
expect(out.map((b) => b.def.nodeId)).toEqual(['A', 'B', 'C']);
|
|
// The surviving A/B keep their finalized/augmented identity, not workspace's.
|
|
expect(out.find((b) => b.def.nodeId === 'A')!.origin).toBe('import');
|
|
});
|
|
|
|
it('keeps finalized metadata when the same nodeId appears in both channels', () => {
|
|
const finalizedDef = {
|
|
nodeId: 'A',
|
|
filePath: 'finalized.ts',
|
|
qualifiedName: 'finalized.A',
|
|
type: 'Function',
|
|
} as SymbolDefinition;
|
|
const augmentedDef = {
|
|
nodeId: 'A',
|
|
filePath: 'augmented.ts',
|
|
qualifiedName: 'augmented.A',
|
|
type: 'Method',
|
|
} as SymbolDefinition;
|
|
const out = lookupBindingsAt(
|
|
SCOPE,
|
|
'name',
|
|
indexesWith({
|
|
finalized: [{ def: finalizedDef, origin: 'import' } as BindingRef],
|
|
augmented: [{ def: augmentedDef, origin: 'namespace' } as BindingRef],
|
|
}),
|
|
);
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0]!.def.filePath).toBe('finalized.ts');
|
|
expect(out[0]!.def.qualifiedName).toBe('finalized.A');
|
|
expect(out[0]!.origin).toBe('import');
|
|
});
|
|
|
|
it('returns the shared empty array on a miss in both channels', () => {
|
|
const a = lookupBindingsAt(SCOPE, 'name', indexesWith({}));
|
|
const b = lookupBindingsAt(SCOPE, 'other', indexesWith({}));
|
|
expect(a).toEqual([]);
|
|
expect(b).toEqual([]);
|
|
expect(a).toBe(b);
|
|
expect(Object.isFrozen(a)).toBe(true);
|
|
});
|
|
|
|
it('treats an empty finalized bucket as absent (returns augmented)', () => {
|
|
const augmented = [ref('Z', 'namespace')];
|
|
const out = lookupBindingsAt(SCOPE, 'name', indexesWith({ finalized: [], augmented }));
|
|
expect(out).toBe(augmented);
|
|
});
|
|
|
|
it('treats an empty augmented bucket as absent (returns finalized)', () => {
|
|
const finalized = [ref('Z', 'import')];
|
|
const out = lookupBindingsAt(SCOPE, 'name', indexesWith({ finalized, augmented: [] }));
|
|
expect(out).toBe(finalized);
|
|
});
|
|
|
|
it('returns the shared empty array when both buckets exist but are empty', () => {
|
|
const out = lookupBindingsAt(SCOPE, 'name', indexesWith({ finalized: [], augmented: [] }));
|
|
expect(out).toEqual([]);
|
|
expect(Object.isFrozen(out)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('walker helpers read bindingAugmentations', () => {
|
|
it('findClassBindingInScope finds class-like refs that exist only in augmentations', () => {
|
|
const moduleScope = scope(SCOPE);
|
|
const classRef = {
|
|
def: { ...def('ClassA'), type: 'Class' },
|
|
origin: 'namespace',
|
|
} as BindingRef;
|
|
const indexes = indexesForScopeLookup(moduleScope, new Map([['ClassA', [classRef]]]));
|
|
|
|
expect(findClassBindingInScope(SCOPE, 'ClassA', indexes)?.nodeId).toBe('ClassA');
|
|
});
|
|
|
|
it('findCallableBindingInScope finds callable refs that exist only in augmentations', () => {
|
|
const moduleScope = scope(SCOPE);
|
|
const callableRef = { def: def('callMe'), origin: 'import' } as BindingRef;
|
|
const indexes = indexesForScopeLookup(moduleScope, new Map([['callMe', [callableRef]]]));
|
|
|
|
expect(findCallableBindingInScope(SCOPE, 'callMe', indexes)?.nodeId).toBe('callMe');
|
|
});
|
|
|
|
it('findExportedDefByName finds callable refs that exist only in augmentations', () => {
|
|
const moduleScope = scope(SCOPE);
|
|
const callableRef = { def: def('fromAugmentation'), origin: 'import' } as BindingRef;
|
|
const indexes = indexesForScopeLookup(moduleScope, new Map([['run', [callableRef]]]));
|
|
const workspaceIndex = {
|
|
moduleScopeByFile: new Map(),
|
|
} as unknown as WorkspaceResolutionIndex;
|
|
|
|
expect(findExportedDefByName('run', SCOPE, indexes, workspaceIndex)?.nodeId).toBe(
|
|
'fromAugmentation',
|
|
);
|
|
});
|
|
});
|