feat(zig): resolve hub re-exports, enum-variant receivers and type-named receivers (real-project audit)

Audit of three real Zig projects indexed with this branch (tigerbeetle 246
files, mach 132, ghostty 788): method reachability was 63 % / 35 % / 55 %,
and three shapes accounted for most of the misses.

1. Hub modules. Zig projects publish types through a file made only of
   re-exports (`pub const Terminal = @import("Terminal.zig");`, `pub const
   PRNG = @import("prng.zig");`, `pub const Thing = @import("thing.zig")
   .Thing;`). Such a file owns NO local binding, and `findExportedDef` reads
   local bindings only — so `terminal.Terminal.init()`, `t: stdx.Thing`,
   `var p = stdx.PRNG.from_seed()` and `h: stdx.BoundedArrayType(u8, 4)`
   all resolved to nothing. Measured before → after: CALLS into ghostty's
   `src/terminal/` from outside it 46 → 253 (150 `terminal.Terminal.` sites
   alone); into tigerbeetle's `stdx` hub from outside it 837 → 1500 (136
   static calls, 289 annotations). Method reachability: tigerbeetle
   2249 → 2272 of 3544, ghostty 2766 → 2865 of 5016, mach 1047 → 1051 of
   2967 (mach's hub publishes generic instantiations, `pub const Quat =
   q.Quat(f32)`, a shape this commit does not cover).
   `findExportedDefIncludingImportedNames` reads the finalized channel
   (origin import / namespace / reexport, def already resolved to the
   declaring file), refusing a name bound to two distinct defs. Opt-in per
   provider (`namespaceExportsIncludeImportedNames`): a module's imports are
   not its exports in most languages; Zig opts in because a hub member a
   consumer can name is public by construction. Used by receiver-bound Case
   1, Case 3, the compound resolver's namespace branch, and a new Case 2
   route that resolves a namespace-qualified class receiver (`stdx.PRNG`)
   through the same lookup.

2. Enum variants as receivers. `Operation.create_accounts.event_max()`
   (147 sites in tigerbeetle): a variant has no written type, but it has
   one — the enum itself. `emitZigScopeCaptures` now emits a field type
   binding per enum variant, so the field walk that already handles
   `self.session.name()` types `Op.create` as `Op`.

3. Receivers named after their type. `self` is a convention, not a rule:
   tigerbeetle writes `replica: *Replica` (777 of 1127 methods), mach
   `pool: *@This()` (764 of 833). Reading only `self` as the receiver
   labelled all of them `isStatic: true`, counted the receiver in their
   arity (`Counter.incr#1`) and sourced the scope binding as a plain
   parameter. `zigReceiverParameter` is the single rule for both phases:
   the FIRST parameter when named `self`, or typed as the enclosing
   container (`@This()`, its binding name, a `const X = @This();` alias),
   pointers / const / optionals stripped.

Fixtures `zig-hub` and `zig-receivers` pin each shape, including the
refusals: a private hub import does not leak, a foreign-typed first
parameter is not a receiver, a factory stays static.
This commit is contained in:
Navid EMAD 2026-09-02 21:16:48 +02:00
parent e2b13233ce
commit e5b1067209
No known key found for this signature in database
22 changed files with 713 additions and 66 deletions

View file

@ -199,6 +199,84 @@ export function zigContainerBindingName(
return zigTypeConstructorOf(containerNode)?.childForFieldName('name')?.text;
}
/** The nominal type spelled at a parameter with its sigils stripped:
* `*const Self` → `Self`, `?*T` → `T`, `Pool(Node)` → `Pool`; `@This()` is
* kept whole (a builtin, not a constructor name). */
function zigParameterNominalType(typeText: string): string {
let t = typeText.trim();
let previous: string;
do {
previous = t;
t = t.replace(/^(?:\*|\?|const\s+|\s+)/, '');
} while (t !== previous);
if (!t.startsWith('@')) {
const paren = t.indexOf('(');
if (paren > 0) t = t.slice(0, paren).trim();
}
return t;
}
/** The `const X = @This();` alias names declared DIRECTLY in `container` (a
* container node, or the root of a file-struct). */
function zigThisAliasNamesIn(container: SyntaxNode): Set<string> {
const out = new Set<string>();
for (let i = 0; i < container.namedChildCount; i++) {
const decl = container.namedChild(i);
if (decl === null || decl.type !== 'variable_declaration') continue;
const named = decl.namedChildren.filter((c): c is SyntaxNode => c !== null);
const value = named[named.length - 1];
if (
named.length === 2 &&
named[0]!.type === 'identifier' &&
value?.type === 'builtin_function' &&
value.namedChild(0)?.text === '@This' &&
isZigKeywordDeclaration(decl)
) {
out.add(named[0]!.text);
}
}
return out;
}
/** The RECEIVER parameter of a container method, or null for a static fn.
*
* Zig has no receiver keyword: `self` is a convention, not a rule, and real
* code names the receiver after its type as often as not — tigerbeetle
* (`replica: *Replica`, 777 of 1127 methods), mach (`pool: *@This()`, 764 of
* 833). Treating only `self` as the receiver labelled those methods static,
* counted the receiver in their arity (`Counter.incr#1`) and left the
* scope-side binding sourced as a plain parameter. The rule, applied to the
* FIRST parameter only:
* - named `self`, whatever its type; or
* - typed as the enclosing container: `@This()`, the container's binding
* name (`Counter`, the generic constructor `Pool` for `Pool(Node)`, the
* file stem for a file-struct when `filePath` is known), or a
* `const X = @This();` alias declared in that container (`Self`, `PRNG`
* in `prng.zig`) — pointers, `const` and optionals stripped.
* `fn` must be a direct child of a container (or the file root); a fn nested
* in a body is never a method. Single source for the method extractor
* (parameters / receiver type / `isStatic`) and `emitZigScopeCaptures`
* (`@type-binding.receiver`), so the structure and scope phases agree. */
export function zigReceiverParameter(fn: SyntaxNode, filePath?: string): SyntaxNode | null {
const paramList = fn.namedChildren.find(
(child): child is SyntaxNode => child?.type === 'parameters',
);
const first = paramList?.namedChild(0);
if (first === null || first === undefined || first.type !== 'parameter') return null;
if (first.childForFieldName('name')?.text === 'self') return first;
const typeNode = first.childForFieldName('type');
if (typeNode === null) return null;
const nominal = zigParameterNominalType(typeNode.text);
if (nominal.length === 0) return null;
if (nominal === '@This()') return first;
const container = fn.parent;
if (container === null || container === undefined) return null;
if (!ZIG_CONTAINER_TYPES.has(container.type) && container.type !== 'source_file') return null;
if (zigThisAliasNamesIn(container).has(nominal)) return first;
const binding = zigContainerBindingName(container, filePath);
return binding !== undefined && nominal === binding ? first : null;
}
/** The graph IDENTITY of a Zig container node — the name its class-like
* node, its members' owner segment (`Method:<file>:<name>.<fn>`) and the
* scope-side def all carry. Single source for the class/field/method
@ -1229,12 +1307,13 @@ export function emitZigScopeCaptures(
}
}
// Zig's receiver convention is specifically the FIRST parameter named
// `self`. Tag first-position parameters so `interpretZigTypeBinding` can
// require the position and not just the name — a later `self` parameter
// (legal Zig) is an ordinary parameter, not a receiver. The synthetic
// capture sits on the name node (smaller than the `parameter` anchor), so
// it never displaces the anchor.
// The receiver is the FIRST parameter when it is named `self` or typed as
// the enclosing container — `zigReceiverParameter` is the single rule,
// shared with the method extractor so the two phases agree. Tag it so
// `interpretZigTypeBinding` sources the binding as `self`; a later `self`
// parameter (legal Zig) is an ordinary parameter, not a receiver. The
// synthetic captures sit on the name node (smaller than the `parameter`
// anchor), so they never displace the anchor.
const paramAnchor = nodeMap['@type-binding.parameter'];
const paramName = nodeMap['@type-binding.name'];
if (
@ -1247,6 +1326,18 @@ export function emitZigScopeCaptures(
paramName,
'true',
);
const fnNode = paramAnchor.parent?.parent;
if (
fnNode !== null &&
fnNode !== undefined &&
zigReceiverParameter(fnNode, _filePath)?.id === paramAnchor.id
) {
grouped['@type-binding.receiver'] = syntheticCapture(
'@type-binding.receiver',
paramName,
'true',
);
}
}
// Mark containers returned by a generic type constructor so
@ -1341,6 +1432,29 @@ export function emitZigScopeCaptures(
? nodeToCapture('@type-binding.type', fieldType)
: syntheticCapture('@type-binding.type', fieldType, rewritten),
});
} else if (
fieldAnchor !== undefined &&
fieldType === undefined &&
fieldName !== undefined &&
fieldName.text !== '_' &&
fieldAnchor.parent?.type === 'enum_declaration'
) {
// An enum VARIANT has no written type, but it has one: the enum itself.
// `Operation.create_accounts.event_max()` — a method called on a variant
// reached through the type — needs `Operation.create_accounts` typed as
// `Operation` for the compound resolver to walk the field like
// `self.session.name()`. tigerbeetle writes this shape 147 times
// (`Operation.<variant>.event_max(…)`, `TestOperation.create.event_size(`).
const enumName =
zigContainerBindingName(fieldAnchor.parent, _filePath) ??
zigContainerName(fieldAnchor.parent, _filePath);
if (enumName !== undefined) {
out.push({
'@type-binding.field': nodeToCapture('@type-binding.field', fieldName),
'@type-binding.name': nodeToCapture('@type-binding.name', fieldName),
'@type-binding.type': syntheticCapture('@type-binding.type', fieldName, enumName),
});
}
}
// `const Page = @import("Page.zig")` binds BOTH the module (namespace:

View file

@ -119,11 +119,13 @@ export function interpretZigTypeBinding(captures: CaptureMatch): ParsedTypeBindi
let source: TypeRef['source'] = 'annotation';
if (captures['@type-binding.parameter'] !== undefined) {
// Zig has no implicit receiver keyword; the convention is a FIRST
// parameter named `self`. `emitZigScopeCaptures` tags first-position
// parameters; the name alone is not enough — `fn f(a: u32, self: T)` is
// legal and `self` there is an ordinary parameter, not a receiver.
const isReceiver = name === 'self' && captures['@type-binding.first-parameter'] !== undefined;
// Zig has no implicit receiver keyword. `emitZigScopeCaptures` tags the
// receiver parameter (`@type-binding.receiver`, see `zigReceiverParameter`):
// the FIRST parameter when it is named `self` OR typed as the enclosing
// container (`counter: *Counter`, `pool: *@This()`, `prng: *PRNG` with
// `const PRNG = @This();`). Position matters — `fn f(a: u32, self: T)` is
// legal and `self` there is an ordinary parameter.
const isReceiver = captures['@type-binding.receiver'] !== undefined;
source = isReceiver ? 'self' : 'parameter-annotation';
} else if (captures['@type-binding.constructor'] !== undefined) {
source = 'constructor-inferred';

View file

@ -30,6 +30,14 @@ export const zigScopeResolver: ScopeResolver = {
// itself — `main → SpawnRequest` looked like a call to a function
// (PR #1432 review). Emits `local-call (constructor)` and friends.
markConstructionSites: true,
// Hub modules are how Zig projects publish their types: `pub const Terminal
// = @import("Terminal.zig");` / `pub const PRNG = @import("prng.zig");` in a
// file that declares nothing itself. Consumers then write
// `terminal.Terminal.init()`, `stdx.PRNG.from_seed()`, `t: stdx.Thing`.
// Measured on real projects before → after this flag: CALLS into ghostty's
// `src/terminal/` from outside it 46 → 253; into tigerbeetle's `stdx` hub
// from outside it 837 → 1500 (136 `stdx.Type.fn(` sites, 289 annotations).
namespaceExportsIncludeImportedNames: true,
loadResolutionConfig: (repoPath: string) => loadZigBuildConfig(repoPath),

View file

@ -2,7 +2,11 @@ import { SupportedLanguages } from 'gitnexus-shared';
import type { MethodExtractionConfig, ParameterInfo } from '../../method-types.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { hasZigPubKeyword } from '../../export-detection.js';
import { ZIG_CONTAINER_TYPES, zigContainerName } from '../../languages/zig/captures.js';
import {
ZIG_CONTAINER_TYPES,
zigContainerName,
zigReceiverParameter,
} from '../../languages/zig/captures.js';
/**
* Zig method extraction.
@ -13,8 +17,10 @@ import { ZIG_CONTAINER_TYPES, zigContainerName } from '../../languages/zig/captu
* {…}; }`) — `zigContainerName` decides. Methods inside a container appear as
* plain `function_declaration` children of the container node.
*
* The first parameter is the receiver iff it is named `self` (convention) —
* unlike Rust, Zig has no dedicated `self_parameter` node type.
* The first parameter is the receiver when it is named `self` OR typed as the
* enclosing container (`replica: *Replica`, `pool: *@This()`) — see
* `zigReceiverParameter`; unlike Rust, Zig has no dedicated `self_parameter`
* node type and `self` is a convention, not a rule.
*/
const extractZigOwnerName = (node: SyntaxNode, filePath?: string): string | undefined =>
@ -44,7 +50,7 @@ const extractZigReturnType = (node: SyntaxNode): string | undefined => {
};
/**
* Regular parameters only. A leading `self` parameter is the receiver — it is
* Regular parameters only. The receiver parameter (`zigReceiverParameter`) is
* reported through `extractReceiverType`, not the parameter list (same split
* as Rust's `self_parameter` skip in `configs/rust.ts`).
*/
@ -52,15 +58,13 @@ const extractZigParameters = (node: SyntaxNode): ParameterInfo[] => {
const paramList = zigParameterList(node);
if (!paramList) return [];
const params: ParameterInfo[] = [];
let seenParameter = false;
const receiver = zigReceiverParameter(node);
for (let i = 0; i < paramList.namedChildCount; i++) {
const param = paramList.namedChild(i);
if (!param || param.type !== 'parameter') continue;
if (receiver !== null && param.id === receiver.id) continue;
const nameNode = param.childForFieldName('name');
const typeNode = param.childForFieldName('type');
const isReceiver = !seenParameter && nameNode?.text === 'self';
seenParameter = true;
if (isReceiver) continue;
params.push({
name: nameNode?.text ?? '?',
type: typeNode?.text?.trim() ?? null,
@ -72,16 +76,8 @@ const extractZigParameters = (node: SyntaxNode): ParameterInfo[] => {
return params;
};
const extractZigReceiverType = (node: SyntaxNode): string | undefined => {
const paramList = zigParameterList(node);
if (!paramList) return undefined;
const first = paramList.namedChild(0);
if (!first || first.type !== 'parameter') return undefined;
const nameNode = first.childForFieldName('name');
if (nameNode?.text !== 'self') return undefined;
const typeNode = first.childForFieldName('type');
return typeNode?.text?.trim();
};
const extractZigReceiverType = (node: SyntaxNode): string | undefined =>
zigReceiverParameter(node)?.childForFieldName('type')?.text?.trim();
/**
* Names a `test_declaration` during the enclosing-function walk (parse-worker
@ -124,13 +120,10 @@ export const zigMethodConfig: MethodExtractionConfig = {
extractReceiverType: extractZigReceiverType,
isStatic(node) {
// A Zig "method" is effectively static if its first parameter is not `self`.
const paramList = zigParameterList(node);
if (!paramList) return true;
const first = paramList.namedChild(0);
if (!first || first.type !== 'parameter') return true;
const nameNode = first.childForFieldName('name');
return nameNode?.text !== 'self';
// A Zig "method" is static when it has no receiver parameter — `self` OR
// a first parameter typed as the enclosing container (`replica:
// *Replica`, `pool: *@This()`); see `zigReceiverParameter`.
return zigReceiverParameter(node) === null;
},
isAbstract() {

View file

@ -902,6 +902,26 @@ export interface ScopeResolver {
*/
readonly markConstructionSites?: boolean;
/**
* When true, a namespace's exported member may also be a name the target
* module IMPORTED and publishes as its own — the hub-module shape, a file
* made only of re-exports (`pub const Terminal = @import("Terminal.zig");`,
* `pub const Thing = @import("thing.zig").Thing;`). Such a file owns no
* local binding, so the default local-only export lookup
* (`findExportedDef`) finds nothing for `terminal.Terminal.init()`,
* `t: stdx.Thing`, `var p = stdx.PRNG.from_seed()` or `var a:
* stdx.BoundedArrayType(u8, 4)`, and the receiver-bound namespace paths
* (Case 1, Case 3, the compound resolver's namespace branch) fall through.
* With the flag those paths use `findExportedDefIncludingImportedNames`,
* which reads the finalized channel where the published names live.
*
* Off by default: in most languages a module's imports are not its exports
* (TypeScript `import { X }` publishes nothing), and the finalized edge does
* not record whether the import was written `pub`. Zig opts in — a hub
* member a consumer can name through the hub is public by construction.
*/
readonly namespaceExportsIncludeImportedNames?: boolean;
/**
* How this language spells a construction expression, so the compound
* receiver resolver can type an INLINE constructor receiver — the

View file

@ -38,6 +38,7 @@ import {
findEnclosingClassDef,
findExportedDef,
findExportedDefByName,
findExportedDefIncludingImportedNames,
findReceiverTypeBinding,
isClassLike,
isNamespaceNameShadowed,
@ -128,6 +129,9 @@ interface ResolveCompoundReceiverOptions {
readonly constructionSyntax?: ScopeResolver['constructionSyntax'];
/** Verified namespace handles visible in the current file. */
readonly namespaceTargets?: ReadonlyMap<string, readonly string[]>;
/** A namespace member may be a name the target module imported and
* publishes (hub modules). See `ScopeResolver.namespaceExportsIncludeImportedNames`. */
readonly namespaceExportsIncludeImportedNames?: boolean;
/** Compact receiver chain for THIS site (`ReferenceSite.receiverChain`), when
* the language's capture emitter produced one. Present ⇒ the structural fold
* is tried before the text cascade; absent ⇒ behaviour is exactly as before.
@ -241,7 +245,11 @@ function resolveConstructionExpressionClass(
if (namespaceFiles.length > 0) {
if (isNamespaceNameShadowed(namespaceName, inScope, scopes)) return undefined;
const namespaceMatches = namespaceFiles
.map((targetFile) => findExportedDef(targetFile, exportedName, index))
.map((targetFile) =>
options.namespaceExportsIncludeImportedNames === true
? findExportedDefIncludingImportedNames(targetFile, exportedName, index, scopes)
: findExportedDef(targetFile, exportedName, index),
)
.filter((def): def is SymbolDefinition => def !== undefined && isClassLike(def.type));
return namespaceMatches.length === 1 ? namespaceMatches[0] : undefined;
}

View file

@ -59,7 +59,7 @@
* resolved to a wrong target.
*/
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../../graph/types.js';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import type { SemanticModel } from '../../model/semantic-model.js';
@ -73,6 +73,7 @@ import {
findEnclosingClassDef,
isReceiverOwnedButUnbound,
findExportedDef,
findExportedDefIncludingImportedNames,
findOwnedMember,
findReceiverTypeBinding,
findValueBindingInScope,
@ -137,6 +138,7 @@ type ReceiverBoundProviderSubset = Pick<
| 'isStaticOnly'
| 'normalizeTypeArgument'
| 'markConstructionSites'
| 'namespaceExportsIncludeImportedNames'
>;
/** A bare, undecorated identifier and nothing else — see {@link isBareTypeName}. */
@ -331,9 +333,42 @@ export function emitReceiverBoundCalls(
const fieldFallback = provider.fieldFallbackOnMethodLookup ?? true;
const collapse = provider.collapseMemberCallsByCallerTarget === true;
const hoistTypeBindingsToModule = provider.hoistTypeBindingsToModule === true;
// Namespace-member lookup for Case 1 / Case 3: local exports only, unless
// the provider publishes imported names too (hub modules — see
// `ScopeResolver.namespaceExportsIncludeImportedNames`).
const lookupNamespaceMember = (targetFile: string, name: string): SymbolDefinition | undefined =>
provider.namespaceExportsIncludeImportedNames === true
? findExportedDefIncludingImportedNames(targetFile, name, index, scopes)
: findExportedDef(targetFile, name, index);
// `ns.Type` as a receiver, where `ns` is a verified namespace of the current
// file and `Type` a class-like member of it. Unique across the namespace's
// target files or nothing — two same-named classes behind one handle would
// mint a confident wrong edge.
const resolveNamespaceQualifiedClass = (
receiverName: string,
inScope: ScopeId,
namespaceTargets: ReadonlyMap<string, readonly string[]>,
): SymbolDefinition | undefined => {
const dot = receiverName.lastIndexOf('.');
if (dot <= 0 || dot === receiverName.length - 1) return undefined;
const head = receiverName.slice(0, dot);
const tail = receiverName.slice(dot + 1);
if (tail.includes('(') || tail.includes('[')) return undefined;
const files = namespaceTargets.get(head);
if (files === undefined || isNamespaceNameShadowed(head, inScope, scopes)) return undefined;
let picked: SymbolDefinition | undefined;
for (const file of files) {
const def = lookupNamespaceMember(file, tail);
if (def === undefined || !isClassLike(def.type)) continue;
if (picked !== undefined && picked.nodeId !== def.nodeId) return undefined;
picked = def;
}
return picked;
};
const compoundOpts = {
fieldFallback,
elementTypeOf: provider.elementTypeOf,
namespaceExportsIncludeImportedNames: provider.namespaceExportsIncludeImportedNames === true,
hoistTypeBindingsToModule,
stripReceiverCastExpressions: provider.stripReceiverCastExpressions === true,
constructionSyntax: provider.constructionSyntax,
@ -1275,7 +1310,7 @@ export function emitReceiverBoundCalls(
if (targetFiles !== undefined && provider.resolveQualifiedReceiverMember === undefined) {
let found = false;
for (const targetFile of targetFiles) {
const memberDef = findExportedDef(targetFile, memberName, index);
const memberDef = lookupNamespaceMember(targetFile, memberName);
if (memberDef !== undefined) {
if (
suppressDeletedCallTarget(
@ -1379,7 +1414,16 @@ export function emitReceiverBoundCalls(
}
// ── Case 2: class-name receiver ──────────────────────────────
const classDef = findClassBindingInScope(site.inScope, receiverName, scopes);
// A namespace-qualified class (`stdx.PRNG.from_seed()`, `terminal
// .Terminal.init()`) binds nothing in the caller's scope chain; when the
// head names a verified namespace, the tail is looked up as that
// module's member — through the same lookup Case 1 / Case 3 use, so a
// hub module (a file made only of re-exports) answers when the provider
// opted in. Only a bare tail is walked here; `ns.Type.field.m()` is the
// compound resolver's shape.
const classDef =
findClassBindingInScope(site.inScope, receiverName, scopes) ??
resolveNamespaceQualifiedClass(receiverName, site.inScope, namespaceTargets);
if (classDef !== undefined) {
const chain = [classDef.nodeId, ...scopes.methodDispatch.mroFor(classDef.nodeId)];
let memberDef: SymbolDefinition | undefined;
@ -1469,7 +1513,7 @@ export function emitReceiverBoundCalls(
if (targetFiles3 !== undefined && className.length > 0) {
let found3 = false;
for (const targetFile3 of targetFiles3) {
const classDef3 = findExportedDef(targetFile3, className, index);
const classDef3 = lookupNamespaceMember(targetFile3, className);
if (classDef3 !== undefined) {
const picked =
site.kind === 'call'

View file

@ -2113,3 +2113,57 @@ export function findExportedDef(
}
return undefined;
}
/**
* `findExportedDef`, then — when the target file declares no such local — a
* name the target file IMPORTED and publishes as its own (a hub module).
*
* A Zig hub is a file made only of re-exports: `pub const Terminal =
* @import("Terminal.zig");`, `pub const Thing = @import("thing.zig").Thing;`.
* Its module scope owns NO local binding, so `findExportedDef` answers nothing
* for `terminal.Terminal.init()` or `t: stdx.Thing`, and the finalized channel
* (`lookupBindingsAt`) is the only place the published names exist — origin
* `import` / `namespace` / `reexport`, def already resolved to the declaring
* file. Measured on ghostty (788 Zig files) before and after this helper:
* CALLS into `src/terminal/` from outside that directory went from 46 to 253;
* on tigerbeetle, CALLS into its `stdx` hub from outside went from 837 to 1500.
*
* Opt-in per provider (`ScopeResolver.namespaceExportsIncludeImportedNames`):
* in most languages a module's imports are NOT its exports (a TypeScript
* `import { X }` publishes nothing), and the finalized edge cannot say whether
* the import was written `pub`. Zig opts in because a hub member a consumer
* can name through the hub IS public — a private import cannot be reached
* through the hub in code that compiles.
*
* Class-like defs win over anything else bound under the name (a re-exported
* type over a same-named value), and a name the finalized channel binds to
* several distinct defs is refused — never guess a namespace member.
*/
export function findExportedDefIncludingImportedNames(
targetFile: string,
memberName: string,
index: WorkspaceResolutionIndex,
scopes: ScopeResolutionIndexes,
): SymbolDefinition | undefined {
const local = findExportedDef(targetFile, memberName, index);
if (local !== undefined) return local;
const moduleScope = index.moduleScopeByFile.get(targetFile);
if (moduleScope === undefined) return undefined;
let picked: SymbolDefinition | undefined;
for (const ref of lookupBindingsAt(moduleScope.id, memberName, scopes)) {
if (ref.origin !== 'import' && ref.origin !== 'namespace' && ref.origin !== 'reexport')
continue;
if (picked === undefined) {
picked = ref.def;
continue;
}
if (picked.nodeId === ref.def.nodeId) continue;
if (isClassLike(ref.def.type) && !isClassLike(picked.type)) {
picked = ref.def;
continue;
}
if (isClassLike(picked.type) && !isClassLike(ref.def.type)) continue;
return undefined; // two distinct defs under one published name → refuse
}
return picked;
}

View file

@ -0,0 +1,56 @@
const stdx = @import("stdx/stdx.zig");
const opmod = @import("op.zig");
const Op = opmod.Op;
fn c1_hub_named_static() void {
_ = stdx.Thing.make();
}
fn c2_hub_module_static() void {
_ = stdx.PRNG.from_seed(1);
}
fn c3_hub_named_annotation() u32 {
var t: stdx.Thing = .{};
return t.m();
}
fn c4_hub_module_annotation() u64 {
var p: stdx.PRNG = .{};
return p.next();
}
fn c5_alias_then_static() u64 {
const PRNG = stdx.PRNG;
var p = PRNG.from_seed(2);
return p.next();
}
fn c6_hub_call_return_typing() u64 {
var p = stdx.PRNG.from_seed(3);
return p.next();
}
fn c7_enum_variant_receiver() u32 {
return Op.create.event_max();
}
fn c8_enum_param_receiver(op: Op) u32 {
return op.event_max();
}
fn c9_enum_qualified_variant_receiver() u32 {
return opmod.Op.lookup.event_max();
}
fn c10_hub_generic_annotation() usize {
var headers: stdx.BoundedArrayType(u8, 4) = .{};
return headers.count();
}
fn c11_hub_reexported_fn() u32 {
return stdx.helper();
}
pub fn main() void {
_ = c10_hub_generic_annotation();
_ = c11_hub_reexported_fn();
c1_hub_named_static();
c2_hub_module_static();
_ = c3_hub_named_annotation();
_ = c4_hub_module_annotation();
_ = c5_alias_then_static();
_ = c6_hub_call_return_typing();
_ = c7_enum_variant_receiver();
_ = c8_enum_param_receiver(.create);
_ = c9_enum_qualified_variant_receiver();
}

View file

@ -0,0 +1,7 @@
pub const Op = enum(u8) {
create = 1,
lookup = 2,
pub fn event_max(self: Op) u32 {
return @intFromEnum(self);
}
};

View file

@ -0,0 +1,9 @@
pub fn BoundedArrayType(comptime T: type, comptime capacity: usize) type {
return struct {
buffer: [capacity]T = undefined,
len: usize = 0,
pub fn count(array: *const @This()) usize {
return array.len;
}
};
}

View file

@ -0,0 +1,9 @@
const PRNG = @This();
state: u64 = 0,
pub fn from_seed(seed: u64) PRNG {
return .{ .state = seed };
}
pub fn next(self: *PRNG) u64 {
self.state += 1;
return self.state;
}

View file

@ -0,0 +1,6 @@
pub const PRNG = @import("prng.zig");
pub const Thing = @import("thing.zig").Thing;
pub const BoundedArrayType = @import("bounded_array.zig").BoundedArrayType;
pub const helper = @import("util.zig").helper;
// Private import: NOT reachable through the hub in compiling Zig.
const secret = @import("util.zig");

View file

@ -0,0 +1,9 @@
pub const Thing = struct {
v: u32 = 0,
pub fn make() Thing {
return .{};
}
pub fn m(self: *Thing) u32 {
return self.v;
}
};

View file

@ -0,0 +1,3 @@
pub fn helper() u32 {
return 1;
}

View file

@ -0,0 +1,32 @@
pub const Counter = struct {
n: u32 = 0,
// receiver named after the type, TigerBeetle style
pub fn incr(counter: *Counter) void {
counter.n += 1;
}
pub fn incr_self(self: *Counter) void {
self.n += 1;
}
pub fn by_value(counter: Counter) u32 {
return counter.n;
}
};
pub fn Pool(comptime Node: type) type {
return struct {
free: ?*Node = null,
// mach style: `pool: *@This()`
pub fn release(pool: *@This(), node: *Node) void {
_ = pool;
_ = node;
}
};
}
pub const Op = enum(u8) {
create = 1,
lookup = 2,
pub fn event_max(op: Op) u32 {
return @intFromEnum(op);
}
};

View file

@ -0,0 +1,39 @@
const stdx = @import("stdx/stdx.zig");
const counter = @import("counter.zig");
const Counter = counter.Counter;
fn use_named_receiver() void {
var c = Counter{};
c.incr();
c.incr_self();
_ = c.by_value();
}
fn use_this_receiver() void {
const P = counter.Pool(u32);
var p = P{};
var node: u32 = 0;
p.release(&node);
}
fn use_enum_variant_receiver() u32 {
return counter.Op.create.event_max();
}
fn use_hub_static_call() u64 {
var prng = stdx.PRNG.from_seed(42);
return prng.next();
}
fn use_hub_generic_annotation() usize {
var headers: stdx.BoundedArrayType(u8, 4) = .{};
return headers.count();
}
pub fn main() void {
use_named_receiver();
use_this_receiver();
_ = use_enum_variant_receiver();
_ = use_hub_static_call();
_ = use_hub_generic_annotation();
}

View file

@ -0,0 +1,9 @@
pub fn BoundedArrayType(comptime T: type, comptime capacity: usize) type {
return struct {
buffer: [capacity]T = undefined,
len: usize = 0,
pub fn count(array: *const @This()) usize {
return array.len;
}
};
}

View file

@ -0,0 +1,9 @@
const PRNG = @This();
state: u64 = 0,
pub fn from_seed(seed: u64) PRNG {
return .{ .state = seed };
}
pub fn next(prng: *PRNG) u64 {
prng.state += 1;
return prng.state;
}

View file

@ -0,0 +1,2 @@
pub const PRNG = @import("prng.zig");
pub const BoundedArrayType = @import("bounded_array.zig").BoundedArrayType;

View file

@ -844,3 +844,135 @@ describe.skipIf(!zigAvailable)(
});
},
);
describe.skipIf(!zigAvailable)('Zig hub modules (`pub const X = @import(…)` re-exports)', () => {
// Audit of three real projects (tigerbeetle, mach, ghostty, 2026-09-02):
// a hub file made only of re-exports owns NO local binding, so the
// local-only export lookup answered nothing for `terminal.Terminal.init()`,
// `t: stdx.Thing`, `var p = stdx.PRNG.from_seed()`. Measured before → after:
// CALLS into ghostty's `src/terminal/` from outside it 46 → 253, into
// tigerbeetle's `stdx` hub from outside it 837 → 1500. The published names
// live in the finalized channel; `namespaceExportsIncludeImportedNames` lets
// the namespace paths read it.
let result: PipelineResult;
let calls: string[];
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-hub'), () => {});
calls = getRelationships(result, 'CALLS')
.filter((e) => e.sourceFilePath.endsWith('main.zig'))
.map((e) => `${e.source} → ${e.target} @ ${e.targetFilePath}`);
}, 60000);
it('resolves a static call through a hub-republished NAMED type (`stdx.Thing.make()`)', () => {
expect(calls).toContain('c1_hub_named_static → make @ src/stdx/thing.zig');
});
it('resolves a static call through a hub-republished MODULE (`stdx.PRNG.from_seed()`, `PRNG = @import("prng.zig")`)', () => {
// ghostty's `terminal.Terminal.init(…)` shape (150 sites).
expect(calls).toContain('c2_hub_module_static → from_seed @ src/stdx/prng.zig');
});
it('types a receiver ANNOTATED with the hub path (`t: stdx.Thing`, `p: stdx.PRNG`)', () => {
// tigerbeetle: 289 `: stdx.Type` annotations.
expect(calls).toContain('c3_hub_named_annotation → m @ src/stdx/thing.zig');
expect(calls).toContain('c4_hub_module_annotation → next @ src/stdx/prng.zig');
});
it('types a receiver from a constructor call through the hub (`var p = stdx.PRNG.from_seed()`)', () => {
expect(calls).toContain('c6_hub_call_return_typing → next @ src/stdx/prng.zig');
expect(calls).toContain('c6_hub_call_return_typing → from_seed @ src/stdx/prng.zig');
});
it('types a generic instantiation annotated through the hub (`h: stdx.BoundedArrayType(u8, 4)`)', () => {
expect(calls).toContain('c10_hub_generic_annotation → count @ src/stdx/bounded_array.zig');
});
it('resolves a hub-republished free function (`stdx.helper()`)', () => {
expect(calls).toContain('c11_hub_reexported_fn → helper @ src/stdx/util.zig');
});
it('still resolves the alias-then-use shape (`const PRNG = stdx.PRNG; PRNG.from_seed()`)', () => {
expect(calls).toContain('c5_alias_then_static → from_seed @ src/stdx/prng.zig');
expect(calls).toContain('c5_alias_then_static → next @ src/stdx/prng.zig');
});
it('never resolves a name through the hub that the hub does not publish', () => {
// `stdx.secret` (a private `const secret = @import(…)`) is not referenced
// by the fixture because it would not compile; the guard here is that no
// call from main.zig lands in util.zig except through the published
// `helper` — nothing leaks via the private import.
const intoUtil = calls.filter((c) => c.endsWith('@ src/stdx/util.zig'));
expect(intoUtil).toEqual(['c11_hub_reexported_fn → helper @ src/stdx/util.zig']);
});
});
describe.skipIf(!zigAvailable)(
'Zig enum variants as typed receivers (`Op.create.event_max()`)',
() => {
let result: PipelineResult;
let calls: string[];
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-hub'), () => {});
calls = getRelationships(result, 'CALLS')
.filter((e) => e.sourceFilePath.endsWith('main.zig'))
.map((e) => `${e.source} → ${e.target}`);
}, 60000);
it('dispatches a method called on a variant reached through the enum type', () => {
// tigerbeetle writes `Operation.<variant>.event_max(…)` 147 times. The
// variant has no written type; its type is the enum, so the field walk
// that already handles `self.session.name()` types `Op.create` as `Op`.
expect(calls).toContain('c7_enum_variant_receiver → event_max');
});
it('dispatches a method on an enum-typed parameter (`op: Op`)', () => {
expect(calls).toContain('c8_enum_param_receiver → event_max');
});
},
);
describe.skipIf(!zigAvailable)(
'Zig receivers named after their type (`counter: *Counter`, `pool: *@This()`)',
() => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-receivers'), () => {});
}, 60000);
it('labels container-typed first parameters as receivers: instance methods, receiver out of the arity', () => {
// `self` is a convention. tigerbeetle names the receiver after its type
// (777 of 1127 methods), mach writes `pool: *@This()` (764 of 833); both
// used to be `isStatic: true` with the receiver counted in `#<arity>`.
const methods = new Map<string, { isStatic: boolean }>();
result.graph.forEachNode((n) => {
if (n.label === 'Method') methods.set(n.id, { isStatic: n.properties.isStatic === true });
});
expect(methods.get('Method:src/counter.zig:Counter.incr#0')?.isStatic).toBe(false);
expect(methods.get('Method:src/counter.zig:Counter.incr_self#0')?.isStatic).toBe(false);
expect(methods.get('Method:src/counter.zig:Counter.by_value#0')?.isStatic).toBe(false);
expect(methods.get('Method:src/counter.zig:Pool.release#1')?.isStatic).toBe(false);
expect(methods.get('Method:src/counter.zig:Op.event_max#0')?.isStatic).toBe(false);
expect(methods.get('Method:src/stdx/prng.zig:prng.next#0')?.isStatic).toBe(false);
// A factory keeps its static label and full arity.
expect(methods.get('Method:src/stdx/prng.zig:prng.from_seed#1')?.isStatic).toBe(true);
// Nothing is left under the old receiver-counted ids.
expect(methods.has('Method:src/counter.zig:Counter.incr#1')).toBe(false);
expect(methods.has('Method:src/counter.zig:Pool.release#2')).toBe(false);
});
it('dispatches calls onto those methods exactly as onto `self` methods', () => {
const calls = edgeSet(getRelationships(result, 'CALLS'));
expect(calls).toEqual(
expect.arrayContaining([
'use_named_receiver → incr',
'use_named_receiver → incr_self',
'use_named_receiver → by_value',
'use_this_receiver → release',
]),
);
});
},
);

View file

@ -194,6 +194,61 @@ const S = struct {
expect(result!.methods[0].parameters.map((p) => p.name)).toEqual(['other', 'self']);
expect(result!.methods[0].receiverType).toBeNull();
});
it('a FIRST parameter typed as the enclosing container is the receiver, whatever its name', () => {
// `self` is a convention, not a rule: tigerbeetle names the receiver after
// the type (777 of 1127 methods), mach writes `pool: *@This()` (764 of
// 833). Reading only `self` as the receiver labelled all of them static
// and counted the receiver in their arity.
const root = parse(`
const Counter = struct {
n: u32,
const Self = @This();
pub fn incr(counter: *Counter) void { counter.n += 1; }
pub fn peek(counter: *const Counter) u32 { return counter.n; }
pub fn by_value(counter: Counter) u32 { return counter.n; }
pub fn via_this(c: *@This(), by: u32) void { c.n += by; }
pub fn via_alias(c: *Self) void { c.n = 0; }
pub fn make(n: u32) Counter { return .{ .n = n }; }
pub fn other(o: *Other) void { _ = o; }
};
const Other = struct { x: u32 };
pub fn Pool(comptime Node: type) type {
return struct {
pub fn release(pool: *@This(), node: *Node) void { _ = pool; _ = node; }
pub fn acquire(pool: *Pool(Node)) ?*Node { _ = pool; return null; }
};
}
`).rootNode;
const counter = extractor.extract(find(root, 'struct_declaration'), ctx)!;
const byName = new Map(counter.methods.map((m) => [m.name, m]));
for (const [name, receiver] of [
['incr', '*Counter'],
['peek', '*const Counter'],
['by_value', 'Counter'],
['via_this', '*@This()'],
['via_alias', '*Self'],
] as const) {
expect(byName.get(name)!.receiverType, name).toBe(receiver);
expect(byName.get(name)!.isStatic, name).toBe(false);
}
expect(byName.get('via_this')!.parameters.map((p) => p.name)).toEqual(['by']);
// A factory (no container-typed first parameter) and a fn whose first
// parameter is ANOTHER type stay static — the type, not the position, is
// what makes a receiver.
expect(byName.get('make')!.isStatic).toBe(true);
expect(byName.get('other')!.isStatic).toBe(true);
expect(byName.get('other')!.parameters.map((p) => p.name)).toEqual(['o']);
const poolDecl = find(root, 'struct_declaration', 'struct {\n pub fn release');
const pool = extractor.extract(poolDecl, ctx)!;
const poolByName = new Map(pool.methods.map((m) => [m.name, m]));
expect(poolByName.get('release')!.receiverType).toBe('*@This()');
expect(poolByName.get('release')!.parameters.map((p) => p.name)).toEqual(['node']);
// `Pool(Node)` names the generic constructor's container.
expect(poolByName.get('acquire')!.receiverType).toBe('*Pool(Node)');
expect(poolByName.get('acquire')!.isStatic).toBe(false);
});
});
describeZig('Zig VariableExtractor — container and import bindings are not variables', () => {
@ -288,31 +343,53 @@ describeZig('Zig scope captures — `@import` in TYPE position is not an import
});
});
describeZig('Zig scope captures — receiver is the FIRST parameter named self', () => {
function parameterBindings(src: string) {
return emitZigScopeCaptures(src, 'test.zig')
.filter((m) => m['@type-binding.parameter'] !== undefined)
.map((m) => interpretZigTypeBinding(m))
.filter((b): b is NonNullable<typeof b> => b !== null)
.map((b) => `${b.boundName}:${b.source}`);
}
describeZig(
'Zig scope captures — receiver is the FIRST parameter, named self or typed as the container',
() => {
function parameterBindings(src: string) {
return emitZigScopeCaptures(src, 'test.zig')
.filter((m) => m['@type-binding.parameter'] !== undefined)
.map((m) => interpretZigTypeBinding(m))
.filter((b): b is NonNullable<typeof b> => b !== null)
.map((b) => `${b.boundName}:${b.source}`);
}
it('marks a leading self as the receiver and later parameters as annotations', () => {
expect(parameterBindings('const S = struct { fn m(self: *S, other: u32) void {} };')).toEqual([
'self:self',
'other:parameter-annotation',
]);
});
it('marks a leading self as the receiver and later parameters as annotations', () => {
expect(parameterBindings('const S = struct { fn m(self: *S, other: u32) void {} };')).toEqual(
['self:self', 'other:parameter-annotation'],
);
});
it('a later parameter named self is an ordinary parameter, not a receiver', () => {
// Legal Zig; `zigReceiverBinding` picks the `self`-sourced binding, so
// sourcing this one as `self` would turn a static fn into an instance method.
expect(parameterBindings('const S = struct { fn f(other: u32, self: S) void {} };')).toEqual([
'other:parameter-annotation',
'self:parameter-annotation',
]);
});
});
it('a leading parameter typed as the enclosing container is the receiver, whatever its name', () => {
// Same rule as the method extractor (`zigReceiverParameter`): the two
// phases must agree on what a method is.
expect(
parameterBindings('const S = struct { fn m(state: *S, other: u32) void {} };'),
).toEqual(['state:self', 'other:parameter-annotation']);
expect(parameterBindings('const S = struct { fn m(s: *@This()) void {} };')).toEqual([
's:self',
]);
expect(
parameterBindings(
'const S = struct { const Self = @This(); fn m(s: *const Self) void {} };',
),
).toEqual(['s:self']);
// A first parameter of ANOTHER type is a plain parameter.
expect(
parameterBindings('const S = struct { fn m(o: *Other) void {} }; const Other = struct {};'),
).toEqual(['o:parameter-annotation']);
});
it('a later parameter named self is an ordinary parameter, not a receiver', () => {
// Legal Zig; `zigReceiverBinding` picks the `self`-sourced binding, so
// sourcing this one as `self` would turn a static fn into an instance method.
expect(parameterBindings('const S = struct { fn f(other: u32, self: S) void {} };')).toEqual([
'other:parameter-annotation',
'self:parameter-annotation',
]);
});
},
);
describeZig('Zig callable-flow captures — member calls, receiver formals, decl literals', () => {
const src = `
@ -1250,7 +1327,7 @@ pub const Kind = enum { a, b };
pub const Payload = union(enum) { x: u32, y: Counter };
`;
it('emits one @type-binding.field per TYPED field — the nominal type, sigils stripped, aliases rewritten', () => {
it('emits one @type-binding.field per typed field and per enum variant — the nominal type, sigils stripped, aliases rewritten', () => {
const fields = emitZigScopeCaptures(SRC, 'src/Page.zig')
.filter((m) => m['@type-binding.field'] !== undefined)
.map((m) => {
@ -1278,7 +1355,12 @@ pub const Payload = union(enum) { x: u32, y: Counter };
['next', '?*Holder', 'Holder', 'annotation'],
// the anonymous inline struct's OWN field, not `inline_` itself
['a', 'u32', 'u32', 'annotation'],
// union variants carry a type; enum variants (`a`, `b`) do not
// enum variants carry no written type, but they HAVE one — the enum
// itself — so `Kind.a.method()` types `Kind.a` as `Kind` (tigerbeetle's
// `Operation.create_accounts.event_max()`, 147 sites)
['a', 'Kind', 'Kind', 'annotation'],
['b', 'Kind', 'Kind', 'annotation'],
// union variants carry a type
['x', 'u32', 'u32', 'annotation'],
['y', 'Counter', 'Counter', 'annotation'],
]);