mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-17 23:52:36 +00:00
* fix(csharp): eliminate global-namespace typeBindings O(files²) OOM (#1871) Large C# solutions with tens of thousands of files in the global (unnamed) namespace OOM'd / hung for hours at "Resolving types (Csharp 2/3)". PR #1905 fixed the BindingRef twin of this via the `workspaceFqnBindings` fast-path, but left the typeBindings propagation loop in `populateCsharpNamespaceSiblings` untouched: it copies every global file's module-scope return-type bindings into every OTHER global file's `Scope.typeBindings`. With S files in the `''` bucket and K distinct method names, that is O(S²) time and O(S·K) memory — ~1.3B Map entries (~65-130 GB) at 36k files. Measured on a concentrated global-namespace fixture: the per-file copy went quadratic (1000→2000 files = 3.06× for 2× the files, 65s at 2000). Route global-namespace module typeBindings through a new scope-independent `workspaceTypeBindings` channel populated ONCE (O(K)) and consulted as a fallback by the typeBindings chain-walkers (`findReceiverTypeBinding`, `followChainPostFinalize`), instead of the per-file copy. After: 2000 files 6.3s, 4000 files 6.8s, heap linear. This also makes resolution MORE correct, not just faster. The C# spec makes the unnamed namespace a single declaration space whose members are "available for use in a named namespace", so global types are visible from every file. The old per-file copy only exposed them to OTHER no-namespace files; named-namespace files never saw them. Consulting the shared channel from every scope chain mirrors how Roslyn resolves against a single `Compilation.GlobalNamespace` symbol rather than copying symbols per file. Strengthen csharp-pipeline-benchmark.test.ts so it would catch this: give each file a unique method name (a shared name collapses the module-typeBinding key and skips all copies, hiding the blow-up) and raise the concentrated scales to 2000 so the sub-quadratic assertion trips on the regression. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(csharp): generalize shared-channel resolution to concentrated named namespaces (#1871) #1954 eliminated the namespace-siblings O(files²) OOM only for the global ('' / no-declared-namespace) bucket. A solution with all files under one named namespace (e.g. file-scoped `namespace Company.Product;`, common in modern .NET) still reproduced the #1871 blow-up — and in BOTH loops: the BindingRef per-scope augmentation (#1905's twin) AND the typeBindings per-file copy (#1954's twin) were each still O(N²) for a named bucket. Generalize the shared-channel approach to named namespaces: - Add namespace-keyed channels `namespaceFqnBindings` / `namespaceTypeBindings` (the per-namespace analogues of `workspaceFqnBindings` / `workspaceTypeBindings`) plus `accessibleNamespacesByScope`, populated ONCE per named bucket from the existing `expandedNamespaces` derivation — O(defs), not O(files × defs). - Make the shared walkers (`findReceiverTypeBinding`, `lookupBindingsAt`, `followChainPostFinalize`) namespace-aware: after the per-scope chain and the flat global channel miss, consult the per-namespace channels gated by the caller module's accessible namespaces. Language-neutral — only the C# hook populates the channels; the machinery names no language (AGENTS rule). - Precedence preserved: local chain → named namespace → global. Named is consulted before the flat global channel because pre-#1871 named siblings lived in the chain / bindingAugmentations (above the workspace channel), so a name in both a named and the global namespace must still resolve named-first. - `using static` member exposure and the global '' fast-paths are unchanged. Parity-neutral: `run-parity.ts --language csharp` passes (legacy DAG == registry-primary, 218 tests each); the C# resolver suite (386 tests) is green. Measured: a concentrated named namespace at 500/1000/2000 files now scales linearly (~0.57×) and ~5.6s at 2000 files, vs the quadratic blow-up before. Tests: - New always-run unit coverage for the walker fallbacks (namespace-channel-lookup.test.ts): global `workspaceTypeBindings` (the #1954 channel previously covered only by a gated benchmark), namespace gating / no-leak, named-before-global precedence, local shadowing, loop termination. - Extend the immutability validator + invariant I8 to the new channels and `workspaceTypeBindings`; update the `mkIndexes` factory. - Add a concentrated-NAMED-namespace shape to the C# pipeline benchmark with the sub-quadratic scaling assertion and an edge-count sanity check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
270 lines
11 KiB
TypeScript
270 lines
11 KiB
TypeScript
/**
|
|
* Unit tests for the dev-mode I8 binding-immutability validator.
|
|
*
|
|
* Mirrors `validateOwnershipParity` (#909) — happy path + drift
|
|
* detection + opt-in runtime gating. Pinning these so a
|
|
* future contributor can't silently re-introduce the issue #1066
|
|
* shape (a hook mutating `indexes.bindings` instead of
|
|
* `indexes.bindingAugmentations`) without tripping the validator.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import type { BindingRef, ScopeId } from 'gitnexus-shared';
|
|
import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js';
|
|
import { validateBindingsImmutability } from '../../../src/core/ingestion/scope-resolution/pipeline/validate-bindings-immutability.js';
|
|
|
|
const mkRef = (nodeId: string): BindingRef =>
|
|
({
|
|
def: { nodeId, filePath: 'x.ts', type: 'Class' },
|
|
origin: 'local',
|
|
}) as unknown as BindingRef;
|
|
|
|
const mkIndexes = (
|
|
bindings: Map<ScopeId, Map<string, readonly BindingRef[]>>,
|
|
augmentations: Map<ScopeId, Map<string, BindingRef[]>>,
|
|
workspace: Map<string, readonly BindingRef[]> = new Map(),
|
|
extra: Partial<{
|
|
workspaceTypeBindings: Map<string, unknown>;
|
|
namespaceFqnBindings: Map<string, Map<string, readonly BindingRef[]>>;
|
|
namespaceTypeBindings: Map<string, Map<string, unknown>>;
|
|
}> = {},
|
|
): ScopeResolutionIndexes =>
|
|
({
|
|
bindings,
|
|
bindingAugmentations: augmentations,
|
|
workspaceFqnBindings: workspace,
|
|
workspaceTypeBindings: extra.workspaceTypeBindings ?? new Map(),
|
|
namespaceFqnBindings: extra.namespaceFqnBindings ?? new Map(),
|
|
namespaceTypeBindings: extra.namespaceTypeBindings ?? new Map(),
|
|
accessibleNamespacesByScope: new Map(),
|
|
}) as unknown as ScopeResolutionIndexes;
|
|
|
|
describe('validateBindingsImmutability', () => {
|
|
beforeEach(() => {
|
|
// Insulate against an ambient VALIDATE_SEMANTIC_MODEL in a developer's
|
|
// shell. Per-test env tweaks override this baseline as needed.
|
|
vi.stubEnv('VALIDATE_SEMANTIC_MODEL', undefined);
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it('is silent when finalized buckets are frozen and augmentation buckets are mutable', () => {
|
|
vi.stubEnv('NODE_ENV', 'development');
|
|
const bindings = new Map<ScopeId, Map<string, readonly BindingRef[]>>([
|
|
['scope:a:module', new Map([['Foo', Object.freeze([mkRef('def:Foo')])]])],
|
|
]);
|
|
const augmentations = new Map<ScopeId, Map<string, BindingRef[]>>([
|
|
['scope:a:module', new Map([['Bar', [mkRef('def:Bar')]]])],
|
|
]);
|
|
const onWarn = vi.fn();
|
|
|
|
const violations = validateBindingsImmutability(mkIndexes(bindings, augmentations), onWarn);
|
|
|
|
expect(violations).toBe(0);
|
|
expect(onWarn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('warns when a bucket in indexes.bindings is NOT frozen', () => {
|
|
vi.stubEnv('NODE_ENV', 'development');
|
|
const bindings = new Map<ScopeId, Map<string, readonly BindingRef[]>>([
|
|
['scope:a:module', new Map([['Foo', [mkRef('def:Foo')] as readonly BindingRef[]]])],
|
|
]);
|
|
const augmentations = new Map<ScopeId, Map<string, BindingRef[]>>();
|
|
const onWarn = vi.fn();
|
|
|
|
const violations = validateBindingsImmutability(mkIndexes(bindings, augmentations), onWarn);
|
|
|
|
expect(violations).toBe(1);
|
|
expect(onWarn).toHaveBeenCalledTimes(1);
|
|
expect(onWarn.mock.calls[0][0]).toMatch(/binding-immutability/);
|
|
expect(onWarn.mock.calls[0][0]).toMatch(/indexes\.bindings/);
|
|
expect(onWarn.mock.calls[0][0]).toMatch(/I8/);
|
|
});
|
|
|
|
it('warns when a bucket in indexes.bindingAugmentations IS frozen', () => {
|
|
vi.stubEnv('NODE_ENV', 'development');
|
|
const bindings = new Map<ScopeId, Map<string, readonly BindingRef[]>>();
|
|
const augmentations = new Map<ScopeId, Map<string, BindingRef[]>>([
|
|
['scope:a:module', new Map([['Bar', Object.freeze([mkRef('def:Bar')]) as BindingRef[]]])],
|
|
]);
|
|
const onWarn = vi.fn();
|
|
|
|
const violations = validateBindingsImmutability(mkIndexes(bindings, augmentations), onWarn);
|
|
|
|
expect(violations).toBe(1);
|
|
expect(onWarn).toHaveBeenCalledTimes(1);
|
|
expect(onWarn.mock.calls[0][0]).toMatch(/binding-immutability/);
|
|
expect(onWarn.mock.calls[0][0]).toMatch(/indexes\.bindingAugmentations/);
|
|
expect(onWarn.mock.calls[0][0]).toMatch(/I8/);
|
|
});
|
|
|
|
it('warns when a bucket in indexes.workspaceFqnBindings IS frozen', () => {
|
|
vi.stubEnv('NODE_ENV', 'development');
|
|
const workspace = new Map<string, readonly BindingRef[]>([
|
|
['User', Object.freeze([mkRef('def:User')]) as BindingRef[]],
|
|
]);
|
|
const onWarn = vi.fn();
|
|
|
|
const violations = validateBindingsImmutability(
|
|
mkIndexes(new Map(), new Map(), workspace),
|
|
onWarn,
|
|
);
|
|
|
|
expect(violations).toBe(1);
|
|
expect(onWarn).toHaveBeenCalledTimes(1);
|
|
expect(onWarn.mock.calls[0][0]).toMatch(/indexes\.workspaceFqnBindings/);
|
|
expect(onWarn.mock.calls[0][0]).toMatch(/I8/);
|
|
});
|
|
|
|
it('warns when indexes.workspaceTypeBindings IS frozen', () => {
|
|
vi.stubEnv('NODE_ENV', 'development');
|
|
const onWarn = vi.fn();
|
|
|
|
const violations = validateBindingsImmutability(
|
|
mkIndexes(new Map(), new Map(), new Map(), {
|
|
workspaceTypeBindings: Object.freeze(new Map([['GetUser', {}]])) as Map<string, unknown>,
|
|
}),
|
|
onWarn,
|
|
);
|
|
|
|
expect(violations).toBe(1);
|
|
expect(onWarn.mock.calls[0][0]).toMatch(/indexes\.workspaceTypeBindings/);
|
|
expect(onWarn.mock.calls[0][0]).toMatch(/I8/);
|
|
});
|
|
|
|
it('warns when a per-namespace bucket in indexes.namespaceFqnBindings IS frozen', () => {
|
|
vi.stubEnv('NODE_ENV', 'development');
|
|
const onWarn = vi.fn();
|
|
const nsFqn = new Map<string, Map<string, readonly BindingRef[]>>([
|
|
['App', new Map([['User', Object.freeze([mkRef('def:User')]) as BindingRef[]]])],
|
|
]);
|
|
|
|
const violations = validateBindingsImmutability(
|
|
mkIndexes(new Map(), new Map(), new Map(), { namespaceFqnBindings: nsFqn }),
|
|
onWarn,
|
|
);
|
|
|
|
expect(violations).toBe(1);
|
|
expect(onWarn.mock.calls[0][0]).toMatch(/indexes\.namespaceFqnBindings\[App\]\[User\]/);
|
|
expect(onWarn.mock.calls[0][0]).toMatch(/I8/);
|
|
});
|
|
|
|
it('warns when a per-namespace map in indexes.namespaceTypeBindings IS frozen', () => {
|
|
vi.stubEnv('NODE_ENV', 'development');
|
|
const onWarn = vi.fn();
|
|
const nsType = new Map<string, Map<string, unknown>>([
|
|
['App', Object.freeze(new Map([['GetUser', {}]])) as Map<string, unknown>],
|
|
]);
|
|
|
|
const violations = validateBindingsImmutability(
|
|
mkIndexes(new Map(), new Map(), new Map(), { namespaceTypeBindings: nsType }),
|
|
onWarn,
|
|
);
|
|
|
|
expect(violations).toBe(1);
|
|
expect(onWarn.mock.calls[0][0]).toMatch(/indexes\.namespaceTypeBindings\[App\]/);
|
|
});
|
|
|
|
it('is silent when the new channels are present and unfrozen', () => {
|
|
vi.stubEnv('NODE_ENV', 'development');
|
|
const onWarn = vi.fn();
|
|
const violations = validateBindingsImmutability(
|
|
mkIndexes(new Map(), new Map(), new Map(), {
|
|
workspaceTypeBindings: new Map([['GetUser', {}]]),
|
|
namespaceFqnBindings: new Map([['App', new Map([['User', [mkRef('def:User')]]])]]),
|
|
namespaceTypeBindings: new Map([['App', new Map([['GetUser', {}]])]]),
|
|
}),
|
|
onWarn,
|
|
);
|
|
expect(violations).toBe(0);
|
|
expect(onWarn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not detect semantically wrong frozen replacements in indexes.bindings', () => {
|
|
vi.stubEnv('NODE_ENV', 'development');
|
|
const bindings = new Map<ScopeId, Map<string, readonly BindingRef[]>>([
|
|
['scope:a:module', new Map([['Foo', Object.freeze([mkRef('def:Wrong')])]])],
|
|
]);
|
|
const augmentations = new Map<ScopeId, Map<string, BindingRef[]>>();
|
|
const onWarn = vi.fn();
|
|
|
|
const violations = validateBindingsImmutability(mkIndexes(bindings, augmentations), onWarn);
|
|
|
|
expect(violations).toBe(0);
|
|
expect(onWarn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('counts violations across multiple scopes', () => {
|
|
vi.stubEnv('NODE_ENV', 'development');
|
|
const bindings = new Map<ScopeId, Map<string, readonly BindingRef[]>>([
|
|
['scope:a:module', new Map([['Foo', [mkRef('def:Foo')] as readonly BindingRef[]]])],
|
|
['scope:b:module', new Map([['Bar', [mkRef('def:Bar')] as readonly BindingRef[]]])],
|
|
]);
|
|
const augmentations = new Map<ScopeId, Map<string, BindingRef[]>>();
|
|
const onWarn = vi.fn();
|
|
|
|
const violations = validateBindingsImmutability(mkIndexes(bindings, augmentations), onWarn);
|
|
|
|
expect(violations).toBe(2);
|
|
expect(onWarn).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('is a no-op when NODE_ENV=production', () => {
|
|
vi.stubEnv('NODE_ENV', 'production');
|
|
const bindings = new Map<ScopeId, Map<string, readonly BindingRef[]>>([
|
|
['scope:a:module', new Map([['Foo', [mkRef('def:Foo')] as readonly BindingRef[]]])],
|
|
]);
|
|
const augmentations = new Map<ScopeId, Map<string, BindingRef[]>>();
|
|
const onWarn = vi.fn();
|
|
|
|
const violations = validateBindingsImmutability(mkIndexes(bindings, augmentations), onWarn);
|
|
|
|
expect(violations).toBe(0);
|
|
expect(onWarn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('is a no-op in default CLI env when NODE_ENV is unset', () => {
|
|
vi.stubEnv('NODE_ENV', undefined);
|
|
const bindings = new Map<ScopeId, Map<string, readonly BindingRef[]>>([
|
|
['scope:a:module', new Map([['Foo', [mkRef('def:Foo')] as readonly BindingRef[]]])],
|
|
]);
|
|
const augmentations = new Map<ScopeId, Map<string, BindingRef[]>>();
|
|
const onWarn = vi.fn();
|
|
|
|
const violations = validateBindingsImmutability(mkIndexes(bindings, augmentations), onWarn);
|
|
|
|
expect(violations).toBe(0);
|
|
expect(onWarn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('runs when VALIDATE_SEMANTIC_MODEL=1 even if NODE_ENV is unset', () => {
|
|
vi.stubEnv('NODE_ENV', undefined);
|
|
vi.stubEnv('VALIDATE_SEMANTIC_MODEL', '1');
|
|
const bindings = new Map<ScopeId, Map<string, readonly BindingRef[]>>([
|
|
['scope:a:module', new Map([['Foo', [mkRef('def:Foo')] as readonly BindingRef[]]])],
|
|
]);
|
|
const augmentations = new Map<ScopeId, Map<string, BindingRef[]>>();
|
|
const onWarn = vi.fn();
|
|
|
|
const violations = validateBindingsImmutability(mkIndexes(bindings, augmentations), onWarn);
|
|
|
|
expect(violations).toBe(1);
|
|
expect(onWarn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('is a no-op when VALIDATE_SEMANTIC_MODEL=0', () => {
|
|
vi.stubEnv('NODE_ENV', 'development');
|
|
vi.stubEnv('VALIDATE_SEMANTIC_MODEL', '0');
|
|
const bindings = new Map<ScopeId, Map<string, readonly BindingRef[]>>([
|
|
['scope:a:module', new Map([['Foo', [mkRef('def:Foo')] as readonly BindingRef[]]])],
|
|
]);
|
|
const augmentations = new Map<ScopeId, Map<string, BindingRef[]>>();
|
|
const onWarn = vi.fn();
|
|
|
|
const violations = validateBindingsImmutability(mkIndexes(bindings, augmentations), onWarn);
|
|
|
|
expect(violations).toBe(0);
|
|
expect(onWarn).not.toHaveBeenCalled();
|
|
});
|
|
});
|