fix(zig): address thirteenth gitnexus-check review pass

- File-struct receivers named after the file stem were always static: the
  method builder called `isStatic` / `extractReceiverType` /
  `extractParameters` without the extractor context's `filePath`, so
  `zigReceiverParameter` could not name a file-struct (`fn add(ledger:
  *Ledger)` in `Ledger.zig`, no `Self` alias) and the fn came out static
  with the receiver in its arity (`Ledger.add#2`) — an id the scope side,
  which always has the path, never produces, so its CALLS edges went
  nowhere. `MethodExtractionConfig` now passes `filePath` as an optional
  trailing argument to those three hooks (same shape as
  `extractOwnerName`); the Zig config threads it through, every other
  config ignores it. Regression tests in `zig-extractors.test.ts` (unit)
  and `resolvers/zig.test.ts` (new `Ledger.zig` in the `zig-receivers`
  fixture: ids, `isStatic`, and the three CALLS edges); both fail on the
  previous source.
- `LINKABLE_LABELS` comment: the remaining `CLASS_KINDS` entries include
  `Namespace`.

Not re-fixed:
- "Ownerless-method assertion regex cannot match `.zig` graph IDs": the
  `[^:]+` segment consumes the whole file path (dots included) up to the
  second colon, and `[^.]+#\d+$` then matches only an owner-less name —
  `Method:src/Sorter.zig:lessThan#3` → true, `…:Sorter.sortBoth$1.lessThan#3`
  → false, checked with node.
- "Public namespace imports are never marked as re-exports": the shared
  `ParsedImport` namespace variant has no `reexportsName` field and
  `contributesReexportEdge` excludes namespace drafts on `base.kind` by
  contract; a `pub const X = @import("x.zig")` hub member is exposed
  through `findExportedDefIncludingImportedNames` instead, which is what
  the audit commit added for exactly that shape.
- "Private Zig namespace imports are treated as public hub exports": a
  private import cannot be named through the hub in code that compiles,
  and `findExportedDef` applies the same no-visibility rule to local
  defs; the finalized binding channel carries no `pub` bit to check.
- "Range binding mutates finalized scopes" and "unconditionally adds an
  optional Zig grammar to the fixture suite": refuted in the tenth and
  eleventh pass notes of the PR body, unchanged since.
This commit is contained in:
Navid EMAD 2026-09-02 22:07:38 +02:00
parent f121e539ed
commit 34c5347323
No known key found for this signature in database
8 changed files with 110 additions and 16 deletions

View file

@ -52,13 +52,17 @@ const extractZigReturnType = (node: SyntaxNode): string | undefined => {
/**
* 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`).
* as Rust's `self_parameter` skip in `configs/rust.ts`). `filePath` is what
* names a file-struct (`fn add(ledger: *Ledger)` in `Ledger.zig`): without it
* the receiver rule cannot see the file stem and such a fn reads as static
* with the receiver in its arity an id the scope side, which always has
* the path, never produces, so its CALLS edges went nowhere.
*/
const extractZigParameters = (node: SyntaxNode): ParameterInfo[] => {
const extractZigParameters = (node: SyntaxNode, filePath?: string): ParameterInfo[] => {
const paramList = zigParameterList(node);
if (!paramList) return [];
const params: ParameterInfo[] = [];
const receiver = zigReceiverParameter(node);
const receiver = zigReceiverParameter(node, filePath);
for (let i = 0; i < paramList.namedChildCount; i++) {
const param = paramList.namedChild(i);
if (!param || param.type !== 'parameter') continue;
@ -76,8 +80,8 @@ const extractZigParameters = (node: SyntaxNode): ParameterInfo[] => {
return params;
};
const extractZigReceiverType = (node: SyntaxNode): string | undefined =>
zigReceiverParameter(node)?.childForFieldName('type')?.text?.trim();
const extractZigReceiverType = (node: SyntaxNode, filePath?: string): string | undefined =>
zigReceiverParameter(node, filePath)?.childForFieldName('type')?.text?.trim();
/**
* Names a `test_declaration` during the enclosing-function walk (parse-worker
@ -119,11 +123,12 @@ export const zigMethodConfig: MethodExtractionConfig = {
extractVisibility: (node) => (hasZigPubKeyword(node) ? 'public' : 'private'),
extractReceiverType: extractZigReceiverType,
isStatic(node) {
isStatic(node, filePath) {
// 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;
// *Replica`, `pool: *@This()`, `ledger: *Ledger` in `Ledger.zig`); see
// `zigReceiverParameter`.
return zigReceiverParameter(node, filePath) === null;
},
isAbstract() {

View file

@ -247,13 +247,15 @@ function buildMethod(
// Static-owner detection is config-driven: each language declares which
// container node types imply static (e.g. Ruby singleton_class, Kotlin companion_object).
const isStatic = (config.staticOwnerTypes?.has(ownerNode.type) ?? false) || config.isStatic(node);
const isStatic =
(config.staticOwnerTypes?.has(ownerNode.type) ?? false) ||
config.isStatic(node, context.filePath);
return {
name,
receiverType: config.extractReceiverType?.(node) ?? null,
receiverType: config.extractReceiverType?.(node, context.filePath) ?? null,
returnType: config.extractReturnType(node) ?? null,
parameters: config.extractParameters(node),
parameters: config.extractParameters(node, context.filePath),
visibility: config.extractVisibility(node),
isStatic,
isAbstract,

View file

@ -89,13 +89,19 @@ export interface MethodExtractionConfig {
bodyNodeTypes: string[];
extractName: (node: SyntaxNode) => string | undefined;
extractReturnType: (node: SyntaxNode) => string | undefined;
extractParameters: (node: SyntaxNode) => ParameterInfo[];
/** The optional `filePath` (the extractor context's) is passed to
* `extractParameters`, `isStatic` and `extractReceiverType` for languages
* whose receiver rule depends on the file Zig's file-struct, whose type
* name is the file stem, so `fn incr(counter: *Counter)` in `Counter.zig`
* is a method only when the file is known. Same optional-trailing-argument
* shape as `extractOwnerName`; every other config ignores it. */
extractParameters: (node: SyntaxNode, filePath?: string) => ParameterInfo[];
extractVisibility: (node: SyntaxNode) => MethodVisibility;
isStatic: (node: SyntaxNode) => boolean;
isStatic: (node: SyntaxNode, filePath?: string) => boolean;
isAbstract: (node: SyntaxNode, ownerNode: SyntaxNode) => boolean;
isFinal: (node: SyntaxNode) => boolean;
extractAnnotations?: (node: SyntaxNode) => string[];
extractReceiverType?: (node: SyntaxNode) => string | undefined;
extractReceiverType?: (node: SyntaxNode, filePath?: string) => string | undefined;
isVirtual?: (node: SyntaxNode) => boolean;
isOverride?: (node: SyntaxNode) => boolean;
isAsync?: (node: SyntaxNode) => boolean;

View file

@ -312,8 +312,8 @@ export const LINKABLE_LABELS: ReadonlySet<NodeLabel> = new Set<NodeLabel>([
//
// Covers every language that spells an alias this way — TypeScript, Kotlin,
// Dart and Rust all emit `@declaration.type_alias`. The remaining
// `CLASS_KINDS` entries (Typedef, Delegate, Annotation, Template) plausibly
// have the same gap, but nothing exercises them today
// `CLASS_KINDS` entries (Typedef, Delegate, Annotation, Template, Namespace)
// plausibly have the same gap, but nothing exercises them today
// and adding labels no test covers is how this list drifts out of sync with
// what it claims.
'TypeAlias',

View file

@ -0,0 +1,12 @@
// A file-struct with NO `const Self = @This();` alias: the only spelling of
// its type is the file stem, so the receiver rule needs the file path.
total: u64 = 0,
pub fn add(ledger: *Ledger, n: u64) void {
ledger.total += n;
}
pub fn sum(ledger: Ledger) u64 {
return ledger.total;
}
pub fn empty() Ledger {
return .{};
}

View file

@ -1,6 +1,7 @@
const stdx = @import("stdx/stdx.zig");
const counter = @import("counter.zig");
const Counter = counter.Counter;
const Ledger = @import("Ledger.zig");
fn use_named_receiver() void {
var c = Counter{};
@ -25,6 +26,12 @@ fn use_hub_static_call() u64 {
return prng.next();
}
fn use_file_struct_receiver() u64 {
var ledger = Ledger.empty();
ledger.add(3);
return ledger.sum();
}
fn use_hub_generic_annotation() usize {
var headers: stdx.BoundedArrayType(u8, 4) = .{};
return headers.count();
@ -36,4 +43,5 @@ pub fn main() void {
_ = use_enum_variant_receiver();
_ = use_hub_static_call();
_ = use_hub_generic_annotation();
_ = use_file_struct_receiver();
}

View file

@ -963,6 +963,30 @@ describe.skipIf(!zigAvailable)(
expect(methods.has('Method:src/counter.zig:Pool.release#2')).toBe(false);
});
it('labels a file-struct receiver typed by the file stem (`ledger: *Ledger` in `Ledger.zig`)', () => {
// `Ledger.zig` declares no `Self` alias: the stem is the only spelling of
// its type, and only the file path can supply it. The structure phase
// used to build these methods without the path, so `add` came out static
// as `Ledger.add#2` while the scope side (which has the path) resolved
// `ledger.add(3)` to `Ledger.add#1` — an id that did not exist.
const methods = new Map<string, boolean>();
result.graph.forEachNode((n) => {
if (n.label === 'Method') methods.set(n.id, n.properties.isStatic === true);
});
expect(methods.get('Method:src/Ledger.zig:Ledger.add#1')).toBe(false);
expect(methods.get('Method:src/Ledger.zig:Ledger.sum#0')).toBe(false);
expect(methods.get('Method:src/Ledger.zig:Ledger.empty#0')).toBe(true);
expect(methods.has('Method:src/Ledger.zig:Ledger.add#2')).toBe(false);
const calls = edgeSet(getRelationships(result, 'CALLS'));
expect(calls).toEqual(
expect.arrayContaining([
'use_file_struct_receiver → empty',
'use_file_struct_receiver → add',
'use_file_struct_receiver → sum',
]),
);
});
it('dispatches calls onto those methods exactly as onto `self` methods', () => {
const calls = edgeSet(getRelationships(result, 'CALLS'));
expect(calls).toEqual(

View file

@ -249,6 +249,43 @@ pub fn Pool(comptime Node: type) type {
expect(poolByName.get('acquire')!.receiverType).toBe('*Pool(Node)');
expect(poolByName.get('acquire')!.isStatic).toBe(false);
});
it('reads a file-struct receiver typed by the file stem — the rule needs the file path', () => {
// `Ledger.zig` with top-level fields IS the type `Ledger`; without a
// `const Self = @This();` alias the stem is the only spelling. The method
// builder must hand the config its `filePath`: without it `zigReceiverParameter`
// cannot name the file-struct, so `add` read as static with `ledger` in
// its arity (`Ledger.add#2`) — an id the scope side never produces, so
// every call to it was dropped.
const root = parse(`
total: u64 = 0,
pub fn add(ledger: *Ledger, n: u64) void { ledger.total += n; }
pub fn sum(ledger: Ledger) u64 { return ledger.total; }
pub fn empty() Ledger { return .{}; }
`).rootNode;
const file = extractor.extract(root, {
filePath: 'src/Ledger.zig',
language: SupportedLanguages.Zig,
})!;
const byName = new Map(file.methods.map((m) => [m.name, m]));
expect(byName.get('add')!.receiverType).toBe('*Ledger');
expect(byName.get('add')!.isStatic).toBe(false);
expect(byName.get('add')!.parameters.map((p) => p.name)).toEqual(['n']);
expect(byName.get('sum')!.receiverType).toBe('Ledger');
expect(byName.get('sum')!.isStatic).toBe(false);
expect(byName.get('empty')!.isStatic).toBe(true);
// Under another file name the same source is a namespace: `Ledger` is
// then some other type, and `add` is a plain static fn of two parameters.
const other = extractor.extract(root, {
filePath: 'src/Book.zig',
language: SupportedLanguages.Zig,
})!;
expect(other.methods.find((m) => m.name === 'add')!.isStatic).toBe(true);
expect(other.methods.find((m) => m.name === 'add')!.parameters.map((p) => p.name)).toEqual([
'ledger',
'n',
]);
});
});
describeZig('Zig VariableExtractor — container and import bindings are not variables', () => {