diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index cab3765a4..81e8b92ec 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -46,8 +46,9 @@ "_note": "#2046: F35 qualified-constructor captures now emit @reference.qualified-name + a simple-name @reference.name on `new Ns.Foo()`/`new A.B.Foo()`; namespace_declaration/file_scoped_namespace_declaration now emit @declaration.namespace name captures (feeding the non-destructive namespacePrefix sidecar for `new B.Foo()` same-tail disambiguation). + csharp-interface-only-base and csharp-namespace-qualified-ctor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.11)." }, "rust": { - "fingerprint": "df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29", + "fingerprint": "f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846", "scaling_budget": 1.5, + "_rebaselined_dyn_trait_object_2604": "#2604: RUST_SCOPE_QUERY now captures function_signature_item (abstract trait methods, no body) as a scope + declaration, so a &dyn Trait receiver can dispatch a CALLS edge to the trait's own method. Additive capture shift across every bench fixture with a required trait method. Prior df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29 -> f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846; scaling 1.033 < 1.5.", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c -> df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29; scaling 1.065 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Rust fn-value callable flow facts with invocation/constructor-result suppression. Prior ac610bbe97666bf285923479dd7b43a2fe4c5354aae8df1bcbafdc04fb220f82 -> 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c; scaling 1.024 < 1.5.", "_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) \u2014 legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", diff --git a/gitnexus/src/core/ingestion/languages/rust/interpret.ts b/gitnexus/src/core/ingestion/languages/rust/interpret.ts index a53a6e1c2..73ecd264e 100644 --- a/gitnexus/src/core/ingestion/languages/rust/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/rust/interpret.ts @@ -2,8 +2,23 @@ import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'git const REF_PREFIX_RE = /^&\s*(mut\s+)?/; const PTR_PREFIX_RE = /^\*\s*(const|mut)?\s*/; +const DYN_PREFIX_RE = /^dyn\s+/; const ENUM_VARIANT_NAMES = new Set(['Some', 'None', 'Ok', 'Err']); +// `dyn Trait`, `&dyn Trait`, `Box` all name a trait object whose +// receiver-dispatch target is the trait itself (#2604) — strip the `dyn` +// keyword and any auto-trait/lifetime bound list (`dyn Trait + Send`) down to +// the principal trait name. Reference/pointer sigils are stripped by the +// caller first; wrapper unwrapping (Box etc.) runs before this so the +// unwrapped inner text still gets the same treatment. +function stripDynBound(t: string): string { + if (!DYN_PREFIX_RE.test(t)) return t; + t = t.replace(DYN_PREFIX_RE, ''); + const plus = t.indexOf('+'); + if (plus !== -1) t = t.slice(0, plus); + return t.trim(); +} + // ─── interpretImport ────────────────────────────────────────────────────── export function interpretRustImport(captures: CaptureMatch): ParsedImport | null { @@ -98,6 +113,7 @@ export function normalizeRustTypeName(text: string): string { const inner = extractFirstGenericArg(t); if (inner !== null) t = inner; } + t = stripDynBound(t); const bracket = t.indexOf('<'); if (bracket !== -1) t = t.slice(0, bracket); // Take last segment of qualified paths (crate::foo::Bar → Bar) @@ -158,6 +174,7 @@ function normalizeRustReturnType(text: string): string { } } } + t = stripDynBound(t); const bracket = t.indexOf('<'); if (bracket !== -1) t = t.slice(0, bracket); const lastColon = t.lastIndexOf('::'); diff --git a/gitnexus/src/core/ingestion/languages/rust/query.ts b/gitnexus/src/core/ingestion/languages/rust/query.ts index a0e75f0fa..bef3f1bd7 100644 --- a/gitnexus/src/core/ingestion/languages/rust/query.ts +++ b/gitnexus/src/core/ingestion/languages/rust/query.ts @@ -10,6 +10,7 @@ const RUST_SCOPE_QUERY = ` (enum_item) @scope.class (union_item) @scope.class (function_item) @scope.function +(function_signature_item) @scope.function (closure_expression) @scope.function (block) @scope.block (if_expression) @scope.block @@ -55,6 +56,14 @@ const RUST_SCOPE_QUERY = ` (function_item name: (identifier) @declaration.name) @declaration.function +;; Declarations — trait method signature (required method, no body, +;; e.g. fn foo(self) -> T; inside a trait body). Without this, an abstract +;; trait method is invisible to scope resolution — never owned by its +;; trait's Class scope, so a dyn Trait receiver can never dispatch to +;; it (#2604). +(function_signature_item + name: (identifier) @declaration.name) @declaration.function + ;; Declarations — struct fields (field_declaration name: (field_identifier) @declaration.name diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index e34432a7a..9852e8ba4 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -437,8 +437,15 @@ export interface RepoMeta { * `Record` node and its `HAS_METHOD` edges for every unchanged record file * (same v7 contract: new nodes/edges the incremental path would otherwise * never backfill); force a full re-analyze instead. + * v11: Rust abstract trait methods (`fn foo(&self) -> T;`, no body) now get a + * scope + declaration capture (#2604): RUST_SCOPE_QUERY had no + * `function_signature_item` pattern, so a `&dyn Trait` receiver could never + * dispatch a CALLS edge to the trait's own method. Same v7/v10 contract: the + * incremental write set only covers changed files, so a top-up against a + * pre-v11 index would keep silently missing these CALLS edges for every + * unchanged Rust trait file; force a full re-analyze instead. */ -export const INCREMENTAL_SCHEMA_VERSION = 10; +export const INCREMENTAL_SCHEMA_VERSION = 11; export interface IndexedRepo { repoPath: string; diff --git a/gitnexus/test/fixtures/lang-resolution/rust-dyn-trait-object/src/lib.rs b/gitnexus/test/fixtures/lang-resolution/rust-dyn-trait-object/src/lib.rs new file mode 100644 index 000000000..d25dad83d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-dyn-trait-object/src/lib.rs @@ -0,0 +1,15 @@ +pub trait Behaviour { + fn trait_target(&self) -> u32; +} + +pub struct Impl1; + +impl Behaviour for Impl1 { + fn trait_target(&self) -> u32 { + 7 + } +} + +pub fn calls_via_dyn(b: &dyn Behaviour) -> u32 { + b.trait_target() +} diff --git a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json index d977d99bf..f632f78aa 100644 --- a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json @@ -1,7 +1,7 @@ { "rust-abstract-dispatch/src/lib.rs": { - "captureGroups": 30, - "digest": "88309004d1ab00054f81bc55c1d058fc4ca25781162d6b94b7e7ce631a5d61b2" + "captureGroups": 34, + "digest": "973679363065ecd54c4e5128a9fab214ea27eca24f0f079c63a3c6f285e678b0" }, "rust-abstract-dispatch/src/main.rs": { "captureGroups": 21, @@ -148,8 +148,8 @@ "digest": "e0120e3f215282e68d83b4f8f5d8918945e0b3e7ce4e0128c6afd2aa43caa1c0" }, "rust-cross-module-collision/src/traits.rs": { - "captureGroups": 3, - "digest": "88eef9d92ea6e370bd8ef7fbf64c42ec622ec933fb53c56b32bc67db87fa8e03" + "captureGroups": 5, + "digest": "c7150a5052e0e2b5fd7fc21cc8ce361530fe5cc60f67ad0e2ef945bb97965b53" }, "rust-deep-field-chain/models.rs": { "captureGroups": 24, @@ -171,6 +171,10 @@ "captureGroups": 22, "digest": "c53db401a81fde2ffd5665393acb9cd605a62ec51c015c3aafb3f41c0897471f" }, + "rust-dyn-trait-object/src/lib.rs": { + "captureGroups": 23, + "digest": "720618dff6a43ab8e5b59aa354c0c448b9057dd6f2f7b3b22b13b82d53745943" + }, "rust-err-unwrap/src/error.rs": { "captureGroups": 9, "digest": "798c8e01c6e54792ba69e845248efc8abf0cba38fa3d16fb8e0d1f6dd2ad2b7e" @@ -316,8 +320,8 @@ "digest": "cd836a2a9c15ab240961d2e15f192f7e33d65eb5ebf2e1a8af2f620a47fe66ae" }, "rust-method-enrichment/src/lib.rs": { - "captureGroups": 40, - "digest": "71627a8218e32514b6945e4e310686eb631ce37451c90c9644e3f5336a37820b" + "captureGroups": 42, + "digest": "a4d9ca570fbb1ff1859a0b4f737aa3507b236f99700d37ded2c8c36518add567" }, "rust-method-enrichment/src/main.rs": { "captureGroups": 18, @@ -360,8 +364,8 @@ "digest": "141388068614e16d96f27cfdf18ac9001b9e202ce832fe10f38dab990637b3ab" }, "rust-parent-resolution/src/serializable.rs": { - "captureGroups": 3, - "digest": "f35d44f44d81e3a0be40f68ba9dbd4bde6f01659fa15b6db34a458ad460f904e" + "captureGroups": 5, + "digest": "f33bb881dd937cdd5af2eca6b0284ea79ca2d296c79217513f882dcba8a82fd8" }, "rust-parent-resolution/src/user.rs": { "captureGroups": 13, @@ -372,8 +376,8 @@ "digest": "bc8946d31db81b85d780633608fdaa7565258cd788285fa00cd6dcb0de3dd16c" }, "rust-qualified-trait/src/traits.rs": { - "captureGroups": 5, - "digest": "15be069f28f1400e4beb0b0860acb59979f78549960486f36a92f56578f05a06" + "captureGroups": 9, + "digest": "10f3bba4c2a16cdac77de0498ff506910ac09cc1a85e7daebfc5743c54e26015" }, "rust-qualified-trait/src/widget.rs": { "captureGroups": 23, @@ -492,12 +496,12 @@ "digest": "f1b9f72d74467be55a8b7679215b49bcabb4d0fced6080f752672070b32ed93d" }, "rust-traits/src/traits/clickable.rs": { - "captureGroups": 3, - "digest": "3ed5b27c172d48f83929715ba92d1030a282f9f1e29ec2fcdd3d7e9efbc54a84" + "captureGroups": 7, + "digest": "83c36832f24446fe03a07288dd394bc7494a54e5979c9b71c1dcb8b19ab54341" }, "rust-traits/src/traits/drawable.rs": { - "captureGroups": 5, - "digest": "1dca39bbc7c1b1b66f1a34730b9a5b4dba04c54ee9d2688255e0fd4e6bc48499" + "captureGroups": 9, + "digest": "cee5091f041038722f1f012394a75ba4e16870d05b2dafa37371200e785198f0" }, "rust-union/lib.rs": { "captureGroups": 10, diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index 9cd4f94e8..fe970b777 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -1951,6 +1951,31 @@ describe('Rust abstract dispatch (Repository trait)', () => { }); }); +// --------------------------------------------------------------------------- +// #2604: trait-object (&dyn Trait) receiver dispatch +// --------------------------------------------------------------------------- + +describe('Rust dyn trait-object dispatch (#2604)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-dyn-trait-object'), () => {}); + }, 60000); + + it('detects Impl1 struct and Behaviour trait', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('Impl1'); + expect(getNodesByLabel(result, 'Trait')).toContain('Behaviour'); + }); + + it('emits exactly one CALLS edge from calls_via_dyn(b: &dyn Behaviour) to trait_target', () => { + const calls = getRelationships(result, 'CALLS'); + const dynCalls = calls.filter( + (c) => c.source === 'calls_via_dyn' && c.target === 'trait_target', + ); + expect(dynCalls.length).toBe(1); + }); +}); + // --------------------------------------------------------------------------- // SM-11: Rust Child extends Parent — qualified-syntax MRO // diff --git a/gitnexus/test/unit/call-summary-schema-version.test.ts b/gitnexus/test/unit/call-summary-schema-version.test.ts index a8240fa0f..2047754c4 100644 --- a/gitnexus/test/unit/call-summary-schema-version.test.ts +++ b/gitnexus/test/unit/call-summary-schema-version.test.ts @@ -73,8 +73,8 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => { }); describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { - it('INCREMENTAL_SCHEMA_VERSION is bumped to 10 (Java record container-node re-index window)', () => { - expect(INCREMENTAL_SCHEMA_VERSION).toBe(10); + it('INCREMENTAL_SCHEMA_VERSION is bumped to 11 (Rust dyn-trait-object dispatch re-index window, #2604)', () => { + expect(INCREMENTAL_SCHEMA_VERSION).toBe(11); }); it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => { @@ -112,7 +112,11 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { // (#2564) — a record's methods would keep being ownerless Method nodes // with no HAS_METHOD edge on unchanged files → must NOT reuse. expect(passesReuseGate(9)).toBe(false); + // A pre-v11 (v10) index predates the Rust dyn-trait-object dispatch fix + // (#2604) — abstract trait methods would keep being uncaptured (no + // ownerId/CALLS resolution) on unchanged Rust trait files → must NOT reuse. + expect(passesReuseGate(10)).toBe(false); // A current-version stamp passes the gate (incremental top-up eligible). - expect(passesReuseGate(10)).toBe(true); + expect(passesReuseGate(11)).toBe(true); }); }); diff --git a/gitnexus/test/unit/scope-resolution/rust/rust-dyn-type-normalization.test.ts b/gitnexus/test/unit/scope-resolution/rust/rust-dyn-type-normalization.test.ts new file mode 100644 index 000000000..273741764 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/rust/rust-dyn-type-normalization.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import type { CaptureMatch } from 'gitnexus-shared'; +import { + normalizeRustTypeName, + interpretRustTypeBinding, +} from '../../../../src/core/ingestion/languages/rust/interpret.js'; + +const RANGE = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 }; + +/** Builds a minimal @type-binding.return CaptureMatch to exercise + * normalizeRustReturnType (private, only reachable through this hook). */ +function returnTypeBinding(type: string): CaptureMatch { + return { + '@type-binding.name': { name: '@type-binding.name', range: RANGE, text: 'f' }, + '@type-binding.type': { name: '@type-binding.type', range: RANGE, text: type }, + '@type-binding.return': { name: '@type-binding.return', range: RANGE, text: '' }, + }; +} + +/** + * #2604 coverage gap (GitNexus review-agent finding): stripDynBound's + * documented Box and bound-list (dyn Trait + Send) shapes had no + * test anywhere, even though the interpret.ts comment claims they're handled. + * These exercise normalizeRustTypeName/normalizeRustReturnType directly — + * stripDynBound itself is a private helper reached only through them. + */ +describe('Rust dyn-trait-object type-name normalization (#2604)', () => { + it('strips a bare dyn Trait parameter type', () => { + expect(normalizeRustTypeName('&dyn Behaviour')).toBe('Behaviour'); + expect(normalizeRustTypeName('dyn Behaviour')).toBe('Behaviour'); + }); + + it('strips dyn through Box/Rc/Arc wrappers', () => { + expect(normalizeRustTypeName('Box')).toBe('Trait'); + expect(normalizeRustTypeName('Rc')).toBe('Trait'); + expect(normalizeRustTypeName('Arc')).toBe('Trait'); + }); + + it('drops an auto-trait/lifetime bound list after dyn', () => { + expect(normalizeRustTypeName('dyn Trait + Send')).toBe('Trait'); + expect(normalizeRustTypeName("dyn Trait + Send + 'static")).toBe('Trait'); + expect(normalizeRustTypeName("Box")).toBe('Trait'); + }); + + it("truncates a dyn trait's own generic arguments after stripping dyn", () => { + expect(normalizeRustTypeName('dyn Iterator')).toBe('Iterator'); + }); + + it('strips dyn in return-type position, including through &', () => { + expect(interpretRustTypeBinding(returnTypeBinding('&dyn Trait'))?.rawTypeName).toBe('Trait'); + expect(interpretRustTypeBinding(returnTypeBinding('dyn Trait + Send'))?.rawTypeName).toBe( + 'Trait', + ); + }); + + it('leaves ordinary (non-dyn) type names untouched', () => { + expect(normalizeRustTypeName('Behaviour')).toBe('Behaviour'); + expect(normalizeRustTypeName('&Behaviour')).toBe('Behaviour'); + expect(normalizeRustTypeName('Box')).toBe('Behaviour'); + }); +});