fix(csharp): bind nullable field types for receiver resolution (#3498) (#3664)

* fix(csharp): bind nullable field types for receiver resolution (#3498)

A C# field written `User? repo;` got no type binding. Its type is a
`nullable_type` node, and the field_declaration rules only matched
identifier, generic_name and qualified_name types. Parameters, properties,
method returns and foreach variables already have a nullable_type rule, so
fields were the odd one out: the class scope had no binding for `repo`,
and `this.repo.Save()` was dropped as receiver-unresolved.

Add the missing field rule. interpretCsharpTypeBinding already strips the
`?`, so the field binds `User`, as the property spelling does.

Shape arm: csharp `decoratedFieldType` moves VISIBLE-GAP -> RESOLVES, with
the edge on `User.Save`. No corpus fixture declares a nullable field, so
the count arm and every other cell are unchanged, and the corpus edge
(2838) and drop (192) sets match main exactly. Scope-capture fingerprints
are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(csharp): preserve nullable field ownership

* fix(csharp): refuse unowned nullable types

* test(csharp): guard nullable-member fixture cleanup (#3664)

Address nexus-check review on resolvers/csharp.test.ts:1471: skip the
temp-root removal when mkdtempSync never ran, so a setup failure is not
masked by a secondary rmSync(undefined) TypeError. Matches the guard in
csharp-nullable-field-review.test.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(bench): scope nullable wrapper claim to what the code does (#3664)

The ownership-controls note said the nullable-field marker preserves
custom generic wrappers. Its only control, Custom.List<User>?, passes
through the qualified-type refusal instead. State that unqualified
wrappers keep the wrapper type (no native control yet) and that
qualified spellings, custom wrappers included, stay unbound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: preserve C# integration notes

* fix(csharp): preserve generic argument qualifiers

Ignore comments when checking outer nullable-field qualification.

Refs #3664

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Gergő Magyar 2026-10-22 23:05:33 +03:00 • committed by GitHub
parent 36e16434ea
commit af5fd9665c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 399 additions and 6 deletions

View file

@ -1,5 +1,15 @@
# Receiver-resolution baseline
## C# nullable field types (#3498)
A field written `User? repo;` has a `nullable_type` as its declared type.
Parameters, properties, method returns and foreach variables already had a
`nullable_type` binding rule, but fields did not, so the class scope held no
binding for `repo` and `this.repo.Save()` was dropped. With the new field rule,
`csharp.decoratedFieldType` moves from VISIBLE-GAP to RESOLVES, and the edge
lands on `User.Save`. No corpus fixture declares a nullable field, so the count
arm and every other shape cell are unchanged.
## PHP namespaced-function factory results (#3498 follow-up)
PHP types results of namespaced function calls such as `rp\make()`,
@ -1110,10 +1120,50 @@ The re-measured combined baseline is 139 call drops, 31 reads, no writes and
Main `2b4456553` is merged normally. The field lookup retains both the declared namespace purpose and provider-qualified type handling. Build and both TypeScript configurations pass, and all 59 focused C++ and CommonJS merge checks pass. The measured receiver baseline has 133 call drops, 160 total drops, 27 reads and 0 writes.
## Nullable field ownership controls
Unqualified nullable wrapper fields such as `List<User>?` keep the wrapper as
their type instead of unwrapping to `User`; no native control covers that
shape. Qualified nullable fields, including custom wrappers such as
`Custom.List<User>?`, remain unsupported: their type binding is refused
until namespace-aware lookup can establish the written owner. Both external
and in-workspace qualified spellings record unresolved call drops instead of
borrowing a workspace-unique tail name. Local User? fields still resolve.
The first mixed fixture masked a unique-name fallback by declaring several
User classes. An isolated one-User fixture reproduces that false edge. The
correction passes 240 native C# tests in three files, including distinct
negative repos, a local positive and explicit receiver-unresolved receipts.
A separate qualified-positive probe exposed the existing unsupported shape;
that case is documented as a limit, not claimed as a repaired positive.
The 5,323-record corpus is byte-identical to main 2b4456553, and the receiver
benchmark retains 133 calls, 160 drops of all kinds, 27 reads and no writes.
The earlier 273-test run predates this last boundary correction.
### After merging #3653 into the C# namespace branch
Main `2b4456553` is merged normally. The measured baseline has 130 call drops and 156 across all kinds, with 26 reads and zero writes. Main has 133 calls, 27 reads and zero writes (160 total). Every shape state matches main. The fresh build and both TypeScript configurations pass, as do all 68 namespace-boundary, capture, hook and CommonJS merge checks.
After merging main caacc3bac normally, 321 tests across six C# ownership, resolver, extractor, golden and namespace-precedence files pass. Package build, both pinned TypeScript configurations and lint pass (zero errors, seven existing warnings). The C# capture check retains fingerprint 28a51b97c32bf95d85e5a322e443ab00cd490a1178269c2f6f9cfc4fb9776686: 186 files, 2,841 groups, ratio 1.109 within the unchanged 1.5 budget. The corpus is byte-identical to main with 5,328 records. Receiver totals are 130 calls, 156 all kinds, 26 reads and zero writes; only csharp.decoratedFieldType moves VISIBLE-GAP to RESOLVES. Qualified nullable fields remain unsupported as described above.
After merging main c30449394 normally, 324 tests across the same six files pass. Package build, both pinned TypeScript configurations and lint (zero errors) pass. All 16 scope-capture checks pass against main's baselines; C# keeps fingerprint 3720a3d2 with 193 files, 2,899 groups and ratio 1.142 within the unchanged 1.5 budget. The 5,501-record corpus is byte-identical to main. The regenerated receiver baseline keeps main's 132 calls, 160 all kinds, 28 reads and zero writes, with origins 30 external, 45 unknown, 55 in-program and 2 unclassified; only csharp.decoratedFieldType moves VISIBLE-GAP to RESOLVES.
### C# nullable fields with the prepared PHP parent
The normal merge of PHP parent `6149ce156` includes Kotlin parent `d5a7c73fa`.
The three C# implementation changes remain identical to the reviewed `8b986c68a`
source. Package build, both pinned TypeScript configurations and 324 tests in
six files pass. The C# capture check retains fingerprint `3720a3d2`, 193 entries
and 2,899 groups, with ratio 1.022 below the unchanged 1.5 budget. The earlier
main-only integration's all-16-language capture run is not repeated here.
The corpus contains 5,559 records, byte-identical to the PHP parent's corpus.
Receiver update and check agree on 138 call drops, 168 total, 30 reads and no
writes, also matching that parent. Origins are 57 in-program, 49 unknown,
30 external and two unclassified. Only `csharp.decoratedFieldType` changes
from VISIBLE-GAP to RESOLVES relative to the parent. Final integration with
current main and exact-head hosted CI remain required.
After merging main caacc3bac normally, totals remain 130 calls, 156 across all kinds, 26 reads and zero writes. Origin labels are 30 external, 45 unknown, 53 in-program and 2 unclassified. Shape states are unchanged. The 5,328-record corpus is byte-identical to current main. Package build, both pinned TypeScript configurations, 23 CommonJS/C# precedence tests and 15 library-origin boundary tests pass.
### Same-package inheritance after #3655

View file

@ -123,7 +123,7 @@
"indexElement": "VISIBLE-GAP",
"fieldReceiverCall": "RESOLVES",
"decoratedReceiverBase": "N/A",
"decoratedFieldType": "VISIBLE-GAP"
"decoratedFieldType": "RESOLVES"
},
"ruby": {
"plainChain": "VISIBLE-GAP",

View file

@ -77,10 +77,28 @@ export function interpretCsharpTypeBinding(captures: CaptureMatch): ParsedTypeBi
const typeCap = captures['@type-binding.type'];
if (nameCap === undefined || typeCap === undefined) return null;
// Strip nullable suffix (`User?` → `User`), single-arg generic wrapper
// (`List<User>` → `User`), and qualifier (`System.User` → `User`) so
// receiver-typed resolution treats these identically.
const rawType = stripQualifier(stripGeneric(stripNullable(typeCap.text.trim())));
// Other bindings retain their existing nullable, wrapper and qualifier
// normalization. Nullable field bindings keep wrappers as receiver types.
const withoutNullable = stripNullable(typeCap.text.trim());
// Qualified nullable fields need namespace-aware type lookup. The generic
// receiver fallback can otherwise borrow an unrelated unique tail name.
// Leave these unbound until the provider can establish the written owner.
if (captures['@type-binding.nullable-field'] !== undefined) {
// Comments can contain punctuation that is not part of the type.
const qualificationText = withoutNullable.replace(/\/\*[\s\S]*?\*\/|\/\/[^\r\n]*/g, '');
let depth = 0;
for (const char of qualificationText) {
if (char === '<') depth++;
else if (char === '>') depth = Math.max(0, depth - 1);
// A qualifier inside a generic argument does not qualify the receiver.
else if (char === '.' && depth === 0) return null;
}
}
// A nullable wrapper field is not its element: List<User> is not User.
const rawType =
captures['@type-binding.nullable-field'] !== undefined
? withoutNullable
: stripQualifier(stripGeneric(withoutNullable));
// Anchor captures distinguish the source of the binding. Order
// matters: more-specific anchors take precedence.

View file

@ -319,6 +319,14 @@ const CSHARP_SCOPE_QUERY = `
(variable_declarator
name: (identifier) @type-binding.name))) @type-binding.annotation
;; \`private User? _user;\` — same nullable_type shape that parameters and
;; properties already bind. Preserve this field's written type ownership.
(field_declaration
(variable_declaration
type: (nullable_type) @type-binding.type
(variable_declarator
name: (identifier) @type-binding.name))) @type-binding.annotation @type-binding.nullable-field
;; Type bindings — property declaration: \`public User Owner { get; set; }\`.
(property_declaration
type: (identifier) @type-binding.type

View file

@ -1937,6 +1937,7 @@ const KNOWN_SUB_TAGS: ReadonlySet<string> = new Set<string>([
'@import.publishes',
'@type-binding.name',
'@type-binding.type',
'@type-binding.nullable-field',
'@reference.name',
'@reference.qualified-name',
// The generic arguments a heritage base was written with, when the emitter's

View file

@ -0,0 +1,87 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
getRelationships,
runPipelineFromRepo,
writeFixtureRepo,
type PipelineResult,
} from './resolvers/helpers.js';
describe('nullable generic field receiver ownership', () => {
let root: string | undefined;
let result: PipelineResult;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-csharp-generic-field-'));
writeFixtureRepo(root, {
'Wrapper.cs': 'class List<T> { public void Save() {} }',
'Outer.cs': 'class Outer<T> { public void Save() {} }',
'Inner.cs': 'class Inner<T> { public void Save() {} }',
'User.cs': `class User { public void Save() {} }
namespace Models { class User { public void Save() {} } }`,
'Host.cs': `class Host {
List<User>? plain;
List<Models.User>? dotted;
Custom.List<User>? qualified;
Outer<User>.Inner<Models.User>? nestedOuter;
List</* > */Models.User>? commented;
List<// >
Models.User>? lineCommented;
Outer</* < */User>.Inner<User>? commentedOuter;
Outer<User>/* < */.Inner<User>? commentedSuffix;
void PlainCall() { this.plain.Save(); }
void DottedCall() { this.dotted.Save(); }
void QualifiedCall() { this.qualified.Save(); }
void NestedOuterCall() { this.nestedOuter.Save(); }
void CommentedCall() { this.commented.Save(); }
void LineCommentedCall() { this.lineCommented.Save(); }
void CommentedOuterCall() { this.commentedOuter.Save(); }
void CommentedSuffixCall() { this.commentedSuffix.Save(); }
}`,
});
result = await runPipelineFromRepo(root, () => {}, {});
}, 240000);
afterAll(() => {
if (root) fs.rmSync(root, { recursive: true, force: true });
});
const saves = (source: string) =>
getRelationships(result, 'CALLS')
.filter((edge) => edge.source === source && edge.target === 'Save')
.map((edge) => edge.targetFilePath);
it('keeps an unqualified wrapper as the receiver', () => {
expect(saves('PlainCall')).toEqual(['Wrapper.cs']);
});
it('keeps qualification inside the argument without selecting User.Save', () => {
expect(saves('DottedCall')).toEqual(['Wrapper.cs']);
});
it('still declines a qualified outer wrapper', () => {
expect(saves('QualifiedCall')).toEqual([]);
});
it('still declines qualification after a generic outer type', () => {
expect(saves('NestedOuterCall')).toEqual([]);
});
it('ignores a block comment when classifying an argument-qualified wrapper', () => {
expect(saves('CommentedCall')).toEqual(['Wrapper.cs']);
});
it('ignores a line comment when classifying an argument-qualified wrapper', () => {
expect(saves('LineCommentedCall')).toEqual(['Wrapper.cs']);
});
it('does not borrow standalone Outer or Inner through an argument comment', () => {
expect(saves('CommentedOuterCall')).toEqual([]);
});
it('does not borrow standalone Outer or Inner through a suffix comment', () => {
expect(saves('CommentedSuffixCall')).toEqual([]);
});
});

View file

@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
getRelationships,
getResolutionOutcomes,
runPipelineFromRepo,
writeFixtureRepo,
} from './resolvers/helpers.js';
describe('C# nullable field isolated owner boundary', () => {
it.each([
['external qualified owner', 'External.User?', '', false],
['local nullable owner', 'User?', '', true],
[
'workspace qualified owner remains unsupported',
'Actual.User?',
'namespace Actual { public class User { public void Save() {} } }',
false,
],
] as const)(
'%s',
async (_label, type, qualifiedDefinition, expected) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-csharp-isolated-owner-'));
try {
writeFixtureRepo(root, {
'Models.cs': `${qualifiedDefinition}
public class User { public void Save() {} }
public class Decoy { public void Save() {} }
public class Host { private ${type} repo; public void Probe() { this.repo.Save(); } }
`,
});
const result = await runPipelineFromRepo(root, () => {}, {});
const calls = getRelationships(result, 'CALLS').filter(
(e) => e.source === 'Probe' && e.target === 'Save',
);
expect(calls).toHaveLength(expected ? 1 : 0);
if (expected) expect(calls[0]!.rel.targetId).toContain('User.Save');
else
expect(
getResolutionOutcomes(result).some(
(outcome) =>
outcome.kind === 'suppressed' &&
outcome.name === 'Save' &&
outcome.reason === 'receiver-unresolved',
),
).toBe(true);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
},
120000,
);
});

View file

@ -0,0 +1,51 @@
import { beforeAll, afterAll, describe, expect, it } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
getRelationships,
runPipelineFromRepo,
writeFixtureRepo,
type PipelineResult,
} from './resolvers/helpers.js';
describe('nullable field receiver ownership', () => {
let root: string;
let result: PipelineResult;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-csharp-nullable-'));
writeFixtureRepo(root, {
'Foreign.cs': `namespace ForeignCaller {
class User { public void Save() {} }
class Decoy { public void Save() {} }
class Host { External.User? repo; void ForeignCall(){this.repo.Save();} }
}`,
'Generic.cs': `namespace Custom { class List<T> {} }
namespace GenericCaller {
class User { public void Save() {} }
class Decoy { public void Save() {} }
class Host { Custom.List<User>? repo; void GenericCall(){this.repo.Save();} }
}`,
'Local.cs': `namespace LocalCaller {
class User { public void Save() {} }
class Host { User? repo; void LocalCall(){this.repo.Save();} }
}`,
});
result = await runPipelineFromRepo(root, () => {}, {});
}, 120000);
afterAll(() => {
if (root) fs.rmSync(root, { recursive: true, force: true });
});
const saves = (source: string) =>
getRelationships(result, 'CALLS')
.filter((e) => e.source === source && e.target === 'Save')
.map((e) => e.targetFilePath);
it('keeps a failed qualified nullable type unresolved', () => {
expect(saves('ForeignCall')).toEqual([]);
});
it('does not unwrap a nullable custom generic wrapper to its argument', () => {
expect(saves('GenericCall')).toEqual([]);
});
it('still binds the local nullable class field', () => {
expect(saves('LocalCall')).toEqual(['Local.cs']);
});
});

View file

@ -1,7 +1,7 @@
/**
* C#: heritage resolution via base_list + ambiguous namespace-import refusal
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import path from 'path';
import fs from 'node:fs';
import os from 'node:os';
@ -1417,6 +1417,80 @@ describe('Field type resolution (C#)', () => {
});
});
// ---------------------------------------------------------------------------
// Nullable member types as call receivers (#3498)
//
// `User? repo;` is a field_declaration whose variable_declaration type is a
// `nullable_type`. Parameters and properties had a nullable_type binding rule;
// fields did not, so the class scope held no binding for `repo` and
// `this.repo.Save()` was dropped as receiver-unresolved. `Address` and `User`
// both declare `Save`, so a by-name fallback cannot pass these rows.
// ---------------------------------------------------------------------------
describe('C# nullable member types as call receivers (#3498)', () => {
let result: PipelineResult;
let root: string;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-csharp-nullable-members-'));
writeFixtureRepo(root, {
'Address.cs': `public class Address {
public void Save() {}
}
`,
'User.cs': `public class User {
public void Save() {}
}
`,
'Holders.cs': `class NullableFieldHolder {
User? repo;
void ViaNullableField() {
this.repo.Save();
}
}
class NullablePropertyHolder {
User? Repo { get; set; }
void ViaNullableProperty() {
this.Repo.Save();
}
}
class PlainFieldHolder {
User repo;
void ViaPlainField() {
this.repo.Save();
}
}
`,
});
result = await runPipelineFromRepo(root, () => {});
}, 60000);
afterAll(() => {
if (root) fs.rmSync(root, { recursive: true, force: true });
});
it.each([
{ caller: 'ViaNullableField', spelling: 'User? repo;' },
{ caller: 'ViaNullableProperty', spelling: 'User? Repo { get; set; }' },
{ caller: 'ViaPlainField', spelling: 'User repo; (control)' },
])('$caller ($spelling) calls User.Save only', ({ caller }) => {
const targets = getRelationships(result, 'CALLS')
.filter((edge) => edge.source === caller && edge.target === 'Save')
.map((edge) => `${edge.targetLabel}:${edge.targetFilePath}`);
expect(targets).toEqual(['Method:User.cs']);
});
it('records no receiver-unresolved drop in the holders', () => {
const drops = getResolutionOutcomes(result)
.filter((outcome) => outcome.kind === 'suppressed')
.filter((outcome) => outcome.filePath === 'Holders.cs')
.map((outcome) => `${outcome.reason}:${outcome.name}:${outcome.range.startLine}`);
expect(drops).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// Phase 8A: Deep field chain resolution (3-level)
// ---------------------------------------------------------------------------

View file

@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { interpretCsharpTypeBinding } from '../../../../src/core/ingestion/languages/csharp/interpret.js';
const range = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 } as const;
const capture = (name: string, text: string): Capture => ({ name, text, range });
function nullableField(type: string) {
const captures: CaptureMatch = {
'@type-binding.name': capture('@type-binding.name', 'field'),
'@type-binding.type': capture('@type-binding.type', type),
'@type-binding.annotation': capture('@type-binding.annotation', type),
'@type-binding.nullable-field': capture('@type-binding.nullable-field', type),
};
return interpretCsharpTypeBinding(captures);
}
describe('nullable field outer qualification', () => {
it.each([
'List<User>',
'List<Models.User>',
'Dictionary<string, Models.User>',
'List<Dictionary<string, Models.User>>',
'Box<Models.Outer<int>.Inner<string>>',
'List</* > */Models.User>',
'List<// >\nModels.User>',
])('preserves the entire unqualified wrapper %s', (type) => {
expect(nullableField(`${type}?`)).toEqual({
boundName: 'field',
rawTypeName: type,
source: 'annotation',
});
});
it.each([
'Actual.User',
'External.User',
'Custom.List<User>',
'Custom.List<Models.User>',
'Outer<User>.Inner<Models.User>',
'Outer<List<Models.User>>.Inner<User>',
'Outer<User>.Values',
'Outer</* < */User>.Inner<User>',
'Outer<User>/* < */.Inner<User>',
'Outer<User>// <\n.Inner<User>',
])('declines a qualified outer receiver %s', (type) => {
expect(nullableField(`${type}?`)).toBeNull();
});
});