diff --git a/gitnexus/bench/receiver-resolution/BASELINE.md b/gitnexus/bench/receiver-resolution/BASELINE.md index 0a609cd50..bec309b2d 100644 --- a/gitnexus/bench/receiver-resolution/BASELINE.md +++ b/gitnexus/bench/receiver-resolution/BASELINE.md @@ -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?` 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?`, 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 diff --git a/gitnexus/bench/receiver-resolution/baseline.json b/gitnexus/bench/receiver-resolution/baseline.json index 16aa58cfb..a43c6613d 100644 --- a/gitnexus/bench/receiver-resolution/baseline.json +++ b/gitnexus/bench/receiver-resolution/baseline.json @@ -123,7 +123,7 @@ "indexElement": "VISIBLE-GAP", "fieldReceiverCall": "RESOLVES", "decoratedReceiverBase": "N/A", - "decoratedFieldType": "VISIBLE-GAP" + "decoratedFieldType": "RESOLVES" }, "ruby": { "plainChain": "VISIBLE-GAP", diff --git a/gitnexus/src/core/ingestion/languages/csharp/interpret.ts b/gitnexus/src/core/ingestion/languages/csharp/interpret.ts index ffb38a257..6ef504e96 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/interpret.ts @@ -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`), 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 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. diff --git a/gitnexus/src/core/ingestion/languages/csharp/query.ts b/gitnexus/src/core/ingestion/languages/csharp/query.ts index a219cc0c3..850aa1d4e 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/query.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/query.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index b09d9d849..ce224ef00 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -1937,6 +1937,7 @@ const KNOWN_SUB_TAGS: ReadonlySet = new Set([ '@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 diff --git a/gitnexus/test/integration/csharp-nullable-field-generic-arguments.test.ts b/gitnexus/test/integration/csharp-nullable-field-generic-arguments.test.ts new file mode 100644 index 000000000..52d7fa517 --- /dev/null +++ b/gitnexus/test/integration/csharp-nullable-field-generic-arguments.test.ts @@ -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 { public void Save() {} }', + 'Outer.cs': 'class Outer { public void Save() {} }', + 'Inner.cs': 'class Inner { public void Save() {} }', + 'User.cs': `class User { public void Save() {} } + namespace Models { class User { public void Save() {} } }`, + 'Host.cs': `class Host { + List? plain; + List? dotted; + Custom.List? qualified; + Outer.Inner? nestedOuter; + List */Models.User>? commented; + List + Models.User>? lineCommented; + Outer.Inner? commentedOuter; + Outer/* < */.Inner? 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([]); + }); +}); diff --git a/gitnexus/test/integration/csharp-nullable-field-isolated-ownership.test.ts b/gitnexus/test/integration/csharp-nullable-field-isolated-ownership.test.ts new file mode 100644 index 000000000..a3c51dbba --- /dev/null +++ b/gitnexus/test/integration/csharp-nullable-field-isolated-ownership.test.ts @@ -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, + ); +}); diff --git a/gitnexus/test/integration/csharp-nullable-field-review.test.ts b/gitnexus/test/integration/csharp-nullable-field-review.test.ts new file mode 100644 index 000000000..27c240ae0 --- /dev/null +++ b/gitnexus/test/integration/csharp-nullable-field-review.test.ts @@ -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 {} } + namespace GenericCaller { + class User { public void Save() {} } + class Decoy { public void Save() {} } + class Host { Custom.List? 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']); + }); +}); diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index a28231da4..4a1849b34 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -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) // --------------------------------------------------------------------------- diff --git a/gitnexus/test/unit/scope-resolution/csharp/csharp-nullable-field-interpret.test.ts b/gitnexus/test/unit/scope-resolution/csharp/csharp-nullable-field-interpret.test.ts new file mode 100644 index 000000000..e57d9e5fa --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/csharp/csharp-nullable-field-interpret.test.ts @@ -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', + 'List', + 'Dictionary', + 'List>', + 'Box.Inner>', + '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', + 'Custom.List', + 'Outer.Inner', + 'Outer>.Inner', + 'Outer.Values', + 'Outer.Inner', + 'Outer/* < */.Inner', + 'Outer// <\n.Inner', + ])('declines a qualified outer receiver %s', (type) => { + expect(nullableField(`${type}?`)).toBeNull(); + }); +});