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>
This commit is contained in:
Gergo Magyar 2026-05-29 18:07:29 +00:00
parent f0381b2616
commit 47d8472c03
8 changed files with 139 additions and 26 deletions

View file

@ -472,21 +472,25 @@ export function populateCsharpNamespaceSiblings(
const local = localScope?.bindings.get(name);
if (local !== undefined && local.some((b) => b.origin === 'local')) continue;
let bucketArr: BindingRef[] | null = null;
let seen: Set<string> | 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 (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.
seen = new Set<string>();
for (const b of bucketArr) seen.add(b.def.nodeId);
const seen = new Set<string>();
for (const b of bucket) seen.add(b.def.nodeId);
inject = { bucket, seen };
}
if (seen!.has(def.nodeId)) continue;
seen!.add(def.nodeId);
bucketArr.push({ def, origin: 'namespace' });
if (inject.seen.has(def.nodeId)) continue;
inject.seen.add(def.nodeId);
inject.bucket.push({ def, origin: 'namespace' });
}
}
}

View file

@ -79,13 +79,13 @@ export interface ScopeResolutionIndexes {
readonly bindingAugmentations: ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>;
/** Workspace-level binding lookup, shared instead of per-scope
* duplication. Consulted by `lookupBindingsAt` as a third source after
* finalized and per-scope augmented bindings. Two languages populate it
* with disjoint key formats that never collide:
* - PHP namespace-siblings (Step 3b): backslash-separated FQNs
* (e.g. `App\Models\User`).
* - C# namespace-siblings: simple names for global-(default-)namespace
* types (e.g. `User`), which are visible from every file — one entry
* per simple name instead of O(scopes × defs) per-scope augmentation. */
* 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[];

View file

@ -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;
}

View file

@ -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);

View file

@ -147,17 +147,18 @@ async function runBenchmark(
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) =>
setTimeout(
new Promise<never>((_, reject) => {
budgetTimer = setTimeout(
() =>
reject(new Error(`Pipeline exceeded ${budgetMs}ms at ${fileCount} files (${shape})`)),
budgetMs,
),
),
);
}),
]);
const elapsedMs = Date.now() - start;
@ -172,6 +173,7 @@ async function runBenchmark(
};
} finally {
clearInterval(heapSampler);
clearTimeout(budgetTimer);
fs.rmSync(dir, { recursive: true, force: true });
}
}

View file

@ -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

View file

@ -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');
@ -383,6 +383,66 @@ describe('populateCsharpNamespaceSiblings', () => {
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);
});
});

View file

@ -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[]>>([