diff --git a/gitnexus/src/core/ingestion/field-extractors/generic.ts b/gitnexus/src/core/ingestion/field-extractors/generic.ts index c8eae1049..7e22053ee 100644 --- a/gitnexus/src/core/ingestion/field-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/field-extractors/generic.ts @@ -149,10 +149,14 @@ export function createFieldExtractor(config: FieldExtractionConfig): FieldExtrac if (result.length === 0 && bodyField) { result.push(bodyField); } - // Last resort: when no body wrapper exists (e.g. tree-sitter-zig's - // struct_declaration directly contains its container_field children), - // use the type-declaration node itself as the body. The downstream - // walk filters by `fieldNodeTypes`, so unrelated children are ignored. + // Grammars with no body wrapper at all: a config that declares NO + // `bodyNodeTypes` (tree-sitter-zig's struct_declaration holds its + // container_field children directly) uses the type-declaration node + // itself as the body. The downstream walk filters by `fieldNodeTypes`, + // so unrelated children are ignored. Deliberately NOT a fallback for + // configs that do declare body wrappers: for them a node without its + // wrapper is a bodiless declaration, and scanning it would change every + // such language for no field it could find. if (result.length === 0 && bodyNodeSet.size === 0) { result.push(node); } diff --git a/gitnexus/src/core/ingestion/languages/zig/query.ts b/gitnexus/src/core/ingestion/languages/zig/query.ts index 778d7a4ed..72edaac0b 100644 --- a/gitnexus/src/core/ingestion/languages/zig/query.ts +++ b/gitnexus/src/core/ingestion/languages/zig/query.ts @@ -356,6 +356,21 @@ const ZIG_SCOPE_QUERY = ` ;; References — constructor uses: T{ ... } (struct_initializer (identifier) @reference.name) @reference.call.constructor + +;; References — qualified constructor uses: mod.T{ ... } / hub.sub.T{ ... } +;; Captured with the RECEIVER, not as a free constructor with a raw qualified +;; name, on purpose: the free-call fallback resolves a qualified constructor by +;; its simple tail, and a workspace-unique \`Thing\` then answers for +;; \`other.Thing{}\` whichever module the source named (measured: \`c.Thing{}\` +;; with no \`Thing\` in c.zig bound to a.zig's). With the receiver the site goes +;; through the receiver-bound namespace case, which resolves the member inside +;; the module the receiver is bound to — the same path \`mod.fn()\` takes — so +;; \`a.Thing{}\` and \`b.Thing{}\` each bind their own file and \`std.Thread.Mutex{}\` +;; binds nothing even when a local \`Mutex\` exists. +(struct_initializer + (field_expression + object: (_) @reference.receiver + member: (identifier) @reference.name)) @reference.call.constructor `; let _parser: Parser | null = null; diff --git a/gitnexus/src/core/ingestion/method-extractors/generic.ts b/gitnexus/src/core/ingestion/method-extractors/generic.ts index 7cdbc0e5e..b47272525 100644 --- a/gitnexus/src/core/ingestion/method-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/method-extractors/generic.ts @@ -166,10 +166,14 @@ function findBodies(node: SyntaxNode, bodyNodeSet: Set): SyntaxNode[] { result.push(bodyField); addNestedBodies(bodyField, bodyNodeSet, result); } - // Last resort: when no body wrapper exists (e.g. tree-sitter-zig's - // struct_declaration directly contains its function_declaration children), - // use the type-declaration node itself as the body. The downstream walk - // filters by `methodNodeTypes`, so unrelated children are ignored. + // Grammars with no body wrapper at all: a config that declares NO + // `bodyNodeTypes` (tree-sitter-zig's struct_declaration holds its + // function_declaration children directly) uses the type-declaration node + // itself as the body. The downstream walk filters by `methodNodeTypes`, so + // unrelated children are ignored. Deliberately NOT a fallback for configs + // that do declare body wrappers: for them a node without its wrapper is a + // bodiless declaration (forward declaration, `declare class`), and scanning + // it would change every such language for no method it could find. if (result.length === 0 && bodyNodeSet.size === 0) { result.push(node); } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 0000b2451..6c4ef4ea4 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -86,6 +86,7 @@ import { tryEmitEdgeWithExplicitTargetId, type CalleeIdCaptureCtx, } from '../graph-bridge/edges.js'; +import { constructionSiteReason } from './free-call-fallback.js'; import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js'; import { resolveCompoundReceiverClass, @@ -135,6 +136,7 @@ type ReceiverBoundProviderSubset = Pick< | 'constraintCompatibility' | 'isStaticOnly' | 'normalizeTypeArgument' + | 'markConstructionSites' >; /** A bare, undecorated identifier and nothing else — see {@link isBareTypeName}. */ @@ -1293,7 +1295,15 @@ export function emitReceiverBoundCalls( nodeLookup, site, memberDef, - memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global', + // A namespace-qualified construction site (`mod.T{…}`) resolves + // here like `mod.fn()` does; the provider's opt-in marker keeps + // it distinguishable from an invocation (see + // `ScopeResolver.markConstructionSites`). + constructionSiteReason( + memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global', + site, + provider.markConstructionSites, + ), seen, 0.85, collapse, diff --git a/gitnexus/test/fixtures/lang-resolution/zig-qualified-literal/src/a.zig b/gitnexus/test/fixtures/lang-resolution/zig-qualified-literal/src/a.zig new file mode 100644 index 000000000..0c9076b7e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-qualified-literal/src/a.zig @@ -0,0 +1,3 @@ +pub const Thing = struct { + a: u32 = 0, +}; diff --git a/gitnexus/test/fixtures/lang-resolution/zig-qualified-literal/src/b.zig b/gitnexus/test/fixtures/lang-resolution/zig-qualified-literal/src/b.zig new file mode 100644 index 000000000..c191a3093 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-qualified-literal/src/b.zig @@ -0,0 +1,3 @@ +pub const Thing = struct { + b: u32 = 0, +}; diff --git a/gitnexus/test/fixtures/lang-resolution/zig-qualified-literal/src/c.zig b/gitnexus/test/fixtures/lang-resolution/zig-qualified-literal/src/c.zig new file mode 100644 index 000000000..c8a13854e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-qualified-literal/src/c.zig @@ -0,0 +1,3 @@ +pub const Other = struct { + c: u32 = 0, +}; diff --git a/gitnexus/test/fixtures/lang-resolution/zig-qualified-literal/src/d.zig b/gitnexus/test/fixtures/lang-resolution/zig-qualified-literal/src/d.zig new file mode 100644 index 000000000..06f993c66 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-qualified-literal/src/d.zig @@ -0,0 +1,3 @@ +pub const Mutex = struct { + locked: bool = false, +}; diff --git a/gitnexus/test/fixtures/lang-resolution/zig-qualified-literal/src/main.zig b/gitnexus/test/fixtures/lang-resolution/zig-qualified-literal/src/main.zig new file mode 100644 index 000000000..9a69b0609 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-qualified-literal/src/main.zig @@ -0,0 +1,43 @@ +const std = @import("std"); +const a = @import("a.zig"); +const b = @import("b.zig"); +const c = @import("c.zig"); +const d = @import("d.zig"); + +// A same-named local type, so an external literal below has a tempting +// same-tail candidate in THIS file as well as in d.zig. +const Mutex = struct { + held: bool = false, +}; + +fn useA() a.Thing { + return a.Thing{ .a = 1 }; +} + +fn useB() b.Thing { + return b.Thing{ .b = 2 }; +} + +fn useMissing() void { + // `c.zig` defines no `Thing`: this must not bind to a.zig's or b.zig's. + _ = c.Thing{}; +} + +fn useExternal() void { + // `std` is external: neither the local `Mutex` nor d.zig's may answer. + _ = std.Thread.Mutex{}; + _ = std.mem.Allocator{ .ptr = undefined, .vtable = undefined }; +} + +fn useLocal() void { + _ = Mutex{}; + _ = d.Mutex{}; +} + +pub fn main() void { + _ = useA(); + _ = useB(); + useMissing(); + useExternal(); + useLocal(); +} diff --git a/gitnexus/test/integration/resolvers/zig.test.ts b/gitnexus/test/integration/resolvers/zig.test.ts index 97b0e83be..8314e753b 100644 --- a/gitnexus/test/integration/resolvers/zig.test.ts +++ b/gitnexus/test/integration/resolvers/zig.test.ts @@ -684,9 +684,10 @@ describe.skipIf(!zigAvailable)('Zig function-local and anonymous containers (F8) // main.zig is a call, not a construction site. expect(reasonsOf('main', 'helper')).toHaveLength(1); expect(reasonsOf('main', 'helper')[0]).not.toContain('(constructor)'); - // No construction site is emitted as anything but CALLS, and every other - // CALLS edge is an invocation. + // Every marked edge targets a container type: a construction site can + // never point at a callable. const constructionSites = calls.filter((e) => e.rel.reason.endsWith('(constructor)')); + expect(constructionSites.length).toBeGreaterThan(0); expect(constructionSites.map((e) => e.targetLabel)).toEqual( constructionSites.map(() => 'Struct'), ); @@ -773,3 +774,73 @@ describe.skipIf(!zigAvailable)('Zig function-local and anonymous containers (F8) ); }); }); + +describe.skipIf(!zigAvailable)( + 'Zig qualified struct literals (`mod.T{…}`) as construction sites', + () => { + // The 2026-09-02 review re-test found that only same-file literals were + // tracked: 163 `mod.Type{ … }` sites in a real project produced no CALLS + // edge at all. A first attempt captured them as free constructors, which + // resolve by the simple tail — `c.Thing{}` bound to a.zig's `Thing` although + // c.zig defines none. Captured WITH the receiver they take the namespace + // path `mod.fn()` takes, which resolves inside the module the receiver is + // bound to: this suite pins the qualifier being honoured, not just the + // edge appearing. + let result: PipelineResult; + let structCalls: ReturnType; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-qualified-literal'), () => {}); + structCalls = getRelationships(result, 'CALLS').filter((e) => e.targetLabel === 'Struct'); + }, 60000); + + const edgesFrom = (source: string): string[] => + structCalls + .filter((e) => e.source === source) + .map((e) => `${e.target} @ ${e.targetFilePath} [${e.rel.reason}]`) + .sort(); + + it('binds each same-named `Thing` to the module its qualifier names, marked as a construction site', () => { + expect(edgesFrom('useA')).toEqual(['Thing @ src/a.zig [import-resolved (constructor)]']); + expect(edgesFrom('useB')).toEqual(['Thing @ src/b.zig [import-resolved (constructor)]']); + }); + + it('emits nothing when the qualifier’s module has no such member (`c.Thing{}`)', () => { + // A tail-only resolution would have picked a.zig's or b.zig's `Thing`. + expect(edgesFrom('useMissing')).toEqual([]); + }); + + it('emits nothing for an external qualifier (`std.Thread.Mutex{}`), even with a same-named local and imported `Mutex`', () => { + expect(edgesFrom('useExternal')).toEqual([]); + }); + + it('keeps the same-file literal and the single-hop qualified literal apart by reason vocabulary', () => { + expect(edgesFrom('useLocal')).toEqual([ + 'Mutex @ src/d.zig [import-resolved (constructor)]', + 'Mutex @ src/main.zig [local-call (constructor)]', + ]); + }); + }, +); + +describe.skipIf(!zigAvailable)( + 'Zig qualified struct literals — zig-basic (`pioneer.Pioneer{…}`, `pioneer.Tag{…}`)', + () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-basic'), () => {}); + }, 60000); + + it('tracks a qualified struct AND union literal as marked construction sites', () => { + const marked = getRelationships(result, 'CALLS') + .filter((e) => e.source === 'main' && e.rel.reason.endsWith('(constructor)')) + .map((e) => `${e.target}:${e.targetLabel} [${e.rel.reason}]`) + .sort(); + expect(marked).toEqual([ + 'Pioneer:Struct [import-resolved (constructor)]', + 'Tag:Union [import-resolved (constructor)]', + ]); + }); + }, +);