From 46bd72b8cac87c44cacae7e4b7a3cdc1aef2ff04 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Fri, 12 Jun 2026 20:06:25 +0200 Subject: [PATCH] fix(zig): Union is class-like + missing-grammar warning (review pass) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review findings on the Zig branch: - scope/walkers.ts `isClassLike` and finalize-algorithm's CALLABLE_OR_TYPE_LIKE did not include 'Union': a `union(enum)` container's methods got no ownerId from populateClassOwnedMembers, so method dispatch on union receivers silently dropped. Widened both sets; the zig-basic fixture gains a Tag method + a CALLS assertion (main → isEnergy) that fails without the widening (verified by reverting). - optional-grammars.ts now lists tree-sitter-zig with an npm `probe` (it is an optionalDependency, not vendored): users with .zig files and no prebuild get the standard one-line stderr warning instead of a silently degraded index. - Deduplicated the container-method predicate: `isZigContainerMethod` + ZIG_CONTAINER_TYPES now live once in languages/zig/captures.ts and feed both the provider labelOverride and the scope-capture relabel. - README language matrices: Zig row now claims Type Annotations, Constructor Inference, and Config (build.zig.zon) — all true since the scope-resolution hooks landed. --- README.md | 2 +- .../scope-resolution/finalize-algorithm.ts | 1 + gitnexus/README.md | 2 +- gitnexus/src/cli/optional-grammars.ts | 28 +++++++++++++++++-- gitnexus/src/core/ingestion/languages/zig.ts | 20 +------------ .../core/ingestion/languages/zig/captures.ts | 18 +++++++----- .../src/core/ingestion/languages/zig/index.ts | 2 +- .../scope-resolution/scope/walkers.ts | 10 ++++--- .../lang-resolution/zig-basic/src/main.zig | 2 ++ .../lang-resolution/zig-basic/src/pioneer.zig | 4 +++ .../test/integration/resolvers/zig.test.ts | 11 ++++++++ 11 files changed, 64 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index c5ea044ab..f4d3c57ea 100644 --- a/README.md +++ b/README.md @@ -580,7 +580,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas | C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ | | C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | | Dart | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | -| Zig | ✓ | — | ✓ | — | — | — | — | — | ✓ | +| Zig | ✓ | — | ✓ | — | ✓ | ✓ | ✓ | — | ✓ | **Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts index e2a90c253..f6038b3f0 100644 --- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -1072,6 +1072,7 @@ const CALLABLE_OR_TYPE_LIKE: ReadonlySet = new Set([ 'Interface', 'Enum', 'Struct', + 'Union', 'Record', 'Trait', 'Namespace', diff --git a/gitnexus/README.md b/gitnexus/README.md index 48cc87fc6..45a6f8ed3 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -362,7 +362,7 @@ TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift, | C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ | | C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | | Dart | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | -| Zig | ✓ | — | ✓ | — | — | — | — | — | ✓ | +| Zig | ✓ | — | ✓ | — | ✓ | ✓ | ✓ | — | ✓ | **Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics diff --git a/gitnexus/src/cli/optional-grammars.ts b/gitnexus/src/cli/optional-grammars.ts index 736ee6628..5c6eadf68 100644 --- a/gitnexus/src/cli/optional-grammars.ts +++ b/gitnexus/src/cli/optional-grammars.ts @@ -7,19 +7,23 @@ * prebuilds activated via node-gyp-build. All can be skipped via * GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (postinstall scripts), or can silently * soft-fail when no prebuild matches the host platform (and a source build was - * unavailable / not attempted). + * unavailable / not attempted). tree-sitter-zig is the one npm-installed + * optionalDependency in the list; its `probe` overrides the vendored load. * * Either path produces the same observable: the .node binding is absent * at runtime. This helper detects that condition and surfaces a single - * stderr line per missing grammar so users learn why .dart/.proto/.swift/.kt + * stderr line per missing grammar so users learn why .dart/.proto/.swift/.kt/.zig * support is unavailable instead of silently getting a degraded index. */ +import { createRequire } from 'node:module'; import { SupportedLanguages } from 'gitnexus-shared'; import { isGrammarRuntimeSkipped } from '../core/tree-sitter/parser-loader.js'; import { requireVendoredGrammar } from '../core/tree-sitter/vendored-grammars.js'; import { cliWarn } from './cli-message.js'; +const _require = createRequire(import.meta.url); + interface OptionalGrammar { /** Display name in warnings */ name: string; @@ -34,6 +38,12 @@ interface OptionalGrammar { * `.proto`, which is a gRPC-extractor concern, not a SupportedLanguages. */ language?: SupportedLanguages; + /** + * Availability probe. Defaults to `requireVendoredGrammar(pkg)`; grammars + * that install from npm as an optionalDependency (zig) override it with a + * plain `require` of the package. + */ + probe?: () => unknown; } const OPTIONAL_GRAMMARS: OptionalGrammar[] = [ @@ -56,6 +66,14 @@ const OPTIONAL_GRAMMARS: OptionalGrammar[] = [ extensions: ['.kt', '.kts'], language: SupportedLanguages.Kotlin, }, + { + name: 'tree-sitter-zig', + pkg: '@tree-sitter-grammars/tree-sitter-zig', + extensions: ['.zig'], + language: SupportedLanguages.Zig, + // npm optionalDependency, not vendored — probe via plain require. + probe: () => _require('@tree-sitter-grammars/tree-sitter-zig'), + }, ]; /** @@ -105,7 +123,11 @@ export function detectMissingOptionalGrammars(): MissingGrammar[] { continue; } try { - requireVendoredGrammar(g.pkg); + if (g.probe !== undefined) { + g.probe(); + } else { + requireVendoredGrammar(g.pkg); + } } catch (err) { const code = (err as NodeJS.ErrnoException | undefined)?.code; const msg = err instanceof Error ? err.message : String(err); diff --git a/gitnexus/src/core/ingestion/languages/zig.ts b/gitnexus/src/core/ingestion/languages/zig.ts index 3a15ccf4d..a15ca61e7 100644 --- a/gitnexus/src/core/ingestion/languages/zig.ts +++ b/gitnexus/src/core/ingestion/languages/zig.ts @@ -37,34 +37,16 @@ import { zigMethodConfig } from '../method-extractors/configs/zig.js'; import { createVariableExtractor } from '../variable-extractors/generic.js'; import { zigVariableConfig } from '../variable-extractors/configs/zig.js'; import { zigTypeConfig } from '../type-extractors/zig.js'; -import type { SyntaxNode } from '../utils/ast-helpers.js'; import { emitZigScopeCaptures, interpretZigImport, interpretZigTypeBinding, + isZigContainerMethod, zigArityCompatibility, zigBindingScopeFor, zigReceiverBinding, } from './zig/index.js'; -const ZIG_CONTAINER_TYPES = new Set(['struct_declaration', 'enum_declaration', 'union_declaration']); - -/** - * A Zig `fn` declared directly inside a struct/enum/union container is a - * method (mirrors `isKotlinClassMethod`). Containers nest function - * declarations directly — there is no intermediate body node. - */ -function isZigContainerMethod( - captureNode: { parent?: SyntaxNode | null } | null | undefined, -): boolean { - let ancestor = captureNode?.parent; - while (ancestor) { - if (ZIG_CONTAINER_TYPES.has(ancestor.type)) return true; - ancestor = ancestor.parent; - } - return false; -} - export const zigProvider = defineLanguage({ id: SupportedLanguages.Zig, extensions: ['.zig'], diff --git a/gitnexus/src/core/ingestion/languages/zig/captures.ts b/gitnexus/src/core/ingestion/languages/zig/captures.ts index 70e096e22..59507b6bb 100644 --- a/gitnexus/src/core/ingestion/languages/zig/captures.ts +++ b/gitnexus/src/core/ingestion/languages/zig/captures.ts @@ -4,7 +4,7 @@ import { getZigParser, getZigScopeQuery } from './query.js'; import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; -const ZIG_CONTAINER_TYPES = new Set([ +export const ZIG_CONTAINER_TYPES: ReadonlySet = new Set([ 'struct_declaration', 'enum_declaration', 'union_declaration', @@ -27,11 +27,15 @@ function isContainerOrImportBinding(declNode: SyntaxNode): boolean { return false; } -/** A `fn` nested in a struct/enum/union container is a method — mirror the - * provider's `labelOverride` so scope-side defs carry the same label the - * worker gives the graph node. */ -function isContainerMethod(fnNode: SyntaxNode): boolean { - let ancestor = fnNode.parent; +/** A `fn` nested in a struct/enum/union container is a method. Single + * predicate shared between the provider's `labelOverride` (worker + * structure phase) and the scope-capture relabel below, so the graph + * node label and the scope-side def label cannot drift apart. The loose + * parameter shape matches what `labelOverride` receives. */ +export function isZigContainerMethod( + captureNode: { readonly parent?: SyntaxNode | null } | null | undefined, +): boolean { + let ancestor = captureNode?.parent; while (ancestor) { if (ZIG_CONTAINER_TYPES.has(ancestor.type)) return true; ancestor = ancestor.parent; @@ -75,7 +79,7 @@ export function emitZigScopeCaptures( // Relabel container-nested fns Function → Method (provider labelOverride // parity). The anchor capture name carries the kind, so rebuild it. const fnAnchor = nodeMap['@declaration.function']; - if (fnAnchor !== undefined && isContainerMethod(fnAnchor)) { + if (fnAnchor !== undefined && isZigContainerMethod(fnAnchor)) { const fnCapture = grouped['@declaration.function']!; delete grouped['@declaration.function']; grouped['@declaration.method'] = { ...fnCapture, name: '@declaration.method' }; diff --git a/gitnexus/src/core/ingestion/languages/zig/index.ts b/gitnexus/src/core/ingestion/languages/zig/index.ts index 9049ffc2a..97f23702b 100644 --- a/gitnexus/src/core/ingestion/languages/zig/index.ts +++ b/gitnexus/src/core/ingestion/languages/zig/index.ts @@ -1,4 +1,4 @@ -export { emitZigScopeCaptures } from './captures.js'; +export { emitZigScopeCaptures, isZigContainerMethod, ZIG_CONTAINER_TYPES } from './captures.js'; export { interpretZigImport, interpretZigTypeBinding, normalizeZigTypeName } from './interpret.js'; export { zigArityCompatibility, diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index 7e1f3a01c..1101af851 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -173,9 +173,10 @@ export function namesAtScope(scopeId: ScopeId, scopes: ScopeResolutionIndexes): * that collapses to `@scope.class` in the scope-extractor query contract. * * Semantics widened historically from `'Class' | 'Interface'` to cover - * C#-shape languages (struct, record, enum, trait). Languages that emit - * only `'Class'` are unaffected — the extra kinds never appear in their - * parsed output. + * C#-shape languages (struct, record, enum, trait) and Zig tagged unions + * (`union(enum)` containers own methods like structs do). Languages that + * emit only `'Class'` are unaffected — the extra kinds never appear in + * their parsed output. */ export function isClassLike(t: string): boolean { return ( @@ -184,7 +185,8 @@ export function isClassLike(t: string): boolean { t === 'Struct' || t === 'Record' || t === 'Enum' || - t === 'Trait' + t === 'Trait' || + t === 'Union' ); } diff --git a/gitnexus/test/fixtures/lang-resolution/zig-basic/src/main.zig b/gitnexus/test/fixtures/lang-resolution/zig-basic/src/main.zig index 92ec01306..fc0bf6b4d 100644 --- a/gitnexus/test/fixtures/lang-resolution/zig-basic/src/main.zig +++ b/gitnexus/test/fixtures/lang-resolution/zig-basic/src/main.zig @@ -5,6 +5,8 @@ pub fn main() void { var p = pioneer.Pioneer{ .energy = 0 }; p.tick(); helper(); + const t = pioneer.Tag{ .energy = 5 }; + _ = t.isEnergy(); } fn helper() void { diff --git a/gitnexus/test/fixtures/lang-resolution/zig-basic/src/pioneer.zig b/gitnexus/test/fixtures/lang-resolution/zig-basic/src/pioneer.zig index 2b6674c41..b387bfd73 100644 --- a/gitnexus/test/fixtures/lang-resolution/zig-basic/src/pioneer.zig +++ b/gitnexus/test/fixtures/lang-resolution/zig-basic/src/pioneer.zig @@ -3,6 +3,10 @@ pub const State = enum { idle, working }; pub const Tag = union(enum) { none, energy: u32, + + pub fn isEnergy(self: Tag) bool { + return self == .energy; + } }; pub const Pioneer = struct { diff --git a/gitnexus/test/integration/resolvers/zig.test.ts b/gitnexus/test/integration/resolvers/zig.test.ts index cf891f809..7267dbd28 100644 --- a/gitnexus/test/integration/resolvers/zig.test.ts +++ b/gitnexus/test/integration/resolvers/zig.test.ts @@ -42,6 +42,17 @@ describe('Zig basic resolution', () => { expect(methods).toContain('reset'); }); + it('extracts union(enum) methods as Methods (Union is class-like)', () => { + expect(getNodesByLabel(result, 'Method')).toContain('isEnergy'); + }); + + it('dispatches method calls on a union receiver (main → isEnergy)', () => { + // Pins the `isClassLike('Union')` widening in scope/walkers.ts: without + // it `populateClassOwnedMembers` finds no class-like def in the Tag + // scope, the method gets no ownerId, and dispatch silently drops. + expect(edgeSet(getRelationships(result, 'CALLS'))).toContain('main → isEnergy'); + }); + it('resolves the relative @import("./pioneer.zig") to pioneer.zig', () => { const imports = getRelationships(result, 'IMPORTS'); const internal = imports.filter((e) => e.targetFilePath.endsWith('pioneer.zig'));