mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-28 01:31:23 +00:00
Merge branch 'main' into feat/multi-cli-devcontainer
This commit is contained in:
commit
5b47593492
10 changed files with 862 additions and 40 deletions
|
|
@ -28,17 +28,19 @@
|
|||
* aliased `using static X = Y.Z;`, attributed namespace declarations,
|
||||
* and preprocessor-guarded declarations correctly because the
|
||||
* tree-sitter grammar parses them as real nodes (not textual
|
||||
* coincidences).
|
||||
* coincidences). When the orchestrator's `treeCache` has no Tree for a
|
||||
* file — the worker path, where native Trees can't cross MessageChannels
|
||||
* — `extractFileStructure` falls back to a line scanner rather than
|
||||
* re-parsing every file from scratch (that re-parse dominated worker-mode
|
||||
* scope-resolution time). See `extractCsharpStructureViaScanner`.
|
||||
*/
|
||||
|
||||
import type { SyntaxNode } from 'tree-sitter';
|
||||
import type { BindingRef, ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { getCsharpParser } from './query.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
||||
|
||||
interface CsharpFileStructure {
|
||||
export interface CsharpFileStructure {
|
||||
/** Declared namespace names in file source order. Empty array means
|
||||
* the file has no `namespace X;` / `namespace X { }` declaration
|
||||
* and sits in the default (global) namespace. */
|
||||
|
|
@ -48,18 +50,174 @@ interface CsharpFileStructure {
|
|||
readonly usingStaticPaths: readonly string[];
|
||||
}
|
||||
|
||||
/** Build a structural view of a C# file by walking the tree-sitter
|
||||
* AST. Prefers `cachedTree` (handed in via `treeCache`) so we don't
|
||||
* re-parse files the orchestrator already parsed for `extractParsedFile`;
|
||||
* falls back to a fresh parse on cache miss. Parser singleton is
|
||||
* shared across calls. */
|
||||
// Line-anchored matchers for the worker-path fallback (see
|
||||
// `extractCsharpStructureViaScanner`). Anchored at line start (after
|
||||
// indentation); the scanner additionally tracks block-comment / string
|
||||
// state across lines so a keyword at the start of a line inside one of
|
||||
// those regions is skipped.
|
||||
const CS_NAMESPACE_RE = /^[ \t]*namespace[ \t]+([A-Za-z_@][A-Za-z0-9_.]*)/;
|
||||
// `global using static`, plain `using static`, and the aliased
|
||||
// `using static Alias = NS.Type;` form (the AST keeps the RHS path, so
|
||||
// the optional `Alias =` is skipped and only the dotted path captured).
|
||||
const CS_USING_STATIC_RE =
|
||||
/^[ \t]*(?:global[ \t]+)?using[ \t]+static[ \t]+(?:[A-Za-z_@][A-Za-z0-9_]*[ \t]*=[ \t]*)?([A-Za-z_@][A-Za-z0-9_.]*)/;
|
||||
|
||||
/** Multi-line lexical state carried line-to-line by the scanner. */
|
||||
type CsScanState = 'code' | 'block' | 'verbatim' | 'raw';
|
||||
|
||||
/** Advance the scanner's lexical state across one line, consuming block
|
||||
* comments (slash-star), line comments (`//`), single-line regular /
|
||||
* interpolated strings, verbatim strings (`@"…"`), and raw string literals
|
||||
* (`"""…"""`, fence length tracked in `rawFence`). Returns the state and
|
||||
* raw-fence length in effect at the START of the next line. Single-line
|
||||
* strings and `//` comments resolve back to `code` before end of line; only
|
||||
* block comments and multi-line strings carry state forward. */
|
||||
function advanceCsScanState(
|
||||
line: string,
|
||||
state: CsScanState,
|
||||
rawFence: number,
|
||||
): [CsScanState, number] {
|
||||
const n = line.length;
|
||||
let i = 0;
|
||||
while (i < n) {
|
||||
if (state === 'block') {
|
||||
const end = line.indexOf('*/', i);
|
||||
if (end === -1) return ['block', rawFence];
|
||||
i = end + 2;
|
||||
state = 'code';
|
||||
} else if (state === 'verbatim') {
|
||||
// Ends at a `"` that is not doubled (`""` is an escaped quote).
|
||||
while (i < n) {
|
||||
if (line[i] === '"') {
|
||||
if (line[i + 1] === '"') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
if (i >= n) return ['verbatim', rawFence];
|
||||
i += 1;
|
||||
state = 'code';
|
||||
} else if (state === 'raw') {
|
||||
// Ends at a run of `"` at least `rawFence` long.
|
||||
let closed = false;
|
||||
while (i < n) {
|
||||
if (line[i] === '"') {
|
||||
let k = i;
|
||||
while (k < n && line[k] === '"') k++;
|
||||
if (k - i >= rawFence) {
|
||||
i = k;
|
||||
state = 'code';
|
||||
rawFence = 0;
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
i = k;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (!closed) return ['raw', rawFence];
|
||||
} else {
|
||||
const c = line[i];
|
||||
const next = line[i + 1];
|
||||
if (c === '/' && next === '/') return ['code', rawFence]; // line comment to EOL
|
||||
if (c === '/' && next === '*') {
|
||||
state = 'block';
|
||||
i += 2;
|
||||
} else if (c === '@' && next === '"') {
|
||||
state = 'verbatim';
|
||||
i += 2;
|
||||
} else if ((c === '$' && next === '@') || (c === '@' && next === '$')) {
|
||||
if (line[i + 2] === '"') {
|
||||
state = 'verbatim'; // interpolated verbatim ($@"…" / @$"…")
|
||||
i += 3;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
} else if (c === '"') {
|
||||
let k = i;
|
||||
while (k < n && line[k] === '"') k++;
|
||||
const run = k - i;
|
||||
if (run >= 3) {
|
||||
state = 'raw';
|
||||
rawFence = run;
|
||||
i = k;
|
||||
} else if (run === 2) {
|
||||
i = k; // "" — empty string
|
||||
} else {
|
||||
// single-line regular / interpolated string; consume to closer
|
||||
let j = i + 1;
|
||||
while (j < n) {
|
||||
if (line[j] === '\\') {
|
||||
j += 2;
|
||||
continue;
|
||||
}
|
||||
if (line[j] === '"') break;
|
||||
j++;
|
||||
}
|
||||
i = j >= n ? n : j + 1;
|
||||
}
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return [state, rawFence];
|
||||
}
|
||||
|
||||
/** Line-scanner used when no cached tree is available (worker-parsed files
|
||||
* can't transfer native tree-sitter Trees across MessageChannels, so
|
||||
* `treeCache` is empty for them). Re-parsing every C# file here with
|
||||
* tree-sitter was the dominant scope-resolution cost on large worker-mode
|
||||
* runs — for a multi-thousand-file solution this loop alone re-parsed the
|
||||
* whole repo a second time. The scanner extracts the same `namespaces` /
|
||||
* `usingStaticPaths` the AST walk produces for line-anchored declarations,
|
||||
* while tracking block-comment and string state across lines (via
|
||||
* `advanceCsScanState`) so a `namespace` / `using static` keyword at the
|
||||
* start of a line inside a block comment, verbatim string, or raw string
|
||||
* literal is NOT mistaken for a declaration. The remaining trade-off vs the
|
||||
* AST is a declaration whose keyword is not at the start of a code line
|
||||
* (split across lines, or sharing a line with a comment/string closer).
|
||||
* Mirrors PHP's `extractNamespaceViaScanner` (issue #1741). */
|
||||
export function extractCsharpStructureViaScanner(content: string): CsharpFileStructure {
|
||||
const namespaces: string[] = [];
|
||||
const usingStaticPaths: string[] = [];
|
||||
let state: CsScanState = 'code';
|
||||
let rawFence = 0;
|
||||
for (const line of content.split('\n')) {
|
||||
// Only match when the line START is real code — keywords reached while
|
||||
// inside a block comment / multi-line string are skipped.
|
||||
if (state === 'code') {
|
||||
const ns = CS_NAMESPACE_RE.exec(line);
|
||||
if (ns !== null) {
|
||||
namespaces.push(ns[1]!);
|
||||
} else {
|
||||
const us = CS_USING_STATIC_RE.exec(line);
|
||||
if (us !== null) usingStaticPaths.push(us[1]!);
|
||||
}
|
||||
}
|
||||
[state, rawFence] = advanceCsScanState(line, state, rawFence);
|
||||
}
|
||||
return { namespaces, usingStaticPaths };
|
||||
}
|
||||
|
||||
/** Build a structural view of a C# file. Prefers `cachedTree` (handed in
|
||||
* via `treeCache`) and walks the tree-sitter AST — the authoritative
|
||||
* path that sees `global using static`, aliased `using static X = Y.Z;`,
|
||||
* attributed namespace declarations, and preprocessor-guarded nodes
|
||||
* correctly. On cache miss (worker-parsed files, whose native Trees
|
||||
* can't cross MessageChannels) it falls back to the line scanner instead
|
||||
* of a fresh tree-sitter parse — the parse here dominated worker-mode
|
||||
* scope-resolution time. Parser singleton is shared across calls. */
|
||||
function extractFileStructure(content: string, cachedTree: unknown): CsharpFileStructure {
|
||||
if (!cachedTree) {
|
||||
return extractCsharpStructureViaScanner(content);
|
||||
}
|
||||
type CsharpTree = ReturnType<ReturnType<typeof getCsharpParser>['parse']>;
|
||||
const tree =
|
||||
(cachedTree as CsharpTree | undefined) ??
|
||||
parseSourceSafe(getCsharpParser(), content, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(content),
|
||||
});
|
||||
const tree = cachedTree as CsharpTree;
|
||||
const namespaces: string[] = [];
|
||||
const usingStaticPaths: string[] = [];
|
||||
|
||||
|
|
@ -277,11 +435,17 @@ export function populateCsharpNamespaceSiblings(
|
|||
// scope, so `Record(...)` (without `Logger.` qualifier) resolves
|
||||
// to `Logger.Record`. AST walk above captured these (including
|
||||
// `global using static` and aliased forms).
|
||||
// Pre-index files by path once: the member-injection lookup below would
|
||||
// otherwise be an O(files) scan per `using static` import.
|
||||
const fileByPath = new Map<string, ParsedFile>(parsedFiles.map((p) => [p.filePath, p]));
|
||||
for (const parsed of parsedFiles) {
|
||||
const struct = structureByFile.get(parsed.filePath);
|
||||
if (struct === undefined) continue;
|
||||
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
|
||||
if (moduleScope === undefined) continue;
|
||||
// Per-file de-dup sets keyed by simple name, seeded lazily from the
|
||||
// augmentation bucket — replaces the per-member O(A) `.some` scan below.
|
||||
const seenByName = new Map<string, Set<string>>();
|
||||
|
||||
for (const fullPath of struct.usingStaticPaths) {
|
||||
const lastDot = fullPath.lastIndexOf('.');
|
||||
|
|
@ -302,7 +466,7 @@ export function populateCsharpNamespaceSiblings(
|
|||
// Inject the class's member methods into the importer's module
|
||||
// scope. `memberByOwner` wasn't built yet here, so we walk the
|
||||
// file's localDefs to find members with `ownerId === targetDef.nodeId`.
|
||||
const targetFile = parsedFiles.find((p) => p.filePath === targetDef.filePath);
|
||||
const targetFile = fileByPath.get(targetDef.filePath);
|
||||
if (targetFile === undefined) continue;
|
||||
for (const memberDef of targetFile.localDefs) {
|
||||
if ((memberDef as { ownerId?: string }).ownerId !== targetDef.nodeId) continue;
|
||||
|
|
@ -316,7 +480,14 @@ export function populateCsharpNamespaceSiblings(
|
|||
// `lookupBindingsAt`, which fans out across `bindings` +
|
||||
// `bindingAugmentations`.
|
||||
const bucketArr = getAugmentationBucket(augmentations, moduleScope.id, simpleName);
|
||||
if (bucketArr.some((b) => b.def.nodeId === memberDef.nodeId)) continue;
|
||||
let seen = seenByName.get(simpleName);
|
||||
if (seen === undefined) {
|
||||
seen = new Set<string>();
|
||||
for (const b of bucketArr) seen.add(b.def.nodeId);
|
||||
seenByName.set(simpleName, seen);
|
||||
}
|
||||
if (seen.has(memberDef.nodeId)) continue;
|
||||
seen.add(memberDef.nodeId);
|
||||
bucketArr.push({ def: memberDef, origin: 'import' });
|
||||
}
|
||||
}
|
||||
|
|
@ -332,6 +503,9 @@ export function populateCsharpNamespaceSiblings(
|
|||
for (const parsed of parsedFiles) {
|
||||
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
|
||||
if (moduleScope === undefined) continue;
|
||||
// Per-file de-dup sets keyed by simple name, seeded lazily from the
|
||||
// augmentation bucket — replaces the per-def O(A) `.some` scan below.
|
||||
const seenByName = new Map<string, Set<string>>();
|
||||
for (const imp of parsed.parsedImports) {
|
||||
if (imp.kind !== 'namespace') continue;
|
||||
const targetNs = imp.targetRaw;
|
||||
|
|
@ -344,41 +518,113 @@ export function populateCsharpNamespaceSiblings(
|
|||
const simpleName = q.includes('.') ? q.slice(q.lastIndexOf('.') + 1) : q;
|
||||
if (simpleName === '') continue;
|
||||
const bucketArr = getAugmentationBucket(augmentations, moduleScope.id, simpleName);
|
||||
if (bucketArr.some((b) => b.def.nodeId === def.nodeId)) continue;
|
||||
let seen = seenByName.get(simpleName);
|
||||
if (seen === undefined) {
|
||||
seen = new Set<string>();
|
||||
for (const b of bucketArr) seen.add(b.def.nodeId);
|
||||
seenByName.set(simpleName, seen);
|
||||
}
|
||||
if (seen.has(def.nodeId)) continue;
|
||||
seen.add(def.nodeId);
|
||||
bucketArr.push({ def, origin: 'namespace' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [, bucket] of buckets) {
|
||||
// De-dup by (nodeId, filePath) across multiple declarations (e.g.
|
||||
// partial classes declaring the same name in two files — we take
|
||||
// both and leave de-dup to downstream consumers of bindings).
|
||||
// Workspace-level binding channel for global-namespace types (see the
|
||||
// global fast-path below). `lookupBindingsAt` consults this as a third
|
||||
// source after finalized + per-scope augmented bindings. Its inner arrays
|
||||
// are mutable by contract (append-only, like `bindingAugmentations` — see
|
||||
// the ScopeResolutionIndexes doc + validateBindingsImmutability), so the
|
||||
// ReadonlyMap→Map cast is localized to this one line and all writes go
|
||||
// through `getWorkspaceBucket`.
|
||||
const workspace = indexes.workspaceFqnBindings as Map<string, BindingRef[]>;
|
||||
|
||||
for (const [nsName, bucket] of buckets) {
|
||||
// Group sibling defs by simple name. Append in place — the previous
|
||||
// `[...prev, def]` copy made this O(D²) per bucket, which on the
|
||||
// global (`''`) namespace bucket of a large Unity solution (tens of
|
||||
// thousands of type defs) was a primary slowness/OOM source. We keep
|
||||
// every declaration (e.g. partial classes across files) and leave
|
||||
// de-dup to downstream consumers.
|
||||
const defsByName = new Map<string, SymbolDefinition[]>();
|
||||
for (const def of bucket.classDefs) {
|
||||
// Simple name = last segment of qualifiedName (e.g. `App.User` → `User`).
|
||||
const q = def.qualifiedName ?? '';
|
||||
const key = q.includes('.') ? q.slice(q.lastIndexOf('.') + 1) : q;
|
||||
if (key === '') continue;
|
||||
const arr = [...(defsByName.get(key) ?? [])];
|
||||
let arr = defsByName.get(key);
|
||||
if (arr === undefined) {
|
||||
arr = [];
|
||||
defsByName.set(key, arr);
|
||||
}
|
||||
arr.push(def);
|
||||
defsByName.set(key, arr);
|
||||
}
|
||||
|
||||
// Global-namespace fast path (Unity OOM guard). Types declared in the
|
||||
// default (global) namespace are visible from EVERY file in C# — the
|
||||
// global namespace is always implicitly in scope — so one workspace-
|
||||
// level entry per simple name is both semantically correct and O(D)
|
||||
// instead of the O(S·D) per-scope augmentation that materialized
|
||||
// billions of BindingRefs on large Unity solutions (tens of thousands
|
||||
// of global types × tens of thousands of scopes). `walkScopeChain`
|
||||
// checks local `scope.bindings` first, so local declarations still
|
||||
// shadow these workspace entries; a file resolving its own global type
|
||||
// hits the local binding before this map. Dedup by `def.nodeId` keeps
|
||||
// partial-class / duplicate declarations from double-emitting.
|
||||
if (nsName === '') {
|
||||
for (const [name, defs] of defsByName) {
|
||||
const bucket = getWorkspaceBucket(workspace, name);
|
||||
const seen = new Set<string>();
|
||||
for (const b of bucket) seen.add(b.def.nodeId);
|
||||
for (const def of defs) {
|
||||
if (seen.has(def.nodeId)) continue; // dedup by nodeId (keeps partials, drops re-emits)
|
||||
seen.add(def.nodeId);
|
||||
bucket.push({ def, origin: 'namespace' });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pre-index the first scope per file once (O(S)) instead of an
|
||||
// O(S) `.find` re-run for every (scope, name) pair, which made the
|
||||
// injection loop O(S²·D) and was the dominant cost on large buckets.
|
||||
// Multiple scopes share a filePath (Module + Namespace); the local
|
||||
// shadow check only needs that file's lexical `Scope.bindings`, which
|
||||
// is identical regardless of which of those scopes we read.
|
||||
const firstScopeByFile = new Map<string, Scope>();
|
||||
for (const s of bucket.scopes) {
|
||||
if (!firstScopeByFile.has(s.filePath)) firstScopeByFile.set(s.filePath, s.scope);
|
||||
}
|
||||
|
||||
for (const { scopeId, filePath } of bucket.scopes) {
|
||||
const localScope = firstScopeByFile.get(filePath);
|
||||
for (const [name, defs] of defsByName) {
|
||||
// Skip names already present locally — `origin: 'local'` in
|
||||
// scope.bindings would naturally shadow the cross-file
|
||||
// namespace entry, but we also keep this index lean.
|
||||
const local = bucket.scopes.find((s) => s.filePath === filePath)?.scope.bindings.get(name);
|
||||
const local = localScope?.bindings.get(name);
|
||||
if (local !== undefined && local.some((b) => b.origin === 'local')) continue;
|
||||
|
||||
let bucketArr: BindingRef[] | null = null;
|
||||
// Bind the augmentation bucket and its seeded de-dup set together
|
||||
// under one nullable lifecycle, so neither needs a non-null
|
||||
// assertion (they are always set or unset as a pair). Stays lazy:
|
||||
// nothing is allocated for a name with no cross-file defs.
|
||||
let inject: { bucket: BindingRef[]; seen: Set<string> } | null = null;
|
||||
for (const def of defs) {
|
||||
if (def.filePath === filePath) continue; // don't self-reference
|
||||
if (bucketArr === null) bucketArr = getAugmentationBucket(augmentations, scopeId, name);
|
||||
if (bucketArr.some((b) => b.def.nodeId === def.nodeId)) continue;
|
||||
bucketArr.push({ def, origin: 'namespace' });
|
||||
if (inject === null) {
|
||||
const bucket = getAugmentationBucket(augmentations, scopeId, name);
|
||||
// Seed the de-dup set from any entries an earlier pass
|
||||
// (using-static / cross-namespace imports) already added,
|
||||
// replacing the per-def O(A) `.some` scan.
|
||||
const seen = new Set<string>();
|
||||
for (const b of bucket) seen.add(b.def.nodeId);
|
||||
inject = { bucket, seen };
|
||||
}
|
||||
if (inject.seen.has(def.nodeId)) continue;
|
||||
inject.seen.add(def.nodeId);
|
||||
inject.bucket.push({ def, origin: 'namespace' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -409,6 +655,22 @@ function getAugmentationBucket(
|
|||
return bucketArr;
|
||||
}
|
||||
|
||||
/** Get-or-create a mutable inner bucket inside the `workspaceFqnBindings`
|
||||
* channel (the scope-independent third channel; see
|
||||
* `ScopeResolutionIndexes.workspaceFqnBindings`). Like
|
||||
* `getAugmentationBucket`, the inner arrays are mutable by contract —
|
||||
* callers `push` directly. Keeping the get-or-create here means the one
|
||||
* ReadonlyMap→Map cast at the call site is the only place the mutable
|
||||
* view is taken. */
|
||||
function getWorkspaceBucket(workspace: Map<string, BindingRef[]>, name: string): BindingRef[] {
|
||||
let bucketArr = workspace.get(name);
|
||||
if (bucketArr === undefined) {
|
||||
bucketArr = [];
|
||||
workspace.set(name, bucketArr);
|
||||
}
|
||||
return bucketArr;
|
||||
}
|
||||
|
||||
function isTypeDef(def: SymbolDefinition): boolean {
|
||||
return (
|
||||
def.type === 'Class' ||
|
||||
|
|
|
|||
|
|
@ -77,11 +77,15 @@ export interface ScopeResolutionIndexes {
|
|||
* are returned first and win duplicate `def.nodeId` metadata, with
|
||||
* unique augmentations appended after. See I8. */
|
||||
readonly bindingAugmentations: ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>;
|
||||
/** Workspace-level FQN binding lookup. Populated by PHP namespace-
|
||||
* siblings Step 3b as a shared map instead of per-scope duplication.
|
||||
* Consulted by `lookupBindingsAt` as a third source after finalized
|
||||
* and per-scope augmented bindings. Keys are backslash-separated FQNs
|
||||
* (e.g. `App\Models\User`). */
|
||||
/** Workspace-level binding lookup, shared instead of per-scope
|
||||
* duplication. Consulted by `lookupBindingsAt` as a third source after
|
||||
* finalized and per-scope augmented bindings. Language-specific
|
||||
* namespace-sibling hooks populate it with disjoint key formats that
|
||||
* never collide — e.g. backslash-separated FQNs (`App\Models\User`) for
|
||||
* backslash-namespace languages, and bare simple names (`User`) for
|
||||
* global-/default-namespace types that are visible from every file. The
|
||||
* shared map gives those workspace-wide names one entry each instead of
|
||||
* O(scopes × defs) per-scope augmentation. */
|
||||
readonly workspaceFqnBindings: ReadonlyMap<string, readonly BindingRef[]>;
|
||||
/** Pre-resolution usage facts; consumed by the resolution phase. */
|
||||
readonly referenceSites: readonly ReferenceSite[];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Dev-mode runtime validator for the two-channel binding lifecycle
|
||||
* (Contract Invariant I8 in `contract/scope-resolver.ts`).
|
||||
* Dev-mode runtime validator for the post-finalize binding-channel
|
||||
* lifecycle (Contract Invariant I8 in `contract/scope-resolver.ts`).
|
||||
*
|
||||
* The two channels:
|
||||
* - `indexes.bindings` — finalize-output channel. After
|
||||
|
|
@ -74,5 +74,21 @@ export function validateBindingsImmutability(
|
|||
}
|
||||
}
|
||||
|
||||
// Third channel: `workspaceFqnBindings` (scope-independent, shared map
|
||||
// populated by language namespace-sibling hooks — PHP FQN keys, C#
|
||||
// global-namespace simple names). Like bindingAugmentations its inner
|
||||
// arrays are mutable by contract (hooks `push()` directly), so freezing
|
||||
// one is the same defect as freezing an augmentation bucket.
|
||||
for (const [name, bucket] of indexes.workspaceFqnBindings) {
|
||||
if (Object.isFrozen(bucket)) {
|
||||
onWarn(
|
||||
`binding-immutability: indexes.workspaceFqnBindings[${name}] is FROZEN — ` +
|
||||
`the workspace channel is mutable by contract; freezing it defeats the ` +
|
||||
`append-only purpose. See ScopeResolver Invariant I8.`,
|
||||
);
|
||||
violations++;
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,6 +99,14 @@ const EMPTY_NAMES: Iterable<string> = Object.freeze([]) as readonly string[];
|
|||
* Fast paths (zero allocation) when at most one channel is populated:
|
||||
* returns the underlying `Map.keys()` iterator directly. Only when both
|
||||
* channels carry names do we materialize a `Set` for deduplication.
|
||||
*
|
||||
* Scope: enumerates only the per-scope `bindings` and `bindingAugmentations`
|
||||
* channels. It deliberately EXCLUDES the scope-independent
|
||||
* `workspaceFqnBindings` channel (PHP FQN keys, C# global-namespace simple
|
||||
* names). `lookupBindingsAt` consults that third channel when resolving a
|
||||
* specific name, but name *enumeration* here does not — those names apply at
|
||||
* every scope and would flood per-scope callers. Callers that need
|
||||
* workspace-level names must read `workspaceFqnBindings` directly.
|
||||
*/
|
||||
export function namesAtScope(scopeId: ScopeId, scopes: ScopeResolutionIndexes): Iterable<string> {
|
||||
const finalized = scopes.bindings.get(scopeId);
|
||||
|
|
|
|||
253
gitnexus/test/integration/csharp-pipeline-benchmark.test.ts
Normal file
253
gitnexus/test/integration/csharp-pipeline-benchmark.test.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
/**
|
||||
* C# ingestion pipeline benchmark.
|
||||
*
|
||||
* Generates synthetic C# codebases at increasing scales and measures
|
||||
* wall-clock time and peak heap through the full pipeline — parsing,
|
||||
* scope extraction, C# namespace-siblings (same-namespace cross-file
|
||||
* visibility, using-static, cross-namespace imports), and call
|
||||
* resolution.
|
||||
*
|
||||
* Mirrors test/integration/php-pipeline-benchmark.test.ts. Two shapes:
|
||||
* 1. "spread" — files distributed across many namespaces (the common
|
||||
* case; each namespace bucket stays small).
|
||||
* 2. "concentrated" — every file in the SAME (or global/no) namespace,
|
||||
* so a single namespace bucket holds all type defs. This is the
|
||||
* shape that drove the Unity-solution OOM: `populateCsharpNamespaceSiblings`
|
||||
* materialises O(scopes × defs) BindingRefs into that one bucket.
|
||||
* The concentrated test is the regression guard for that path.
|
||||
*
|
||||
* Run: GITNEXUS_BENCH=1 npx vitest run test/integration/csharp-pipeline-benchmark.test.ts
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
|
||||
const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1';
|
||||
|
||||
interface BenchResult {
|
||||
fileCount: number;
|
||||
classCount: number;
|
||||
namespaceCount: number;
|
||||
elapsedMs: number;
|
||||
peakHeapMB: number;
|
||||
nodeCount: number;
|
||||
edgeCount: number;
|
||||
}
|
||||
|
||||
type FixtureShape = 'spread' | 'concentrated';
|
||||
|
||||
function generateCsharpFixture(
|
||||
fileCount: number,
|
||||
namespacesPerLevel: number,
|
||||
shape: FixtureShape,
|
||||
): { dir: string; classCount: number; namespaceCount: number } {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `csharp-bench-${shape}-${fileCount}-`));
|
||||
|
||||
// "spread": square grid of namespaces. "concentrated": a single
|
||||
// global (no-namespace) bucket so every type lands in the `''` bucket
|
||||
// — the OOM-prone path.
|
||||
const namespaces: string[] = [];
|
||||
if (shape === 'spread') {
|
||||
for (let i = 0; i < namespacesPerLevel; i++) {
|
||||
for (let j = 0; j < namespacesPerLevel; j++) {
|
||||
namespaces.push(`App.Module${i}.Sub${j}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
namespaces.push(''); // global / no namespace declaration
|
||||
}
|
||||
|
||||
const classCount = fileCount;
|
||||
const namespaceCount = namespaces.length;
|
||||
|
||||
for (let f = 0; f < fileCount; f++) {
|
||||
const ns = namespaces[f % namespaces.length]!;
|
||||
const className = `Class${f}`;
|
||||
// Concentrated files share a flat directory; spread files mirror the
|
||||
// namespace as a directory tree (matches typical C# project layout).
|
||||
const targetDir = ns === '' ? dir : path.join(dir, ns.replace(/\./g, '/'));
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
const siblingIdx = (f + 1) % fileCount;
|
||||
const siblingClass = `Class${siblingIdx}`;
|
||||
|
||||
const crossNsIdx = (f + Math.floor(fileCount / 3)) % fileCount;
|
||||
const crossNs = namespaces[crossNsIdx % namespaces.length]!;
|
||||
const crossClass = `Class${crossNsIdx}`;
|
||||
const usesCross = ns !== '' && ns !== crossNs;
|
||||
|
||||
const body = [
|
||||
ns !== '' ? `namespace ${ns};` : '',
|
||||
usesCross ? `using ${crossNs};` : '',
|
||||
'',
|
||||
`public class ${className}`,
|
||||
'{',
|
||||
' private int id;',
|
||||
' private string name;',
|
||||
'',
|
||||
' public int GetId()',
|
||||
' {',
|
||||
' return this.id;',
|
||||
' }',
|
||||
'',
|
||||
` public ${siblingClass} Process()`,
|
||||
' {',
|
||||
` var sibling = new ${siblingClass}();`,
|
||||
' return sibling;',
|
||||
' }',
|
||||
usesCross
|
||||
? [
|
||||
'',
|
||||
` public ${crossClass} CrossCall()`,
|
||||
' {',
|
||||
` var cross = new ${crossClass}();`,
|
||||
' cross.GetId();',
|
||||
' return cross;',
|
||||
' }',
|
||||
].join('\n')
|
||||
: '',
|
||||
'}',
|
||||
'',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
fs.writeFileSync(path.join(targetDir, `${className}.cs`), body);
|
||||
}
|
||||
|
||||
// Minimal SDK-style csproj so the C# project-loading phase engages
|
||||
// (matches the real-world Unity/.NET solution path).
|
||||
const csproj = [
|
||||
'<Project Sdk="Microsoft.NET.Sdk">',
|
||||
' <PropertyGroup>',
|
||||
' <TargetFramework>net8.0</TargetFramework>',
|
||||
' <Nullable>enable</Nullable>',
|
||||
' </PropertyGroup>',
|
||||
'</Project>',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(dir, 'Bench.csproj'), csproj);
|
||||
|
||||
return { dir, classCount, namespaceCount };
|
||||
}
|
||||
|
||||
async function runBenchmark(
|
||||
fileCount: number,
|
||||
nsLevels: number,
|
||||
shape: FixtureShape,
|
||||
budgetMs: number,
|
||||
): Promise<BenchResult> {
|
||||
const { dir, classCount, namespaceCount } = generateCsharpFixture(fileCount, nsLevels, shape);
|
||||
|
||||
let peakHeapMB = 0;
|
||||
const heapSampler = setInterval(() => {
|
||||
const heap = process.memoryUsage().heapUsed / 1024 / 1024;
|
||||
if (heap > peakHeapMB) peakHeapMB = heap;
|
||||
}, 50);
|
||||
|
||||
let budgetTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const start = Date.now();
|
||||
const result = await Promise.race([
|
||||
runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true }),
|
||||
new Promise<never>((_, reject) => {
|
||||
budgetTimer = setTimeout(
|
||||
() =>
|
||||
reject(new Error(`Pipeline exceeded ${budgetMs}ms at ${fileCount} files (${shape})`)),
|
||||
budgetMs,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
const elapsedMs = Date.now() - start;
|
||||
|
||||
return {
|
||||
fileCount,
|
||||
classCount,
|
||||
namespaceCount,
|
||||
elapsedMs,
|
||||
peakHeapMB: Math.round(peakHeapMB),
|
||||
nodeCount: result.graph.nodeCount,
|
||||
edgeCount: result.graph.relationshipCount,
|
||||
};
|
||||
} finally {
|
||||
clearInterval(heapSampler);
|
||||
clearTimeout(budgetTimer);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function printResults(label: string, results: BenchResult[]) {
|
||||
console.log(`\n${label}`);
|
||||
console.log('┌──────────┬─────────┬──────────┬───────────┬──────────┬───────┬───────┐');
|
||||
console.log('│ Files │ Classes │ NS Count │ Time (ms) │ Heap MB │ Nodes │ Edges │');
|
||||
console.log('├──────────┼─────────┼──────────┼───────────┼──────────┼───────┼───────┤');
|
||||
for (const r of results) {
|
||||
console.log(
|
||||
`│ ${String(r.fileCount).padStart(8)} │ ${String(r.classCount).padStart(7)} │ ${String(r.namespaceCount).padStart(8)} │ ${String(r.elapsedMs).padStart(9)} │ ${String(r.peakHeapMB).padStart(8)} │ ${String(r.nodeCount).padStart(5)} │ ${String(r.edgeCount).padStart(5)} │`,
|
||||
);
|
||||
}
|
||||
console.log('└──────────┴─────────┴──────────┴───────────┴──────────┴───────┴───────┘');
|
||||
|
||||
if (results.length >= 2) {
|
||||
console.log('\nScaling ratios (time_ratio / file_ratio):');
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
const fileRatio = results[i].fileCount / results[i - 1].fileCount;
|
||||
const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs;
|
||||
const scaling = timeRatio / fileRatio;
|
||||
console.log(
|
||||
` ${results[i - 1].fileCount} → ${results[i].fileCount}: ${scaling.toFixed(2)}x (${scaling < 1.5 ? 'linear' : scaling < 3 ? 'superlinear' : 'WARNING: quadratic'})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!BENCH_ENABLED)('C# pipeline benchmark', () => {
|
||||
it('scales with file count — namespaces spread across the solution', async () => {
|
||||
const scales = [100, 250, 500];
|
||||
const results: BenchResult[] = [];
|
||||
|
||||
for (const fileCount of scales) {
|
||||
const nsLevels = Math.max(2, Math.ceil(Math.sqrt(fileCount / 4)));
|
||||
const result = await runBenchmark(fileCount, nsLevels, 'spread', 180_000);
|
||||
results.push(result);
|
||||
console.log(
|
||||
` ${fileCount} files: ${result.elapsedMs}ms, ${result.peakHeapMB}MB heap, ${result.nodeCount} nodes, ${result.edgeCount} edges`,
|
||||
);
|
||||
}
|
||||
|
||||
printResults('C# Pipeline — Namespaces Spread', results);
|
||||
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
const fileRatio = results[i].fileCount / results[i - 1].fileCount;
|
||||
const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs;
|
||||
expect(timeRatio / fileRatio).toBeLessThan(3);
|
||||
}
|
||||
}, 600_000);
|
||||
|
||||
it('scales with file count — all types in one (global) namespace bucket', async () => {
|
||||
// Regression guard for the Unity-solution OOM: a single namespace
|
||||
// bucket holds every type def, so naive per-scope binding
|
||||
// materialisation is O(files²). Time must stay sub-quadratic and the
|
||||
// run must not OOM.
|
||||
const scales = [100, 250, 500];
|
||||
const results: BenchResult[] = [];
|
||||
|
||||
for (const fileCount of scales) {
|
||||
const result = await runBenchmark(fileCount, 1, 'concentrated', 180_000);
|
||||
results.push(result);
|
||||
console.log(
|
||||
` ${fileCount} files: ${result.elapsedMs}ms, ${result.peakHeapMB}MB heap, ${result.nodeCount} nodes, ${result.edgeCount} edges`,
|
||||
);
|
||||
}
|
||||
|
||||
printResults('C# Pipeline — Concentrated Global Namespace', results);
|
||||
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
const fileRatio = results[i].fileCount / results[i - 1].fileCount;
|
||||
const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs;
|
||||
expect(timeRatio / fileRatio).toBeLessThan(3);
|
||||
}
|
||||
}, 600_000);
|
||||
});
|
||||
|
|
@ -2447,9 +2447,12 @@ describe('C# class-name receiver write ACCESSES (merged Case 2 kind-aware branch
|
|||
// cross-namespace `using` and a colliding local class. Pins both fixes in
|
||||
// the resolver dataset:
|
||||
//
|
||||
// 1. emitCsharpScopeCaptures + extractFileStructure must use the adaptive
|
||||
// `getTreeSitterBufferSize` on cache miss, otherwise UserService.cs
|
||||
// fails to reparse with "Invalid argument" and CreateUser is dropped.
|
||||
// 1. emitCsharpScopeCaptures must use the adaptive `getTreeSitterBufferSize`
|
||||
// on cache miss, otherwise UserService.cs fails to reparse with "Invalid
|
||||
// argument" and CreateUser is dropped. (extractFileStructure no longer
|
||||
// re-parses on cache miss — it uses the line scanner,
|
||||
// extractCsharpStructureViaScanner — so this fixture's line-anchored
|
||||
// namespaces are read identically by either branch.)
|
||||
// 2. populateCsharpNamespaceSiblings must append to bindingAugmentations
|
||||
// instead of mutating frozen finalize-produced BindingRef[] arrays;
|
||||
// otherwise the cross-namespace inject loop throws "Cannot add property
|
||||
|
|
|
|||
99
gitnexus/test/unit/csharp-namespace-extraction.test.ts
Normal file
99
gitnexus/test/unit/csharp-namespace-extraction.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { extractCsharpStructureViaScanner } from '../../src/core/ingestion/languages/csharp/namespace-siblings.js';
|
||||
|
||||
// Scanner fallback used on the worker path, where native tree-sitter Trees
|
||||
// can't cross MessageChannels so `treeCache` is empty. It must reproduce
|
||||
// the AST walk's `namespaces` / `usingStaticPaths` for the common
|
||||
// line-anchored declaration forms (see namespace-siblings.ts).
|
||||
describe('extractCsharpStructureViaScanner', () => {
|
||||
it('extracts a file-scoped namespace declaration', () => {
|
||||
const src = `namespace App.Models;\n\npublic class User {}`;
|
||||
expect(extractCsharpStructureViaScanner(src).namespaces).toEqual(['App.Models']);
|
||||
});
|
||||
|
||||
it('extracts a block namespace declaration', () => {
|
||||
const src = `namespace App.Services\n{\n public class Svc {}\n}`;
|
||||
expect(extractCsharpStructureViaScanner(src).namespaces).toEqual(['App.Services']);
|
||||
});
|
||||
|
||||
it('extracts multiple namespaces in source order', () => {
|
||||
const src = `namespace A.One\n{\n}\nnamespace A.Two\n{\n}`;
|
||||
expect(extractCsharpStructureViaScanner(src).namespaces).toEqual(['A.One', 'A.Two']);
|
||||
});
|
||||
|
||||
it('returns empty namespaces for a global (no-namespace) file', () => {
|
||||
const src = `public class Global {}\n`;
|
||||
expect(extractCsharpStructureViaScanner(src).namespaces).toEqual([]);
|
||||
});
|
||||
|
||||
it('captures a plain `using static` path', () => {
|
||||
const src = `using static System.Math;\nnamespace App;`;
|
||||
const out = extractCsharpStructureViaScanner(src);
|
||||
expect(out.usingStaticPaths).toEqual(['System.Math']);
|
||||
expect(out.namespaces).toEqual(['App']);
|
||||
});
|
||||
|
||||
it('captures a `global using static` path', () => {
|
||||
const src = `global using static App.Utils.Logger;\n`;
|
||||
expect(extractCsharpStructureViaScanner(src).usingStaticPaths).toEqual(['App.Utils.Logger']);
|
||||
});
|
||||
|
||||
it('captures the RHS path of an aliased `using static`', () => {
|
||||
const src = `using static M = App.Utils.MathUtils;\n`;
|
||||
expect(extractCsharpStructureViaScanner(src).usingStaticPaths).toEqual(['App.Utils.MathUtils']);
|
||||
});
|
||||
|
||||
it('does not treat a plain `using` directive as using-static', () => {
|
||||
const src = `using System.Collections.Generic;\nusing App.Models;\n`;
|
||||
expect(extractCsharpStructureViaScanner(src).usingStaticPaths).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not treat a `using var`/`using (...)` statement as using-static', () => {
|
||||
const src = `using var stream = File.Open(p);\nusing (var x = Get()) { }\n`;
|
||||
expect(extractCsharpStructureViaScanner(src).usingStaticPaths).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores a `// namespace X` line comment', () => {
|
||||
const src = `// namespace Fake.Comment;\nnamespace App.Real;`;
|
||||
expect(extractCsharpStructureViaScanner(src).namespaces).toEqual(['App.Real']);
|
||||
});
|
||||
|
||||
it('handles indentation before declarations', () => {
|
||||
const src = `\t\tnamespace App.Indented;\n`;
|
||||
expect(extractCsharpStructureViaScanner(src).namespaces).toEqual(['App.Indented']);
|
||||
});
|
||||
|
||||
it('handles an empty file', () => {
|
||||
const out = extractCsharpStructureViaScanner('');
|
||||
expect(out.namespaces).toEqual([]);
|
||||
expect(out.usingStaticPaths).toEqual([]);
|
||||
});
|
||||
|
||||
// Cross-line comment/string state: a keyword at the start of a line inside
|
||||
// a block comment or multi-line string must NOT be read as a declaration
|
||||
// (the worker path would otherwise mis-bucket the file vs the AST).
|
||||
it('skips a `namespace` line inside a block comment', () => {
|
||||
const src = `/*\nnamespace Fake.InComment;\n*/\nnamespace App.Real;`;
|
||||
expect(extractCsharpStructureViaScanner(src).namespaces).toEqual(['App.Real']);
|
||||
});
|
||||
|
||||
it('skips a `using static` line inside a block comment', () => {
|
||||
const src = `/*\nusing static Fake.Helpers;\n*/\nusing static App.Real.Helpers;`;
|
||||
expect(extractCsharpStructureViaScanner(src).usingStaticPaths).toEqual(['App.Real.Helpers']);
|
||||
});
|
||||
|
||||
it('skips a `namespace` line inside a raw string literal', () => {
|
||||
const src = `var sql = """\nnamespace Fake.InRaw;\n""";\nnamespace App.Real;`;
|
||||
expect(extractCsharpStructureViaScanner(src).namespaces).toEqual(['App.Real']);
|
||||
});
|
||||
|
||||
it('skips a `namespace` line inside a verbatim string literal', () => {
|
||||
const src = `var s = @"\nnamespace Fake.InVerbatim;\n";\nnamespace App.Real;`;
|
||||
expect(extractCsharpStructureViaScanner(src).namespaces).toEqual(['App.Real']);
|
||||
});
|
||||
|
||||
it('still reads a real declaration after a closed same-line block comment', () => {
|
||||
const src = `/* header */ class C {}\nnamespace App.Real;`;
|
||||
expect(extractCsharpStructureViaScanner(src).namespaces).toEqual(['App.Real']);
|
||||
});
|
||||
});
|
||||
|
|
@ -283,7 +283,7 @@ describe('populateCsharpNamespaceSiblings', () => {
|
|||
expect(Object.isFrozen(augmented)).toBe(false);
|
||||
});
|
||||
|
||||
it('parses UTF-8-heavy cache-miss files before namespace sibling injection', () => {
|
||||
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');
|
||||
|
|
@ -322,6 +322,128 @@ describe('populateCsharpNamespaceSiblings', () => {
|
|||
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -22,10 +22,12 @@ const mkRef = (nodeId: string): BindingRef =>
|
|||
const mkIndexes = (
|
||||
bindings: Map<ScopeId, Map<string, readonly BindingRef[]>>,
|
||||
augmentations: Map<ScopeId, Map<string, BindingRef[]>>,
|
||||
workspace: Map<string, readonly BindingRef[]> = new Map(),
|
||||
): ScopeResolutionIndexes =>
|
||||
({
|
||||
bindings,
|
||||
bindingAugmentations: augmentations,
|
||||
workspaceFqnBindings: workspace,
|
||||
}) as unknown as ScopeResolutionIndexes;
|
||||
|
||||
describe('validateBindingsImmutability', () => {
|
||||
|
|
@ -88,6 +90,24 @@ describe('validateBindingsImmutability', () => {
|
|||
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('does not detect semantically wrong frozen replacements in indexes.bindings', () => {
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
const bindings = new Map<ScopeId, Map<string, readonly BindingRef[]>>([
|
||||
|
|
|
|||
|
|
@ -32,9 +32,11 @@ const ref = (nodeId: string, origin: BindingRef['origin'] = 'local'): 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) {
|
||||
|
|
@ -43,7 +45,13 @@ function indexesWith({
|
|||
}
|
||||
const bindingAugmentations = new Map<ScopeId, Map<string, readonly BindingRef[]>>();
|
||||
if (augmented !== undefined) bindingAugmentations.set(SCOPE, new Map([['name', augmented]]));
|
||||
return { bindings, bindingAugmentations } as unknown as ScopeResolutionIndexes;
|
||||
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 {
|
||||
|
|
@ -105,6 +113,33 @@ describe('lookupBindingsAt', () => {
|
|||
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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue