From 34c53473235c164137449ec536755fa5ab0fc80f Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Wed, 2 Sep 2026 22:07:38 +0200 Subject: [PATCH] fix(zig): address thirteenth gitnexus-check review pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../method-extractors/configs/zig.ts | 21 +++++++---- .../ingestion/method-extractors/generic.ts | 8 ++-- gitnexus/src/core/ingestion/method-types.ts | 12 ++++-- .../graph-bridge/node-lookup.ts | 4 +- .../zig-receivers/src/Ledger.zig | 12 ++++++ .../zig-receivers/src/main.zig | 8 ++++ .../test/integration/resolvers/zig.test.ts | 24 ++++++++++++ gitnexus/test/unit/zig-extractors.test.ts | 37 +++++++++++++++++++ 8 files changed, 110 insertions(+), 16 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-receivers/src/Ledger.zig diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/zig.ts b/gitnexus/src/core/ingestion/method-extractors/configs/zig.ts index 2665f6a0d..6cd044c37 100644 --- a/gitnexus/src/core/ingestion/method-extractors/configs/zig.ts +++ b/gitnexus/src/core/ingestion/method-extractors/configs/zig.ts @@ -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() { diff --git a/gitnexus/src/core/ingestion/method-extractors/generic.ts b/gitnexus/src/core/ingestion/method-extractors/generic.ts index b47272525..3b92f4531 100644 --- a/gitnexus/src/core/ingestion/method-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/method-extractors/generic.ts @@ -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, diff --git a/gitnexus/src/core/ingestion/method-types.ts b/gitnexus/src/core/ingestion/method-types.ts index 67eec253c..5e8f833b5 100644 --- a/gitnexus/src/core/ingestion/method-types.ts +++ b/gitnexus/src/core/ingestion/method-types.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts index df7f9b948..fe7f4506b 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts @@ -312,8 +312,8 @@ export const LINKABLE_LABELS: ReadonlySet = new Set([ // // 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', diff --git a/gitnexus/test/fixtures/lang-resolution/zig-receivers/src/Ledger.zig b/gitnexus/test/fixtures/lang-resolution/zig-receivers/src/Ledger.zig new file mode 100644 index 000000000..10d59ee4a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-receivers/src/Ledger.zig @@ -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 .{}; +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-receivers/src/main.zig b/gitnexus/test/fixtures/lang-resolution/zig-receivers/src/main.zig index 63c71133a..31abd0460 100644 --- a/gitnexus/test/fixtures/lang-resolution/zig-receivers/src/main.zig +++ b/gitnexus/test/fixtures/lang-resolution/zig-receivers/src/main.zig @@ -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(); } diff --git a/gitnexus/test/integration/resolvers/zig.test.ts b/gitnexus/test/integration/resolvers/zig.test.ts index a09c64ace..23d51aff6 100644 --- a/gitnexus/test/integration/resolvers/zig.test.ts +++ b/gitnexus/test/integration/resolvers/zig.test.ts @@ -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(); + 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( diff --git a/gitnexus/test/unit/zig-extractors.test.ts b/gitnexus/test/unit/zig-extractors.test.ts index e32b387f2..1378b202d 100644 --- a/gitnexus/test/unit/zig-extractors.test.ts +++ b/gitnexus/test/unit/zig-extractors.test.ts @@ -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', () => {