feat(zig): track qualified struct literals (mod.T{…}) as construction sites

PR #1432 re-test (issue comment on 97571d23): 163 qualified literals
`mod.Type{ … }` in a real project produced no CALLS edge at all, so only
same-file and imported-name literals were tracked as construction sites.

One query rule captures `(struct_initializer (field_expression object
member))` as `@reference.call.constructor` WITH the receiver. Captured as a
free constructor instead, the site resolves by its simple tail and a
workspace-unique `Thing` answers for `c.Thing{}` whichever module the
source named (measured: c.zig defines no `Thing`, the edge went to a.zig's).
With the receiver the site takes the receiver-bound namespace case — the
path `mod.fn()` takes — which resolves inside the module the receiver is
bound to: `a.Thing{}` / `b.Thing{}` bind their own files, `c.Thing{}` binds
nothing, `std.Thread.Mutex{}` binds nothing next to a local `Mutex`.

That case's edge now goes through `constructionSiteReason` too, so the
opt-in marker (`import-resolved (constructor)` / `global (constructor)`)
reaches it; `markConstructionSites` joins `ReceiverBoundProviderSubset`.
Byte-identical for every provider that does not set the flag.

Also answers the twelfth gitnexus-check pass: the `bodyNodeSet.size === 0`
guard on the extractor factories' no-wrapper branch is deliberate (a config
with wrappers whose node lacks one is a bodiless declaration); the two
comments now say so instead of reading as a universal last resort. Go's
method config, the only other empty-`bodyNodeTypes` config, never reaches
the branch (its `extract()` gates on method/function nodes the class-node
caller never passes).

Tests: new `zig-qualified-literal` fixture (same-named `Thing` in two
modules, a module without it, an external `std` qualifier next to a local
and an imported `Mutex`); `zig-basic` pins `pioneer.Pioneer{…}` and the
union `pioneer.Tag{…}` as marked construction sites.
This commit is contained in:
Navid EMAD 2026-09-02 20:35:27 +02:00
parent 97571d23f5
commit e2b13233ce
No known key found for this signature in database
10 changed files with 170 additions and 11 deletions

View file

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

View file

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

View file

@ -166,10 +166,14 @@ function findBodies(node: SyntaxNode, bodyNodeSet: Set<string>): 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);
}

View file

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

View file

@ -0,0 +1,3 @@
pub const Thing = struct {
a: u32 = 0,
};

View file

@ -0,0 +1,3 @@
pub const Thing = struct {
b: u32 = 0,
};

View file

@ -0,0 +1,3 @@
pub const Other = struct {
c: u32 = 0,
};

View file

@ -0,0 +1,3 @@
pub const Mutex = struct {
locked: bool = false,
};

View file

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

View file

@ -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<typeof getRelationships>;
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)]',
]);
});
},
);