feat: add Zig language support (#1432)

* feat: add Zig language support

Adds a Zig LanguageProvider grounded in @tree-sitter-grammars/tree-sitter-zig 1.1.2.
The grammar's published peerOptional `tree-sitter@^0.22.1` is suppressed via an
npm `overrides` entry that aliases the peer to the bundled `tree-sitter@0.21.x`;
load-time smoke testing confirmed ABI compatibility.

v1 capabilities:
  - .zig file detection + Prism syntax mapping
  - Top-level + nested function_declaration as Function/Method
  - struct/enum/union (anonymous in the grammar — owner name resolved from the
    enclosing variable_declaration in class/field/method extractors)
  - container_field as struct/union fields and enum variants
  - top-level const/var as Variable nodes
  - free + member call_expression as @call edges
  - @import("./foo.zig") local-file resolution; std and external packages
    return empty (no ghost edges)
  - pub keyword detection for export checking
  - no heritage hooks (Zig has no inheritance; queries never emit @heritage.*)

Generic extractor changes (backward-compatible):
  - field-extractors/generic.ts and method-extractors/generic.ts: empty
    `bodyNodeTypes` falls back to the type declaration node itself as its own
    body container — needed because Zig's struct_declaration directly contains
    its container_field children. (The `extractOwnerName` hook this commit
    originally introduced now exists upstream; Zig just configures it.)

Out of scope (deferred):
  - usingnamespace, build.zig.zon package graph, comptime/anytype
  - scope-resolution hooks (emitScopeCaptures, interpretImport, …): Zig is
    classified `experimental` and uses the generic fallback resolution path
  - cross-package imports (std, deps)

Tests:
  - new fixture test/fixtures/sample-code/simple.zig
  - Zig describe block in tree-sitter-languages integration test
  - simple.zig added to parsing.test.ts fixture-existence list
  - Zig added to ingestion-utils detection unit test
  - Zig smoke case in parser-loader-abi.test.ts
  - Zig grammar registered in the grammar-literal validation gate

* feat(zig): integrate build.zig.zon resolution + Union label from PR 1096

Ports the additive pieces of grgisme's standalone Zig provider PR
(https://github.com/abhigyanpatwari/GitNexus/pull/1096) onto the rebased
provider:

  - build.zig.zon `.path` dependency resolution for bare-name
    @import("pkg") (parseZigBuildZon / loadZigBuildZon in
    language-config.ts, resolveZigImportInternal in import-resolvers/zig.ts,
    wired through ImportConfigs.zigBuildZon). `.url` deps and
    repo-escaping paths return null cleanly. 13 unit tests.
  - `union(enum)` containers now produce `Union` nodes (not Struct):
    'Union' added to ClassLikeNodeLabel + CLASS_LIKE_LABELS, the Zig query
    tags @definition.union, CONTAINER_TYPE_TO_LABEL maps union_declaration
    to 'Union'. The label was already plumbed graph-wide on main.
  - /^build$/ entry-point pattern (build.zig).
  - zig-basic lang-resolution fixture + resolvers integration test.

Adapted to current main while porting:

  - labelOverride relabels container-nested fns Function → Method
    (mirrors isKotlinClassMethod); the structure phase no longer derives
    Method from the legacy method-extraction path for plain
    @definition.function captures.
  - IMPORTS/CALLS edges require scope-resolution hooks
    (emitScopeCaptures / interpretImport) since the legacy DAG removal;
    Zig does not implement them yet, so the integration test documents
    that with a skipped import-edge case. The resolver itself is wired
    into the resolver factory and becomes live when the hooks land.

Not ported: named-bindings extractor (the legacy namedBindingExtractor
API no longer exists) and the bespoke field extractor (the generic
factory's extractOwnerName / empty-bodyNodeTypes hooks cover Zig).

Co-authored-by: Garrett Griffin-Morales <grgisme@gmail.com>

* feat(zig): scope-resolution hooks — IMPORTS and CALLS edges (Ring 3)

Implements the registry-primary scope-resolution path for Zig, the
prerequisite for cross-file edges since the legacy DAG removal. Adds the
standard per-language stack under languages/zig/:

  - query.ts: scope query (containers as Class scopes, blocks, functions),
    declarations (container anchors placed on the container node itself so
    the def lands in its own Class scope and the name binding auto-hoists
    to the parent — populateClassOwnedMembers needs the class-like def
    among the class scope's ownedDefs), @import statements (#eq?-gated
    builtin), parameter/constructor type bindings, and call/constructor
    reference sites. The grammar is required lazily (optionalDependency).
  - captures.ts: emitZigScopeCaptures — groups query matches, drops the
    plain-variable group for container/import bindings (their dedicated
    rules bind the name), and relabels container-nested fns
    @declaration.function → @declaration.method (labelOverride parity).
  - interpret.ts: namespace-kind imports (const x = @import("…")) and
    type bindings — self-parameter convention marks the receiver, Zig
    sigils (*, ?, [], error unions, const) stripped from type names while
    dotted qualifiers (mod.T) are preserved for Case-3 namespace-prefix
    receiver dispatch.
  - simple-hooks.ts: parameter bindings stay function-local (Go
    rationale), local-over-import merge precedence, bounds-check arity
    (always 'unknown' today — no synthesized arity metadata).
  - scope-resolver.ts: emit-side wiring; build.zig.zon threads through
    loadResolutionConfig into the same resolveZigImportInternal the legacy
    resolver config wraps. fieldFallbackOnMethodLookup off (statically
    typed). Registered in SCOPE_RESOLVERS.

The resolvers integration test un-skips the import-edge case and gains
CALLS assertions: free call (main → helper) and receiver-bound method
dispatch through a namespace-qualified constructor
(var p = pioneer.Pioneer{…}; p.tick() → main → tick).

* fix(zig): Union is class-like + missing-grammar warning (review pass)

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.

* fix(zig): anchor @declaration.variable to the binding identifier

`(variable_declaration (identifier) @declaration.name)` matched EVERY
identifier child of the node, so `const first = target;` also declared a
phantom local named `target` in the enclosing block. That phantom shadowed
the real function for later references (and starved callable-value-flow
seeds of a target). The `.` anchor pins the pattern to the first named
child — the bound name.

Regression test in resolvers/zig.test.ts pins the capture set.

* feat(zig): callable-value-flow captures + main's per-language conformance gates

Post-rebase catch-up: since this branch forked, main added three "every
registered language must appear here" tests. Each needs a Zig entry:

- callable-value-flow (#2522): Zig now emits `@callable-flow.*` facts via
  `synthesizeCallableFlowCaptures` (ZIG_CALLABLE_CAPTURE_OPTIONS in
  zig/captures.ts). tree-sitter-zig's `call_expression` carries arguments
  as direct children with no wrapper node, which the shared helper could
  not decompose, so this adds a language-neutral `extractCallArguments`
  hook (mirror of `extractFunctionParameters`; `undefined` = shared path).
  Zig joins the provider matrix as 'matrix' with a real assign→copy→
  argument→invoke case.
- external-import-conformance (#2953): `@import("std")` beside a decoy
  `src/std.zig` resolves to nothing; the decoy stays reachable via the
  relative spelling. Zig holds the property (no suffix fallback), so it is
  a case, not a KNOWN_GAPS entry.
- import-target-index-reuse contract (#2909): membership-probe-only
  fixture (minimumScans: 0, same shape as Rust).

* fix(zig): address gitnexus-check review findings

One commit per the bot's list so each item is easy to check off:

- parser-loader: Zig row gains `userSkippable: true`, so
  `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` (=1 or a list naming `zig`) disables it
  at analyze time like swift/dart/kotlin — as `optional-grammars.ts` already
  documented. Covered in parser-loader-skip-optional.test.ts.
- export-detection: `zigExportChecker` stops at the first declaration it
  reaches. A non-`pub` fn inside `pub const T = struct {…}` was reported
  exported because the walk continued up to the wrapper.
- import-resolvers/zig: a `.path = "."` dep normalizes to '' and no longer
  grows a leading slash (`/src/main.zig` could never match).
- language-config: build.zig.zon parsing strips `//` comments string-aware
  (a `//` inside `.url = "https://…"` survives) and matches braces while
  skipping string literals, so a commented-out `.path` cannot declare a dep
  and a `}` in a comment/string cannot truncate the block.
- method-extractors/configs/zig: the leading `self` receiver is excluded
  from `parameters` (Rust parity). Fixing that exposed a worse bug: the
  `parameters` node is a plain child of `function_declaration`, not a
  `parameters:` field, so `childForFieldName('parameters')` was always null
  and every Zig method had no parameters, no receiver and `isStatic: true`.
  One `zigParameterList` helper now feeds all three readers.
- variable-extractors/configs/zig: container (`struct`/`enum`/`union`) and
  `@import` bindings are skipped via the same predicate the scope captures
  use (`isZigContainerOrImportBinding`), instead of the comment merely
  claiming they were.
- tree-sitter-languages.test.ts: the "missing grammar" case now forces the
  absent-binding path through the loader's runtime opt-out on a fresh module
  instead of passing vacuously when the package is installed.

New: test/unit/zig-extractors.test.ts (exports, receiver/parameters,
variable guard); zig-import-resolver.test.ts gains the `.` dep, comment and
brace cases.

The `createFieldExtractor` heads-up needs no change: the added branch is
unreachable for every existing config (none has empty `bodyNodeTypes`).

* fix(zig): address second gitnexus-check review pass

- import-resolvers/zig: `..` above the repository root now returns null
  instead of aliasing a same-named root file (`../bar.zig` from `main.zig`
  is not `bar.zig`); the stale "extension is stripped and re-added" comment
  is corrected to what the code does.
- variable-extractors/configs/zig `extractType`: read the `type:` field only.
  The positional fallback returned the INITIALIZER of `const f = target;`
  as its type and gave up on compound annotations (`*Foo`, `?[]const u8`).
  The comment claiming 1.1.2 has no `type` field on variable_declaration
  was wrong (verified by AST dump) — and it is what led the review to
  suspect the callable-flow `extractAssignment` callback, which was
  already correct for `extern var f: T;`.
- receiver detection: only a FIRST parameter named `self` is the receiver.
  `emitZigScopeCaptures` tags first-position parameters
  (`@type-binding.first-parameter`), `interpretZigTypeBinding` requires the
  tag as well as the name, so `zigReceiverBinding` no longer turns
  `fn f(a: u32, self: T)` into an instance method.
- resolvers/zig.test.ts: both suites `describe.skipIf(!zigAvailable)`
  (Swift/Dart pattern) — the grammar is an optionalDependency.
- tree-sitter-languages.test.ts: the Zig parsing case gates on
  `isLanguageAvailable` instead of a catch-all `return`, so an installed
  grammar that fails to load fails the test; comment no longer calls Dart
  and Swift npm optionalDependencies (they are vendored).
- test/helpers/literal-collectors: `DIR_LANG` gains `zig`, so literals under
  `languages/zig/**` are validated against the Zig grammar alone rather than
  against every grammar.
- walkers.ts `isShapeLike` doc: Union IS included (via isClassLike, wired by
  Zig's union member container); Typedef remains the only deferred one.
- language-config `ZigBuildZonConfig.pathDeps` doc: values are the raw
  `.path` strings; the resolver normalizes.

Tests: zig-import-resolver (+1), zig-extractors (+3).

* fix(zig): address third gitnexus-check review pass

- language-config `parseZigBuildZon`: the `.dependencies = .{` header and
  the per-entry `.<name> = .{` headers are matched only OUTSIDE string
  literals (per-offset string mask + `matchZonHeader`). A `.name` or
  `.description` value spelling `.dependencies = .{ .fake = .{ .path = … } }`
  used to be taken as the block and returned the fake dep instead of the
  real top-level one.
- import-resolvers/zig: an absolute import (`@import("/foo.zig")`) returns
  null. The path walker skipped every empty component, so the leading `/`
  vanished and `/foo.zig` resolved as importer-relative `src/foo.zig` — an
  in-repo edge for an import Zig rejects as outside the module path.
- tree-sitter-languages.test.ts: the Zig parsing case gates on the PACKAGE
  being installed (`createRequire().resolve`, minus a deliberate
  `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` opt-out), not on `isLanguageAvailable`,
  which is false for absent AND for installed-but-broken bindings — so a
  load failure (ABI mismatch, bad export) now fails the test instead of
  skipping it, as the comment already claimed.
- walkers.ts `isShapeLike` doc: `Union` sits in `isClassLike` because that
  is the label set the ownership walkers consult, not because unions
  inherit — Zig has no inheritance and no heritage hooks. The previous
  wording ("inheritance-capable owner") said otherwise.
- Not re-fixed (already addressed in the second pass, findings carried
  over): "receiver = any parameter named self" — `interpretZigTypeBinding`
  only sources a first-position parameter as `self`; `zigReceiverBinding`'s
  doc now states that invariant. "`DIR_LANG` has no zig entry" — it does.
  Extended one level out: `BASENAME_LANGS` (`zig.ts`) and `PREFIX_LANGS`
  (`ZIG_`) map to the Zig grammar too, so extractor configs and the
  export-detection set are validated against Zig alone. That immediately
  caught a dead `childForFieldName('parameters')` in
  method-extractors/configs/zig `zigParameterList` (there is no such field;
  the named-child lookup was already the one doing the work) — removed.

Tests: zig-import-resolver (+2: absolute path, header inside a string);
both fail on the previous code.

* fix(zig): address fourth gitnexus-check review pass

- language-config: `parseZigBuildZon` only accepts a `.path` that is a
  DIRECT field of a dependency entry. Nested blocks inside the entry body
  are blanked (string-aware, offsets preserved) before the `.path` regex
  runs, and a match starting inside a string literal is rejected, so
  `.dep = .{ .url = "…", .meta = .{ .path = "x" } }` no longer becomes a
  path dep. Regression test in zig-import-resolver.test.ts (fails on the
  previous code).
- tree-sitter-languages test: the "grammar is absent" case now drives the
  loader's real `source.load()` catch branch — `node:module` is stood in
  with a `createRequire` whose require throws MODULE_NOT_FOUND for
  `@tree-sitter-grammars/tree-sitter-zig` and delegates everything else —
  instead of the `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` opt-out, which has its
  own test. It also asserts the opt-out flag is NOT set and that other
  grammars still load (non-fatal optional failure).

Not re-fixed:
- "Return type is read from the wrong tree-sitter field": tree-sitter-zig
  1.1.2 has NO `return_type` field on function_declaration — the type after
  `)` is the `type` field (AST dump: `builtin_type "i32" field=type`; the
  proposed `childForFieldName('return_type')` is null for every function).
  A pin test in zig-extractors.test.ts asserts both the grammar fact and
  that `returnType` is extracted (`void`, `!*Counter`).
- "`DIR_LANG` has no zig entry": it does (added in the first pass and
  answered again in the third); the finding is carried over unchanged.

* fix(zig): address fifth gitnexus-check review pass

- tree-sitter-languages test: the Zig "functions, structs, enums, and
  imports" case now asserts the `import.source` capture for
  `const std = @import("std");` (the fixture's only import), so a query
  change that drops Zig import matching fails it instead of passing
  unchanged.

Not re-fixed:
- "Return type is read from the wrong tree-sitter field": carried over from
  the fourth pass unchanged. tree-sitter-zig 1.1.2 has no `return_type`
  field on function_declaration; the return type IS the `type` field, and
  the pin test added in the fourth-pass commit
  (zig-extractors.test.ts, `childForFieldName('return_type')` is null,
  `returnType` = `void` / `!*Counter`) proves it.
- "`DIR_LANG` has no zig entry": carried over unchanged for the third time;
  the entry exists since the second-pass commit.

* feat(zig): export fn visibility, opaque containers, named test blocks, member ownership

Ports the parts of upstream PR #305 (closed, unmerged) that our Zig
provider lacked, plus two gaps found while porting.

- `export fn` / `export var` (C-ABI linkage, never `pub`) are exported;
  the pub/export predicate is now shared by the export checker and the
  method/variable extractors' visibility (`hasZigVisibilityKeyword`).
- `const H = opaque { … }` is a Struct-labelled container (it may own
  methods, never fields) in both the structure queries and the scope
  query; ZIG_CONTAINER_TYPES is the single source for the extractor
  configs.
- `test "name" { … }` blocks are Function nodes named by the string
  node WITH quotes, so `test "add"` beside `fn add` cannot merge onto
  Function:<file>:add; `test_declaration` joins FUNCTION_NODE_TYPES and
  the Zig method config names it in the enclosing-function walk, so
  calls inside a test attribute to the test. Anonymous `test {}` and
  decl-tests `test add {}` are scopes without a node (an empty-name hook
  result stops the walk instead of falling through to the identifier of
  the function under test).
- Empty container bodies (`struct {}`, `opaque {}`) no longer mint a
  nameless Property: tree-sitter-zig 1.1.2 recovers them as a
  container_field with a MISSING identifier; #not-eq? guards in both
  queries and the field extractor drop it.
- Owner walk (`findEnclosingClassInfo`): an anonymous container bound
  by the enclosing `variable_declaration` takes the binding identifier,
  same shape as the Go `type_spec` branch. Before this NO Zig member
  had an owner — zero HAS_METHOD / HAS_PROPERTY edges for Zig.

Not ported from #305, deliberately: `builtInNames` (a bare-name call-site
drop filter; `alloc`/`free`/`append`/`print` are the most common user
method names in Zig and `std.*` receivers are already external via the
import binding), `usingnamespace` (removed in Zig 0.15), `@cImport`,
`build.zig` ignore, and the web-app changes.

* test(zig): absent-grammar case owns GITNEXUS_SKIP_OPTIONAL_GRAMMARS

The loader parses the opt-out variable lazily once per module copy, so
under `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=zig` (or `all`) — a supported way
to run — the fresh loader took the opt-out branch and the "not the
opt-out path" assertion failed before the absent-binding path ran.

Clear the variable for the fresh module and restore it in `finally`,
instead of returning early: the branch stays exercised in every
environment. Verified: fails on the previous code under `=zig`, passes
with and without the variable now.

* fix(zig): declare the Union relation pairs — analyze aborted on any union

`gitnexus analyze` exited 1 on every Zig repository that declares a
`union` (including the zig-basic fixture itself): the member-ownership
commit made `union_declaration` a MEMBER_OWNER, so HAS_PROPERTY /
HAS_METHOD edges are emitted FROM a Union node, but `Union` was not in
LINKABLE_LABELS, so the schema's scope-bridge cross product never
generated a `FROM Union` pair and LadybugDB rejected the edge
(`labelPair: "Union|Property"`). Resolver tests stayed green because
they never write to the DB.

- `Union` joins LINKABLE_LABELS (also bridges `Tag{…}` constructor
  references); the three hand-written `→ Union` target pairs move to the
  generated half, per the STRUCTURAL_PAIR_DDL rule.
- structural-pair-coverage gains an optional-grammar corpus with
  zig-basic (`Union|Property`, `Union|Method` sentinels), skipped when
  the grammar is absent.
- Rust `union_item` note updated: the three gates it cited are widened.

Note for reviewers: the DDL fingerprint changes (#2808), so existing
indexes are rebuilt on next analyze.

* feat(zig): resolve path deps through the dep's build.zig and src/root.zig

The bare-name resolver only knew `src/<name>.zig` and `src/main.zig`.
`zig init` has written `src/root.zig` for libraries since 0.12, so the
default library layout never resolved. Now: the root the dep's own
build.zig declares (`b.addModule("<name>", .{ .root_source_file =
b.path("…") })`, name-matched module first), then src/root.zig,
src/<name>.zig, src/main.zig. `normalizeZigDepPath` is shared by the
loader and the resolver.

* feat(zig): Const/Variable defs, member imports, receiver typing, generic type constructors

Coverage gaps found by indexing idiomatic Zig against the branch:

- Const / Variable nodes: ZIG_QUERIES had no @definition.const /
  @definition.variable, so `pub const VERSION`, error sets and type
  aliases were absent and zigVariableConfig never ran. Rules are gated on
  the literal `const` / `var` keyword — tree-sitter-zig 1.1.2 parses
  statement assignments (`x = 5;`, `x += 1;`, `_ = expr;`) as keyword-
  less `variable_declaration`s, and the scope query minted a phantom
  local per assignment and one `_` per discard. Container and @import
  bindings are skipped via `shouldSkipDefinitionCapture`.
- Imports: `const X = @import("x.zig").X` (named / alias), `const X =
  ns.X` where `ns` is an @import binding of the file (promoted to a
  named import), and `pub usingnamespace @import(...)` (wildcard, with
  `expandsWildcardTo`). All three lost the file-level IMPORTS edge.
- Receiver typing: `var x: T = undefined` / decl literals `const x: T =
  .init()` (annotation), `var c = T.init()` / `mod.T.init()` (call
  return), `List(u8){}` (instantiation literal); `normalizeZigTypeName`
  drops the comptime argument list.
- Generic type constructors `fn List(comptime T: type) type { return
  struct {…}; }`: the returned container is a Struct/Union/Enum named
  after the fn, owns its members (HAS_METHOD / HAS_PROPERTY), binds in
  the module scope beside the Function def, and is emitted ahead of it
  so a named import binds the type.
- `export` vs `pub`: `visibility` is now `pub`-only (Zig-module fact);
  `isExported` keeps `pub|export` (visible outside the unit, as C's
  external linkage). `export fn` without `pub` is not reachable from
  other Zig files.
- Extractor configs share `zigContainerName`; ast-helpers' owner walk
  learns the type-constructor shape.

Tests: zig-idioms fixture (10 resolver cases), extractor/interpret unit
cases for each rule.

* fix(zig): address sixth gitnexus-check review pass

- Windows absolute `.path` deps (`C:\x`, `C:/x`) return null from
  `normalizeZigDepPath` like POSIX ones; a `/`-only check let them
  through as repo-relative.
- `parseZigBuildZon` accepts the `.dependencies = .{` header only at
  brace depth 1 (a direct field of the file's `.{`), so a same-named
  field nested in an earlier struct cannot hijack the block.
- `importsExecuteWhereWritten: false` on the provider: `@import` is
  compile-time name lookup (as C `#include`, Rust `use`); a body-level
  `@import` is no longer marked `runsOnlyWhenCalled`.
- Namespace imports record the MODULE as `importedName`
  (`zigModuleNameOf`: last path segment without `.zig`), per the shared
  contract; the local handle stays `localName`.
- Keyword-less `<ident> = @import(…)` (`_ = @import("x.zig")` in a test
  block) is a `side-effect` import: file edge, no binding. Only
  `const`/`var` declarations bind a name or feed alias promotion.
- `extractZigFunctionName` doc: an empty name is falsy, so the enclosing-
  function walk skips the test node and continues to the File; it does
  not "end" there.

Not re-fixed: "DIR_LANG has no zig entry" — carried over for the fourth
pass in a row; `test/helpers/literal-collectors.ts` has had `zig` in
`DIR_LANG` (line 91) and `BASENAME_LANGS` since the second-pass commit.

Regression tests: absolute-path spellings, nested `.dependencies`
decoy, namespace/side-effect interpretation, function-scoped `@import`
not deferred (all four fail on the previous source).

* fix(zig): address seventh gitnexus-check review pass

- The scope query's `@import` binding rules are keyword-gated (`"const"` /
  `"var"`, first-child anchored) like every other binding rule, and the
  keyword-less `<ident> = @import(…)` statement has its own
  `@import.side-effect` rule. Tree-sitter queries cannot express "no
  keyword child", so that rule also matches the keyword shapes and
  `emitZigScopeCaptures` drops those (they are the binding rules'
  matches). Behaviour is unchanged from the sixth-pass fix — the existing
  side-effect test covers it — the query text now carries the guard the
  finding asked for.

Not re-fixed: "DIR_LANG has no zig entry" — fifth pass in a row;
`test/helpers/literal-collectors.ts` has had `zig` in `DIR_LANG` since
the second-pass commit. Left for a human reviewer to close.

* fix(zig): resolve @import of the repo's own build.zig modules (F3)

Bare-name imports were resolved through build.zig.zon path deps only, so
the module a repo's ROOT build.zig declares for itself —
`b.addModule("lightpanda", .{ .root_source_file = b.path("src/lightpanda.zig") })`,
imported by name from 378/567 Lightpanda files — never produced an IMPORTS
edge, and nothing reached through `lp.X` resolved. A repo with a build.zig
but no build.zig.zon got no resolution config at all.

- language-config: `parseZigRootModules` (static scan of the root build.zig:
  `addModule("<name>", …root_source_file = b.path("<p>.zig")…)`, and
  `createModule`/`addModule` bindings named via `addImport("<name>", m)` or
  `.imports = &.{ .{ .name, .module = m } }`; generated / `.url` / computed
  modules are skipped) → `ZigBuildZonConfig.rootModules`.
- `loadZigBuildZon` → `loadZigBuildConfig`: reads the zon AND the root
  build.zig; null only when neither contributes.
- resolver: root modules are consulted before path deps; std/builtin/root
  still never resolve.
- fixtures: zig-idioms gains a Lightpanda-shaped root module (+ decoy
  `addOptions().createModule()`); new zig-rootmodule (build.zig, no zon).

Corpus (Lightpanda): IMPORTS 3014→3389 (378 edges to src/lightpanda.zig,
was 0), CALLS 13885→13989, ns.f() 79.3%→83.3%,
param.m() type=ns-qualified 43→46/417.

* fix(zig): import every @import in expression position; resolve @import("x").f()

Both query sets only saw `@import` as the value of a const/var or under
`usingnamespace`, so an @import in any other position produced no file
edge: Lightpanda's `pub const Interfaces = .{ @import("a.zig"), … }`
registration table (288 modules), call arguments
(`CounterEnum("size", @import("ArenaPool.zig").BucketSize)`), comparison
operands (`JsApi == @import("x.zig").JsApi`) and member-call receivers
(`try @import("dump.zig").root(...)`) — 417 of 3,401 in-repo import pairs
had no IMPORTS edge, and the 80 inline-receiver calls resolved 0 times.

Scope query: a catch-all `@import.inline` rule matches every `@import`
builtin; `emitZigScopeCaptures` drops the ones a binding rule (or the
keyword-less side-effect rule) already claimed (by string-node id) so a
bound import is never doubled, emits the rest as side-effect imports once
per distinct source per file, and binds a member-call receiver as a
namespace import whose local name is the builtin's own text — the
`@reference.receiver` text on that call is identical, so the shared
namespace-receiver lookup (Case 1) resolves the member in the imported
module.

ZIG_QUERIES: the three variable_declaration/usingnamespace-anchored
`@import` rules collapse into the same single builtin rule (the structure
phase only skips import matches; one match per builtin keeps
tree-sitter-languages' exact-capture assertion intact).

Lightpanda corpus (zig-corpus-check, before → after): IMPORTS 3014 → 3426,
in-repo pairs missing 417 → 5 (4 under a default-ignored `cache/` dir, 1 a
commented-out import the census regex counts), `@import(..).f()` 0/80 →
72/80 (the 8 left are `@import("root")` and non-import builtins the census
mislabels), CALLS 13885 → 13959; every other line unchanged.

* feat(zig): model file-structs — a file with top-level fields is a Struct named after the file

In Zig every file is a struct; one that declares top-level fields is an
instantiable type whose name is the file stem (`Page.zig` declares `Page`,
`@typeName` agrees), and its top-level `fn`s taking `self` are its methods.
Lightpanda spells 413 of 567 files this way and, before this, `page.getArena()`
on a `page: *Page` parameter resolved 23 of 993 times (2.3 %) — `impact` on
`Page.getArena` reported 0 callers for 159 call sites, and 2,395 top-level
fields were ownerless Property nodes.

Definition phase: `((source_file (container_field …)) @definition.struct)` +
the class extractor names it from the file path (`zigContainerName(source_file,
filePath)`); top-level fns/fields are owned through the new
`LanguageProvider.resolveFileTypeOwner` hook (consulted by
`findEnclosingClassInfo` when the walk reaches the tree root, and by the
method/field extractors' owner lookup) — ids become `Method:<file>:Page.getArena#0`
/ `Property:<file>:Page.session` with HAS_METHOD / HAS_PROPERTY edges.

Scope phase: `emitZigScopeCaptures` emits a Class scope over the whole file
(same range as the Module scope, nested under it — the pair `canParentScope`
already admits) plus a Struct def anchored on it; member NAME bindings are
hoisted back to the Module scope by `zigBindingScopeFor` so `Page.init()`
(namespace member) keeps working, while ownedDefs stay in the Class scope so
`populateClassOwnedMembers` stamps the owner. The file-level `const Page =
@This();` alias no longer mints a Const (it would shadow the Struct); `@This()`
aliases in type position (`self: *SigHandler` in Sighandler.zig, nested
`Self`) are rewritten to the container name so receivers resolve. A namespace
import of a `.zig` file gets a NAMED twin of the file stem so `x: *Page` in the
importer binds the type as well as the module.

Shared, additive: `resolveFileTypeOwner` hook; `filePath` threaded to
`ClassExtractionConfig.extractName` / `extractOwnerName`; nameless
`definition.struct` passes `getLabelFromCaptures` like `definition.class`
already did (extractor synthesizes the name).

Lightpanda corpus (zig-corpus-check): param receivers typed by a file import
23/993 → 993/993; `self.m()` 99.2 → 100 %; annotated locals 11.6 → 23.2 %;
CALLS 13,885 → 15,689; HAS_METHOD 1,477 → 8,003; ownerless Property 2,395 →
276; Function/Method 8,378/1,518 → 2,562/7,330; no row regressed.
Fixture `zig-filestruct` (Page/Session/Sighandler/util) + unit tests pin the
shape, the stem naming, the alias rewrite, the namespace twin and the
unchanged namespace-file behaviour.

* test(zig): expression-position import case sees the file-struct type twin

* fix(zig): stop reading member calls `x.f(arg)` as direct calls named `f`

tree-sitter-zig spells `field_expression` as `object:`/`member:`; the shared
callable-flow reader only knew `property`/`field`/`method`, so every Zig
member call collapsed to a DIRECT call named after the member and the
solver fanned each argument out to every same-named callable
(4,761 cap warnings on Lightpanda, `Global.deinit -> Global.deinit`
self-loops through `pub const release = deinit;`).

Shared (grammar-neutral, receiver-gated):
- `memberParts` also reads `member` (only C/C++ `offsetof_expression` and
  JS `class_body` expose that field, without a receiver field).
- A member call is a field-stored-callable invoke only when a MEMBER store
  (`o->run = handler`, `self.f = target`) or a declared callable-typed field
  is visible — a same-named plain binding no longer gates it.
- `direct-callee-name` requires a direct designator: `.init(x)`,
  `' '.join(x)`, `string.Join(x)` name no callee to seed by simple name.
Zig: formals are numbered without the leading `self`, so `r.run(target)`
joins `cb` and yields `Runner.run -> target`.

Goldens for python/csharp regenerated: the only drift is the dropped
`direct-callee-name` on `' '.join(...)`, `text.strip().ljust(...)`,
`string.Join(...)`.

Lightpanda: cap-warnings 4761 -> 2, cvf self-loops 10 -> 0,
CALLS 13885 -> 13857 (28 removed, all callable-value-flow: 10 self-loops,
17 same-name fan-out, 1 lost `on -> TypeErased.start`; +1 correct
`Arena.alloc -> allocator`).

* fix(zig): bind container field types so `self.field.m()` resolves (F5)

A container's field types were never bound on its Class scope: the scope
query's `container_field` rule captured only the name, and
`emitZigScopeCaptures` synthesized no `@type-binding.field` group. The
compound resolver reads member types from that scope
(`typeOfMemberOnClass` → `classScope.typeBindings.get(field)`), so
`self.session.name()`, `self.counter.incr()` — Lightpanda's dominant
cross-object call shape — resolved 9 of 2803 times (0.3 %).

- query.ts: capture `type: (_)? @declaration.field-type` on
  `container_field` (enum variants have none).
- captures.ts: per typed field, push a `@type-binding.field` group (name =
  field, type = the type text, `@This()` aliases rewritten to the container
  name like parameter types); anonymous inline containers are skipped. The
  binding lands on the container's Class scope — the file's Class scope for
  a file-struct — since `zigBindingScopeFor` hoists only declaration names.
- query.ts/captures.ts: `const page = self.page;` / `var s = self.session;`
  one-level field aliases become `@type-binding.alias` bindings whose "type"
  is the RHS path; the resolver's member-alias branch re-resolves it as a
  receiver chain. Import aliases (`const Counter = counter.Counter;`) are
  dropped — they are named imports.
- interpret.ts: `@type-binding.field` → 'annotation',
  `@type-binding.alias` → 'assignment-inferred' (an annotation on the same
  binding wins).

Corpus (Lightpanda, zig-corpus-check): self.field.m() 11/2803 (0.4 %) →
1433/2803 (51.1 %); ident.m() bound=local-field-access 29/1902 (1.5 %) →
307/1902 (16.1 %); chain.m() 146/5022 (2.9 %) → 642/5022 (12.8 %);
CALLS 15838 → 18066. self.m() / free f() / ns.f() unchanged.

Tests: unit (zig-extractors) — one @type-binding.field per typed field with
sigils stripped and aliases rewritten; the binding hosted on the container's
Class scope (file-struct: the file's Class scope, not Module) with the
written spelling as declaredSpelling; field aliases bound to the RHS path
and never for import aliases. Integration (zig-idioms `holder.zig`,
zig-filestruct `Page.zig`): `viaField → incr` ×2 into counter.zig,
`viaAlias → get/twice`, `sessionName → name` / `sessionLabel → name` into
Session.zig. All fail without the change. The optional-payload capture
`if (self.opt) |c| c.incr()` is not asserted (F6).

* fix(zig): `pub const X = @import(…)` at file scope republishes X (reexportsName)

Lightpanda's `lightpanda.zig` is one long list of `pub const Arena =
@import("Arena.zig");`, and most files name their types through it (`const
lp = @import("lightpanda"); const Arena = lp.Arena;`, `arena: *lp.Arena`).
The scope side treated those bindings as plain imports of the hub file, so
the hub never published the names it re-exports and a third file's `const
Arena = lp.Arena;` (promoted to a named import of `Arena` from the hub) found
nothing.

`emitZigScopeCaptures` now marks named/alias import groups whose declaration
is a file-level `pub const` — the `@import(...).X` form, the alias promotion
`pub const Bar = ns.Bar`, and the file-struct type twin of `pub const Arena =
@import("Arena.zig")` — and `interpretZigImport` sets the shared contract's
`reexportsName: true` on them (the Python `__init__.py` shape, consumed by
`buildReexportClosures`). Private and fn-local bindings stay unflagged.

Not covered here: a receiver ANNOTATED with the dotted hub path (`arena:
*lp.Arena`) — Case 3 of the receiver-bound pass looks the member up with
`findExportedDef`, which only sees locally declared names; following
re-exports there is a shared change left for a follow-up.

* fix(zig): type receivers through `const X = <type expr>;` aliases (F7)

`const LocalAlias = Local;`, `const T2 = Thing;` (alias of an alias/import)
and `const B = util.List(u8);` (an INSTANTIATED generic type constructor)
were plain `@declaration.variable` bindings, so `LocalAlias.mk()`,
`var l = LocalAlias.mk(); l.go()`, `T2.make()`, `B.init()`, `B{}` and
`var x: B` all typed nothing (review repro r3-flow b1..b9; Lightpanda:
`pub const Proto = HtmlElement;` x68, `const Allocator = std.mem.Allocator`
x104, `pub const KeyIterator = GenericIterator(...)`, fn-local
`const R = ...(...)`).

Model: a `@type-binding.alias` binding of the alias NAME to the value's type
text — Rust's `let x = y` / JS's `const B = Foo`, source
'assignment-inferred' — NOT a TypeAlias def. Reasons: (1) the shared
machinery already chains typeBindings (`followChainedRef` in the extractor,
`followChainPostFinalize` after propagation), so `var l = LocalAlias.mk()`
and `var x: B` reach the target through the alias with no new shared code;
(2) nothing shared follows a `TypeAlias` def to its target — `isShapeLike`
only makes the alias itself a member owner (TS object-type aliases) — so a
relabel would have needed language-named shared code; (3) graph node ids
are UNCHANGED: every alias stays `Const:<file>:X`. `normalizeZigTypeName`
already drops the comptime arguments, so `util.List(u8)` binds `util.List`
and resolves through the namespace import (Case 3). The identifier /
member shapes also take `var` (`var cur = orig; cur.go()` — the cursor
idiom, same binding as Rust's `let x = y`).

Heuristic, stated as such: a CALL value is kept only when the callee's last
identifier is TitleCase (Zig's naming convention for types), because the
grammar cannot tell `util.List(u8)` from `util.makeThing()` and the latter
belongs to the call-return rules; the call-return group is dropped for the
same TitleCase shape so the two never race on match order. Import bindings
(`const Stack = @import("x.zig").Stack`), promoted namespace-member aliases,
enum/decl literals (`.foo`) and the `type:` annotation of
`var b: T = undefined;` are excluded.

Not done: the two-hop `pub const bridge = js.Bridge(T); bridge.accessor()`
chain. `js.Bridge` is a Function that RETURNS `bridge.Builder(T)` (a call,
not a container), so the alias binds `js.Bridge`, Case 3 finds a Function
with no members in js.zig, and Case 3b is skipped for a namespace head.
Following that hop needs a namespace-member return-type route in shared
code (or a Zig `resolveQualifiedReceiverMember` hook that re-implements
member lookup without the model); left for a follow-up.

Corpus (Lightpanda, `harness/zig-corpus-check.mjs`): CALLS 15838 -> 16051
(+213, 0 removed), `ident.m() bound=local-alias` 6/63 -> 36/63,
`local-call` 159 -> 176, `module/unknown` 110 -> 116, `local-other`
271 -> 273; `self.m()`, `free f()`, `ns.f()` unchanged or up.

* fix(zig): one alias rule set — F7's alias rules subsume F5's field-access alias rules

* fix(zig): type locals through try/catch/orelse, return types and payload captures

F6 of the gitnexus-check review. Three gaps in the value flow that types a
local receiver, all measured on Lightpanda:

1. `@type-binding.call-return` needed the `call_expression` as the DIRECT
   value child, so `const p = try Page.init(…)` (2,551 sites), `… catch
   return` (410) and `… orelse return` typed nothing. The rule is now one
   keyword-gated declaration match; `emitZigScopeCaptures` unwraps `try`,
   `catch`, `orelse` and parentheses (`zigUnwrapValue`) and decides what the
   value types (`zigCallReturnTypeOf`): a module-level receiver still names
   the type (`Counter.init()` → Counter, Rust `Foo::new()`); a free call
   binds the callee name (`makeThing`); a member call on a fn-LOCAL receiver
   (parameter / local / payload — Zig forbids shadowing, so "declared in the
   fn" is exact) binds the compound `node.asElement()` the shared resolver
   walks to the method's return type — instead of typing `el` as `Node`. A
   TitleCase callee (`List(u8)`) is a type constructor and binds nothing.

2. No `@type-binding.return` existed. `fn make() !*Thing` now binds
   `make ↦ Thing` in the enclosing scope (Module for free fns, the container's
   Class scope for methods, where the compound resolver reads it). Builtins,
   `type`, `@TypeOf(…)` and comptime type parameters (`?*T`) bind nothing;
   `@This()` / `Self` returns name the container. `normalizeZigTypeName` now
   strips the error union BEFORE the payload's sigils, so
   `Allocator.Error!*Page` → `Page` (it used to leave `*Page`).

3. Payload captures had no binding at all. `populateZigRangeBindings`
   (registered as `populateRangeBindings`) types `for (items) |it| / |*it|`,
   `for (items, 0..) |it, i|`, `if (opt) |v|`, `if (call()) |v|`,
   `while (it.next()) |x|` from the SUBJECT's written type minus one layer
   (`[]T` element, `?T` payload) — declining when the layer is not visible
   (`ArrayList(T)`) — and the same projection for `const t = items[i]` /
   `opt.?` / `ptr.*`. `catch |err|` and `switch` prongs are skipped.

Corpus (Lightpanda, gate before → after): CALLS 15838 → 17853;
`ident.m() bound=local-try/catch/orelse` 11/1298 → 584/1298;
`local-call` 159/1282 → 398/1282; `payload` 82/1085 → 233/1085;
`other-recv:call_expression` 5/991 → 457/991; `local-other` 271 → 364;
`self.m()` 100 %, `free f()` 97.5 %, `ns.f()` 83.4 % unchanged; nothing down.

Not covered: `const t = ns.f()` (a namespace fn's return type across files —
Case 3 has no path from a namespace head to a callable's return binding),
and expression receivers (`items[i].run()`, `o.?.run()`).

* fix(zig): resolve leftover fixture merge markers (Page.zig)

* fix(zig): reconcile F6 value inference with F7 aliases and F5 field bindings

- A fn-local TitleCase receiver (`const R = generic.List(u8); var l =
  R.init();`) is a type alias (F7), not a value local: `R.init()` names the
  type `R` like `Counter.init()` does at module level, so `l` chains
  R → util.List → push. F6's local-receiver rule now excludes TitleCase heads.
- The F6 unit helper only collects the value-inferred / return kinds it
  owns; F5 field and F7 alias bindings for the same names are asserted in
  their own suites.

* fix(zig): give function-local and anonymous containers an identity (F8)

`const R = struct {…}` declared inside a fn (Lightpanda's reflection.zig
has ~20, one per builder) all collapsed onto one `Struct:<file>:R` with one
`R.get`; anonymous containers (`std.sort.pdq(…, struct { fn lessThan … }
.lessThan)`, `const byte_size = struct { fn it … }.it;`, `?struct { min,
max }` field types) had no identity at all, so their fns were OWNERLESS
Methods (`Method:<file>:lessThan#3`) that collided across a file.

`zigContainerName` now yields the graph IDENTITY on both phases:
  - function-local named: `<enclosing callable>$<name>` — `Reflect.string$R`
    (Java local-class `$` chain; `populateClassOwnedMembers` leaves it whole);
  - anonymous: `<host>$<ordinal>` — `build$1`, `Outer$1`, `Page$1`
    (javac's `Outer$1` numbering per host, in source order);
  - a `test` host is keyed `test@L<line>` (its string does not survive the
    class extractor's qualified-name normalization).
`zigContainerBindingName` keeps the spelling code writes (`R`) for scope
bindings and `@This()` alias rewrites (`@declaration.binding-name`).

Structure phase: bare `(struct|enum|union|opaque_declaration)` rules mint the
local/anonymous nodes via the class extractor; `shouldSkipDefinitionCapture`
keeps exactly one rule per container (`zigContainerAnchor`); a new
grammar-neutral `resolveContainerTypeOwner` provider hook lets the shared
owner walk name a container from context, so `Method:<file>:Reflect.string$R
.get#0` and its HAS_METHOD source agree by construction. Scope phase: the
wrapper group splits name/binding-name for locals and anonymous containers
get synthesized `@declaration.<kind>` defs (`is-synthetic`).

Lightpanda: ownerless Methods 14 → 0, fns without a node 55 → 0, ownerless
Properties 276 → 5, HAS_METHOD 8003 → 8074, HAS_PROPERTY 7275 → 7403,
CALLS 15838 → 15857, Struct 1905 → 2199; no resolution bucket dropped.

* fix(zig): re-add the implicit receiver on the call side so both method-call spellings reach the callback formal

`extractFunctionParameters` sliced the leading `self` off the formals, which
lined up `r.run(target)` (target@0 ↔ cb@0) but lost the explicit spelling
`Runner.run(&r, target)` (&r@0, target@1 ↔ cb@0): the callback never joined
its formal and `run → target` was missing (PR #1432 review by koriyoshi2041).

Formals are numbered once per function while the receiver differs per call
shape, so the fix lives in `extractCallArguments`: keep `self` as formal 0
and prepend the receiver as actual 0 when the callee is a member call on a
VALUE receiver — chain head is a fn-local name that is not TitleCase, the
same value-vs-type rule F6 uses. Namespace / type / decl-literal receivers
(`Runner.init(cb)`, `helpers.apply(cb)`, `List(u8).init`, `.init(cb)`) get
no prepend. Known residual gap, documented: a module-level value receiver
(`global_runner.run(cb)`) is not fn-local and still misses.

Tests: the F2 integration case now asserts both spellings plus a namespace
call; the unit contract pins `self@0, cb@1` and the per-call actual index.

* fix(zig): address eighth gitnexus-check review pass

- Named dependency modules: `parseZigBuildModuleRoots` scanned
  `addModule("<name>", .{ … })` with a `[^}]*` regex, so a nested field
  before `.root_source_file` (`.imports = &.{ .{ … } }`) ended the match
  at the inner `}` and demoted the module to an unnamed fallback — the
  first exe/lib root in the file then answered `@import("<name>")`. The
  named lookup now walks the balanced `addModule(…)` argument list
  (same scanner as `parseZigRootModules`, comment-stripped, string-aware);
  the unnamed fallbacks are unchanged. Regression test in
  `zig-import-resolver.test.ts`.
- Type-position `@import`: `var x: @import("m.zig").T = undefined;` was
  read as an import binding of `x` on both sides — the query rules match
  the `type:` child like a value, and `isZigContainerOrImportBinding`
  scanned every named child — so `x` was never declared and became a
  named import of `T`. The helper now skips the `type:` field, and
  `emitZigScopeCaptures` drops binding-rule matches whose `@import` sits
  in the annotation (`isZigTypePositionImport`) without claiming the
  source, so `x` binds as a variable and the file edge survives as a
  side-effect import. Regression tests in `zig-extractors.test.ts`
  (variable extractor + scope captures).
- Union in the class-capture skip guard: the parse-worker's inline
  class-like predicate lacked `Union`, so a `Union` definition bypassed
  `shouldSkipClassCapture` unlike every other `ClassLikeNodeLabel`.
  Added the label; no Zig behavior changes (Zig defines no skip hook), so
  no test.
- File-owned method ids in `findEnclosingFunctionId`: the arity lookup
  used `findEnclosingClassNode` while the owner came from the file-owner
  aware `cachedFindEnclosingClassInfo`, so a Zig file-struct's top-level
  fn produced `Method::Page.get` without the `#<arity>` suffix. It now
  uses `findEnclosingClassNodeOrFileOwner`, the definition-phase lookup.
  Consistency fix only: `ParseWorkerResult.calls` / `.assignments` (the
  sole consumers of this id) are merged but not read since #942 — CALLS
  edges come from the scope pipeline, whose ids were already right — so
  no observable graph change and no test.

Not re-fixed:
- `test/helpers/literal-collectors.ts` `DIR_LANG` has no `zig` entry
  (raised for the seventh time): the entry exists (`zig:
  SupportedLanguages.Zig`, added by the second-pass commit), so
  `languages/zig/**` literals are already validated against the Zig
  grammar alone; documented in the PR body since the fifth pass.

* fix(zig): keyword-gate the constructor type-binding rules

The three `@type-binding.constructor` rules (`const p = T{…}`, `mod.T{…}`,
`List(u8){…}`) matched any `variable_declaration` with an identifier and a
`struct_initializer`, keyword or not — and tree-sitter-zig 1.1.2 parses a
re-assignment `p = T{…};` (and `_ = T{…};`) as the same node type. So an
assignment minted a constructor binding for `p` in its own block, and one
for `_`. Zig's static typing makes the extra binding redundant (`p`
already carries its type from its declaration: annotation, constructor or
inferred value), so it cost little, but it declared nothing and stood out
against every other binding rule (`@declaration.variable`, the import
rules, the call-return rules), which are keyword-gated for exactly this
shape. Split each rule into `"const" .` / `"var" .` variants, like the
call-return rules.

Regression test in `zig-extractors.test.ts`: `p = T{…}`, `q = mod.T{…}`,
`l = List(u8){}` and `_ = T{…}` after their declarations yield only the
three declaration bindings (fails on the previous query). The zig,
callable-value-flow, grammar-literal and tree-sitter-languages suites are
unchanged.

Raised twice by gitnexus-check (passes on 2026-08-18 12:42 and 12:56).

* fix(zig): re-baseline the callable-flow capture fingerprints, keep `await f<T>(x)` a direct callee

The `benchmarks (GITNEXUS_BENCH)` CI job gates two capture fingerprints
that this branch's shared callable-flow change (3e62b99a) moved without
re-baselining: `bench/python-scope/baseline-fingerprint.txt` and four
languages in `bench/scope-capture/baselines.json` (csharp, cpp,
typescript, kotlin). Both `--check` runs pass on origin/main and failed
on this branch; every other language matched its baseline on the same
run.

Drift, verified by dumping the canonical matches on both trees:
- csharp / kotlin / python: exactly the intended change — a MEMBER call
  (`string.Join(x)`, `.forEach { }`, `' '.join(x)`, `.ljust(w)`) no
  longer carries `direct-callee-name`; the argument fact is unchanged.
- cpp: `choice.select(1)` (cpp-deleted-overload) drops an INDIRECT
  invoke + its synthetic `@reference.call.free` that were gated only by
  the same-named free `select` binding; the site is a genuine method
  call already captured as `@reference.call.member`. capture_groups_fp
  4605 -> 4601.
- typescript: `await svc.verify<T>(x)` (member) and `initializer()(cb)`
  (call-of-call) drop the name as intended. But `await verifyToken<T>(x)`
  — a DIRECT call — lost it too, because tree-sitter-typescript parses
  `await f<T>(x)` as `call_expression(function: await_expression(f),
  type_arguments, …)` and the new direct-designator gate saw an
  await_expression, not `f`. `wrappedExpression` now unwraps
  `await_expression` (no named field, so the field-based unwrap missed
  it), restoring parity with main for the direct spelling while the
  member spelling stays nameless. Regression test added; it fails
  without the unwrap on both assertions.

Gates run locally: scope-capture --check (15 languages), python-scope
--check, import-target --check, tsc, eslint, prettier, the callable-flow
/ golden / tripwire / resolver test files (33 files), full suite with
coverage (82.9/71.5/89.1/86.4 vs 26/23/28/27 thresholds).

* test(zig): fail CI when the optional Zig grammar is absent

`@tree-sitter-grammars/tree-sitter-zig` is an optionalDependency, so every
Zig suite gates on `isLanguageAvailable(Zig)` and the ABI load-smoke accepts
a clean load failure for an optional grammar. Both are the right contract for
a platform with no prebuild, and together they leave a hole: if the grammar
never installed on any CI runner, this PR would merge with all eight Zig
resolver suites plus the structure-phase suite reported green-by-skip, having
never executed the native Zig parser once.

Close it with the `GITNEXUS_REQUIRE_FTS` idiom already used for the FTS
suites. `GITNEXUS_REQUIRE_ZIG=1` declares "this runner has a prebuild, the
grammar MUST be here", and a missing grammar becomes a failure instead of a
skip. tree-sitter-zig@1.1.2 publishes prebuilds for {darwin,linux,win32}-
{x64,arm64}, so the flag is set on two required jobs that all run on covered
platforms: the sharded ubuntu `tests` job and the three-OS `abi-assert` job.

- test/helpers/optional-grammar.ts: the registry mapping a language to its
  require-variable, plus `describeGrammarPresence`, a presence assertion that
  FAILS when required-but-absent. Deliberately a separate test rather than
  flipping the suites from skip to fail: a skipped suite reports success, so
  only a failing test can turn "Zig never ran" into a red job.
- parser-loader-abi.test.ts: the optional exemption is revoked for a language
  the environment declares required, so an ABI-broken Zig binding fails the
  smoke instead of passing as a clean absence.
- optional-grammar-gate.test.ts: pins the two ways the gate could silently
  never fire — reading a variable name CI does not set, or accepting a value
  CI does not write.

Nothing changes for a run that leaves the variable unset: local runs and any
future prebuild-less platform still skip. Verified both directions --
`GITNEXUS_REQUIRE_ZIG=1` alone: 149 passed, 0 skipped; with
`GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` forcing the grammar away it fails 2 tests
with an actionable message; with the skip flag but no require flag it is
green-by-skip exactly as before.

Addresses the test-only blocker in the gitnexus-check review of #1432.

* fix(zig): type the optional-grammar gate by grammar key, not language

The gitnexus-check finding on parser-loader-abi.test.ts:155 is right, and
none of the gates caught it: `tsconfig.json` includes only `src/**/*`, so
neither `tsc --noEmit` nor CI typechecks the test tree, and vitest strips
types without checking them. Confirmed with a scoped tsc run over the file:
`error TS2345: Argument of type 'string' is not assignable to parameter of
type 'SupportedLanguages'`.

Not fixed with the proposed `key as SupportedLanguages` cast, which would
assert something false: `listGrammarSources()` yields one row per SOURCES
entry, including variants like `typescript:tsx` that are not enum members.
`isOptionalGrammarRequired` now takes the grammar KEY it is really given,
and the registry keeps a `satisfies Partial<Record<SupportedLanguages,
string>>` so every key we write is still pinned to a real language.

Two new cases cover the failure mode the type error was pointing at — a
registry key that can never match what the ABI smoke passes, leaving the
gate configured-looking and permanently inert: every OPTIONAL_GRAMMAR_ENV
key must be a key `listGrammarSources()` yields and must be marked optional
there, and an unregistered variant (`typescript:tsx`) must not be required
even with the variable set.

Same blind spot, two more latent errors in files this PR adds, both fixed:
`Parser.Language` is not an exported member (use the `setLanguage` parameter
type, as parser-loader-abi.test.ts already does), and the `ParsedImport`
filter did not narrow the union, so `localName` was read through a `!` on an
arm that has no such property — now a type predicate. The one remaining
error under the same probe, in `resolvers/callable-value-flow.test.ts:319`,
predates this branch (authored 2026-07-17, on main) and is left alone.

structural-pair-coverage's optional-grammar case switches from
`it.concurrent.each` to `it.concurrent.for`: only `for` passes the test
context as a second argument (`each`'s callback is `(...args: T[])`), and
that context carries the dynamic `skip()` the per-language gate calls.
Behaviour is unchanged — grammar present: 10 passed; grammar forced away:
9 passed, 1 skipped.

Whole test tree typechecking is a separate, much larger job: the same probe
over `test/**` minus fixtures reports 734 pre-existing errors across the
repo. Out of scope here.

* fix(zig): address tenth gitnexus-check review pass

- `normalizeZigDepPath`: normalize backslashes BEFORE the absolute-path
  check. A UNC dep (`\\server\share\dep`) used to slip past the check and
  normalize to the repo-relative `server/share/dep`; root-relative `\dep`
  had the same hole. Both now return null. Regression case added to the
  absolute-spellings test with the files those misreadings would resolve.
- `bindPayloads`: a pointer capture `for (pages) |*p|` now records `*Page`
  (declaredSpelling) instead of `Page` — the `*` is an anonymous payload
  child before the identifier. Method dispatch is unchanged (`rawName`
  strips the sigil), but a deref projection `const q = p.*;` now sees the
  pointer layer. New fixture fn `viaPtrCaptureDeref` + assertion; fails on
  the previous code (verified by stashing the src fix).
- `optional-grammar-gate.test.ts`: renamed the `typescript:tsx` case — the
  key IS a registry row; what makes it inert is the missing gate entry. Now
  also asserts a key no registry yields.
- `structural-pair-coverage.test.ts`: header updated — ten tables (not
  eleven) are absent from every rule's target side; `Union` left the set
  when Zig made it linkable.
- `language-classification.ts`: doc comment now names zig in the
  experimental set (added after Ring 1).

Not re-fixed (invalid findings):
- "owner-hook contract wired to an undeclared variable": stale-diff read —
  `findEnclosingClassInfo` declares `resolveFileTypeOwner` /
  `resolveContainerTypeOwner` as optional parameters (ast-helpers.ts:905,
  917) and parse-worker threads them at every call site; tsc compiles clean.
- "optional Zig grammar added unconditionally to the parsing fixture
  suite": the cited block only `fs.readFile`s the committed fixture file to
  assert it is non-empty — no parser or grammar load is involved.

* fix(scope-resolution): mark construction-site CALLS edges in reason (opt-in), enable for Zig

PR #1432 human review, item 2: a Zig struct literal `T{ .f = x }` (no
parens) is modelled as a CALLS edge to the type — the Rust `T { .. }` /
Go `T{}` shape — and nothing on the edge told it apart from an invocation
(`get_next_spawn → SpawnRequest` from seven `return SpawnRequest{ … }`).

`ScopeResolver.markConstructionSites` (default off): when set, the edge
emitted for a `callForm === 'constructor'` site gets ` (constructor)`
appended to its reason, in both emit paths — `local-call (constructor)` /
`import-resolved (constructor)` in the free-call fallback and
`scope-resolution: call (constructor)` in the reference bridge. The Zig
resolver opts in. `Reference` gains an optional `callForm`, copied from
the site by `buildReference`, so the bridge can see the form.

Why `reason` and not a property or edge type: relationships carry no
arbitrary properties, a new column changes the relation DDL and moves
SCHEMA_FINGERPRINT, and `reason` is the channel the IMPLEMENTS `-pointer`
receiver form already uses. Why opt-in: the unsuffixed strings are a
pinned contract asserted verbatim by the other language suites
(php/cpp constructor calls expect exactly `import-resolved`); every
non-Zig edge stays byte-identical.

Tests: `references-to-edges-call-form.test.ts` pins both vocabularies
and the default-off behaviour; `zig.test.ts` asserts
`Reflect.string → Accessor` / `Reflect.url → Accessor` carry
`local-call (constructor)` next to a plain invocation, and that every
marked edge targets a Struct.

* 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.

* 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.

* 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.

* fix(zig): close the adversarial review's ten findings (8.2–8.12)

PR #1432 review 5095267917 on 34c53473 retained eight P1 and two P2
findings; each is reproduced on the new `zig-chains` / `zig-buildmodules`
fixtures with the decoy that made the old answer wrong, and pinned by a
test named after its number.

- 8.2 per-build-module import tables (`parseZigBuildModules`): a source
  resolves a bare name through its own module's `addImport` table (root
  file, else deepest root directory), fails closed when same-directory
  modules disagree, and follows `addImport("api", dep.module("core"))`
  through the dep's `addModule`; repo-wide names and zon deps remain the
  fallback.
- 8.3 module-level value receivers (`zigHostValueNames`) prepend the
  implicit `self` like fn-locals, so `global_runner.run(cb)` joins `cb@1`.
- 8.4 deep member aliases (`@import("lib.zig").B.work`, `lib.B.work`)
  keep the written owner: the module is bound as a namespace and the
  alias's use sites are rewritten to `receiver . member`; only one-level
  aliases are promoted to named imports.
- 8.5 container-hosted containers get owner-qualified identities
  (`A.Item`, `B.Item`, `Outer.Inner`), minted by the bare-container rule,
  while the scope keeps the lexical binding.
- 8.6 result-location `.init(…)` / `.{…}` under an annotation, a return
  type or a field type emit the call / construction site with the
  expected type as receiver.
- 8.7 Zig arm in bench/import-target (five dispatchers, config-free
  fingerprint) + baselines row; `--check` passes.
- 8.9 fn-local `@import` bindings and their uses are keyed per callable
  (`m$f_sib_a`), so sibling fns no longer share one namespace bucket.
- 8.10 `ScopeResolver.resolveNamespaceChains` (opt-in, Zig only): Case 1
  / Case 2 / Case 3 and the compound resolver walk a qualified receiver
  segment by segment — republished modules, nested types, enum variants
  through the module — refusing ambiguous hops. Off, every lookup keeps
  its one-hop split; the 70 resolver suites are unchanged.
- 8.11 `@import("a.zig").Thing{}` binds the module as a namespace in type
  position; `List(u8){}` / `lists.List(u8){}` get constructor sites.
- 8.12 a fieldless file whose top-level fn takes the file's own type
  (`self: *@This()`, `self: *Self`) is a file-struct; two over-matching
  ZIG_QUERIES rules are filtered by `shouldSkipDefinitionCapture`.

Also asserts the committed `opmod.Op.lookup.event_max()` call in zig-hub.

* fix(zig): address gitnexus-check findings on 215f70e3

- receiver-bound Case 3 wraps its reason in constructionSiteReason, like
  Case 1 and the nested-type route of Case 2 (one vocabulary per provider)
- resolveZigImportInternal rejects drive-qualified absolute imports
  (C:\foo.zig), the same test normalizeZigDepPath applies; unit case added
- the compound resolver's chain seed also tries the whole receiver as the
  qualified class (opmod.Op), as a bare class-name head already does
- markConstructionSites contract text names the receiver-bound routes

The return_type field claim is refuted: tree-sitter-zig exposes a fn's
return type as the type field (checked on the grammar).

* fix(zig): a build-module alias bound to an unindexed root fails closed

resolveThroughBuildModules returned undefined when the containing module
bound the alias to a file that is not indexed, which let the repo-wide
addModule map answer under the same name (gitnexus-check on 5299c552).
The module's table is the authority for its aliases: bound-but-unindexed
is null, only an unbound name falls through. Unit case with a same-named
repo-wide decoy, plus the outside-module file that still reaches it.

* fix(zig): an unindexed root module fails closed, never a same-named zon dep

The root build.zig's addModule declaration is authoritative for a bare
name when it binds it; a root that is not indexed used to fall through
to a build.zig.zon path dep of the same name — a different declaration
answering under the name (gitnexus-check on fe24b37f). Same rule as the
build-module tables. Unit case added.

* Address PR review feedback (#1432)

Tighten Zig build-module parsing, receiver/merge helpers, and container queries that gitnexus-check flagged on the open threads.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#1432)

Attach the paren-matcher doc comment to findZigParenEnd instead of zigTopLevelStaticRoot.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Simplify Zig review-feedback helpers after #1432.

Reuse ZON brace/string walkers for top-level root_source_file, drop the dead bind flag and one-off staticRoot wrapper, and merge bindings via a first-wins map.

Co-authored-by: Cursor <cursoragent@cursor.com>

* bench(receiver-resolution): rebaseline for the Zig lang-resolution fixtures

The receiver-resolution gate (#2856/#2899) landed on main after this branch
forked and counts call drops over test/fixtures/lang-resolution, which this
branch extends with the zig-* fixture projects. Regenerated with
`measure.mjs --update-baseline`: callDrops 102 -> 113, all 11 new drops in
.zig files, shape `no-chain`.

Every new drop is a call whose callee has no node in the corpus, not a
resolver regression: `std.Build.Module.addImport` in the three build.zig
fixtures (7, classified in-program), `std.sort.pdq` in Sorter.zig (2,
unknown), `std.Thread.Mutex{}` / `std.mem.Allocator{}` literals in
zig-qualified-literal (2, unknown, the fixture asserts std stays external),
and one `.init()` decl literal on a generic instantiation
(`const u: Stack(u16) = .init()`, in-program). Shape arm unchanged.

---------

Co-authored-by: Garrett Griffin-Morales <grgisme@gmail.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Navid EMAD 2026-09-03 14:29:42 +02:00 • committed by GitHub
parent 12763a40c8
commit 932d937085
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
156 changed files with 12786 additions and 125 deletions

View file

@ -24,8 +24,14 @@ jobs:
shard: ${{ fromJSON(needs.shard-plan.outputs.cov_shards) }}
# Fail loudly (don't silently skip) if the FTS extension is unavailable, so
# FTS-dependent lbug integration suites are guaranteed to run in CI.
# Same contract for Zig's optionalDependency grammar: this runner is
# linux-x64, which @tree-sitter-grammars/tree-sitter-zig publishes a
# prebuild for, so an absent grammar here is a packaging regression and not
# an unsupported platform. Without it every Zig suite skips and the job is
# green having never executed the native Zig parser once.
env:
GITNEXUS_REQUIRE_FTS: '1'
GITNEXUS_REQUIRE_ZIG: '1'
steps:
# persist-credentials: false — runs tests + uploads a blob artifact; the
# default-persisted token must not be capturable through it (zizmor
@ -278,8 +284,14 @@ jobs:
shell: bash
run: python3 .github/scripts/check-tree-sitter-upgrade-readiness.py --assert-current
# GITNEXUS_REQUIRE_ZIG=1: every OS in this matrix has a published
# tree-sitter-zig prebuild, so the smoke's "optional grammar may be
# absent" exemption is revoked here and an ABI-broken Zig binding fails
# the job instead of being accepted as a clean absence.
- name: Run parser-loader ABI load-smoke (dynamic)
run: npx vitest run test/unit/parser-loader-abi.test.ts
env:
GITNEXUS_REQUIRE_ZIG: '1'
working-directory: gitnexus
# End-to-end smoke test for the #1728 packaging fix: pack the published

View file

@ -657,6 +657,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
| C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ |
| C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Dart | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| 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

View file

@ -53,6 +53,7 @@ const EXTENSION_MAP: Record<SupportedLanguages, readonly string[]> = {
[SupportedLanguages.Dart]: ['.dart'],
[SupportedLanguages.Vue]: ['.vue'],
[SupportedLanguages.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'],
[SupportedLanguages.Zig]: ['.zig'],
} satisfies Record<SupportedLanguages, readonly string[]>; // Ensure exhaustiveness
/** Pre-built reverse lookup: extension → language (built once at module load). */
@ -121,6 +122,7 @@ const SYNTAX_MAP: Record<SupportedLanguages, string> = {
[SupportedLanguages.Dart]: 'dart',
[SupportedLanguages.Vue]: 'typescript',
[SupportedLanguages.Cobol]: 'cobol',
[SupportedLanguages.Zig]: 'zig',
} satisfies Record<SupportedLanguages, string>; // Ensure exhaustiveness
/** Non-code file extensions → Prism-compatible syntax identifiers */

View file

@ -22,4 +22,5 @@ export enum SupportedLanguages {
Vue = 'vue',
/** Standalone regex processor — no tree-sitter, no LanguageProvider. */
Cobol = 'cobol',
Zig = 'zig',
}

View file

@ -1072,6 +1072,7 @@ const CALLABLE_OR_TYPE_LIKE: ReadonlySet<string> = new Set([
'Interface',
'Enum',
'Struct',
'Union',
'Record',
'Trait',
'Namespace',

View file

@ -12,6 +12,9 @@
* - experimental: vue (embedded-language / SFC complexity),
* cobol (regex-provider path)
* - quarantined: (none)
*
* Added after Ring 1: zig enters as `experimental` (new language
* integration; promotion to `production` is a separate governance PR).
*/
import { SupportedLanguages } from '../languages.js';
@ -41,6 +44,7 @@ export const LanguageClassifications: Readonly<Record<SupportedLanguages, Langua
[SupportedLanguages.Dart]: 'production',
[SupportedLanguages.Vue]: 'experimental',
[SupportedLanguages.Cobol]: 'experimental',
[SupportedLanguages.Zig]: 'experimental',
};
/** Convenience predicate: is this language gating Ring 4 retirement? */

View file

@ -24,6 +24,9 @@
import type { NodeLabel } from '../graph/types.js';
import type { SymbolDefinition } from './symbol-definition.js';
// Type-only, so the `reference-site.ts` → `types.ts` import cycle is erased
// at compile time.
import type { CallForm } from './reference-site.js';
// ─── §2.1 Type aliases ──────────────────────────────────────────────────────
@ -752,6 +755,15 @@ export interface Reference {
| 'import-use'
| 'value-ref'
| 'macro';
/**
* Call form of the site this reference was resolved from, copied verbatim
* from `ReferenceSite.callForm`; set only when `kind === 'call'`. The
* emit phase reads it to tell a construction site (`T{…}`, `new T()`,
* `T { .. }` — form `'constructor'`) apart from an invocation, which in the
* graph are both `CALLS` edges. Optional and additive: a `Reference` built
* without it is emitted exactly as before.
*/
readonly callForm?: CallForm;
readonly confidence: number;
readonly evidence: readonly ResolutionEvidence[];
}

View file

@ -416,7 +416,7 @@ GitNexus supports indexing multiple repositories. Each `gitnexus analyze` regist
## Supported Languages
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift, Ruby, Dart
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift, Ruby, Dart, Zig
### Language Feature Matrix
@ -436,6 +436,7 @@ TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift,
| C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ |
| C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Dart | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| 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
@ -594,7 +595,7 @@ results until you re-run `gitnexus analyze --repair-fts` from a shell where the
### Installation fails with native module errors
Some optional language grammars (Dart, Proto, Swift, Kotlin) require native compilation. If they fail, GitNexus still works — those languages will be skipped. To skip them intentionally (no C++ toolchain needed), set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before installing.
Some optional language grammars (Dart, Proto, Swift, Kotlin) require native compilation. If they fail, GitNexus still works — those languages will be skipped. To skip them intentionally (no C++ toolchain needed), set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before installing. Zig (`@tree-sitter-grammars/tree-sitter-zig`, an npm `optionalDependency`) behaves the same way: if its native binding fails to install, `.zig` files are skipped.
If `npm install -g gitnexus` fails on native modules:

File diff suppressed because one or more lines are too long

View file

@ -4,7 +4,9 @@
* `--check` inventory arm at the foot of this file fails when the two disagree
* — over ONE shared corpus so the arms are directly comparable. One arm per
* registered language, plus a second `csharp` arm carrying csproj configs
* (#2902), so there is one more arm than there are languages.
* (#2902), so there is one more arm than there are languages. The newest row
* is `zig` (PR #1432), added the day its resolver registered — the inventory
* arm below is what noticed it missing, which is the arm doing its job.
*
* NO LANGUAGE IS OMITTED, and that is the point of the list rather than an
* accident of it. Nine of these arms (go, csharp, csharp_csproj, dart, ruby,
@ -109,6 +111,19 @@
* depth-then-lexicographic tie-break, so the collide arm (a `mod{n}` header
* in every service's `include/`) is where it grows: 2.54 / 2.64 against
* 1.06 on file count.
* - zig: `resolveZigImportInternal` is rust's shape — an `@import("…zig")`
* path is walked component by component from the importer's directory and
* probed with two `allFiles.has(...)` calls (as written, then `+ '.zig'`),
* and a bare name is a Map lookup in the build config or a miss. No index
* is built, so the cost is O(path SEGMENTS) and flat in the file count,
* and — as for rust — its collide arm is a deep tree whose spellings carry
* ~4x the components rather than a shared-leaf layout that cannot fail.
* The arm passes NO build config (`buildZon` null): the bare-name legs
* (`b.addModule` roots, zon `.path` deps) read `build.zig` / `build.zig.zon`
* through `loadZigBuildConfig` and are gated by
* `test/unit/zig-import-resolver.test.ts`, so this fingerprint pins the
* path-walking resolver alone and does not move when that config parsing
* changes.
*
* Two properties of the corpus are load-bearing and must not be "simplified":
*
@ -221,7 +236,9 @@
* corpus is a deep module tree whose targets carry ~2x the `::` segments,
* which is the axis that CAN grow; the ratio across file counts staying at
* 1.06 on it is the assertion, and `collide_ms_ceiling` bounds the absolute
* cost of the long-path probe.
* cost of the long-path probe. zig's collide arm is built the same way and
* for the same reason: a deep tree whose `../../…/l4/mod{n}/file.zig`
* spellings walk ~4x the components of the unique arm's `../mod{n}/…`.
*
* This is a scope-of-claim limit, not a regression: on the MISS path with a
* shared leaf name the bucket grows with the file count BY CONSTRUCTION, and
@ -308,7 +325,9 @@
*
* Only rust's exclusion survived unchanged: 16 B at 8000 files and 16 B at
* 32 000, identical in all five runs, because it probes candidate paths with
* `allFilePaths.has(...)` and builds nothing.
* `allFilePaths.has(...)` and builds nothing. zig joined that tier on the same
* reading for the same reason (`resolveZigImportInternal` holds no per-pass
* structure at all), and takes rust's absolute 1 MiB bound.
*
* So the nine are still not BUDGETED — their ceilings, floors and ratio arms
* are not this change to write — but they are all measured and all bounded. See
@ -463,6 +482,7 @@ import { javaScopeResolver } from '../../src/core/ingestion/languages/java/scope
import { cobolScopeResolver } from '../../src/core/ingestion/languages/cobol/scope-resolver.ts';
import { resolveSwiftImportTarget } from '../../src/core/ingestion/languages/swift/import-target.ts';
import { resolveRustImportTarget } from '../../src/core/ingestion/languages/rust/import-target.ts';
import { resolveZigImportInternal } from '../../src/core/ingestion/import-resolvers/zig.ts';
import { resolvePythonImportTarget } from '../../src/core/ingestion/languages/python/import-target.ts';
import { makeJsResolveImportTarget } from '../../src/core/ingestion/languages/javascript/import-target.ts';
import { makeVueResolveImportTarget } from '../../src/core/ingestion/languages/vue/import-target.ts';
@ -736,6 +756,7 @@ const EXTENSION = {
vue: '.vue',
c: '.c',
cpp: '.cpp',
zig: '.zig',
};
/** C and C++ resolve `#include` against HEADERS, which reach the resolver
* through `resolutionConfig` rather than through `allFilePaths` — see
@ -859,6 +880,13 @@ function uniqueDir(lang, d, i) {
// `resolutionConfig` load-bearing. Odd `i` is the header.
if (lang === 'c' || lang === 'cpp') return i % 2 === 1 ? `include/comp${d}` : `src/comp${d}`;
if (lang === 'ruby') return `lib/mod${d}`;
// One flat `src/mod{d}/` per index and NO nested slice, on purpose: a Zig
// import is spelled RELATIVE TO THE IMPORTER, and `uniqueTarget` does not
// know which file issues it, so every importer has to sit at one depth for
// `../mod{n}/file{j}.zig` to mean the same file from all of them. The miss
// share the other unique arms take from a nested directory comes from the
// target instead (see `uniqueTarget`).
if (lang === 'zig') return `src/mod${d}`;
throw unwiredLanguage('uniqueDir', lang);
}
@ -947,6 +975,12 @@ function collideDir(lang, d, i) {
if (lang === 'vue') return `src/pkg${d}/components`;
if (lang === 'c' || lang === 'cpp') return i % 2 === 1 ? `svc${d}/include` : `svc${d}/src`;
if (lang === 'ruby') return `svc${d}/lib/models`;
// Rust's reasoning, verbatim: the resolver walks path components and probes
// `.has()`, never searches, so file count is not an axis its cost has and a
// shared-leaf layout would be an arm that cannot fail. A deep tree is the
// axis that CAN grow — `collideTarget` spells its imports up through the
// tree and back down, ~4x the components of the unique arm.
if (lang === 'zig') return `src/l0/l1/l2/l3/l4/mod${d}`;
throw unwiredLanguage('collideDir', lang);
}
@ -1373,6 +1407,29 @@ function uniqueTarget(lang, { local, r, d, j, dirs }) {
? ['json', 'set', 'net/http', 'digest'][(r >>> 4) % 4]
: `gem${(r >>> 4) % 97}/missing/thing`;
}
if (lang === 'zig') {
// `@import("../mod{n}/file{j}.zig")`, importer-relative — every file sits
// in `src/mod{d}/`, so one `..` reaches `src/` from all of them (see
// `uniqueDir`). The target is file `j`'s OWN directory, `j % dirs`, so a
// hit is a real file; the `d % 7` slice names an `inner/` that exists
// nowhere and misses, which is where the resolved count comes from, as in
// the rust arm. One local spelling in three drops the extension, which is
// the second `.has()` probe (`candidate + '.zig'`) — the leg an
// extension-only corpus would never reach. The misses are the three
// kinds a Zig file has: the compiler's own modules (`std`, `builtin`,
// `root`), which the resolver rejects by name before any walk; a bare
// package name with no build config to map it, which falls through every
// leg to null; and a relative path to a vendored file that is not in the
// corpus, which walks to the end and misses on both probes.
if (local) {
if (d % 7 === 0) return `../mod${d}/inner/file${j}.zig`;
return (r >>> 3) % 3 === 0 ? `../mod${j % dirs}/file${j}` : `../mod${j % dirs}/file${j}.zig`;
}
const miss = (r >>> 3) % 3;
if (miss === 0) return ['std', 'builtin', 'root'][(r >>> 4) % 3];
if (miss === 1) return `ghost${(r >>> 4) % 97}`;
return `../vendor${(r >>> 4) % 97}/missing.zig`;
}
throw unwiredLanguage('uniqueTarget', lang);
}
@ -1583,6 +1640,27 @@ function collideTarget(lang, { local, r, d, j, dirs }) {
? ['json', 'set', 'net/http', 'digest'][(r >>> 4) % 4]
: `gem${(r >>> 4) % 97}/missing/thing`;
}
if (lang === 'zig') {
// The same three families in the same proportions as the unique arm, so
// the resolved count is identical by construction (asserted), spelled up
// six levels to `src/` and back down through `l0/…/l4` — thirteen
// components against the unique arm's three, in the hits and in the path
// misses alike, because component count is the only axis this resolver's
// cost has. The `d % 7` slice and the extension-less third mirror the
// unique arm's; the by-name misses are unchanged, since no walk is what
// they measure.
const up = '../../../../../../l0/l1/l2/l3/l4';
if (local) {
if (d % 7 === 0) return `${up}/mod${d}/inner/file${j}.zig`;
return (r >>> 3) % 3 === 0
? `${up}/mod${j % dirs}/file${j}`
: `${up}/mod${j % dirs}/file${j}.zig`;
}
const miss = (r >>> 3) % 3;
if (miss === 0) return ['std', 'builtin', 'root'][(r >>> 4) % 3];
if (miss === 1) return `ghost${(r >>> 4) % 97}`;
return `${up}/vendor${(r >>> 4) % 97}/missing.zig`;
}
throw unwiredLanguage('collideTarget', lang);
}
@ -1784,6 +1862,10 @@ function resolveOne(lang, from, target, pass) {
);
}
if (lang === 'rust') return resolveRustImportTarget(target, from, allFilePaths, undefined);
// Quotes already stripped — `configs/zig.ts` strips them before this call in
// production too. `null` build config: this arm pins the path walk alone
// (see the header); the config legs are gated by their own unit tests.
if (lang === 'zig') return resolveZigImportInternal(from, target, allFilePaths, null);
if (lang === 'python') {
// `from <target> import X` — the spelling the orchestrator actually hands
// the provider, and the ONLY one that reads `context.parsedFiles`: a
@ -2062,7 +2144,7 @@ const HEAP_PROBE_TARGET = {
// - `kotlin` misses after building its declared-package/module-binding index;
// - `cobol` misses in both tier maps, `swift` in `byModule`, and `rust`
// probes candidate paths and builds nothing — that last is the reading
// the exclusion rests on;
// the exclusion rests on, and `zig` shares it exactly;
// - `typescript`, `vue` and `cpp` carry the same spelling shape as the
// `javascript` and `c` arms they are excluded as duplicates OF, so the
// bound compares like with like. `vue`'s is bare rather than `@/…`
@ -2073,6 +2155,10 @@ const HEAP_PROBE_TARGET = {
cobol: 'VENDOR0',
swift: 'ExternalPkg0',
rust: 'ghost0::Missing',
// A relative path to a file the corpus does not hold: both `.has()` probes
// miss after the full component walk, which is the longest leg the resolver
// has (a by-name miss returns before any walk).
zig: '../vendor0/missing.zig',
typescript: 'vendor0/lib/missing',
vue: 'vendor0/lib/Missing.vue',
cpp: 'vendor0/missing.hpp',
@ -2308,6 +2394,7 @@ const LANG_REGISTRY = {
vue: SupportedLanguages.Vue,
c: SupportedLanguages.C,
cpp: SupportedLanguages.CPlusPlus,
zig: SupportedLanguages.Zig,
};
const LANGS = Object.keys(LANG_REGISTRY);
/**
@ -2853,17 +2940,19 @@ for (const lang of HEAP_BUDGETED) {
* `heap_bound_bytes` is the "exclusion still holds" bound. It does not claim
* these indexes are small enough, which is what a ceiling claims about a
* budgeted one; it claims each is still the SIZE the decision to leave it out
* was taken on. `HEAP_BOUNDED` derives to THREE today — cobol, swift, rust.
* was taken on. `HEAP_BOUNDED` derives to SEVEN today — cobol, swift, rust,
* the ts family (#2953), and zig, which reads what rust reads (16 B) because
* `resolveZigImportInternal` builds nothing and takes rust's absolute bound.
* The prose below still counts nine because six were promoted to tier one
* after it was written; read the counts as history, and `HEAP_BOUNDED` itself
* as the answer. The re-entry condition the MEMORY section states — "if any of
* the four ever diverges in what it ASKS, it earns an arm the same way" — is a
* claim about growth, and this is the only thing in the file that can see it.
*
* NO FLOOR, and the reason is per language rather than uniform. rust reads 16 B
* because it builds nothing, so any floor at all would be a floor on noise and
* `1.5 x 0 B` is 0 — its bound is ABSOLUTE (1 MiB) for the same reason: a
* multiplier on 16 B fails on the first byte of anything. The other eight are
* NO FLOOR, and the reason is per language rather than uniform. rust (and zig)
* reads 16 B because it builds nothing, so any floor at all would be a floor
* on noise and `1.5 x 0 B` is 0 — its bound is ABSOLUTE (1 MiB) for the same
* reason: a multiplier on 16 B fails on the first byte of anything. The other eight are
* stable enough today to floor (0.24% peak-to-peak at worst over five runs).
* The two this paragraph named as floor candidates, kotlin and dart, TOOK that
* promotion: both now carry a ceiling and a recorded reading in tier one, which

View file

@ -1 +1 @@
2600a1f6f8a042eb4f520a7870c34d9ca292765824537c3bc861b40dac8769a8
7412edd9db56b4626c77fc09363e0d49a2f95763453756dceb82db6606280c28

View file

@ -199,15 +199,16 @@
}
},
"countArm": {
"callDrops": 102,
"totalDropsAllKinds": 148,
"callDrops": 113,
"totalDropsAllKinds": 159,
"bySiteKind": {
"call": 102,
"call": 113,
"read": 27,
"write": 19
},
"callDropsByExtension": {
".java": 49,
".zig": 11,
".cs": 8,
".ts": 7,
".cpp": 7,
@ -224,14 +225,14 @@
"callDropsByShape": {
"chain-field": 60,
"chain-call": 27,
"no-chain": 12,
"no-chain": 23,
"chain-mixed": 2,
"chain-unwrap": 1
},
"callDropsByOrigin": {
"external": 44,
"in-program": 36,
"unknown": 22
"in-program": 43,
"unknown": 26
}
}
}

View file

@ -35,7 +35,7 @@
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)."
},
"cpp": {
"fingerprint": "bf3587674267be1759e7c45abef143c3b81fe8629cfd17da5f8af40e83cc39ec",
"fingerprint": "3aaee42523f02718795ba348782d78ec1418f35eff2e1b602422b192544fed96",
"scaling_budget": 1.5,
"_rebaselined_2833_qualified_member_fields": "#2833 follow-up: the six per-qualifier-depth `field_declaration` type-binding rules for a QUALIFIED generic member are replaced by three depth-agnostic ones that match the outer `qualified_identifier` itself, with the qualifier reduced to its top-level tail in `interpret.ts` (`cppQualifiedTail`). This is a CAPTURE-LOGIC change and it moves the fingerprint in two places at once. (1) A qualified NON-generic member (`ns::Address addr;`, `std::string name;`) was captured by nothing at all and now binds \u2014 that is the whole +24 on the fixture corpus, every one of them a `std::string` member. (2) Qualifier depth is no longer enumerated, so `a::b::c::Repo<User>` (depth 3+) is captured where the old rules stopped at 2. Capture-name histogram, cpp-* corpus (278 files): `@type-binding.field` 8 -> 32, `@type-binding.name` and `@type-binding.type` 401 -> 425; synthetic DAO-20: `@type-binding.field` 40 -> 60, `@type-binding.name` and `@type-binding.type` 61 -> 81 (= 20 entities x the one `std::string name;` member the DAO unit already declared). NO OTHER TAG MOVED in either set \u2014 not one `@declaration.*`, `@scope.*` or `@reference.*` count \u2014 which is the property that says three rules replaced six without widening what a field_declaration matches. Measured over the 13 cpp-* fixture repos whose sources gained a binding, the distinct CALLS edge set is byte-identical before and after (32 edges): a reduced tail that names no workspace class binds nothing. Prior bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a -> db1156d81b3e3341faf5e938a4a34417f4fd246588b6150b4686481823262529; scaling 1.04 < 1.5.",
"_rebaselined_2833_generic_member_fields": "#2833 review follow-up: the cpp DAO generator's unit gains two GENERIC member fields \u2014 `Repo<Entity_n> repo;` (bare template_type) and `std::vector<Entity_n> items;` (qualified_identifier wrapping a template_type) \u2014 plus the header declaring `template <typename T> class Repo`. CORPUS CHANGE, NOT A CAPTURE-LOGIC CHANGE: no extractor edit accompanies it. It exists because the corpus had ZERO template-typed member fields and, across 279 cpp-* fixtures, not one qualified generic member either, so BOTH rounds of new `field_declaration` type-binding rules landed with a byte-identical cpp fingerprint \u2014 the gate was structurally blind to the exact thing being changed. Measured under the new corpus, the three states now differ: pre-#2833 query 0e7cbda71360b7ff35dd76091c77f288d6af6a5cfa9185ad85a372aae8c85191 (4521 groups) -> the three template_type field rules de07d8b5300ed867b460918e16b4d80259c7eb6efc1034d32bebe9ff7cab126d (4541) -> the six qualified rules bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a (4561); under the OLD corpus all three were 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc. Capture-name histogram over the synthetic DAO-20: `@type-binding.field` 0 -> 40, `@declaration.field` 40 -> 80, `@type-binding.type`/`@type-binding.name` 20 -> 61, `@declaration.name` 104 -> 147 \u2014 40 = 20 entities x 2 fields, with the residual +1/+2/+3 attributable to the one-off header declaration; every `@reference.*` count is unchanged. Prior 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc -> bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a; scaling 1.058 < 1.5. `c` is unaffected (3418cded..., unchanged).",
@ -53,12 +53,13 @@
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5 -> 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc.",
"capture_groups_small": 5021,
"capture_groups_large": 16021,
"capture_groups_fp": 4605,
"fixture_count": 279
"capture_groups_fp": 4601,
"fixture_count": 279,
"_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Only drift: `choice.select(1)` / `choice.select(1.5)` (cpp-deleted-overload/main.cpp) no longer mint an INDIRECT `@callable-flow.invoke` (callee-kind binding) plus its synthetic `@reference.call.free` - that invoke was gated only by the same-named free `select` binding while the site is a genuine method call already captured as `@reference.call.member`, so the free-call duplicate is gone. capture_groups_fp 4605 -> 4601 (-4: 2 invokes + 2 synthetic call.free). Prior bf3587674267be1759e7c45abef143c3b81fe8629cfd17da5f8af40e83cc39ec -> 3aaee42523f02718795ba348782d78ec1418f35eff2e1b602422b192544fed96."
},
"csharp": {
"_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.",
"fingerprint": "2930ef49fdce984a4c051409880bddfe8445e30e1c6bf802bd90a0a0f8f6b094",
"fingerprint": "9c4d2ca55707a03185ea45e554462e81e6d83c9f8d0ea3cd63db5c362ad1bfec",
"scaling_budget": 1.5,
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a -> 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1; scaling 1.061 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C# method-group/delegate callable flow facts with invocation-result suppression. Prior 2bb5bc8c19cb8eb08c9590545ad8a1968a7152951f7e12746e2d7901d542fed9 -> f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a; scaling 1.115 < 1.5.",
@ -69,7 +70,8 @@
"capture_groups_small": 4259,
"capture_groups_large": 13609,
"capture_groups_fp": 2657,
"fixture_count": 178
"fixture_count": 178,
"_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Only drift: `string.Join(\", \", args)` (csharp-variadic-resolution/Utils/Logger.cs) loses `direct-callee-name|Join`; the argument fact itself is unchanged, capture_groups_fp 2657 (unchanged). Prior 2930ef49fdce984a4c051409880bddfe8445e30e1c6bf802bd90a0a0f8f6b094 -> 9c4d2ca55707a03185ea45e554462e81e6d83c9f8d0ea3cd63db5c362ad1bfec."
},
"rust": {
"fingerprint": "e61653008ff2de506cfd47f905fa9eb22d82fbbfe94d2a1d8190c358211b57b7",
@ -169,7 +171,7 @@
"capture_groups_fp": 680
},
"typescript": {
"fingerprint": "05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633",
"fingerprint": "fed04ed1d5db112387781e405da208ae6b3ab803889773b0455be96f01b893ff",
"scaling_budget": 1.5,
"_rebaselined_2934_import_type_only": "#2934: `import-decomposer.ts` attaches a presence-only `@import.type-only` synthetic capture to specifiers `tsc` erases, so `check --cycles` can stop counting type-only edges as initialization cycles. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE \u2014 the tag is added to import matches that already existed, never a new match, the same shape as the #2747 receiver-chain rebaseline. Every count is unchanged: capture_groups_fp 2414, fixture_count 155, capture_groups_small/large 4503/14403 (those measure the SYNTHETIC scaling source, which has no imports at all). The fingerprint moves because `canonicalizeMatch` in measure.mjs hashes every TAG on every match, synthetics included, so one extra presence-only tag on an existing match rewrites that match's canonical string. Attribution is exact, not inferred: neutralizing ONLY the `m['@import.type-only'] = \u2026` assignment in import-decomposer.ts and re-running returns the fingerprint to c2fbf8a89e5686dd\u2026 byte-for-byte, so nothing else in the TypeScript capture stream moved. All 14 other languages report ok. Scaling 0.997 < 1.5. NOTE ON THE CONTROL: javascript did not move (2026993b\u2026, 43 fixtures), but it is a WEAK control here \u2014 `import type` is TypeScript-only syntax, so a JS corpus cannot express the construct and could not have drifted either way. It evidences no collateral damage, not the correctness of the TS change; the exact-attribution check above is what does that. Prior c2fbf8a89e5686dd1ff3659b20d41d8b05ebcc9790356e3653ee0c8ca5d365c8 -> f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f.",
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78 -> e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63; scaling 0.983 < 1.5.",
@ -191,7 +193,8 @@
"_rebaselined_blind_spots_2856": "#2856 blind-spots series: the JS/TS SCOPE queries gained capture rules, so fingerprint drift is expected and additive. Verified before re-baselining by diffing the capture-name sets in both scope queries against origin/main: TypeScript gained exactly @reference.read.identifier (A2 bare-identifier reads in value positions) and @reference.type (R2-2 type references, so a declared contract stops reporting incoming:{}); JavaScript gained exactly @reference.read.identifier, @reference.read.destructured (R2-1c) and @reference.write.property-key (R2-1b record-construction writes). NOTHING was removed on either side \u2014 the delta is a pure superset, which is the check that no existing capture moved. capture_groups_small/large are unchanged (4503/14403) because those measure the SYNTHETIC scaling source, which this branch does not touch; only the fixture-corpus count moves. capture_groups_fp 2097 -> 2338 and fixture_count 146 -> 151 from 21 new lang-resolution fixtures. Scaling stayed linear and inside budget: typescript 1.116 < 1.5, javascript 1.010 < 1.5. Prior typescript ed92588e0fc7b28b3a0174339ac378b4dd85965fe007db1208dea97a65ce0571 -> f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7; prior javascript 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594 -> 2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3.",
"_rebaselined_type_parameter_shadowing_w2_8": "W2-8: `@declaration.type-parameters` is now captured on generic FUNCTIONS, generator functions and type ALIASES, not only on class/interface declarations. NO NEW CAPTURE NAME \u2014 verified by diffing the capture-name sets against the wave-1 branch, which returns empty; the tag already existed and simply fires on more declarations. That is the whole delta: capture_groups_fp 2338 -> 2371 (+33 occurrences of an existing tag) and fixture_count 151 -> 152 (one new fixture, typescript-type-parameters). capture_groups_small/large unchanged at 4503/14403, since those measure the synthetic scaling source this does not touch. Scaling 1.06 < 1.5. JavaScript is untouched \u2014 it has no type parameters \u2014 and its fingerprint does not move, which is the check that this is the TS declaration rules and not something broader. Prior f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7 -> 62c7f1bfbe568eed927fb78f00061ed5e49d12511fd8260648b876df386f3b4c.",
"_rebaselined_2899_review_type_parameter_scope_fixtures": "PR #2899 review follow-up: FIXTURE-CORPUS GROWTH ONLY \u2014 no query rule changed and no capture name was added or removed. `typescript/query.ts` is byte-identical to the previous baseline; the type-parameter shadowing defect was fixed on the RESOLUTION side (`walkers.ts` gains a `declarationOpenedScope` gate so a declaration's `typeParameters` bind only inside the scope that declaration opened, and the `USES` guard moved from `graph-bridge/references-to-edges.ts` to `resolve-references.ts` where the spelled `site.name` is in hand). The fingerprint moves because measure.mjs fingerprints the whole `lang-resolution/typescript-*` fixture corpus and the regression tests add three files to `typescript-type-parameters/src/` (values.ts, aliased.ts, namespaced.ts) plus two scope-less generic aliases in shapes.ts. Per-file accounting sums exactly to the delta: shapes.ts 33->35 (+2), values.ts +11, aliased.ts +10, namespaced.ts +20 = +43. capture_groups_fp 2371 -> 2414; fixture_count 152 -> 155. capture_groups_small/large unchanged at 4503/14403 (they measure the SYNTHETIC scaling source, untouched). JAVASCRIPT IS THE CONTROL AND DID NOT MOVE (fingerprint 2026993b..., 43 fixtures) \u2014 which is the check that this is corpus growth and not a capture regression; all 14 other languages report `ok`. Scaling 0.976 < 1.5. Prior 62c7f1bfbe568eed927fb78f00061ed5e49d12511fd8260648b876df386f3b4c -> c2fbf8a89e5686dd1ff3659b20d41d8b05ebcc9790356e3653ee0c8ca5d365c8.",
"_rebaselined_2953_workspace_fixture": "#2953 adds test/fixtures/lang-resolution/typescript-pnpm-workspace-imports, a pnpm monorepo of 12 .ts files, and the TypeScript capture corpus is collected from test/fixtures. CORPUS GROWTH ONLY, NOT A CAPTURE CHANGE: fixture_count 155 -> 167 and capture_groups_fp 2414 -> 2465 are the 12 new files' own matches; capture_groups_small/large are unchanged at 4503/14403 because those measure the SYNTHETIC scaling source, which the fixture corpus does not feed. Attribution is exact rather than inferred: moving that one fixture directory aside and re-running returns typescript to f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f byte-for-byte with fixture_count back at 155, and [scope-capture --check] PASSES for all 15 languages - so nothing in the TypeScript capture stream moved. #2953 changes import RESOLUTION, which runs after capture and feeds no capture tag. Prior f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f -> 05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633."
"_rebaselined_2953_workspace_fixture": "#2953 adds test/fixtures/lang-resolution/typescript-pnpm-workspace-imports, a pnpm monorepo of 12 .ts files, and the TypeScript capture corpus is collected from test/fixtures. CORPUS GROWTH ONLY, NOT A CAPTURE CHANGE: fixture_count 155 -> 167 and capture_groups_fp 2414 -> 2465 are the 12 new files' own matches; capture_groups_small/large are unchanged at 4503/14403 because those measure the SYNTHETIC scaling source, which the fixture corpus does not feed. Attribution is exact rather than inferred: moving that one fixture directory aside and re-running returns typescript to f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f byte-for-byte with fixture_count back at 155, and [scope-capture --check] PASSES for all 15 languages - so nothing in the TypeScript capture stream moved. #2953 changes import RESOLUTION, which runs after capture and feeds no capture tag. Prior f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f -> 05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633.",
"_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Drift: `await svc.verify<GuestPayload>(token, ...)` (typescript-generic-calls/src/guest.ts, member call) and `initializer()(() => {...})` (typescript-hof-callbacks/src/store.ts, call-of-call) lose `direct-callee-name`. `await verifyToken<AdminPayload>(token, ...)` (admin.ts/auth.ts) KEEPS `direct-callee-name|verifyToken`: tree-sitter-typescript parses `await f<T>(x)` as call_expression(function: await_expression(f), type_arguments, ...), and wrappedExpression now unwraps `await_expression` so the direct designator survives as it does for the un-awaited spelling. capture_groups_fp 2465 (unchanged). Prior 05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633 -> fed04ed1d5db112387781e405da208ae6b3ab803889773b0455be96f01b893ff."
},
"javascript": {
"fingerprint": "2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3",
@ -209,7 +212,7 @@
"_rebaselined_blind_spots_2856": "#2856 blind-spots series: the JS/TS SCOPE queries gained capture rules, so fingerprint drift is expected and additive. Verified before re-baselining by diffing the capture-name sets in both scope queries against origin/main: TypeScript gained exactly @reference.read.identifier (A2 bare-identifier reads in value positions) and @reference.type (R2-2 type references, so a declared contract stops reporting incoming:{}); JavaScript gained exactly @reference.read.identifier, @reference.read.destructured (R2-1c) and @reference.write.property-key (R2-1b record-construction writes). NOTHING was removed on either side \u2014 the delta is a pure superset, which is the check that no existing capture moved. capture_groups_small/large are unchanged (4503/14403) because those measure the SYNTHETIC scaling source, which this branch does not touch; only the fixture-corpus count moves. capture_groups_fp 2097 -> 2338 and fixture_count 146 -> 151 from 21 new lang-resolution fixtures. Scaling stayed linear and inside budget: typescript 1.116 < 1.5, javascript 1.010 < 1.5. Prior typescript ed92588e0fc7b28b3a0174339ac378b4dd85965fe007db1208dea97a65ce0571 -> f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7; prior javascript 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594 -> 2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3."
},
"kotlin": {
"fingerprint": "aeafc7a87402c933786ef582b7c98683b1822b78fa909e605cb97552867fa0d5",
"fingerprint": "a9d3f0db7547ff47856159debf15d2a6f427efca97a6af27b4004174ed432132",
"scaling_budget": 1.5,
"_rebaselined_interface_abstract_2885": "#2885: Kotlin interface property accessors stay in the capture set (groups still 5753/18403 and capture_groups_fp 2563) but Method isAbstract is now true for body-less interface properties, which changes accessor-plan identity in the fixture digest. Prior 82ae5e1f750580383344d4c84c400a290474528cd502be4af8cd56705819a683 -> aeafc7a87402c933786ef582b7c98683b1822b78fa909e605cb97552867fa0d5; CI scaling 0.838 < 1.5.",
"_rebaselined_jvm_property_accessors_2885": "#2885: Kotlin val/var properties now emit JVM getter/setter scope and declaration captures, including data-class constructor properties and custom accessors. Synthetic scaling counts move 4753/15203 -> 5753/18403; fixture-corpus groups move 2367 -> 2563. Accessor declaration sidecars use the canonical @declaration.qualified_name key, preserve same-name owner identity, follow JvmAbi is-prefix naming, and suppress @JvmName-renamed accessors until their custom names are modeled. Prior f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832 -> 82ae5e1f750580383344d4c84c400a290474528cd502be4af8cd56705819a683; scaling 0.869 < 1.5.",
@ -228,6 +231,9 @@
"capture_groups_small": 5753,
"capture_groups_large": 18403,
"capture_groups_fp": 2563,
"fixture_count": 141
"fixture_count": 141,
"_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Only drift: `users.map { it.name }.forEach { name -> println(name) }` (kotlin-lambda-scopes/App.kt) loses `direct-callee-name|forEach` (member call). capture_groups_fp 2334 (unchanged). Prior a184f8ff0ae40d246db855b63f7ff26bda3afac03e5f4c76e4593c7e2cefce54 -> 5a181af0dbc9451937da0964c40d3f3f9820914ca429d873bb5c812b5e2b9284.",
"_rebaselined_1432_rebase_onto_2960": "#1432 rebase onto main @ aac7515d: the kotlin fingerprint is a COMBINATION of two independent changes, so neither side of the merge conflict was correct on its own and resolving it by picking a side would have committed a fingerprint no run can reproduce. main's #2960 added four declared-package fixture files (fixture_count 137 -> 141, capture_groups_fp 2334 -> 2367); this branch's `_rebaselined_1432_member_call_callee_name` drops `direct-callee-name|forEach` from one member call. Recomputed under both: capture_groups_fp 2367 and fixture_count 141 match main's committed counts EXACTLY (this branch's change is emission-only and moves no count), capture_groups_small/large stay 4753/15203 (the SYNTHETIC scaling source, which neither change touches), scaling 1.003 < 1.5, and the other 14 languages report ok against their committed baselines in the same run - which is the check that the rebase replayed nothing else into the capture stream. Attribution is exact rather than inferred: moving test/fixtures/lang-resolution/kotlin-import-package-evidence aside and re-running returns kotlin to 5a181af0dbc9451937da0964c40d3f3f9820914ca429d873bb5c812b5e2b9284 byte-for-byte with fixture_count back at 137 and capture_groups_fp back at 2334 - this branch's pre-rebase value - so the whole delta is #2960's corpus growth layered on top, with nothing else moving. Prior (this branch, pre-rebase) 5a181af0dbc9451937da0964c40d3f3f9820914ca429d873bb5c812b5e2b9284 and (main) f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832 -> 973d702510002dda76166e017c5eca90cae37a139f5a512877b7a1b04ad19dc5.",
"_rebaselined_1432_merge_main_2885": "#1432 merge of main @ 212e007a: the kotlin fingerprint is again a COMBINATION of two independent changes \u2014 main's #2885 JVM property accessors / interface-abstract (4753/15203 -> 5753/18403, capture_groups_fp 2367 -> 2563) and this branch's `_rebaselined_1432_member_call_callee_name` (drops `direct-callee-name|forEach` from one member call). Neither side's value reproduces under the merged tree. Recomputed under both: counts 5753/18403/2563 and fixture_count 141 match main's committed counts EXACTLY (this branch's change is emission-only), scaling 1.061 < 1.5, and csharp / cpp / typescript measure byte-for-byte at this branch's committed values (main did not touch them since the merge-base) while the other 11 languages report ok \u2014 the check that the merge replayed nothing else into the capture stream. Prior (main) aeafc7a87402c933786ef582b7c98683b1822b78fa909e605cb97552867fa0d5 and (this branch) 973d702510002dda76166e017c5eca90cae37a139f5a512877b7a1b04ad19dc5 -> a9d3f0db7547ff47856159debf15d2a6f427efca97a6af27b4004174ed432132."
}
}

View file

@ -75,6 +75,7 @@
},
"optionalDependencies": {
"@huggingface/transformers": "^4.1.0",
"@tree-sitter-grammars/tree-sitter-zig": "1.1.2",
"onnxruntime-node": "^1.24.0"
}
},
@ -1760,6 +1761,26 @@
"tslib": "^2.8.0"
}
},
"node_modules/@tree-sitter-grammars/tree-sitter-zig": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tree-sitter-grammars/tree-sitter-zig/-/tree-sitter-zig-1.1.2.tgz",
"integrity": "sha512-J0L31HZ2isy3F5zb2g5QWQOv2r/pbruQNL9ADhuQv2pn5BQOzxt80WcEJaYXBeuJ8GHxVT42slpCna8k1c8LOw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"peerDependencies": {
"tree-sitter": "^0.22.1"
},
"peerDependenciesMeta": {
"tree-sitter": {
"optional": true
}
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",

View file

@ -98,6 +98,7 @@
},
"optionalDependencies": {
"@huggingface/transformers": "^4.1.0",
"@tree-sitter-grammars/tree-sitter-zig": "1.1.2",
"onnxruntime-node": "^1.24.0"
},
"trustedDependencies": [
@ -127,6 +128,9 @@
"sharp": ">=0.35.0",
"@huggingface/transformers": {
"onnxruntime-node": "$onnxruntime-node"
},
"@tree-sitter-grammars/tree-sitter-zig": {
"tree-sitter": "$tree-sitter"
}
},
"engines": {

View file

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

View file

@ -0,0 +1,6 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { CallExtractionConfig } from '../../call-types.js';
export const zigCallConfig: CallExtractionConfig = {
language: SupportedLanguages.Zig,
};

View file

@ -0,0 +1,42 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { ClassExtractionConfig, ClassLikeNodeLabel } from '../../class-types.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import {
isZigFileStruct,
ZIG_CONTAINER_TYPES,
zigContainerLabel,
zigContainerName,
} from '../../languages/zig/captures.js';
/**
* Zig containers (struct/enum/union/opaque) are anonymous in the grammar:
*
* const Point = struct { ... };
* pub fn List(comptime T: type) type { return struct { ... }; }
* fn build() void { const R = struct { ... }; sort(struct { fn lt … }.lt); }
*
* The identity is the binding name (first identifier of the parent
* variable_declaration), the generic type constructor's name, or — for a
* FUNCTION-LOCAL or ANONYMOUS container — a synthesized `host$Name` /
* `host$N` (F8). `zigContainerName` is the single source shared with the
* field/method extractors and the owner walk, so owner ids and node ids
* agree by construction. Which of the (up to three) ZIG_QUERIES rules that
* match one container gets to mint it is decided by the provider's
* `shouldSkipDefinitionCapture` (`isZigRedundantContainerCapture`).
*/
const extractZigContainerName = (node: SyntaxNode, filePath?: string): string | undefined =>
zigContainerName(node, filePath);
const extractZigContainerType = (node: SyntaxNode): ClassLikeNodeLabel | undefined => {
// The file itself, when it declares top-level fields (file-struct); a
// namespace-only file is not a type — `extract` then yields no symbol.
if (node.type === 'source_file') return isZigFileStruct(node) ? 'Struct' : undefined;
return zigContainerLabel(node);
};
export const zigClassConfig: ClassExtractionConfig = {
language: SupportedLanguages.Zig,
typeDeclarationNodes: [...ZIG_CONTAINER_TYPES, 'source_file'],
extractName: extractZigContainerName,
extractType: extractZigContainerType,
};

View file

@ -57,6 +57,7 @@ const CLASS_LIKE_LABELS = new Set<ClassLikeNodeLabel>([
'Interface',
'Enum',
'Record',
'Union',
]);
const extractScopeSegmentsFromNode = (
@ -129,11 +130,15 @@ export function createClassExtractor(config: ClassExtractionConfig): ClassExtrac
fallback?: {
name?: string;
type?: NodeLabel | null;
filePath?: string;
},
): ExtractedClassSymbol | null => {
if (!typeDeclarationSet.has(node.type)) return null;
const name = config.extractName?.(node) ?? extractTypeNameFromNode(node) ?? fallback?.name;
const name =
config.extractName?.(node, fallback?.filePath) ??
extractTypeNameFromNode(node) ??
fallback?.name;
const type =
config.extractType?.(node) ??
DEFAULT_LABEL_BY_NODE_TYPE[node.type] ??

View file

@ -3,7 +3,7 @@ import type { SyntaxNode } from './utils/ast-helpers.js';
export type ClassLikeNodeLabel = Extract<
NodeLabel,
'Class' | 'Struct' | 'Interface' | 'Enum' | 'Record'
'Class' | 'Struct' | 'Interface' | 'Enum' | 'Record' | 'Union'
>;
export interface ExtractedClassSymbol {
@ -41,6 +41,8 @@ export interface ClassExtractor {
fallback?: {
name?: string;
type?: NodeLabel | null;
/** Repo-relative path of the file being extracted, when known. */
filePath?: string;
},
): ExtractedClassSymbol | null;
extractQualifiedName(node: SyntaxNode, simpleName: string): string | null;
@ -72,7 +74,9 @@ export interface ClassExtractionConfig {
*/
qualifiedNodeId?: boolean;
scopeNameNodeTypes?: string[];
extractName?: (node: SyntaxNode) => string | undefined;
/** `filePath` is supplied when known (definition-phase extraction) — a
* language whose file IS a type names it from the path. */
extractName?: (node: SyntaxNode, filePath?: string) => string | undefined;
extractType?: (node: SyntaxNode) => ClassLikeNodeLabel | undefined;
extractScopeSegments?: (node: SyntaxNode) => string[] | null | undefined;
extractTemplateArguments?: (node: SyntaxNode) => string[] | undefined;

View file

@ -242,3 +242,64 @@ export const rubyExportChecker: ExportChecker = (_node, _name) => true;
/** Dart: public if no leading underscore (convention, same as Python). */
export const dartExportChecker: ExportChecker = (_node, name) => !name.startsWith('_');
/** Zig declaration node types whose `pub` / `export` keyword child marks the symbol public. */
const ZIG_DECL_TYPES = new Set(['function_declaration', 'variable_declaration']);
/**
* Zig: walk to the enclosing decl, scan its direct children for an unnamed `pub`
* or `export` keyword token (tree-sitter-zig models both as anonymous keyword
* children of function_declaration / variable_declaration). Two different
* facts share this one flag, on purpose:
* - `pub` is Zig-module visibility — reachable from another `.zig` file
* through `@import`;
* - `export` is C-ABI linkage (`export fn add(...)`) — the symbol lands in
* the object file for FFI callers, and it never carries `pub`.
* `isExported` means "visible outside this compilation unit" graph-wide (C uses
* external linkage for the same flag), so both qualify. The `visibility`
* property is the Zig-only fact and is `pub`-only — see `hasZigPubKeyword`:
* an `export fn` without `pub` is public to C and PRIVATE to other Zig files.
* Container fields (struct/enum variants) are public if their enclosing
* variable_declaration is public.
*
* The walk stops at the FIRST declaration it reaches: a `fn` inside
* `pub const T = struct { … }` carries its own `pub` (or not), independent of
* the container's. Continuing up to the wrapper marked every private method of
* a public container as exported.
*/
export const zigExportChecker: ExportChecker = (node, _name) => {
let current: SyntaxNode | null = node;
while (current) {
if (ZIG_DECL_TYPES.has(current.type)) return hasZigVisibilityKeyword(current);
current = current.parent;
}
return false;
};
/**
* Does this Zig declaration carry a `pub` or `export` keyword child? Feeds
* `isExported` (visible outside the compilation unit — to Zig importers OR to
* C callers).
*/
export function hasZigVisibilityKeyword(declNode: SyntaxNode): boolean {
for (let i = 0; i < declNode.childCount; i++) {
const child = declNode.child(i);
if (child?.type === 'pub' || child?.type === 'export') return true;
}
return false;
}
/**
* Does this Zig declaration carry a `pub` keyword child? Feeds `visibility`,
* the Zig-module fact: per the language reference, only `pub` declarations are
* accessible from another file via `@import`; `export` alone gives C linkage
* and leaves the declaration private to Zig code. So `export fn c_add` reports
* `isExported: true` (FFI surface) with `visibility: 'private'` (Zig surface)
* — two facts, two properties, deliberately not one.
*/
export function hasZigPubKeyword(declNode: SyntaxNode): boolean {
for (let i = 0; i < declNode.childCount; i++) {
if (declNode.child(i)?.type === 'pub') return true;
}
return false;
}

View file

@ -0,0 +1,58 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { FieldExtractionConfig } from '../generic.js';
import { ZIG_CONTAINER_TYPES, zigContainerName } from '../../languages/zig/captures.js';
/**
* Zig containers (struct/enum/union/opaque) are anonymous in tree-sitter-zig;
* the binding name is the first identifier child of the parent
* variable_declaration, or the enclosing generic type constructor's name —
* `zigContainerName` is the single source.
*/
const extractZigOwnerName = (node: SyntaxNode, filePath?: string): string | undefined =>
zigContainerName(node, filePath);
/**
* Container fields appear as direct children of struct_declaration /
* enum_declaration / union_declaration — there is no separate body wrapper
* in this grammar, so `bodyNodeTypes` is empty and the generic factory's
* "iterate immediate children" pass picks them up.
*/
export const zigFieldConfig: FieldExtractionConfig = {
language: SupportedLanguages.Zig,
// `source_file`: a file-struct's top-level fields belong to the file's Struct.
typeDeclarationNodes: [...ZIG_CONTAINER_TYPES, 'source_file'],
fieldNodeTypes: ['container_field'],
bodyNodeTypes: [],
defaultVisibility: 'public',
extractOwnerName: extractZigOwnerName,
extractName(node) {
const name = node.childForFieldName('name');
// An empty container body (`struct {}`, `opaque {}`) is recovered by
// tree-sitter-zig 1.1.2 as one container_field with a zero-width MISSING
// identifier. Not a field — declining here keeps it out of the field map.
if (name === null || name.text.length === 0) return undefined;
return name.text;
},
extractType(node) {
const typeNode = node.childForFieldName('type');
return typeNode?.text?.trim();
},
extractVisibility() {
// Zig has no per-field visibility — fields inherit the container's
// module-level visibility. Treat as public; the export checker decides
// what the *container* exposes.
return 'public';
},
isStatic() {
return false;
},
isReadonly() {
return false;
},
};

View file

@ -34,7 +34,7 @@ export interface FieldExtractionConfig {
/** Default visibility when no modifier is present */
defaultVisibility: FieldVisibility;
/** Extract owner type name from a type declaration node. */
extractOwnerName?: (node: SyntaxNode) => string | undefined;
extractOwnerName?: (node: SyntaxNode, filePath?: string) => string | undefined;
/** Find body nodes inside a type declaration node. */
findBodyNodes?: (node: SyntaxNode) => SyntaxNode[];
/**
@ -103,7 +103,8 @@ export function createFieldExtractor(config: FieldExtractionConfig): FieldExtrac
extract(node: SyntaxNode, context: FieldExtractorContext): ExtractedFields | null {
if (!this.isTypeDeclaration(node)) return null;
const ownerFqn = config.extractOwnerName?.(node) ?? node.childForFieldName('name')?.text;
const ownerFqn =
config.extractOwnerName?.(node, context.filePath) ?? node.childForFieldName('name')?.text;
if (!ownerFqn) return null;
const fields: FieldInfo[] = [];
@ -148,6 +149,17 @@ export function createFieldExtractor(config: FieldExtractionConfig): FieldExtrac
if (result.length === 0 && bodyField) {
result.push(bodyField);
}
// 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);
}
return result;
}

View file

@ -0,0 +1,36 @@
/**
* Zig import resolution.
*
* Local-file imports (`@import("./foo.zig")`, `@import("foo.zig")`) resolve
* relative to the importer. Bare names (`@import("bar")`) resolve through
* build.zig.zon `.path` deps when a parsed ZigBuildZonConfig is available
* (see language-config.ts `loadZigBuildConfig`). Everything unresolvable —
* `std`, `builtin`, `root`, `.url`-based deps — returns an empty result so
* it doesn't produce ghost import edges.
*/
import { SupportedLanguages } from 'gitnexus-shared';
import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js';
import { resolveZigImportInternal } from '../zig.js';
const stripQuotes = (s: string): string => s.replace(/^['"]|['"]$/g, '');
export const zigImportStrategy: ImportResolverStrategy = (rawImportPath, filePath, ctx) => {
// tree-sitter-zig captures the string with surrounding quotes.
const stripped = stripQuotes(rawImportPath);
const resolved = resolveZigImportInternal(
filePath,
stripped,
ctx.allFilePaths,
ctx.configs.zigBuildZon ?? null,
);
// Unresolvable (stdlib / builtin / .url dep / missing file): stop the
// chain with an empty result rather than falling through to suffix
// matching, which could ghost-match an unrelated same-named file.
return { kind: 'files', files: resolved ? [resolved] : [] };
};
export const zigImportConfig: ImportResolutionConfig = {
language: SupportedLanguages.Zig,
strategies: [zigImportStrategy],
};

View file

@ -0,0 +1,210 @@
/**
* Zig module import resolution — internal helpers.
*
* Zig imports take three shapes:
* const std = @import("std"); → stdlib, unresolvable
* const builtin = @import("builtin"); → compiler builtin, unresolvable
* const root = @import("root"); → user's main module, unresolvable here
* const foo = @import("./foo.zig"); → relative path
* const foo = @import("foo.zig"); → also relative (Zig treats unprefixed
* paths with a `.zig` extension as
* filesystem-relative to the importer)
* const lp = @import("lightpanda"); → the repo's OWN module, declared by
* its root build.zig (`b.addModule`)
* const bar = @import("bar"); → package dep declared in build.zig.zon
*
* Bare-name resolution is handled when a parsed ZigBuildZonConfig is
* supplied (see `loadZigBuildConfig`). The import table of the build module
* the importer belongs to comes first (`buildModules` — per-module
* `addImport` aliases, see `zigModulesContaining`), then the root
* build.zig's named modules flattened repo-wide (`rootModules`, name → root
* file — a repo with no build.zig.zon still resolves them). `.url`-based deps unpack into a build cache outside
* the repo and so are returned as null; `.path`-based deps are resolved
* through the root the dep's own build.zig declares, then the conventional
* `<dep_root>/src/root.zig`, `<dep_root>/src/<name>.zig`,
* `<dep_root>/src/main.zig` layouts.
*/
import {
normalizeZigDepPath,
type ZigBuildModule,
type ZigBuildZonConfig,
} from '../language-config.js';
const ZIG_STDLIB_NAMES = new Set(['std', 'builtin', 'root']);
/**
* The build module(s) a source file belongs to: the module whose ROOT the
* file is, else the module(s) whose root's directory is the deepest prefix
* of the file's path. Membership is not declared anywhere static — a module
* is its root plus whatever that root reaches through relative imports —
* so the directory is the proxy, and several modules may share one
* (`src/main.zig` executable beside `src/root.zig` library is the `zig init`
* layout). A root file is unambiguous by construction: it is its own module.
*/
function zigModulesContaining(
currentFile: string,
modules: readonly ZigBuildModule[],
): ZigBuildModule[] {
const own = modules.filter((m) => m.root === currentFile);
if (own.length > 0) return own;
let best = -1;
let out: ZigBuildModule[] = [];
for (const mod of modules) {
const slash = mod.root.lastIndexOf('/');
const dir = slash === -1 ? '' : mod.root.slice(0, slash);
if (dir !== '' && !currentFile.startsWith(`${dir}/`)) continue;
if (dir.length > best) {
best = dir.length;
out = [mod];
} else if (dir.length === best) {
out.push(mod);
}
}
return out;
}
/**
* A bare `@import("<name>")` through the containing module(s)' own import
* tables. `undefined` when no containing module binds the name (the caller
* falls back to the repo-wide tables); `null` when the containing modules
* DISAGREE — two same-directory modules that bind one alias to different
* roots — or when ANY containing module binds the alias to a root that is
* not indexed (a generated or skipped file), including the mixed case
* where a sibling module's target *is* indexed. Both are fail-closed on purpose: the module's
* own table is the authority for the alias, and falling through to the
* repo-wide map would reintroduce the first-wins answer this table exists
* to remove, under a name the module never meant.
*/
function resolveThroughBuildModules(
currentFile: string,
importPath: string,
allFiles: ReadonlySet<string>,
modules: readonly ZigBuildModule[],
): string | null | undefined {
const targets = new Set<string>();
for (const mod of zigModulesContaining(currentFile, modules)) {
const target = mod.imports.get(importPath);
if (target === undefined) continue;
if (!allFiles.has(target)) return null;
targets.add(target);
}
if (targets.size === 0) return undefined;
if (targets.size > 1) return null;
return targets.values().next().value ?? null;
}
/** Resolve a Zig @import argument to a file path in the repository.
* Returns null when the import is a stdlib / builtin / root reference,
* an unresolvable build.zig.zon package dep, or genuinely unresolvable.
*
* `buildZon` (optional) supplies the parsed `.dependencies` map from
* build.zig.zon. */
export function resolveZigImportInternal(
currentFile: string,
importPath: string,
allFiles: ReadonlySet<string>,
buildZon?: ZigBuildZonConfig | null,
): string | null {
// Stdlib / compiler builtin / root — not resolvable from source files alone.
if (ZIG_STDLIB_NAMES.has(importPath)) return null;
// Normalize path separators for the path arithmetic below. The `.zig`
// extension is kept as written: the first candidate is the path as spelled
// and only the fallback appends `.zig` for extension-less spellings.
const trimmed = importPath.replace(/\\/g, '/');
// Absolute paths point outside the repository (Zig itself rejects
// `@import("/abs.zig")` as an import outside the module path). Splitting
// would drop the empty leading component and read `/foo.zig` as an
// importer-relative `foo.zig`, fabricating an in-repo edge.
// A drive-qualified spelling (`C:/foo.zig`, normalized from `C:\foo.zig`)
// is absolute too: it carries a `/`, so without this guard it would take
// the importer-relative branch and probe `src/C:/foo.zig`. Same test as
// `normalizeZigDepPath`.
if (trimmed.startsWith('/') || /^[A-Za-z]:\//.test(trimmed)) return null;
// Path-bearing import: resolve relative to the current file's directory.
// Zig allows both "./foo.zig" and "foo.zig" — both are filesystem-relative.
if (trimmed.endsWith('.zig') || trimmed.includes('/')) {
const currentDir = currentFile.split('/').slice(0, -1);
const parts = trimmed.split('/');
for (const part of parts) {
if (part === '' || part === '.') continue;
if (part === '..') {
// Above the repository root: the import names a file outside the
// repo, so it must not alias a same-named root file (`../bar.zig`
// from `main.zig` is NOT `bar.zig`).
if (currentDir.length === 0) return null;
currentDir.pop();
} else {
currentDir.push(part);
}
}
const candidate = currentDir.join('/');
if (allFiles.has(candidate)) return candidate;
if (allFiles.has(candidate + '.zig')) return candidate + '.zig';
return null;
}
// Bare name without extension or slashes (e.g. @import("bar")).
if (buildZon) {
// First the import table of the build module the importer belongs to:
// an alias is scoped to the module whose `addImport` declared it, so
// this is the only table that can tell `app`'s `@import("config")` from
// `tool`'s. A disagreement between same-directory modules is `null`
// here and stops the chain — the repo-wide fallbacks below would only
// reintroduce the first-wins answer.
if (buildZon.buildModules !== undefined && buildZon.buildModules.length > 0) {
const scoped = resolveThroughBuildModules(
currentFile,
importPath,
allFiles,
buildZon.buildModules,
);
if (scoped !== undefined) return scoped;
}
// The repo's own modules, as its root build.zig names them
// (`b.addModule("lightpanda", .{ .root_source_file = b.path("src/lightpanda.zig") })`),
// take precedence: that declaration is exactly what an in-repo
// `@import("lightpanda")` means, whatever the zon says. `std` / `builtin`
// / `root` were rejected above and can never be reached from here.
// Authoritative when it binds the name: a root that is not indexed (a
// generated or skipped file) is `null`, never a fall-through to a
// same-named zon dep — that would be a different declaration answering
// under the name (gitnexus-check on fe24b37f; same rule as the
// build-module tables above).
const rootModule = buildZon.rootModules?.get(importPath);
if (rootModule !== undefined) return allFiles.has(rootModule) ? rootModule : null;
// Then build.zig.zon `.path` deps.
const depPath = buildZon.pathDeps.get(importPath);
if (depPath) {
const normalized = normalizeZigDepPath(depPath);
if (normalized !== null) {
// What the dep's own build.zig declares comes first (its
// `addModule` root_source_file — see `parseZigBuildModuleRoots`), then
// the conventional layouts: `src/root.zig` (the `zig init` library
// root since 0.12), `src/<name>.zig` (older name-matched convention),
// `src/main.zig` (executables / older inits). A dep at `.path = "."`
// (the repo itself) normalizes to '' and must not grow a leading slash
// — `allFiles` keys are repo-relative.
const prefix = normalized === '' ? '' : `${normalized}/`;
const candidates = [
...(buildZon.moduleRoots?.get(importPath) ?? []),
`${prefix}src/root.zig`,
`${prefix}src/${importPath}.zig`,
`${prefix}src/main.zig`,
];
for (const c of candidates) {
if (allFiles.has(c)) return c;
}
}
}
}
// Bare name with no resolution (no build.zig / build.zig.zon, .url-based dep, generated module, or
// unconventional layout).
return null;
}

View file

@ -179,6 +179,69 @@ export interface SwiftPackageConfig {
targets: Map<string, string>;
}
/** Zig package config parsed from build.zig.zon and the root build.zig */
export interface ZigBuildZonConfig {
/**
* Map of dependency name -> the raw `.path = "..."` value, exactly as
* written in build.zig.zon (relative to the repo root, and possibly
* escaping it: `../local_dep`). Consumers normalize — see
* `normalizeZigDepPath` below, which rejects absolute
* and repo-escaping values. `.url`-based deps cannot be resolved to a
* repo-local file (they unpack into a build cache outside the repo) and so
* are not included here.
*/
pathDeps: Map<string, string>;
/**
* Per path-dep: repo-relative root source files the dep's own `build.zig`
* declares (`b.addModule("name", .{ .root_source_file = b.path("src/x.zig")
* })`), keyed by dep name, in file order. Entries whose module name matches
* the dep name come first — that is the module a consumer's
* `@import("<dep>")` maps to under the ecosystem convention that the zon key
* and the module name agree. Absent (or empty) when the dep has no readable
* `build.zig`; the resolver then falls back to the conventional layouts.
*/
moduleRoots?: Map<string, readonly string[]>;
/**
* Modules the repo's OWN root `build.zig` declares under an importable
* name, module name → repo-relative root source file
* (`b.addModule("lp", .{ .root_source_file = b.path("src/lp.zig") })`, or a
* `createModule` binding later named through `addImport("lp", binding)`).
* These are what an in-repo `@import("lp")` means — the most common shape in
* single-package repos, where every file imports the package's own root
* module by name. Independent of `build.zig.zon`: a repo with a `build.zig`
* and no zon still resolves them. See `parseZigRootModules`.
*/
rootModules?: Map<string, string>;
/**
* Every build module the root `build.zig` declares, each with ITS OWN
* import table — `addModule` / `createModule` roots and the root modules of
* `addExecutable` / `addLibrary` / `addTest` artifacts, with the aliases
* their `addImport("<alias>", …)` calls and `.imports = &.{ … }` fields
* bind. `rootModules` flattens all of those into one first-wins map, which
* is wrong as soon as two modules bind one alias to different roots (an
* `app` and a `tool` executable that each `addImport("config", …)` their
* own `config.zig`): the second module's files resolved to the first
* module's target. The resolver walks a source file to its containing
* module(s) and consults their tables first — see
* `resolveZigImportInternal` / `parseZigBuildModules`.
*/
buildModules?: readonly ZigBuildModule[];
}
/** One build module of the root `build.zig` — see `ZigBuildZonConfig.buildModules`. */
export interface ZigBuildModule {
/** The `addModule("<name>", …)` name; absent for `createModule` bindings
* and artifact root modules, which are reachable only through aliases. */
readonly name?: string;
/** Repo-relative root source file (`b.path("src/x.zig")`). */
readonly root: string;
/** Alias → repo-relative root source file, as this module's own
* `addImport` calls and `.imports` field declare it. Includes aliases to
* a path dep's module (`addImport("api", dep.module("core"))`) when the
* dep's build.zig declares that module. */
readonly imports: ReadonlyMap<string, string>;
}
// ============================================================================
// LANGUAGE-SPECIFIC CONFIG LOADERS
// ============================================================================
@ -548,6 +611,703 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPac
return null;
}
/**
* Load the Zig build configuration a repo's `build.zig.zon` + root `build.zig`
* declare: `.path` deps (and the roots their own build.zig names) from the
* zon, and the repo's own named modules from the root build.zig. Either file
* may be missing — a repo with a `build.zig` but no `build.zig.zon` still
* resolves `@import("<own module>")`. Null only when neither contributes.
*
* `build.zig.zon` is Zig source (an anonymous-struct literal), not JSON.
* Rather than pull in a tree-sitter parse for one file, we use a small
* regex-based extractor that handles the common shapes:
*
* .dependencies = .{
* .ziggit_pkg = .{
* .url = "https://...",
* .hash = "1220...",
* },
* .local_dep = .{
* .path = "../local_dep",
* },
* },
*
* Limitations (intentional — bail to null on anything weirder):
* - Only the top-level `.dependencies = .{ ... }` block is parsed (brace
* depth 1); a same-named field nested in another struct is ignored.
* - Each dep entry is matched by a single shape: `.<name> = .{ ... }`
* where `<name>` is a bare identifier (no `@"…"` quoted form).
* - Only `.path = "..."` is captured. `.url` deps are left unresolved
* because their unpacked location lives outside the repo
* (.zig-cache/p/<hash>/ or ~/.cache/zig/p/<hash>/) and is therefore
* not in our `allFilePaths` set.
* - `//` line comments are stripped before scanning (string-aware, so a
* `//` inside `.url = "https://…"` survives), and brace matching skips
* string literals — a commented-out `.path` or a `}` inside a comment
* or string cannot declare a dep or truncate the block.
*/
export async function loadZigBuildConfig(repoRoot: string): Promise<ZigBuildZonConfig | null> {
let config: ZigBuildZonConfig | null = null;
try {
const raw = await fs.readFile(path.join(repoRoot, 'build.zig.zon'), 'utf-8');
config = parseZigBuildZon(raw);
} catch {
// No zon (or unreadable): the root build.zig may still declare modules.
}
// The repo's own importable modules, from its root build.zig. Independent
// of the zon: `@import("<own module>")` is how single-package repos refer
// to their root file from every other file.
let rootModules: Map<string, string> | undefined;
let rootBuildZig: string | null = null;
try {
rootBuildZig = await fs.readFile(path.join(repoRoot, 'build.zig'), 'utf-8');
const parsed = parseZigRootModules(rootBuildZig);
if (parsed.size > 0) rootModules = parsed;
} catch {
// No root build.zig — nothing to declare.
}
if (config === null) {
if (rootBuildZig === null) return null;
// No zon: no path deps, so `dep.module(…)` operands resolve to nothing.
const buildModules = parseZigBuildModules(rootBuildZig);
if (!rootModules && buildModules.length === 0) return null;
return {
pathDeps: new Map(),
...(rootModules ? { rootModules } : {}),
...(buildModules.length > 0 ? { buildModules } : {}),
};
}
// A path dep's importable root is whatever ITS build.zig declares, not a
// fixed layout: read `root_source_file` per `addModule` and remember it
// repo-relative. Best effort — an unreadable build.zig just leaves the
// conventional-layout fallback in place.
const moduleRoots = new Map<string, readonly string[]>();
// Per path dep: the modules its build.zig NAMES (`addModule("core", …)`),
// repo-relative — what a root-build.zig `dep.module("core")` operand means.
const depModules = new Map<string, ReadonlyMap<string, string>>();
for (const [depName, depPath] of config.pathDeps) {
const rel = normalizeZigDepPath(depPath);
if (rel === null) continue;
let buildZig: string;
try {
buildZig = await fs.readFile(path.join(repoRoot, rel, 'build.zig'), 'utf-8');
} catch {
continue;
}
const prefixed = (r: string): string => (rel === '' ? r : `${rel}/${r}`);
const roots = parseZigBuildModuleRoots(buildZig, depName).map(prefixed);
if (roots.length > 0) moduleRoots.set(depName, roots);
const named = new Map<string, string>();
for (const mod of parseZigBuildModules(buildZig)) {
if (mod.name !== undefined && !named.has(mod.name)) named.set(mod.name, prefixed(mod.root));
}
if (named.size > 0) depModules.set(depName, named);
}
const buildModules = rootBuildZig === null ? [] : parseZigBuildModules(rootBuildZig, depModules);
return {
...config,
...(moduleRoots.size > 0 ? { moduleRoots } : {}),
...(rootModules ? { rootModules } : {}),
...(buildModules.length > 0 ? { buildModules } : {}),
};
}
/**
* Normalize a `.path` value from build.zig.zon into a repo-relative form.
* Returns null for paths that escape the repo root (start with `..`) or
* are absolute — those point to files we don't index. `.` / `./` normalize
* to the empty string (the repo root itself). Shared with the import
* resolver so both sides agree on which deps are in-repo.
*/
export function normalizeZigDepPath(depPath: string): string | null {
// Normalize separators BEFORE the absolute check so every Windows spelling
// is visible to it: POSIX (`/x`), drive (`C:\x`, `C:/x`), root-relative
// (`\x` → `/x`) and UNC (`\\server\share` → `//server/share`) paths all
// point outside the repository.
const normalized = depPath.replace(/\\/g, '/');
if (normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized)) return null;
const parts: string[] = [];
for (const part of normalized.split('/')) {
if (part === '' || part === '.') continue;
if (part === '..') {
if (parts.length === 0) return null;
parts.pop();
} else {
parts.push(part);
}
}
return parts.join('/');
}
/**
* The `root_source_file` paths a `build.zig` declares, dep-relative, with the
* module whose `addModule("<name>", …)` name equals `preferredName` first.
*
* Reads two shapes, which between them cover `zig init` output and the
* common hand-written build scripts:
* - `b.addModule("name", .{ .root_source_file = b.path("src/root.zig") })`
* - any other `.root_source_file = b.path("…")` (exe/lib/test artifacts),
* kept as unnamed fallbacks in file order.
* A `.zig` under `b.path` is required — `.{ .cwd_relative = … }` and
* `LazyPath` values computed at build time are not resolvable statically and
* are skipped. Duplicates collapse to the first occurrence.
*/
export function parseZigBuildModuleRoots(buildZig: string, preferredName: string): string[] {
const named: string[] = [];
const unnamed: string[] = [];
const seen = new Set<string>();
const add = (into: string[], p: string): void => {
const norm = normalizeZigDepPath(p);
if (norm === null || norm === '' || !norm.endsWith('.zig') || seen.has(norm)) return;
seen.add(norm);
into.push(norm);
};
const rootRe = /\.root_source_file\s*=\s*b\.path\(\s*"([^"\n]+)"\s*\)/;
// The named module: scan the whole `addModule(…)` argument list, balanced
// on parentheses, so a nested field before `.root_source_file` (`.imports =
// &.{ .{ … } }`) does not end the match early — a `[^}]*` regex stopped at
// that inner `}` and silently demoted the module to an unnamed fallback.
const text = stripZonComments(buildZig);
const mask = zonStringMask(text);
const callRe = /\baddModule\s*\(/g;
let m: RegExpExecArray | null;
while ((m = callRe.exec(text)) !== null) {
if (mask[m.index] !== 0) continue;
const argsStart = m.index + m[0].length;
const argsEnd = findZigParenEnd(text, argsStart);
if (argsEnd < 0) break;
const args = text.slice(argsStart, argsEnd);
const nameMatch = /^\s*"([^"\n]+)"\s*,/.exec(args);
if (nameMatch?.[1] !== preferredName) continue;
const root = zigTopLevelStaticRoot(args);
if (root !== null) add(named, root);
}
const anyRe = new RegExp(rootRe.source, 'g');
while ((m = anyRe.exec(text)) !== null) add(unnamed, m[1]!);
return [...named, ...unnamed];
}
/**
* The importable modules a repo's ROOT `build.zig` declares, module name →
* repo-relative root source file. Static scan (no execution) of two shapes:
*
* - `b.addModule("<name>", .{ .root_source_file = b.path("<p>.zig"), … })`
* names the module directly;
* - `const m = b.createModule(.{ .root_source_file = b.path("<p>.zig"), … })`
* (or `const m = b.addModule(…)`) bound to an identifier and later named
* by `x.addImport("<name>", m)` or `.imports = &.{ .{ .name = "<name>",
* .module = m } }`.
*
* Deliberately NOT resolved — they are not in-repo source files: modules whose
* root is not a static `b.path("….zig")` (generated `opts.createModule()` from
* `addOptions`, `translate_c.createModule()`, `.cwd_relative` / computed
* LazyPaths), `addImport("<name>", dep.module("…"))` (a `.url` / path dep,
* handled through the zon), and aliases whose module operand is anything but a
* bare identifier bound above (`config.lp_module`). Comments are stripped and
* string literals skipped; the first declaration of a name wins.
*/
export function parseZigRootModules(buildZig: string): Map<string, string> {
const text = stripZonComments(buildZig);
const mask = zonStringMask(text);
const modules = new Map<string, string>();
// identifier → repo-relative root, for `const m = b.createModule(…)` /
// `const m = b.addModule(…)` bindings later named via addImport.
const bindings = new Map<string, string>();
const callRe = /\b(addModule|createModule)\s*\(/g;
let m: RegExpExecArray | null;
while ((m = callRe.exec(text)) !== null) {
if (mask[m.index] !== 0) continue;
const argsStart = m.index + m[0].length;
const argsEnd = findZigParenEnd(text, argsStart);
if (argsEnd < 0) break;
const args = text.slice(argsStart, argsEnd);
const root = zigTopLevelStaticRoot(args);
if (root === null) continue;
if (m[1] === 'addModule') {
const nameMatch = /^\s*"([^"\n]+)"\s*,/.exec(args);
if (nameMatch && !modules.has(nameMatch[1]!)) modules.set(nameMatch[1]!, root);
}
const binding = ZIG_MODULE_BINDING_RE.exec(text.slice(0, m.index));
if (binding && !bindings.has(binding[1]!)) bindings.set(binding[1]!, root);
}
if (bindings.size === 0) return modules;
const aliasRes = [
/\.addImport\(\s*"([^"\n]+)"\s*,\s*([A-Za-z_]\w*)\s*\)/g,
/\.name\s*=\s*"([^"\n]+)"\s*,\s*\.module\s*=\s*([A-Za-z_]\w*)\s*[,}]/g,
];
for (const re of aliasRes) {
while ((m = re.exec(text)) !== null) {
if (mask[m.index] !== 0) continue;
const root = bindings.get(m[2]!);
if (root !== undefined && !modules.has(m[1]!)) modules.set(m[1]!, root);
}
}
return modules;
}
/**
* Every build module the ROOT `build.zig` declares, each with its OWN import
* table (`ZigBuildModule`). Static scan (no execution) of:
*
* - `b.addModule("<name>", .{ .root_source_file = b.path("<p>.zig"), … })`
* and `const m = b.createModule(.{ .root_source_file = … })` — a module,
* bound to the identifier a preceding `const m =` names;
* - `b.addExecutable` / `addLibrary` / `addStaticLibrary` /
* `addSharedLibrary` / `addTest` / `addObject(.{ .root_source_file =
* b.path("<p>.zig"), … })` — an artifact whose ROOT MODULE is a module of
* its own (reached as `exe.root_module.addImport(…)`), or `.root_module =
* m` / `.root_module = b.createModule(…)` naming one declared inline;
* - `<m>.addImport("<alias>", <operand>)`, `<exe>.root_module.addImport(…)`
* and the `.imports = &.{ .{ .name = "<alias>", .module = <operand> } }`
* field of a module's own arguments — an entry in THAT module's table.
* The operand is a module binding (`m`) or a path dep's named module,
* `dep.module("<name>")` with `const dep = b.dependency("<zon name>", …)`,
* looked up in `depModules` (zon dep name → module name → repo-relative
* root, from the dep's own build.zig).
*
* Why per module rather than one map (`parseZigRootModules`): an alias is
* scoped to the module that declares it. Two executables that each
* `addImport("config", …)` their own `config.zig` are the ordinary
* multi-target layout, and a single first-wins map sent the second module's
* `@import("config")` to the first module's file — a confident wrong
* `IMPORTS` edge and every `config.*` call behind it. Deliberately NOT
* resolved, as in `parseZigRootModules`: generated roots
* (`addOptions().createModule()`, `translate_c.createModule()`, computed
* LazyPaths), `.url` deps, and operands that are not a bare identifier or a
* `dep.module("…")` on a `b.dependency` binding. Comments stripped, string
* literals masked; the first binding of an identifier wins.
*/
export function parseZigBuildModules(
buildZig: string,
depModules?: ReadonlyMap<string, ReadonlyMap<string, string>>,
): ZigBuildModule[] {
const text = stripZonComments(buildZig);
const mask = zonStringMask(text);
// Pass 1 — modules and the identifiers bound to them. `at` is the offset
// of the call's name token, so an inline `.root_module = b.createModule(…)`
// can be matched back to the module it minted.
interface Draft {
readonly name?: string;
readonly root: string;
readonly at: number;
readonly argsStart: number;
readonly argsEnd: number;
readonly imports: Map<string, string>;
}
const drafts: Draft[] = [];
const bindings = new Map<string, number>(); // identifier → drafts index
const bind = (prefixEnd: number, idx: number): void => {
const binding = ZIG_MODULE_BINDING_RE.exec(text.slice(0, prefixEnd));
if (binding && !bindings.has(binding[1]!)) bindings.set(binding[1]!, idx);
};
// Artifact bindings whose `.root_module = <ident>` names a module declared
// by another call; resolved once every binding is known.
const pendingArtifactAliases: { readonly ident: string; readonly module: string }[] = [];
const callRe =
/\b(addModule|createModule|addExecutable|addLibrary|addStaticLibrary|addSharedLibrary|addTest|addObject)\s*\(/g;
let m: RegExpExecArray | null;
while ((m = callRe.exec(text)) !== null) {
if (mask[m.index] !== 0) continue;
const argsStart = m.index + m[0].length;
const argsEnd = findZigParenEnd(text, argsStart);
if (argsEnd < 0) break;
const args = text.slice(argsStart, argsEnd);
const kind = m[1]!;
if (kind === 'addModule' || kind === 'createModule') {
const root = zigTopLevelStaticRoot(args);
if (root === null) continue;
const nameMatch = kind === 'addModule' ? /^\s*"([^"\n]+)"\s*,/.exec(args) : null;
drafts.push({
...(nameMatch ? { name: nameMatch[1]! } : {}),
root,
at: m.index,
argsStart,
argsEnd,
imports: new Map(),
});
bind(m.index, drafts.length - 1);
continue;
}
// An artifact. Its root module is either declared inline by
// `.root_source_file`, or handed over through `.root_module = …`.
const rootModule = /\.root_module\s*=\s*((?:[A-Za-z_]\w*\.)*)([A-Za-z_]\w*)\s*(\()?/.exec(args);
if (rootModule) {
if (rootModule[3] === '(' && rootModule[2] === 'createModule') {
// Inline `.root_module = b.createModule(.{ … })`: the module is minted
// by the createModule call inside these args (a later iteration of
// this loop); remember the artifact's binding for it.
const nameOffset = rootModule.index + rootModule[0].lastIndexOf('createModule');
const binding = ZIG_MODULE_BINDING_RE.exec(text.slice(0, m.index));
if (binding) {
pendingArtifactAliases.push({
ident: binding[1]!,
module: `@${argsStart + nameOffset}`,
});
}
} else if (rootModule[1] === '' && rootModule[3] === undefined) {
const binding = ZIG_MODULE_BINDING_RE.exec(text.slice(0, m.index));
if (binding) pendingArtifactAliases.push({ ident: binding[1]!, module: rootModule[2]! });
}
continue;
}
const root = zigTopLevelStaticRoot(args);
if (root === null) continue;
drafts.push({ root, at: m.index, argsStart, argsEnd, imports: new Map() });
bind(m.index, drafts.length - 1);
}
for (const alias of pendingArtifactAliases) {
if (bindings.has(alias.ident)) continue;
const idx = alias.module.startsWith('@')
? drafts.findIndex((d) => d.at === Number(alias.module.slice(1)))
: (bindings.get(alias.module) ?? -1);
if (idx >= 0) bindings.set(alias.ident, idx);
}
if (drafts.length === 0) return [];
// `const dep = b.dependency("<zon name>", …)` bindings, for `dep.module("…")`.
const dependencyBindings = new Map<string, string>();
const depRe =
/(?:const|var)\s+([A-Za-z_]\w*)\s*=\s*(?:[A-Za-z_]\w*\.)*dependency\(\s*"([^"\n]+)"/g;
while ((m = depRe.exec(text)) !== null) {
if (mask[m.index] !== 0) continue;
if (!dependencyBindings.has(m[1]!)) dependencyBindings.set(m[1]!, m[2]!);
}
// An import operand → the repo-relative root it names, or null.
const operandRoot = (operand: string): string | null => {
const bare = /^([A-Za-z_]\w*)$/.exec(operand);
if (bare) {
const idx = bindings.get(bare[1]!);
return idx === undefined ? null : drafts[idx]!.root;
}
const viaDep = /^([A-Za-z_]\w*)\.module\(\s*"([^"\n]+)"\s*\)$/.exec(operand);
if (viaDep) {
const zonName = dependencyBindings.get(viaDep[1]!);
return zonName === undefined ? null : (depModules?.get(zonName)?.get(viaDep[2]!) ?? null);
}
return null;
};
const addImport = (idx: number, alias: string, operand: string): void => {
const root = operandRoot(operand.trim());
const table = drafts[idx]!.imports;
if (root !== null && !table.has(alias)) table.set(alias, root);
};
// Pass 2a — `<m>.addImport("<alias>", <operand>)` / `<exe>.root_module.addImport(…)`.
const addImportRe = /\b([A-Za-z_]\w*)(?:\.root_module)?\.addImport\s*\(/g;
while ((m = addImportRe.exec(text)) !== null) {
if (mask[m.index] !== 0) continue;
const idx = bindings.get(m[1]!);
if (idx === undefined) continue;
const argsStart = m.index + m[0].length;
const argsEnd = findZigParenEnd(text, argsStart);
if (argsEnd < 0) break;
const args = text.slice(argsStart, argsEnd);
const aliasMatch = /^\s*"([^"\n]+)"\s*,/.exec(args);
if (!aliasMatch) continue;
addImport(idx, aliasMatch[1]!, args.slice(aliasMatch[0].length));
}
// Pass 2b — `.imports = &.{ .{ .name = "<alias>", .module = <operand> }, … }`
// inside a module's own argument list. The operand runs to the next `,` or
// `}` at paren depth 0 (`dep.module("core")` carries parentheses).
const entryRe = /\.name\s*=\s*"([^"\n]+)"\s*,\s*\.module\s*=\s*/g;
drafts.forEach((draft, idx) => {
const args = text.slice(draft.argsStart, draft.argsEnd);
let e: RegExpExecArray | null;
while ((e = entryRe.exec(args)) !== null) {
if (mask[draft.argsStart + e.index] !== 0) continue;
let depth = 0;
let end = e.index + e[0].length;
for (; end < args.length; end++) {
const ch = args[end];
if (ch === '(') depth++;
else if (ch === ')') {
if (depth === 0) break;
depth--;
} else if (depth === 0 && (ch === ',' || ch === '}')) break;
}
addImport(idx, e[1]!, args.slice(e.index + e[0].length, end));
}
});
return drafts.map(({ name, root, imports }) => ({
...(name !== undefined ? { name } : {}),
root,
imports,
}));
}
/** First `.root_source_file = b.path("….zig")` at the TOP level of a
* module-options `.{ … }` — not a nested `.imports = &.{ .{ … } }` entry. */
function zigTopLevelStaticRoot(args: string): string | null {
const mask = zonStringMask(args);
let structAt = -1;
for (let i = 0; i < args.length - 1; i++) {
if (mask[i] !== 0) continue;
if (args[i] === '.' && args[i + 1] === '{') {
structAt = i;
break;
}
}
if (structAt < 0) return null;
const bodyStart = structAt + 2;
const bodyEnd = findZonBlockEnd(args, bodyStart);
if (bodyEnd < 0) return null;
const match = /\.root_source_file\s*=\s*b\.path\(\s*"([^"\n]+)"\s*\)/.exec(
zonBlankNestedBlocks(args.slice(bodyStart, bodyEnd)),
);
if (match === null) return null;
const root = normalizeZigDepPath(match[1]!);
return root === null || root === '' || !root.endsWith('.zig') ? null : root;
}
/** `const m = b.createModule` / `const m = b.addModule` — not `config.createModule`. */
const ZIG_MODULE_BINDING_RE = /(?:const|var)\s+([A-Za-z_]\w*)\s*=\s*b\.$/;
/**
* Index of the `)` matching the `(` that precedes `start`, skipping parens
* inside `"…"` literals. -1 when unbalanced. Call on comment-stripped text.
*/
function findZigParenEnd(text: string, start: number): number {
let depth = 1;
let inString = false;
for (let i = start; i < text.length; i++) {
const ch = text[i];
if (inString) {
if (ch === '\\') i++;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === '(') depth++;
else if (ch === ')' && --depth === 0) return i;
}
return -1;
}
/**
* Blank out `//` line comments (and `\\` multiline-string-literal lines) in
* ZON source, string-aware: a `//` inside a `"…"` literal (`.url =
* "https://…"`) is content, not a comment. Comment bytes are replaced with
* spaces so every surviving character keeps its offset.
*/
function stripZonComments(raw: string): string {
const out = raw.split('');
let inString = false;
for (let i = 0; i < raw.length; i++) {
const ch = raw[i];
if (inString) {
if (ch === '\\')
i++; // skip the escaped char
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') {
inString = true;
continue;
}
const isLineComment = ch === '/' && raw[i + 1] === '/';
const isMultilineLiteral =
ch === '\\' &&
raw[i + 1] === '\\' &&
/^[ \t]*$/.test(raw.slice(raw.lastIndexOf('\n', i) + 1, i));
if (isLineComment || isMultilineLiteral) {
while (i < raw.length && raw[i] !== '\n') out[i++] = ' ';
}
}
return out.join('');
}
/**
* Index of the `}` matching the `{` that precedes `start`, skipping braces
* inside `"…"` literals. -1 when unbalanced. Call on comment-stripped text.
*/
function findZonBlockEnd(text: string, start: number): number {
let depth = 1;
let inString = false;
for (let i = start; i < text.length; i++) {
const ch = text[i];
if (inString) {
if (ch === '\\') i++;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === '{') depth++;
else if (ch === '}' && --depth === 0) return i;
}
return -1;
}
/**
* `body` with every nested `{ … }` block (string-aware) replaced by spaces of
* equal length, so a regex over the result only sees the block's DIRECT
* fields and offsets still line up with the original text.
*/
function zonBlankNestedBlocks(body: string): string {
const out = body.split('');
let depth = 0;
let inString = false;
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (inString) {
if (ch === '\\') {
if (depth > 0 && i + 1 < body.length) out[i + 1] = ' ';
i++;
} else if (ch === '"') inString = false;
if (depth > 0) out[i] = ' ';
continue;
}
if (ch === '"') inString = true;
else if (ch === '{') depth++;
else if (ch === '}' && depth > 0) {
depth--;
out[i] = ' ';
continue;
}
if (depth > 0) out[i] = ' ';
}
return out.join('');
}
/**
* Per-offset "is inside a `"…"` literal" mask for comment-stripped ZON text,
* so header regexes can reject a match that merely LOOKS like a field
* (`.name = ".dependencies = .{ … }"` is a string, not the dependencies
* block). Escaped quotes (`\"`) do not end the literal.
*/
function zonStringMask(text: string): Uint8Array {
const mask = new Uint8Array(text.length);
let inString = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (inString) {
mask[i] = 1;
if (ch === '\\' && i + 1 < text.length) mask[++i] = 1;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') {
inString = true;
mask[i] = 1;
}
}
return mask;
}
/**
* Per-offset brace depth for comment-stripped ZON text, string-aware: the
* depth AT an offset is the number of unclosed `{` before it. The file's
* top-level `.{` puts every direct field at depth 1.
*/
function zonDepthMask(text: string): Uint8Array {
const depth = new Uint8Array(text.length);
let d = 0;
let inString = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
depth[i] = d;
if (inString) {
if (ch === '\\' && i + 1 < text.length) depth[++i] = d;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === '{') d++;
else if (ch === '}' && d > 0) d--;
}
return depth;
}
/**
* First match of a sticky-free global `re` in `text[from, to)` whose start
* lies outside a string literal (per `mask`) and, when `depthAt` is given, at
* exactly that brace depth (per `depth`). Null when none.
*/
function matchZonHeader(
text: string,
re: RegExp,
mask: Uint8Array,
from: number,
to: number,
depth?: Uint8Array,
depthAt?: number,
): RegExpExecArray | null {
re.lastIndex = from;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null && m.index < to) {
if (mask[m.index] !== 0) continue;
if (depth !== undefined && depthAt !== undefined && depth[m.index] !== depthAt) continue;
return m;
}
return null;
}
/** Pure parser split out for testability. Returns null when no path-deps found. */
export function parseZigBuildZon(raw: string): ZigBuildZonConfig | null {
const text = stripZonComments(raw);
const mask = zonStringMask(text);
const depth = zonDepthMask(text);
// Locate the `.dependencies = .{ ... }` block. Use brace counting because
// dep entries are nested anonymous structs and a naive `}` match would stop
// early — and only accept a header outside string literals AND at brace
// depth 1 (a direct field of the file's top-level `.{`), so neither a
// `.name` value spelling `.dependencies = .{` nor a `.dependencies` field
// nested in some earlier anonymous struct can hijack it.
const depsHeader = matchZonHeader(
text,
/\.dependencies\s*=\s*\.\{/g,
mask,
0,
text.length,
depth,
1,
);
if (!depsHeader) return null;
const start = depsHeader.index + depsHeader[0].length;
const end = findZonBlockEnd(text, start);
if (end < 0) return null;
const pathDeps = new Map<string, string>();
// Walk each `.<name> = .{ ... }` entry inside [start, end); the body ends
// at the matching brace (string-aware), not at the first `}` in the text,
// and an entry header inside a string (`.url = "…/.x = .{"`) is not an entry.
const entryHeaderRe = /\.([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\.\{/g;
let cursor = start;
let m: RegExpExecArray | null;
while ((m = matchZonHeader(text, entryHeaderRe, mask, cursor, end)) !== null) {
const depName = m[1];
const bodyStart = m.index + m[0].length;
const bodyEnd = findZonBlockEnd(text, bodyStart);
if (bodyEnd < 0 || bodyEnd > end) break;
cursor = bodyEnd + 1;
// Only a `.path` that is a DIRECT field of the entry counts: a nested
// object inside the entry (`.foo = .{ .url = "…", .x = .{ .path = "…" } }`)
// must not turn a URL dep into a path dep. Blank nested blocks first and
// reject a match that starts inside a string literal.
const body = zonBlankNestedBlocks(text.slice(bodyStart, bodyEnd));
const pathMatch = matchZonHeader(
body,
/\.path\s*=\s*"([^"\n]+)"/g,
mask.subarray(bodyStart, bodyEnd),
0,
body.length,
);
if (pathMatch) {
pathDeps.set(depName, pathMatch[1]);
}
}
if (pathDeps.size === 0) return null;
if (isDev) {
logger.info(`📦 Loaded ${pathDeps.size} Zig path-dep(s) from build.zig.zon`);
}
return { pathDeps };
}
// ============================================================================
// BUNDLED CONFIG LOADER
// ============================================================================
@ -571,6 +1331,9 @@ export interface ImportConfigs {
csharpConfigs: CSharpProjectConfig[];
/** In-repo namespace evidence gating C# suffix-fallback resolution (#1881). */
csharpNamespaces?: CSharpNamespaceEvidence;
/** Zig `.path` deps from build.zig.zon. Optional so call sites that
* hand-build ImportConfigs (tests) don't have to supply it. */
zigBuildZon?: ZigBuildZonConfig | null;
}
/** Load all language-specific configs once for an ingestion run. */
@ -583,5 +1346,6 @@ export async function loadImportConfigs(repoRoot: string): Promise<ImportConfigs
swiftPackageConfig: await loadSwiftPackageConfig(repoRoot),
csharpConfigs: csharpScan.configs,
csharpNamespaces: csharpScanToEvidence(csharpScan),
zigBuildZon: await loadZigBuildConfig(repoRoot),
};
}

View file

@ -257,6 +257,43 @@ interface LanguageProviderConfig {
* Default: undefined (no remapping). */
readonly resolveEnclosingOwner?: (node: SyntaxNode) => SyntaxNode | null;
/**
* The type a whole FILE declares, when the language makes the file itself
* a type (Zig: a `.zig` file with top-level fields is a struct whose name
* is the file stem — `Page.zig` declares `Page`, and `page.getArena()`
* dispatches onto the file's top-level `fn getArena(self: *Page)`).
*
* Consulted by the enclosing-owner walk when it reaches the tree root
* without meeting a container, and by the class/method/field extractors
* for the owner name. Return `null` for a file that is only a namespace.
* The name is the class-like node's name (`Struct:<file>:<name>`), so the
* owner id and the node id agree by construction.
* Default: undefined (a file never owns members). */
readonly resolveFileTypeOwner?: (
root: SyntaxNode,
filePath: string,
) => { readonly name: string; readonly label: NodeLabel } | null;
/**
* The type a CONTAINER node declares, when the language names it from its
* context rather than from a name child of the node — a binding wrapper,
* an enclosing callable, an ordinal among anonymous siblings (Zig:
* `const T = struct {…}` is `T`; a function-local `const R = struct {…}`
* inside `fn string` is `string$R`; `struct { fn lessThan … }.lessThan`
* passed to a sort is `<fn>$1`).
*
* Consulted by the enclosing-owner walk for every `CLASS_CONTAINER_TYPES`
* node it meets (after `resolveEnclosingOwner` remapping), BEFORE the
* generic name-child derivation; return `null` to fall back to it. The name
* must be the one the class-like node is minted under
* (`<label>:<file>:<name>`), so a member's owner id and the node id agree
* by construction.
* Default: undefined (containers are named by the generic derivation). */
readonly resolveContainerTypeOwner?: (
container: SyntaxNode,
filePath: string,
) => { readonly name: string; readonly label: NodeLabel } | null;
// ── Enclosing function resolution ───────────────────────────────
/** Resolve the enclosing function name + label from an AST ancestor node
* that is NOT a standard FUNCTION_NODE_TYPE. For languages where the

View file

@ -25,6 +25,7 @@ import { swiftProvider } from './swift.js';
import { dartProvider } from './dart.js';
import { vueProvider } from './vue.js';
import { cobolProvider } from './cobol.js';
import { zigProvider } from './zig.js';
export const providers = {
[SupportedLanguages.JavaScript]: javascriptProvider,
@ -43,6 +44,7 @@ export const providers = {
[SupportedLanguages.Dart]: dartProvider,
[SupportedLanguages.Vue]: vueProvider,
[SupportedLanguages.Cobol]: cobolProvider,
[SupportedLanguages.Zig]: zigProvider,
} satisfies Record<SupportedLanguages, LanguageProvider>;
/** Get provider by language enum (always succeeds for SupportedLanguages). */

View file

@ -36,14 +36,14 @@ const RUST_SCOPE_QUERY = `
type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.enum
;; Declarations — union
;; Deliberately tagged @declaration.struct (→ Struct label), NOT a
;; @declaration.union: every registry-primary resolution gate —
;; isLinkableLabel (node-lookup.ts), CALLABLE_OR_TYPE_LIKE
;; (finalize-algorithm.ts), ClassLikeNodeLabel (class-types.ts) — includes
;; Struct but EXCLUDES Union, so a Union-labeled node would be an
;; unresolvable orphan. A Rust union is a type whose literal is a real
;; constructor, so Struct is both the resolvable and the semantically
;; honest label here. #1934 F71.
;; Tagged @declaration.struct (→ Struct label), NOT @declaration.union.
;; Historically forced: the registry-primary gates (isLinkableLabel,
;; CALLABLE_OR_TYPE_LIKE, ClassLikeNodeLabel) excluded Union, so a
;; Union-labeled node was an unresolvable orphan (#1934 F71). Zig support
;; widened all three for its union(enum) containers, so the label is now
;; resolvable — Struct is kept here on the semantic argument alone (a Rust
;; union is a type whose literal is a real constructor) and to leave the
;; graph node ids of existing Rust indexes unchanged.
(union_item
name: (type_identifier) @declaration.name
type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.struct

View file

@ -0,0 +1,145 @@
/**
* Zig Language Provider.
*
* Key Zig traits:
* - mroStrategy: default 'first-wins' is irrelevant — Zig has no inheritance,
* and no heritage hooks are provided (Zig queries never produce
* `@heritage.*` captures).
* - exportChecker: walks to the enclosing variable_declaration /
* function_declaration and looks for a `pub` or `export` keyword child.
* - importResolver: resolves local `@import("./foo.zig")` paths and
* build.zig.zon `.path` deps (root the dep's build.zig declares, then
* src/root.zig, src/<name>.zig, src/main.zig); `@import("std")` and
* `.url` packages are deliberately external.
* - namedBindingExtractor: omitted — the scope side handles
* `const Foo = @import("x").Foo` and `const Foo = ns.Foo` (ns an
* @import binding) as NAMED imports instead (`zig/captures.ts`).
* - scope-resolution hooks (Ring 3): `emitScopeCaptures` walks the file via
* `zig/query.ts` (containers as Class scopes — including the container a
* generic type constructor `fn List(comptime T: type) type` returns,
* named after the fn; container-nested fns relabeled @declaration.method;
* plain-variable groups filtered for container/import bindings and for
* the keyword-less `variable_declaration`s tree-sitter-zig uses for
* statement assignments); `interpretImport` maps `const x = @import("…")`
* to a namespace import, member forms to named/alias imports and
* `usingnamespace` to a wildcard; receiver types come from `self`
* parameters, `T{…}` / `mod.T{…}` / `List(u8){…}` literals, `T.init()`
* call returns, `x: T` annotations (incl. decl literals), container
* FIELD types on the container's class scope (`self.session.name()`)
* and one-level field aliases (`const s = self.session; s.name()`). The
* emit-side wiring lives in `zig/scope-resolver.ts` (SCOPE_RESOLVERS).
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { defineLanguage } from '../language-provider.js';
import { ZIG_QUERIES } from '../tree-sitter-queries.js';
import { zigExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
import { zigImportConfig } from '../import-resolvers/configs/zig.js';
import { createCallExtractor } from '../call-extractors/generic.js';
import { zigCallConfig } from '../call-extractors/configs/zig.js';
import { createClassExtractor } from '../class-extractors/generic.js';
import { zigClassConfig } from '../class-extractors/configs/zig.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { zigFieldConfig } from '../field-extractors/configs/zig.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
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 {
emitZigScopeCaptures,
interpretZigImport,
interpretZigTypeBinding,
isZigContainerMethod,
isZigFileStruct,
isZigRedundantContainerCapture,
isZigTypeShadowingBinding,
zigArityCompatibility,
zigContainerLabel,
zigContainerName,
zigFileStructName,
zigBindingScopeFor,
zigReceiverBinding,
ZIG_CONTAINER_TYPES,
} from './zig/index.js';
export const zigProvider = defineLanguage({
id: SupportedLanguages.Zig,
extensions: ['.zig'],
entryPointPatterns: [
/^main$/, // standard executable entry point
/^build$/, // build.zig entry point
],
astFrameworkPatterns: [],
treeSitterQueries: ZIG_QUERIES,
typeConfig: zigTypeConfig,
exportChecker: zigExportChecker,
importResolver: createImportResolver(zigImportConfig),
callExtractor: createCallExtractor(zigCallConfig),
classExtractor: createClassExtractor(zigClassConfig),
fieldExtractor: createFieldExtractor(zigFieldConfig),
methodExtractor: createMethodExtractor(zigMethodConfig),
variableExtractor: createVariableExtractor(zigVariableConfig),
// A `const`/`var` whose value is a container or an `@import` is the
// Struct/Enum/Union node or the import binding, not a Const beside it.
// Up to three ZIG_QUERIES rules match one container (wrapper, type
// constructor, bare container — F8); `zigContainerAnchor` names the one
// that mints it and the others are dropped here.
shouldSkipDefinitionCapture: (captureMap, defaultLabel) => {
if (defaultLabel === 'Const' || defaultLabel === 'Variable') {
const decl = captureMap['definition.const'] ?? captureMap['definition.variable'];
return decl !== undefined && isZigTypeShadowingBinding(decl);
}
if (defaultLabel === 'Struct' || defaultLabel === 'Enum' || defaultLabel === 'Union') {
const decl =
captureMap['definition.struct'] ??
captureMap['definition.enum'] ??
captureMap['definition.union'];
if (decl === undefined) return false;
// The file-struct rules over-match (a `@This` first parameter, a
// top-level `@This()` alias — see ZIG_QUERIES); the one predicate decides.
if (decl.type === 'source_file') return !isZigFileStruct(decl);
return isZigRedundantContainerCapture(decl, captureMap['name']);
}
return false;
},
// A file whose top level declares fields IS a struct named after the file
// (`Page.zig` → `Page`): its top-level fns/fields are members of that
// Struct. Files without fields are namespaces and own nothing.
resolveFileTypeOwner: (root, filePath) =>
isZigFileStruct(root) ? { name: zigFileStructName(filePath), label: 'Struct' } : null,
// Every container's identity comes from `zigContainerName` — the binding
// name for `const T = struct {…}` at file/container level, the fn name for
// a generic type constructor, and (F8) `string$R` / `build$1` for
// function-local and anonymous containers, which no name child spells. The
// class extractor names the node from the same function, so a member's
// owner id (`Method:<file>:string$R.get`) and the node id agree.
resolveContainerTypeOwner: (container, filePath) => {
if (!ZIG_CONTAINER_TYPES.has(container.type)) return null;
const name = zigContainerName(container, filePath);
const label = zigContainerLabel(container);
return name !== undefined && label !== undefined ? { name, label } : null;
},
labelOverride: (functionNode, defaultLabel) => {
if (defaultLabel !== 'Function') return defaultLabel;
if (isZigContainerMethod(functionNode)) return 'Method';
return defaultLabel;
},
// ── RFC #909 Ring 3: scope-based resolution hooks ──
emitScopeCaptures: emitZigScopeCaptures,
interpretImport: interpretZigImport,
// `@import` is compile-time name lookup, not an executed statement: one
// written inside a function body is resolved exactly as one at file scope
// (same answer as C `#include` and Rust `use`). Without this, a
// function-scoped `@import` would be marked `runsOnlyWhenCalled` and a
// real import cycle through it would be hidden from `check --cycles`.
importsExecuteWhereWritten: false,
interpretTypeBinding: interpretZigTypeBinding,
bindingScopeFor: zigBindingScopeFor,
receiverBinding: zigReceiverBinding,
// Provider contract is (def, callsite); the ScopeResolver contract is
// (callsite, def) — same function, adapted argument order.
arityCompatibility: (def, callsite) => zigArityCompatibility(callsite, def),
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,36 @@
export {
emitZigScopeCaptures,
isZigContainerMethod,
isZigContainerOrImportBinding,
isZigFileStruct,
isZigFileThisAlias,
isZigKeywordDeclaration,
isZigRedundantContainerCapture,
isZigTypeShadowingBinding,
zigCallableQualifiedName,
zigContainerAnchor,
zigContainerBindingName,
zigContainerLabel,
zigContainerName,
zigFileStructName,
zigImportRootOf,
zigCallReturnTypeOf,
zigReturnTypeIsNominal,
zigTypeConstructorOf,
zigUnwrapValue,
ZIG_CONTAINER_TYPES,
} from './captures.js';
export {
populateZigRangeBindings,
zigElementSpelling,
zigOptionalPayloadSpelling,
zigPointeeSpelling,
} from './range-binding.js';
export { interpretZigImport, interpretZigTypeBinding, normalizeZigTypeName } from './interpret.js';
export {
expandZigWildcardNames,
zigArityCompatibility,
zigBindingScopeFor,
zigMergeBindings,
zigReceiverBinding,
} from './simple-hooks.js';

View file

@ -0,0 +1,176 @@
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
const stripQuotes = (s: string): string => s.replace(/^["']|["']$/g, '');
/**
* The module a `@import` target names, for the `importedName` of a namespace
* import (the shared contract wants the MODULE there — Go's `import foo
* "pkg/bar"` records `bar` — not the local handle): the last path segment
* without its `.zig` extension. `"std"` → `std`, `"./net/socket.zig"` →
* `socket`, `"mylib"` (a build.zig.zon dep) → `mylib`.
*/
export function zigModuleNameOf(targetRaw: string): string {
const last = targetRaw.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? targetRaw;
return last.endsWith('.zig') ? last.slice(0, -'.zig'.length) : last;
}
/**
* `const std = @import("std");` binds the imported module to a const handle
* accessed via qualified syntax — a namespace import (closest peers: Python
* `import numpy`, Go `import "pkg/bar"`). `localName` is the handle the
* author chose, `importedName` the module (`zigModuleNameOf`).
*
* `_ = @import("x.zig");` (and any keyword-less `<ident> = @import(…)`, a
* statement rather than a declaration in this grammar) references the file
* without binding a name — the `refAllDecls` / test-aggregation idiom. That
* is a `side-effect` import: file edge, no binding (TS `import './x'`). So is
* an `@import` in any expression position (a tuple element, a call argument,
* a comparison operand — `emitZigScopeCaptures`'s `@import.inline` rule).
*
* The receiver of a member call, `@import("dump.zig").root(...)`, arrives as
* a namespace import whose `@import.name` is the builtin's own text: the
* shared namespace-receiver lookup keys on the receiver text, and that is
* how the call resolves into `dump.zig` without a `const` handle.
*/
export function interpretZigImport(captures: CaptureMatch): ParsedImport | null {
const source = captures['@import.source']?.text;
if (source === undefined) return null;
const targetRaw = stripQuotes(source);
if (targetRaw.length === 0) return null;
// `pub usingnamespace @import("x.zig");` — every pub decl of the target
// becomes a decl of this container. A wildcard, expanded by
// `expandZigWildcardNames` in the scope resolver.
if (captures['@import.wildcard'] !== undefined) {
return { kind: 'wildcard', targetRaw };
}
if (captures['@import.side-effect'] !== undefined) {
return { kind: 'side-effect', targetRaw };
}
const name = captures['@import.name']?.text;
if (name === undefined) return null;
// `const Foo = @import("x.zig").Foo;` — one member, under a name of the
// importer's choosing (a rename when it differs: `const Alloc =
// @import("std").mem;`). Same fact as TS `import { Foo } from './x'` /
// `import { Foo as Bar }`.
const imported = captures['@import.imported']?.text;
if (imported !== undefined) {
// `pub const X = …` at file scope republishes the name (Python
// `__init__.py` shape): a third file reads it as `thisModule.X`. The
// marker is set by `emitZigScopeCaptures` where the syntax node is
// still available (`isZigPublishingImport`).
const republish = captures['@import.reexports'] !== undefined ? { reexportsName: true } : {};
return imported === name
? { kind: 'named', localName: name, importedName: imported, targetRaw, ...republish }
: {
kind: 'alias',
localName: name,
importedName: imported,
alias: name,
targetRaw,
...republish,
};
}
return {
kind: 'namespace',
localName: name,
importedName: zigModuleNameOf(targetRaw),
targetRaw,
};
}
/**
* Strip Zig type sigils that wrap the nominal type: pointers (`*T`, `[*]T`),
* optionals (`?T`), error unions (`!T` / `E!T`), slices (`[]T`), arrays
* (`[N]T`), and `const` qualifiers. Keeps the bare type name so registry
* lookup matches the container declaration.
*/
export function normalizeZigTypeName(text: string): string {
let t = text.trim();
// Error union first: the payload of `E!*T` / `!?T` carries its own sigils
// (F6: `Allocator.Error!*Page` is the shape of every fallible constructor).
const bang = t.lastIndexOf('!');
if (bang !== -1) t = t.slice(bang + 1).trim();
let previous: string;
do {
previous = t;
t = t.replace(/^(\*|\?|\[\*?c?\]|\[[^\]]*\])\s*/, '');
t = t.replace(/^const\s+/, '');
} while (t !== previous);
// Generic instantiation `List(u8)` / `std.ArrayList(u8)` → the type
// constructor `List` / `std.ArrayList`: Zig spells a generic type as a
// call, and the container def is registered under the function's name.
// Builtins (`@This()`, `@TypeOf(x)`) keep their parentheses — they are
// not constructor names and must not turn into `@This`.
if (!t.startsWith('@')) {
const paren = t.indexOf('(');
if (paren > 0 && t.endsWith(')')) t = t.slice(0, paren).trim();
}
return t;
}
export function interpretZigTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
const name = captures['@type-binding.name']?.text;
const type = captures['@type-binding.type']?.text;
if (name === undefined || type === undefined) return null;
let source: TypeRef['source'] = 'annotation';
if (captures['@type-binding.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';
} else if (captures['@type-binding.call-return'] !== undefined) {
// `var c = Counter.init();` — the call's receiver names the type when it
// is a container (`Counter`, `mod.Counter`, `List(u8)`); a value receiver
// (`std.mem`, `self.items`) simply finds no container and declines.
// `const t = makeThing();` — the callee name, chained to its return
// binding by the shared resolver.
source = 'constructor-inferred';
// `const el = node.asElement();` on a fn-LOCAL receiver (F6): the type is
// the METHOD's return type, spelled as the compound `node.asElement()` the
// shared resolver walks (receiver binding → class scope → the method's
// `@type-binding.return`). Kept verbatim: `normalizeZigTypeName` would
// read the `()` as a generic instantiation and strip it.
if (captures['@type-binding.member-call-return'] !== undefined) {
return { boundName: name, rawTypeName: type.trim(), source };
}
} else if (captures['@type-binding.return'] !== undefined) {
// `fn make() !*Thing` — `make ↦ Thing`, in the enclosing scope, so a
// call site chains through it. Error unions / pointers / optionals are
// stripped by `normalizeZigTypeName` (`Allocator.Error!*Page` → `Page`).
source = 'return-annotation';
} else if (captures['@type-binding.annotation'] !== undefined) {
// `var x: T = undefined;` / `const x: T = .init(…);` — the declared type.
source = 'annotation';
} else if (captures['@type-binding.field'] !== undefined) {
// `session: *Session,` — a container field's declared type, hosted in
// the container's Class scope so `self.session.name()` walks it
// (synthesized by `emitZigScopeCaptures`, F5). A declaration, hence
// 'annotation': it must outrank nothing and be outranked by nothing —
// a field has exactly one type source.
source = 'annotation';
} else if (captures['@type-binding.alias'] !== undefined) {
// Two alias shapes share the group: `const page = self.page;` — the RHS
// member path IS the "type"; the compound resolver's member-alias branch
// re-resolves `self.page` as a receiver chain (F5) — and
// `const LocalAlias = Local;` / `const B = util.List(u8);` — the alias
// name is bound to the value's type text (`util.List` after the comptime
// arguments are dropped) and chained to the target by the shared
// `followChainedRef` (F7). Rust's `let x = y` source: it must rank
// BELOW an annotation on the same name (`const x: T = y;` is typed by
// `T`), and the default here is 'annotation'.
source = 'assignment-inferred';
}
return { boundName: name, rawTypeName: normalizeZigTypeName(type), source };
}

View file

@ -0,0 +1,432 @@
import Parser from 'tree-sitter';
import { createRequire } from 'node:module';
const _require = createRequire(import.meta.url);
/**
* Zig scope-resolution query (RFC #909 Ring 3).
*
* The grammar is an optionalDependency (`@tree-sitter-grammars/tree-sitter-zig`),
* so the language module is required lazily and `getZigParser` /
* `getZigScopeQuery` throw only when actually invoked without the grammar
* installed. That is safe: the parse pipeline filters `.zig` files through
* `parser-loader.isLanguageAvailable` before any scope extraction runs.
*
* Zig specifics encoded here:
* - Containers (struct/enum/union/opaque) are anonymous nodes bound by the
* enclosing `variable_declaration`; declarations capture the binding
* identifier from the wrapper.
* - `@import` is a builtin call, not import-statement syntax; the
* `#eq?` predicate keeps other builtins (@sizeOf, @as, …) out.
* - A plain `(variable_declaration (identifier))` rule would also match
* container and import bindings — `emitZigScopeCaptures` filters those
* groups out so a name binds exactly once.
*/
const ZIG_SCOPE_QUERY = `
;; Scopes
(source_file) @scope.module
(struct_declaration) @scope.class
(enum_declaration) @scope.class
(union_declaration) @scope.class
(opaque_declaration) @scope.class
(function_declaration) @scope.function
(test_declaration) @scope.function
(block) @scope.block
;; Declarations — functions (relabeled @declaration.method inside containers
;; by emitZigScopeCaptures, mirroring the provider's labelOverride)
(function_declaration
name: (identifier) @declaration.name) @declaration.function
;; Declarations — named tests. Same naming rule as ZIG_QUERIES: the string
;; node WITH quotes, so the def joins the graph node and never collides with
;; a same-named fn. Anonymous / decl-form tests are scopes without a def.
(test_declaration
(string) @declaration.name) @declaration.function
;; Declarations — containers. The binding name lives on the wrapper
;; variable_declaration, but the ANCHOR is the container node itself so its
;; range equals the @scope.class range: the extractor then attaches the def
;; to the class scope (walkers.populateClassOwnedMembers expects the
;; class-like def among the class scope's ownedDefs) and auto-hoists the
;; name binding to the parent scope. Keyword-gated like the ordinary
;; binding rules: a keyword-less \`x = struct {…};\` is an assignment
;; (tree-sitter-zig 1.1.2 reuses variable_declaration), not a container def.
(variable_declaration
"const" . (identifier) @declaration.name
(struct_declaration) @declaration.struct)
(variable_declaration
"var" . (identifier) @declaration.name
(struct_declaration) @declaration.struct)
(variable_declaration
"const" . (identifier) @declaration.name
(enum_declaration) @declaration.enum)
(variable_declaration
"var" . (identifier) @declaration.name
(enum_declaration) @declaration.enum)
(variable_declaration
"const" . (identifier) @declaration.name
(union_declaration) @declaration.union)
(variable_declaration
"var" . (identifier) @declaration.name
(union_declaration) @declaration.union)
;; opaque {} is a fieldless container that may own methods — Struct, as in
;; ZIG_QUERIES (see the rationale there).
(variable_declaration
"const" . (identifier) @declaration.name
(opaque_declaration) @declaration.struct)
(variable_declaration
"var" . (identifier) @declaration.name
(opaque_declaration) @declaration.struct)
;; Declarations — generic type constructors. \`fn List(comptime T: type) type
;; { return struct {…}; }\` is Zig's only spelling of a generic type; the
;; returned container is anonymous in the grammar but every reader (and every
;; caller: \`List(u8)\`) names it after the function. Anchor on the container
;; so the def sits in its own class scope; the name binding is hoisted to the
;; MODULE scope by \`zigBindingScopeFor\` (not the fn body, where the name
;; would be invisible to callers) and coexists with the Function def of the
;; same name — \`List\` really is both a callable and a type.
((function_declaration
name: (identifier) @declaration.name
type: (builtin_type) @_ret
body: (block (expression_statement (return_expression
(struct_declaration) @declaration.struct))))
(#eq? @_ret "type"))
((function_declaration
name: (identifier) @declaration.name
type: (builtin_type) @_ret
body: (block (expression_statement (return_expression
(union_declaration) @declaration.union))))
(#eq? @_ret "type"))
((function_declaration
name: (identifier) @declaration.name
type: (builtin_type) @_ret
body: (block (expression_statement (return_expression
(enum_declaration) @declaration.enum))))
(#eq? @_ret "type"))
;; Declarations — container fields (struct fields, enum/union variants).
;; The #not-eq? guard drops the MISSING placeholder identifier tree-sitter-zig
;; recovers for an empty container body (see ZIG_QUERIES). The optional
;; \`type:\` (absent on enum variants) is captured as @declaration.field-type:
;; \`emitZigScopeCaptures\` turns it into a @type-binding.field on the
;; container's Class scope so \`self.session.name()\` can walk the field's
;; type (Rust/Go parity — see the F5 block in captures.ts).
((container_field
name: (identifier) @declaration.name
type: (_)? @declaration.field-type) @declaration.field
(#not-eq? @declaration.name ""))
;; Declarations — const/var bindings (import/container groups filtered in TS).
;; The \`.\` anchor pins the FIRST named child: without it the pattern also
;; matched the initializer of \`const first = target;\`, minting a phantom
;; local named \`target\` that shadowed the real callee for every later
;; reference in the block. The literal keyword is load-bearing too:
;; tree-sitter-zig 1.1.2 parses statement assignments (\`x = 5;\`, \`x += 1;\`,
;; \`_ = expr;\`) as \`variable_declaration\` WITHOUT a keyword child, and
;; without the keyword every assignment and every discard minted a phantom
;; local (one \`_\` per statement).
(variable_declaration
"const" . (identifier) @declaration.name) @declaration.variable
(variable_declaration
"var" . (identifier) @declaration.name) @declaration.variable
;; Imports — const x = @import("...") / var x = @import("..."). Keyword-gated
;; like every binding rule: a keyword-less \`x = @import("...")\` is a
;; statement (see the side-effect rule below), not a binding.
(variable_declaration
"const" . (identifier) @import.name
(builtin_function
(builtin_identifier) @_builtin
(arguments (string) @import.source))
(#eq? @_builtin "@import")) @import.statement
(variable_declaration
"var" . (identifier) @import.name
(builtin_function
(builtin_identifier) @_builtin
(arguments (string) @import.source))
(#eq? @_builtin "@import")) @import.statement
;; Imports — const X = @import("...").X : a NAMED import of one member. The
;; local name is whatever the user chose (\`const Alloc = @import("std").mem;\`
;; is a rename), the imported name is the member. A deeper chain
;; (\`@import("std").mem.Allocator\`, \`@import("lib.zig").B.work\`) is matched
;; by the second rule below but is NOT bound as a named import of the
;; innermost member: that discarded the written owner (\`B\`) and let a
;; same-named \`A.work\` answer first. \`emitZigScopeCaptures\` binds the module
;; under the builtin's text instead and rewrites the alias's use sites to the
;; full path (\`collectZigDeepAliases\`, PR #1432 review 8.4).
(variable_declaration
"const" . (identifier) @import.name
(field_expression
object: (builtin_function
(builtin_identifier) @_builtin
(arguments (string) @import.source))
member: (identifier) @import.imported)
(#eq? @_builtin "@import")) @import.statement
(variable_declaration
"const" . (identifier) @import.name
(field_expression
object: (field_expression
object: (builtin_function
(builtin_identifier) @_builtin
(arguments (string) @import.source)))
member: (identifier) @import.imported)
(#eq? @_builtin "@import")) @import.statement
;; Imports — a keyword-less \`<ident> = @import("...");\` statement
;; (\`_ = @import("all_tests.zig");\` in a test block, the refAllDecls
;; idiom): tree-sitter-zig reuses \`variable_declaration\` for assignments, so
;; the shape is a declaration minus the keyword. It references the file
;; without binding a name — a side-effect import. Tree-sitter queries cannot
;; say "no keyword child", so this rule matches the keyword-bearing shapes
;; too; \`emitZigScopeCaptures\` keeps it only when \`isZigKeywordDeclaration\`
;; is false (the keyword shapes are the binding rules above).
(variable_declaration
. (identifier)
(builtin_function
(builtin_identifier) @_builtin
(arguments (string) @import.source))
(#eq? @_builtin "@import")) @import.side-effect
;; Aliases of a namespace member — const Counter = counter.Counter; where
;; \`counter\` is an @import binding of THIS file. The query cannot know which
;; identifiers are import bindings, so it captures every one-level member
;; alias and \`emitZigScopeCaptures\` promotes the ones whose object is a
;; known @import to a named import (same fact as \`const Counter =
;; @import("counter.zig").Counter;\`); the rest stay ordinary variables.
;; Only the ONE-level shape is promoted; a deeper chain (\`lib.B.work\`,
;; \`std.mem.Allocator\`) is a deep alias — a Const whose use sites are
;; rewritten to the written owner path (see the import rule above, 8.4).
(variable_declaration
"const" . (identifier) @alias.name
(field_expression
object: (identifier) @alias.namespace
member: (identifier) @alias.member) .) @alias.statement
(variable_declaration
"const" . (identifier) @alias.name
(field_expression
object: (field_expression
object: (identifier) @alias.namespace)
member: (identifier) @alias.member) .) @alias.statement
;; Imports — pub usingnamespace @import("..."); : every pub decl of the target
;; becomes a decl of this container (removed from the language in 0.15, still
;; everywhere in 0.11–0.14 code). Modelled as a wildcard import.
(using_namespace_declaration
(builtin_function
(builtin_identifier) @_builtin
(arguments (string) @import.source))
(#eq? @_builtin "@import")) @import.wildcard
;; Imports — \`@import("...")\` in ANY other position: a tuple element
;; (\`pub const Interfaces = .{ @import("a.zig"), @import("b.zig") }\`, the
;; JS-API registration table), a call argument (\`event.is(@import("x.zig"))\`),
;; a comparison operand (\`T == @import("x.zig").T\`), the receiver of a
;; member call (\`try @import("dump.zig").root(...)\`), a 3-deep member chain…
;; Every one of them is a file dependency; only the const/var/usingnamespace
;; shapes above bind a name. This rule matches EVERY \`@import\` builtin, the
;; bound shapes included — \`emitZigScopeCaptures\` drops the matches whose
;; string node a binding rule (or the keyword-less side-effect rule) already
;; claimed, so a bound import is never doubled, and emits the rest as
;; side-effect imports (file edge, no binding) — except the member-call
;; receiver, which becomes a namespace import keyed by its own source text so
;; the call resolves into the imported module (see the emitter).
((builtin_function
(builtin_identifier) @_builtin
(arguments (string) @import.source))
(#eq? @_builtin "@import")) @import.inline
;; Type bindings — parameter annotations (incl. self: *T receivers)
(parameter
name: (identifier) @type-binding.name
type: (_) @type-binding.type) @type-binding.parameter
;; Type bindings — constructor inference: const p = T{ ... }. Keyword-gated
;; like every binding rule: a keyword-less \`p = T{ ... };\` is a
;; re-assignment (same node type in tree-sitter-zig 1.1.2), and Zig's static
;; typing means \`p\` already carries its type from its declaration
;; (annotation, constructor or inferred value) — the assignment declares
;; nothing, and \`_ = T{ ... };\` must not bind \`_\`.
(variable_declaration
"const" . (identifier) @type-binding.name
(struct_initializer
(identifier) @type-binding.type)) @type-binding.constructor
(variable_declaration
"var" . (identifier) @type-binding.name
(struct_initializer
(identifier) @type-binding.type)) @type-binding.constructor
;; Type bindings — qualified constructor: const p = mod.T{ ... }. The whole
;; field_expression is captured so the dotted text "mod.T" survives —
;; receiver dispatch resolves the namespace prefix through the import
;; binding (emitReceiverBoundCalls Case 3).
(variable_declaration
"const" . (identifier) @type-binding.name
(struct_initializer
(field_expression) @type-binding.type)) @type-binding.constructor
(variable_declaration
"var" . (identifier) @type-binding.name
(struct_initializer
(field_expression) @type-binding.type)) @type-binding.constructor
;; Type bindings — generic instantiation literal: const l = List(u8){ ... }.
;; The callee is the type constructor; \`normalizeZigTypeName\` drops the
;; comptime argument list so \`List(u8)\` looks up \`List\`.
(variable_declaration
"const" . (identifier) @type-binding.name
(struct_initializer
(call_expression) @type-binding.type)) @type-binding.constructor
(variable_declaration
"var" . (identifier) @type-binding.name
(struct_initializer
(call_expression) @type-binding.type)) @type-binding.constructor
;; Type bindings — declared type: var x: T = …; const x: T = .init(…);
;; The annotation is the ONLY type source for \`= undefined\` and for 0.14+
;; decl literals (\`.init\`, \`.empty\`), which are the idiomatic
;; constructors in current std. Ranked below constructor inference by the
;; shared resolver (source 'annotation'), so a literal on the right still
;; wins when both are present.
(variable_declaration
. (identifier) @type-binding.name
type: (_) @type-binding.type) @type-binding.annotation
;; Type bindings — value inference (F6): const t = <value>; where <value> is a
;; call (\`Counter.init()\`, \`makeThing()\`, \`node.asElement()\`), possibly
;; wrapped in \`try\` / \`catch …\` / \`… orelse …\` / parentheses — the shape of
;; nearly every Zig constructor call (\`const p = try Page.init(…)\`). The
;; query only pins the declaration; \`emitZigScopeCaptures\` unwraps the
;; wrappers and rewrites \`@type-binding.type\` to the type source (see
;; \`zigCallReturnTypeOf\`). Keyword-gated: \`_ = e.top();\` is an assignment
;; (same node type), not a binding of \`_\`. Rust's twin is
;; \`let x = Foo::new()\` / \`let x = foo().await\`.
(variable_declaration
"const" . (identifier) @type-binding.name
(_) @type-binding.value .) @type-binding.call-return
(variable_declaration
"var" . (identifier) @type-binding.name
(_) @type-binding.value .) @type-binding.call-return
;; Type bindings — return-type annotation (F6): \`fn make() !*Thing\` binds
;; \`make ↦ Thing\` in the enclosing scope (Module for free fns, the container's
;; Class scope for methods — that is where the compound resolver reads a
;; method's return type for \`node.asElement()\`), so \`const t = makeThing()\`
;; chains to \`Thing\`. Rust: \`(function_item … return_type:) @type-binding.return\`.
;; \`emitZigScopeCaptures\` drops builtin / \`type\` returns (a \`List ↦ type\`
;; binding would hijack the \`List(u8){}\` constructor chain).
(function_declaration
name: (identifier) @type-binding.name
type: (_) @type-binding.type) @type-binding.return
;; Type bindings — aliases (F5 field-access aliases + F7 type aliases):
;; \`const page = self.page;\` (F5: the RHS path is kept verbatim as the
;; "type", the compound resolver's member-alias branch re-resolves it as a
;; receiver chain — head \`self\` → class → field type), \`const LocalAlias = Local;\`,
;; \`const Proto = HtmlElement;\`, \`const T2 = Thing;\` (alias of an alias /
;; import), \`const B = util.List(u8);\` (an INSTANTIATED generic type
;; constructor). Zig has no \`type X = Y\` syntax — a type alias is a const
;; whose value is a type expression, and it stays a Const in the graph. What
;; must change is the scope side: bind the alias NAME to the value's type
;; text (Rust's \`let x = y\` / JS's \`const B = Foo\` \`@type-binding.alias\`,
;; source 'assignment-inferred'), so \`LocalAlias.mk()\` types through Case 4,
;; \`B.init()\` / \`x: B\` / \`B{}\` through Case 3 once \`normalizeZigTypeName\`
;; drops the comptime arguments (\`util.List(u8)\` → \`util.List\`), and every
;; binding that names the alias (\`var l = LocalAlias.mk()\`, \`var x: B\`) is
;; chained to the target by the shared \`followChainedRef\` /
;; \`followChainPostFinalize\`. The identifier / member shapes take \`var\` too:
;; \`var node = orig_node;\` is the same value alias as Rust's \`let x = y\`
;; and chains to the type of \`orig_node\` (a Zig type is comptime and never
;; \`var\`, so the type-alias reading only ever applies to \`const\`). The
;; call shape is \`const\`-only and kept only when the callee's last
;; identifier is TitleCase — see \`emitZigScopeCaptures\` (a value call
;; \`const t = util.makeThing()\` belongs to the call-return rules above and
;; must not receive a competing binding). A promoted namespace-member alias
;; (\`const Counter = counter.Counter;\` → named import) is skipped there too:
;; the import binding already carries the type.
(variable_declaration
"const" . (identifier) @type-binding.name
(identifier) @type-binding.type .) @type-binding.alias
(variable_declaration
"var" . (identifier) @type-binding.name
(identifier) @type-binding.type .) @type-binding.alias
(variable_declaration
"const" . (identifier) @type-binding.name
(field_expression) @type-binding.type .) @type-binding.alias
(variable_declaration
"var" . (identifier) @type-binding.name
(field_expression) @type-binding.type .) @type-binding.alias
(variable_declaration
"const" . (identifier) @type-binding.name
(call_expression) @type-binding.type .) @type-binding.alias
;; References — free calls: foo(...)
(call_expression
function: (identifier) @reference.name) @reference.call.free
;; References — member calls: obj.method(...) / mod.fn(...)
(call_expression
function: (field_expression
object: (_) @reference.receiver
member: (identifier) @reference.name)) @reference.call.member
;; 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
;; References — generic instantiation literals: List(u8){ ... } /
;; lists.List(u8){ ... }. The type head is a call_expression — the
;; instantiation of the type constructor — which neither constructor rule
;; above matches, so the OUTER aggregate event had no site: only the inner
;; \`List(u8)\` call (a free / member call reference on the call node) reached
;; the graph (PR #1432 review, 8.11). Two sites on two anchors: the call
;; (an invocation of \`List\`) and this initializer (a construction of the
;; container \`List\` returns, marked \`(constructor)\`). The receiver form goes
;; through the same namespace path as \`mod.T{}\`.
(struct_initializer
(call_expression
function: (identifier) @reference.name)) @reference.call.constructor
(struct_initializer
(call_expression
function: (field_expression
object: (_) @reference.receiver
member: (identifier) @reference.name))) @reference.call.constructor
`;
let _parser: Parser | null = null;
let _query: Parser.Query | null = null;
function getZigLanguage(): Parameters<Parser['setLanguage']>[0] {
return _require('@tree-sitter-grammars/tree-sitter-zig');
}
export function getZigParser(): Parser {
if (_parser === null) {
_parser = new Parser();
_parser.setLanguage(getZigLanguage());
}
return _parser;
}
export function getZigScopeQuery(): Parser.Query {
if (_query === null) {
_query = new Parser.Query(getZigLanguage(), ZIG_SCOPE_QUERY);
}
return _query;
}

View file

@ -0,0 +1,375 @@
/**
* Zig payload captures (F6): `for (items) |it|`, `if (opt) |v|`,
* `while (it.next()) |x|`. The captured name has no annotation anywhere — its
* type is the SUBJECT's type minus one layer (slice/array element, optional
* payload) — so the tree-sitter query cannot bind it; this post-finalize hook
* reads the subject's binding from the finished scope model and injects the
* payload's. Rust/Go do the same (`rust/range-binding.ts`,
* `go/range-binding.ts`).
*
* Deliberately narrow and honest: a payload binds only when the subject's
* WRITTEN type visibly has the layer the construct removes — `[]T` / `[N]T` /
* `[*]T` / `*[N]T` for `for`, `?T` (after any `E!`) for `if` / `while`. A
* subject typed `std.ArrayList(T)`, `anytype`, or anything unresolved binds
* nothing rather than guessing. `catch |err|` (an error, never a container
* receiver) and `switch` prongs are skipped. The same one-layer projection
* types `const t = items[i];` / `opt.?` / `ptr.*` (`bindProjectedLocal`).
*
* Runs after `propagateImportedReturnTypes`, so return bindings hoisted from
* other files are visible when a subject is a call.
*/
import type { ParsedFile, Scope, ScopeId, TypeRef } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { getZigParser } from './query.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe, ParseTimeoutError } from '../../../tree-sitter/safe-parse.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { logger } from '../../../logger.js';
import {
findClassBindingInScope,
findReceiverTypeBinding,
isClassLike,
} from '../../scope-resolution/scope/walkers.js';
import { isZigKeywordDeclaration, zigUnwrapValue } from './captures.js';
import { normalizeZigTypeName } from './interpret.js';
type ZigTree = ReturnType<ReturnType<typeof getZigParser>['parse']>;
const PAYLOAD_HOSTS: ReadonlySet<string> = new Set([
'for_statement',
'for_expression',
'if_statement',
'if_expression',
'while_statement',
'while_expression',
]);
export function populateZigRangeBindings(
parsedFiles: readonly ParsedFile[],
indexes: ScopeResolutionIndexes,
ctx: {
readonly fileContents: ReadonlyMap<string, string>;
readonly treeCache?: { get(filePath: string): unknown };
},
): void {
const parser = getZigParser();
// Class def nodeId → its Class scope (whose typeBindings hold the members'
// field types and the methods' return types). Same derivation as
// `buildWorkspaceResolutionIndex`, which this hook does not receive.
const classScopeByDefId = new Map<string, Scope>();
for (const parsed of parsedFiles) {
for (const scope of parsed.scopes) {
if (scope.kind !== 'Class') continue;
const cd = scope.ownedDefs.find((d) => isClassLike(d.type));
if (cd !== undefined) classScopeByDefId.set(cd.nodeId, scope);
}
}
for (const parsed of parsedFiles) {
const sourceText = ctx.fileContents.get(parsed.filePath);
if (sourceText === undefined) continue;
let tree = ctx.treeCache?.get(parsed.filePath) as ZigTree | undefined;
if (tree === undefined) {
try {
tree = parseSourceSafe(parser, sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
} catch (err) {
if (err instanceof ParseTimeoutError) {
logger.warn(
{ file: parsed.filePath },
'zig range-binding: parse timed out, skipping file',
);
continue;
}
throw err;
}
}
const scopes = parsed.scopes;
if (scopes.length === 0) continue;
const resolver = new ZigSubjectTypeResolver(scopes, indexes, classScopeByDefId);
// Pre-order: an outer payload is bound before an inner construct reads it
// (`for (pages) |p| { if (p.frame) |f| … }`).
const visit = (node: SyntaxNode): void => {
if (PAYLOAD_HOSTS.has(node.type)) bindPayloads(node, resolver);
else if (node.type === 'variable_declaration') bindProjectedLocal(node, resolver);
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c !== null) visit(c);
}
};
visit(tree.rootNode);
}
}
/** Bind the payload identifiers of one `for` / `if` / `while` node. */
function bindPayloads(node: SyntaxNode, resolver: ZigSubjectTypeResolver): void {
const named = node.namedChildren.filter(
(c): c is SyntaxNode => c !== null && c.type !== 'comment',
);
// The payload right after the subject(s); a trailing `else |err|` payload
// belongs to the else_clause and is not a child here.
const payload = named.find((c) => c.type === 'payload');
if (payload === undefined) return;
const isFor = node.type === 'for_statement' || node.type === 'for_expression';
// A `for` lists its subjects BEFORE the payload; the body follows it.
const subjects: SyntaxNode[] = isFor
? named.slice(0, named.indexOf(payload)).filter((c) => c.type !== 'block_label')
: [node.childForFieldName('condition')].filter((c): c is SyntaxNode => c !== null);
if (subjects.length === 0) return;
// For a `for` the payload names pair positionally with the subjects
// (`for (items, 0..) |it, i|`); `if` / `while` capture one value.
const captured = payload.namedChildren.filter(
(c): c is SyntaxNode => c !== null && c.type === 'identifier',
);
const host = payloadHostScope(node, resolver);
if (host === undefined) return;
for (let i = 0; i < captured.length && i < subjects.length; i++) {
const name = captured[i]!.text;
if (name === '_') continue;
const subject = subjects[i]!;
if (subject.type === 'range_expression') continue; // `0..` — an index
const spelling = resolver.spellingOf(subject);
if (spelling === undefined) continue;
const projected = isFor ? zigElementSpelling(spelling) : zigOptionalPayloadSpelling(spelling);
if (projected === undefined) continue;
// `|*p|` captures a POINTER to the element/payload (the `*` is an
// anonymous payload child right before the identifier). Keep the written
// `*` so a later deref projection (`const q = p.*;`) still sees the
// layer; `rawName` strips it again, so method dispatch is unchanged.
const element = captured[i]!.previousSibling?.type === '*' ? `*${projected}` : projected;
const rawName = normalizeZigTypeName(element);
if (rawName.length === 0 || rawName.startsWith('@')) continue;
const existing = host.typeBindings.get(name);
// Zig forbids shadowing, so an existing binding of this name in the host
// can only be a sibling construct's payload (bare-expression bodies share
// the enclosing scope). Two different types for one name: decline both
// rather than let the last one type the first one's body.
if (existing !== undefined) {
if (existing.rawName !== rawName) {
(host.typeBindings as Map<string, TypeRef>).delete(name);
}
continue;
}
const ref: TypeRef =
element === rawName
? { rawName, declaredAtScope: host.id, source: 'annotation' }
: { rawName, declaredSpelling: element, declaredAtScope: host.id, source: 'annotation' };
(host.typeBindings as Map<string, TypeRef>).set(name, ref);
}
}
/** `const t = items[i];` / `const t = opt.?;` / `const t = ptr.*;` — a local
* bound to ONE LAYER under a typed subject: the element of a slice/array
* (not a `[a..b]` re-slice), the payload of an optional, the pointee of a
* pointer. Same honesty rule as the payloads: the subject's WRITTEN type
* must show the layer. Skipped when the name is already typed (an
* annotation, or a capture-time inference). */
function bindProjectedLocal(decl: SyntaxNode, resolver: ZigSubjectTypeResolver): void {
if (!isZigKeywordDeclaration(decl)) return;
const named = decl.namedChildren.filter(
(c): c is SyntaxNode => c !== null && c.type !== 'comment',
);
if (named.length < 2 || named[0]!.type !== 'identifier') return;
const value = named[named.length - 1]!;
if (value.id === decl.childForFieldName('type')?.id) return; // `const x: T;`
const inner = zigUnwrapValue(value);
let subject: SyntaxNode | null;
let project: (spelling: string) => string | undefined;
switch (inner.type) {
case 'index_expression':
if (inner.childForFieldName('index')?.type === 'range_expression') return; // `a[0..n]`
subject = inner.childForFieldName('object');
project = zigElementSpelling;
break;
case 'null_coercion_expression':
subject = inner.namedChild(0);
project = zigOptionalPayloadSpelling;
break;
case 'dereference_expression':
subject = inner.namedChild(0);
project = zigPointeeSpelling;
break;
default:
return;
}
if (subject === null) return;
const name = named[0]!.text;
const host = resolver.scopeAt(decl.startPosition.row + 1, decl.startPosition.column, false);
if (host === undefined || host.typeBindings.has(name)) return;
const spelling = resolver.spellingOf(subject);
if (spelling === undefined) return;
const projected = project(spelling);
if (projected === undefined) return;
const rawName = normalizeZigTypeName(projected);
if (rawName.length === 0 || rawName.startsWith('@')) return;
const ref: TypeRef =
projected === rawName
? { rawName, declaredAtScope: host.id, source: 'assignment-inferred' }
: {
rawName,
declaredSpelling: projected,
declaredAtScope: host.id,
source: 'assignment-inferred',
};
(host.typeBindings as Map<string, TypeRef>).set(name, ref);
}
/** `*T` / `*const T` / `E!*T` → `T`; undefined when not visibly a pointer. */
export function zigPointeeSpelling(spelling: string): string | undefined {
let t = spelling.trim();
const bang = t.lastIndexOf('!');
if (bang !== -1) t = t.slice(bang + 1).trim();
const m = /^\*(const\s+)?/.exec(t);
if (m === null) return undefined;
const rest = t.slice(m[0].length).trim();
return rest.length > 0 ? rest : undefined;
}
function body(node: SyntaxNode): SyntaxNode | null {
return node.childForFieldName('body');
}
/** The scope the payload name lives in: the body block's own scope when the
* body is a block (`|t| { … }`), otherwise the innermost scope enclosing the
* construct (a bare-expression body: `for (items) |t| t.run();`). */
function payloadHostScope(node: SyntaxNode, resolver: ZigSubjectTypeResolver): Scope | undefined {
// for/if/while_statement carry `body:`; in the *_expression forms the
// body is the named child right after the payload.
let bodyNode = body(node);
if (bodyNode === null) {
const named = node.namedChildren.filter((c): c is SyntaxNode => c !== null);
const at = named.findIndex((c) => c.type === 'payload');
bodyNode = at === -1 ? null : (named[at + 1] ?? null);
}
let block: SyntaxNode | null = bodyNode;
if (block?.type === 'block_expression')
block = block.namedChildren.find((c) => c?.type === 'block') ?? null;
if (block?.type === 'labeled_statement')
block = block.namedChildren.find((c) => c?.type === 'block') ?? null;
if (block?.type === 'block') {
const exact = resolver.scopeAt(block.startPosition.row + 1, block.startPosition.column, true);
if (exact !== undefined) return exact;
}
return resolver.scopeAt(node.startPosition.row + 1, node.startPosition.column, false);
}
/** `[]T` / `[]const T` / `[N]T` / `[*]T` / `[*:0]T` / `*[N]T` / `*const []T`
* → `T` (with `T`'s own sigils kept: `[]*Thing` → `*Thing`). Undefined when
* the written type has no slice/array layer to remove. */
export function zigElementSpelling(spelling: string): string | undefined {
let t = spelling.trim();
const bang = t.lastIndexOf('!');
if (bang !== -1) t = t.slice(bang + 1).trim();
// A pointer TO an array/slice iterates the pointee.
t = t.replace(/^\*(const\s+)?(?=\[)/, '');
const m = /^\[[^\]]*\]\s*(const\s+)?/.exec(t);
if (m === null) return undefined;
const rest = t.slice(m[0].length).trim();
return rest.length > 0 ? rest : undefined;
}
/** `?T` / `?*T` / `E!?T` → `T` (sigils of `T` kept). Undefined when the
* written type is not visibly optional. */
export function zigOptionalPayloadSpelling(spelling: string): string | undefined {
let t = spelling.trim();
const bang = t.lastIndexOf('!');
if (bang !== -1) t = t.slice(bang + 1).trim();
if (!t.startsWith('?')) return undefined;
const rest = t.slice(1).trim();
return rest.length > 0 ? rest : undefined;
}
/** Resolves a subject expression to the WRITTEN type spelling of its binding
* (`declaredSpelling` when the capture layer reduced it, else `rawName`),
* through the finished scope model of one file. */
class ZigSubjectTypeResolver {
constructor(
private readonly scopes: readonly Scope[],
private readonly indexes: ScopeResolutionIndexes,
private readonly classScopeByDefId: ReadonlyMap<string, Scope>,
) {}
/** Innermost scope containing (line, col); with `exact`, only a scope
* STARTING there (a block's own scope). */
scopeAt(line: number, col: number, exact: boolean): Scope | undefined {
let best: Scope | undefined;
for (const s of this.scopes) {
const r = s.range;
if (exact) {
if (r.startLine === line && r.startCol === col && s.kind === 'Block') return s;
continue;
}
const startsBefore = r.startLine < line || (r.startLine === line && r.startCol <= col);
const endsAfter = r.endLine > line || (r.endLine === line && r.endCol >= col);
if (!startsBefore || !endsAfter) continue;
if (best === undefined || contains(best.range, r)) best = s;
}
return best;
}
spellingOf(subject: SyntaxNode): string | undefined {
const ref = this.typeRefOf(subject);
return ref === undefined ? undefined : (ref.declaredSpelling ?? ref.rawName);
}
private typeRefOf(subjectRaw: SyntaxNode): TypeRef | undefined {
const subject = zigUnwrapValue(subjectRaw);
const scope = this.scopeAt(subject.startPosition.row + 1, subject.startPosition.column, false);
if (scope === undefined) return undefined;
switch (subject.type) {
case 'identifier':
return findReceiverTypeBinding(scope.id, subject.text, this.indexes);
case 'field_expression': {
// `self.items` / `page.session` — the member's binding on the
// object's class scope (field type, or a method's return type).
const object = subject.childForFieldName('object');
const member = subject.childForFieldName('member');
if (object === null || member === null) return undefined;
return this.memberRefOf(object, member.text, scope.id);
}
case 'call_expression': {
const callee = subject.childForFieldName('function');
if (callee === null) return undefined;
if (callee.type === 'identifier') {
// A free call: the fn's return binding, in the scope chain.
return findReceiverTypeBinding(scope.id, callee.text, this.indexes);
}
if (callee.type !== 'field_expression') return undefined;
const object = callee.childForFieldName('object');
const member = callee.childForFieldName('member');
if (object === null || member === null) return undefined;
return this.memberRefOf(object, member.text, scope.id);
}
default:
return undefined;
}
}
/** The binding of `member` on the class the `object` expression is typed
* by — recursively through field chains (`self.a.b`). */
private memberRefOf(object: SyntaxNode, member: string, scopeId: ScopeId): TypeRef | undefined {
const objectRef = this.typeRefOf(object);
if (objectRef === undefined) return undefined;
// A compound rawName (`node.asElement()`, F6's member-call shape) needs
// the shared compound resolver; not walked here.
if (objectRef.rawName.includes('(')) return undefined;
const classDef = findClassBindingInScope(scopeId, objectRef.rawName, this.indexes);
if (classDef === undefined) return undefined;
const classScope = this.classScopeByDefId.get(classDef.nodeId);
return classScope?.typeBindings.get(member);
}
}
function contains(outer: Scope['range'], inner: Scope['range']): boolean {
const startsBefore =
outer.startLine < inner.startLine ||
(outer.startLine === inner.startLine && outer.startCol <= inner.startCol);
const endsAfter =
outer.endLine > inner.endLine ||
(outer.endLine === inner.endLine && outer.endCol >= inner.endCol);
return startsBefore && endsAfter;
}

View file

@ -0,0 +1,79 @@
/**
* Zig `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by the
* generic `runScopeResolution` orchestrator.
*
* Thin wiring: Zig has no inheritance (default MRO linearization over an
* empty heritage set), no `super`, and is statically typed (field-fallback
* heuristic off per the contract guidance). Import resolution reuses the
* same `resolveZigImportInternal` the legacy import-resolver config wraps,
* with `build.zig.zon` `.path` deps threaded through `loadResolutionConfig`.
*/
import type { ParsedFile } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { loadZigBuildConfig, type ZigBuildZonConfig } from '../../language-config.js';
import { resolveZigImportInternal } from '../../import-resolvers/zig.js';
import { zigProvider } from '../zig.js';
import { expandZigWildcardNames, zigArityCompatibility, zigMergeBindings } from './index.js';
import { populateZigRangeBindings } from './range-binding.js';
export const zigScopeResolver: ScopeResolver = {
language: SupportedLanguages.Zig,
languageProvider: zigProvider,
importEdgeReason: 'zig-scope: import',
// A struct literal `T{ .f = x }` is a CALLS edge to the Struct node (the
// Rust `T { .. }` / Go `T{}` shape). Zig has no Constructor nodes, so
// nothing but this marker tells that edge from an invocation on the edge
// 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,
// A qualified receiver is a chain of `const` handles — hub modules
// republishing modules (`hub.sub.Thing{}`), types nested in types
// (`mod.Outer.Inner{}`), enum variants through the module
// (`opmod.Op.lookup.event_max()`) — walked hop by hop from the verified
// import; a one-hop split at the last dot resolved none of them.
resolveNamespaceChains: true,
loadResolutionConfig: (repoPath: string) => loadZigBuildConfig(repoPath),
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) =>
resolveZigImportInternal(
fromFile,
targetRaw,
allFilePaths,
(resolutionConfig as ZigBuildZonConfig | null | undefined) ?? null,
),
// `pub usingnamespace @import("x.zig");` — target decls become local decls.
expandsWildcardTo: (targetModuleScope, parsedFiles) =>
expandZigWildcardNames(targetModuleScope, parsedFiles),
mergeBindings: zigMergeBindings,
arityCompatibility: zigArityCompatibility,
buildMro: (graph, parsedFiles, nodeLookup) =>
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
// Payload captures — `for (items) |it|`, `if (opt) |v|`, `while (it.next())
// |x|` — typed from the subject's binding after finalize (F6).
populateRangeBindings: populateZigRangeBindings,
// Zig has no `super`.
isSuperReceiver: () => false,
// Statically typed — the field-fallback heuristic over-connects.
fieldFallbackOnMethodLookup: false,
};

View file

@ -0,0 +1,164 @@
import type {
BindingRef,
Callsite,
CaptureMatch,
ParsedFile,
Scope,
ScopeId,
ScopeTree,
SymbolDefinition,
TypeRef,
} from 'gitnexus-shared';
/** Keep parameter (incl. `self`) typeBindings in the function scope —
* hoisting them to Module would pollute other functions' receiver
* resolution (same rationale as `goBindingScopeFor`). */
export function zigBindingScopeFor(
decl: CaptureMatch,
innermost: Scope,
tree: ScopeTree,
): ScopeId | null {
if (decl['@type-binding.parameter'] !== undefined) {
return innermost.id;
}
// A container returned by a generic type constructor (`fn List(comptime T:
// type) type { return struct {…}; }`) is anchored on the container node,
// whose innermost enclosing scope is the FUNCTION BODY. Its name is only
// useful to callers (`List(u8){}`, `List(u8).init()`), so bind it in the
// module scope beside the Function def of the same name — `List` really is
// both a callable and a type. `zigTypeConstructorOf` in the walker decides
// WHICH containers get this rule; the marker capture carries the verdict.
if (decl['@declaration.type-constructor'] !== undefined) {
let scope: Scope | undefined = innermost;
while (scope !== undefined && scope.kind !== 'Module') scope = tree.getParent(scope.id);
return scope?.id ?? null;
}
// File-struct members: the file's Class scope spans the whole file (same
// range as the Module scope, nested under it). Their DEFS stay owned by the
// Class scope (that is what makes them methods/fields of the file's Struct),
// but their NAMES must stay visible at module level — `Page.init()` from
// another file is a namespace-member lookup over the module scope's
// bindings, and `usingnamespace` expansion reads the same. Hoist every
// declaration binding hosted by an equal-range Class scope to its Module
// parent. Type bindings (`@type-binding.*`, incl. field types) are NOT
// hoisted: the compound resolver reads member types from the class scope.
const anchor = ZIG_DECLARATION_ANCHORS.map((k) => decl[k]).find((c) => c !== undefined);
if (anchor !== undefined) {
// Reproduce the extractor's auto-hoist (a scope-creating declaration is
// bound in the scope OUTSIDE its own body), then step past the file's
// Class scope when that is where the name would land.
let host: Scope | undefined = innermost;
if (host.parent !== null && sameRange(anchor.range, host.range)) {
host = tree.getParent(host.id);
}
if (host !== undefined && host.kind === 'Class' && host.parent !== null) {
const parent = tree.getParent(host.id);
if (parent !== undefined && parent.kind === 'Module' && sameRange(parent.range, host.range)) {
return parent.id;
}
}
}
return null; // default auto-hoist for other bindings
}
const ZIG_DECLARATION_ANCHORS = [
'@declaration.function',
'@declaration.method',
'@declaration.struct',
'@declaration.enum',
'@declaration.union',
'@declaration.field',
'@declaration.variable',
] as const;
function sameRange(a: Scope['range'], b: Scope['range']): boolean {
return (
a.startLine === b.startLine &&
a.startCol === b.startCol &&
a.endLine === b.endLine &&
a.endCol === b.endCol
);
}
/** `pub usingnamespace @import("x.zig");` — every declaration of the target
* module becomes a declaration of the importer. Enumerates the target's
* module-level names for the finalize wildcard expansion (Dart pattern).
* Zig visibility (`pub`) is not recorded on scope-side defs, so this
* over-approximates to every top-level name; the structure phase's
* `isExported` is the authoritative visibility. */
export function expandZigWildcardNames(
targetModuleScope: ScopeId,
parsedFiles: readonly ParsedFile[],
): readonly string[] {
const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope);
if (target === undefined) return [];
const seen = new Set<string>();
const names: string[] = [];
for (const def of target.localDefs) {
const qn = def.qualifiedName;
if (qn === undefined || qn.length === 0 || qn.includes('.')) continue; // top-level only
if (seen.has(qn)) continue;
seen.add(qn);
names.push(qn);
}
return names;
}
/** Zig's receiver convention is a FIRST parameter named `self`; the
* `self`-sourced typeBinding on the function scope carries its type.
* Position is enforced upstream, not here: `interpretZigTypeBinding` only
* sources a binding as `self` when `emitZigScopeCaptures` tagged it
* `@type-binding.first-parameter`, so a later parameter named `self`
* arrives as `parameter-annotation` and is never returned by this hook. */
export function zigReceiverBinding(functionScope: Scope): TypeRef | null {
if (functionScope.kind !== 'Function') return null;
for (const binding of functionScope.typeBindings.values()) {
if (binding.source === 'self') return binding;
}
return null;
}
const TIER: Record<BindingRef['origin'], number> = {
local: 0,
namespace: 1,
import: 2,
reexport: 3,
wildcard: 4,
};
/** Local declarations shadow imports; deterministic order within a tier. */
export function zigMergeBindings(
existing: readonly BindingRef[],
incoming: readonly BindingRef[],
_scopeId: string,
): BindingRef[] {
const all = [...existing, ...incoming];
if (all.length === 0) return [];
let bestTier = 99;
for (const b of all) {
const t = TIER[b.origin] ?? 99;
if (t < bestTier) bestTier = t;
}
const byNode = new Map<string, BindingRef>();
for (const b of all) {
if ((TIER[b.origin] ?? 99) !== bestTier) continue;
if (!byNode.has(b.def.nodeId)) byNode.set(b.def.nodeId, b);
}
return [...byNode.values()].sort((a, b) => a.def.nodeId.localeCompare(b.def.nodeId));
}
/** Zig has no overloading; without synthesized arity metadata on
* declarations the comparison is always 'unknown' — kept as a real
* bounds check so it turns on if arity captures are added later. */
export function zigArityCompatibility(
callsite: Callsite,
def: SymbolDefinition,
): 'compatible' | 'unknown' | 'incompatible' {
const max = def.parameterCount;
const min = def.requiredParameterCount;
if (max === undefined && min === undefined) return 'unknown';
if (!Number.isFinite(callsite.arity) || callsite.arity < 0) return 'unknown';
if (min !== undefined && callsite.arity < min) return 'incompatible';
if (max !== undefined && callsite.arity > max) return 'incompatible';
return 'compatible';
}

View file

@ -0,0 +1,141 @@
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,
zigReceiverParameter,
} from '../../languages/zig/captures.js';
/**
* Zig method extraction.
*
* tree-sitter-zig containers (struct/enum/union/opaque) are anonymous; the
* binding name lives on the parent variable_declaration, or on the enclosing
* generic type constructor (`fn List(comptime T: type) type { return struct
* {…}; }`) — `zigContainerName` decides. Methods inside a container appear as
* plain `function_declaration` children of the container node.
*
* 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 =>
zigContainerName(node, filePath);
const extractZigName = (node: SyntaxNode): string | undefined => {
const nameNode = node.childForFieldName('name');
return nameNode?.text;
};
/**
* The `parameters` node of a function_declaration. tree-sitter-zig 1.1.2
* attaches it as a plain named child — NOT under a `parameters:` field (only
* `name`, `type` and `body` are fields), so a field lookup is always null
* (and the grammar-literal gate flags it as a dead field). Reading it that
* way silently produced empty parameter lists, no receiver, and
* `isStatic: true` for every method.
*/
const zigParameterList = (node: SyntaxNode): SyntaxNode | null =>
node.namedChildren.find((child): child is SyntaxNode => child?.type === 'parameters') ?? null;
const extractZigReturnType = (node: SyntaxNode): string | undefined => {
// tree-sitter-zig labels the return type as the `type` field on
// function_declaration (the same field name used for parameter types).
const typeNode = node.childForFieldName('type');
return typeNode?.text?.trim();
};
/**
* 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`). `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, filePath?: string): ParameterInfo[] => {
const paramList = zigParameterList(node);
if (!paramList) return [];
const params: ParameterInfo[] = [];
const receiver = zigReceiverParameter(node, filePath);
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');
params.push({
name: nameNode?.text ?? '?',
type: typeNode?.text?.trim() ?? null,
rawType: typeNode?.text?.trim() ?? null,
isOptional: false,
isVariadic: false,
});
}
return params;
};
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
* `findEnclosingFunctionId`) with the SAME spelling ZIG_QUERIES captures as
* `@name` — the string node with its quotes — so calls inside `test "x" {}`
* attribute to the test's own Function node.
*
* Anonymous `test {}` and decl-tests `test add {}` are not graph nodes. They
* return `''`, not `null`: `null` falls through to `genericFuncName`, whose
* first-identifier scan would name `test add {}` "add" — the REAL `fn add`'s
* id — and hang the test body's calls on it. The empty name is falsy, so
* `findEnclosingFunctionId` skips this node WITHOUT attributing to it and
* keeps walking up; a test block can only sit at container level, so the walk
* reaches the file and the calls attribute to the File.
*/
const extractZigFunctionName = (
node: SyntaxNode,
): { funcName: string | null; label: 'Function' } | null => {
if (node.type !== 'test_declaration') return null;
const nameString = node.namedChildren.find(
(child): child is SyntaxNode => child?.type === 'string',
);
return { funcName: nameString?.text ?? '', label: 'Function' };
};
export const zigMethodConfig: MethodExtractionConfig = {
language: SupportedLanguages.Zig,
// `source_file`: a file-struct's top-level fns are methods of the file's Struct.
typeDeclarationNodes: [...ZIG_CONTAINER_TYPES, 'source_file'],
methodNodeTypes: ['function_declaration'],
bodyNodeTypes: [],
extractOwnerName: extractZigOwnerName,
extractName: extractZigName,
extractFunctionName: extractZigFunctionName,
extractReturnType: extractZigReturnType,
extractParameters: extractZigParameters,
// `pub` only: `export fn` is C linkage, still private to other Zig files
// (isExported carries the FFI fact — see export-detection.ts).
extractVisibility: (node) => (hasZigPubKeyword(node) ? 'public' : 'private'),
extractReceiverType: extractZigReceiverType,
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()`, `ledger: *Ledger` in `Ledger.zig`); see
// `zigReceiverParameter`.
return zigReceiverParameter(node, filePath) === null;
},
isAbstract() {
return false;
},
isFinal() {
return false;
},
};

View file

@ -90,7 +90,7 @@ export function createMethodExtractor(config: MethodExtractionConfig): MethodExt
// Resolve owner name: config hook → field-based → type_identifier → simple_identifier → "Companion"
let ownerName: string | undefined;
if (config.extractOwnerName) {
ownerName = config.extractOwnerName(node);
ownerName = config.extractOwnerName(node, context.filePath);
}
if (!ownerName) {
const nameField = node.childForFieldName('name');
@ -166,6 +166,17 @@ function findBodies(node: SyntaxNode, bodyNodeSet: Set<string>): SyntaxNode[] {
result.push(bodyField);
addNestedBodies(bodyField, bodyNodeSet, result);
}
// 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);
}
return result;
}
@ -236,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;
@ -107,7 +113,7 @@ export interface MethodExtractionConfig {
* When the ownerNode matches one of these types, isStatic is forced true. */
staticOwnerTypes?: ReadonlySet<string>;
/** Resolve the owner name from a standalone method node (e.g. Go receiver type). */
extractOwnerName?: (node: SyntaxNode) => string | undefined;
extractOwnerName?: (node: SyntaxNode, filePath?: string) => string | undefined;
/** Extract a primary constructor from the owner node itself (e.g. C# 12 class Point(int x, int y)). */
extractPrimaryConstructor?: (
ownerNode: SyntaxNode,

View file

@ -322,6 +322,10 @@ function buildReference(site: ReferenceSite, top: Resolution): Reference {
toDef: top.def.nodeId,
atRange: site.atRange,
kind: site.kind,
// The call form survives resolution so the graph bridge can mark
// construction sites (`callForm: 'constructor'`) on the CALLS edge it
// emits — a `Reference` otherwise keeps only the resolved def.
...(site.kind === 'call' && site.callForm !== undefined ? { callForm: site.callForm } : {}),
confidence: top.confidence,
evidence: top.evidence,
};

View file

@ -878,6 +878,71 @@ export interface ScopeResolver {
*/
readonly constructorCallTargetsClass?: boolean;
/**
* When true, the CALLS edge emitted for a constructor-form site
* (`callForm === 'constructor'`) carries ` (constructor)` appended to its
* `reason` — `local-call (constructor)`, `import-resolved (constructor)`,
* `scope-resolution: call (constructor)` — so a consumer can tell
* "constructs an instance of" apart from "invokes" on the edge alone.
*
* Opt-in because the unsuffixed strings are a pinned contract: the legacy
* DAG vocabulary (`'import-resolved' | 'local-call' | …`, see the
* same-graph guarantee above) is asserted verbatim by consumers and by the
* per-language resolver suites, constructor sites included. A language
* that links a construction site to the TYPE node itself — a struct
* literal `T{…}` in Zig, where nothing but the marker distinguishes the
* edge from an invocation in the schema (PR #1432 review) — opts in; the
* default leaves every existing edge byte-identical.
*
* The marker rides in `reason` because relationships carry no arbitrary
* properties (adding one moves SCHEMA_FINGERPRINT — the IMPLEMENTS
* `-pointer` precedent in `pipeline/run.ts`). Applies to the free-call
* fallback, the reference bridge and the receiver-bound paths — a
* namespace-qualified literal (`mod.T{…}`, Case 1), a type nested in the
* receiver's class (`A.Item{}`, Case 2) and a dotted type binding (Case 3)
* all go through `constructionSiteReason` — so an opted-in provider sees
* one vocabulary whichever path resolved the site.
*/
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;
/**
* When true, a qualified receiver is walked SEGMENT BY SEGMENT from its
* verified namespace root instead of being split once at the last dot:
* `hub.sub.Thing{}` (a namespace republished by a hub — `pub const sub =
* @import("sub.zig");`), `mod.Outer.Inner{}` (a type nested in a type),
* `opmod.Op.lookup` (an enum variant reached through the module), and the
* typed forms `x: mod.Outer.Inner`. Each hop is either a class-like member
* of the current module(s) / the current class, or a namespace import
* edge the current module's scope binds under that name; a hop that is
* ambiguous — two files behind one handle disagree, or a name is both a
* type and a republished module — resolves nothing rather than picking a
* first match. Off, the receiver-bound paths (Case 1, Case 2's
* namespace-qualified class, Case 3) keep their one-hop lookups exactly
* as they are, so no existing edge moves; Zig opts in (PR #1432 review,
* 8.10), whose module system is nothing but nested `const` handles.
*/
readonly resolveNamespaceChains?: boolean;
/**
* How this language spells a construction expression, so the compound
* receiver resolver can type an INLINE constructor receiver — the

View file

@ -288,6 +288,15 @@ export const LINKABLE_LABELS: ReadonlySet<NodeLabel> = new Set<NodeLabel>([
// are unreachable and label-agnostic fallback can alias it to a same-named
// Constructor or Method (#2801).
'Record',
// Union is linkable because Zig wires `union` / `union(enum)` as a member
// container: the definition phase emits HAS_METHOD / HAS_PROPERTY edges FROM
// the Union node (`union_declaration` in MEMBER_OWNER_NODE_TYPES) and the
// scope side dispatches methods on union receivers (`main → isEnergy` in
// test/integration/resolvers/zig.test.ts). Without this entry the schema
// never declares a `FROM Union` pair and `analyze` aborts on the first Zig
// repo that declares a union (reproduced on the zig-basic fixture itself).
// Also lets `Tag{ .energy = 5 }` constructor references bridge to the node.
'Union',
// Trait nodes are linkable so MRO builders can bridge PHP/Rust trait
// defs between scope-resolution DefIds and the graph's node ids.
// IMPLEMENTS edges from classes to traits are otherwise invisible to
@ -303,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, Record, Union, 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

@ -6,7 +6,7 @@
* chain looking for an enclosing Function/Method/Class.
* 2. Resolve `toDef` → target graph-node id via `nodeLookup`.
* 3. Emit the edge (`CALLS` / `READS` / `WRITES` / `EXTENDS` / `USES`)
* with the standard reason format.
* with the standard reason format (`referenceEdgeReason`).
*
* Skips (without throwing) when either side fails to map — either side
* may legitimately not exist as a graph node (e.g. a resolved target
@ -36,6 +36,36 @@ import { isValueDefinitionLabel } from '../../utils/ast-helpers.js';
*/
type ReferenceSiteSkipSet = ReadonlySet<string>;
export interface EmitReferencesOptions {
/** When true, a constructor-form call site's edge gets ` (constructor)`
* appended to its reason. See `ScopeResolver.markConstructionSites`. */
readonly markConstructionSites?: boolean;
}
/**
* `reason` of the edge a resolved reference emits: `scope-resolution: <kind>`,
* plus ` (constructor)` for a construction site when the provider opted in —
* a struct literal (`T{…}` in Zig, `T { .. }` in Rust, `T{}` in Go) or a
* `new T()` resolves to the type (or its constructor) as a CALLS edge exactly
* like an invocation does, and nothing else on the edge tells the two apart
* (PR #1432 review).
*
* The marker rides in `reason` because relationships carry no arbitrary
* properties — adding one would change the relation DDL and move
* SCHEMA_FINGERPRINT (see the IMPLEMENTS `-pointer` precedent in
* `pipeline/run.ts`). The plain `scope-resolution: call` prefix is kept, so a
* consumer matching the prefix still sees every call; one matching the exact
* string sees invocations only.
*/
export function referenceEdgeReason(
ref: Pick<Reference, 'kind' | 'callForm'>,
markConstructionSites: boolean | undefined,
): string {
return markConstructionSites === true && ref.kind === 'call' && ref.callForm === 'constructor'
? 'scope-resolution: call (constructor)'
: `scope-resolution: ${ref.kind}`;
}
/**
* Value labels whose defs MAY be function-local. A reference to one of these is
* dropped only when the def is positively identified as living inside a function
@ -76,6 +106,7 @@ export function emitReferencesViaLookup(
* Optional so callers that never capture bare identifiers are unchanged.
*/
functionLocalValueDefIds?: ReadonlySet<string>,
options?: EmitReferencesOptions,
): { emitted: number; skipped: number } {
let emitted = 0;
let skipped = 0;
@ -153,7 +184,7 @@ export function emitReferencesViaLookup(
targetId: targetGraphId,
type: edgeType,
confidence: ref.confidence,
reason: `scope-resolution: ${ref.kind}`,
reason: referenceEdgeReason(ref, options?.markConstructionSites),
});
emitted++;
}

View file

@ -38,6 +38,7 @@ import {
findEnclosingClassDef,
findExportedDef,
findExportedDefByName,
findExportedDefIncludingImportedNames,
findReceiverTypeBinding,
isClassLike,
isNamespaceNameShadowed,
@ -128,6 +129,20 @@ 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;
/** Resolve a qualified CLASS name (`opmod.Op`, `hub.sub.Thing`,
* `mod.Outer.Inner`) through the language's namespace chain walk
* (`ScopeResolver.resolveNamespaceChains`). Seeds the dotted-chain walk
* when its head is a namespace rather than a value: `opmod.Op.lookup` is
* the enum `Op` reached through the module `opmod`, then its variant
* `lookup` — a value of `Op` — and only then a method (PR #1432 review,
* 8.10). Absent ⇒ the head must bind in scope, exactly as before. */
readonly resolveQualifiedClass?: (
qualifiedName: string,
inScope: ScopeId,
) => SymbolDefinition | undefined;
/** 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 +256,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;
}
@ -1179,8 +1198,30 @@ export function resolveCompoundReceiverClass(
options,
);
}
// Namespace-qualified chain head — `opmod.Op.lookup` / `hub.sub.Thing.x`:
// no binding and no class named `opmod`, but the LONGEST prefix the
// language's chain walk accepts as a class seeds the walk (the class
// itself, so a variant / static member hop is read off the class scope),
// and the remaining segments are walked as members. Longest first: the
// prefix is a class, not a value, and `a.B.C` must seed at `C`, not stop
// at `B` and read `C` as a member of it. The whole receiver may be the
// class (`opmod.Op` — no member segment left), exactly as a bare class
// name head resolves to the class constant above.
let firstHop = 1;
if (currentClass === undefined && headType === undefined && options.resolveQualifiedClass) {
for (let k = parts.length; k >= 2; k--) {
const prefix = parts.slice(0, k).join('.');
if (prefix.includes('(')) continue;
const seeded = options.resolveQualifiedClass(prefix, inScope);
if (seeded === undefined) continue;
currentClass = seeded;
currentIsClassConstant = true;
firstHop = k;
break;
}
}
for (let i = 1; i < parts.length && currentClass !== undefined; i++) {
for (let i = firstHop; i < parts.length && currentClass !== undefined; i++) {
const segment = parts[i];
if (segment === undefined) break;
const memberName = stripCallParens(segment);

View file

@ -22,6 +22,7 @@ import type {
ParameterTypeClass,
ParsedFile,
Reference,
ReferenceSite,
ScopeId,
SymbolDefinition,
} from 'gitnexus-shared';
@ -66,6 +67,9 @@ export function emitFreeCallFallback(
/** When true, `Type(...)` constructor calls link to the Class def
* itself rather than its explicit Constructor. Swift opts in. */
readonly constructorCallTargetsClass?: boolean;
/** When true, a constructor-form site's edge gets ` (constructor)`
* appended to its reason. See `ScopeResolver.markConstructionSites`. */
readonly markConstructionSites?: boolean;
readonly isFileLocalDef?: (def: SymbolDefinition) => boolean;
readonly isCallableVisibleFromCaller?: (ctx: {
readonly callerParsed: ParsedFile;
@ -621,8 +625,13 @@ export function emitFreeCallFallback(
type: 'CALLS',
confidence: 0.85,
// Match legacy DAG's reason convention so consumers that
// assert `reason === 'import-resolved'` keep working.
reason: fnDef.filePath !== parsed.filePath ? 'import-resolved' : 'local-call',
// assert `reason === 'import-resolved'` keep working. The
// construction-site marker is opt-in for the same reason.
reason: constructionSiteReason(
fnDef.filePath !== parsed.filePath ? 'import-resolved' : 'local-call',
site,
options.markConstructionSites,
),
});
emitted++;
}
@ -630,6 +639,19 @@ export function emitFreeCallFallback(
return emitted;
}
/** `reason` of a free-call edge: the legacy string, plus ` (constructor)` for
* a construction site when the provider opted in
* (`ScopeResolver.markConstructionSites`). */
export function constructionSiteReason(
base: string,
site: Pick<ReferenceSite, 'callForm'>,
markConstructionSites: boolean | undefined,
): string {
return markConstructionSites === true && site.callForm === 'constructor'
? `${base} (constructor)`
: base;
}
function siteKey(
filePath: string,
site: { readonly atRange: { readonly startLine: number; readonly startCol: number } },

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,
@ -86,6 +87,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,
@ -115,6 +117,53 @@ import type { DecodedReceiverChain } from '../../utils/receiver-chain-codec.js';
/** Subset of `ScopeResolver` consumed by this pass. Accepting the
* subset rather than the full provider keeps tests and partial
* refactors lighter — callers only need to populate what we read. */
/** Split `text` at the dots that sit at nesting depth 0 and outside string
* literals — `@import("a.zig").Outer.Inner` → three segments, not four;
* `List(u8).Node` → two. The chain walk's segmenter. */
function splitTopLevelDots(text: string): string[] {
const out: string[] = [];
let depth = 0;
let inString = false;
let start = 0;
for (let i = 0; i < text.length; i++) {
const ch = text[i]!;
if (inString) {
if (ch === '\\') i++;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === '(' || ch === '[' || ch === '<') depth++;
else if (ch === ')' || ch === ']' || ch === '>') depth--;
else if (ch === '.' && depth === 0) {
out.push(text.slice(start, i));
start = i + 1;
}
}
out.push(text.slice(start));
return out.filter((s) => s.length > 0);
}
/** Index of the last depth-0, outside-string dot of `text`, or -1. */
function lastTopLevelDot(text: string): number {
let depth = 0;
let inString = false;
let last = -1;
for (let i = 0; i < text.length; i++) {
const ch = text[i]!;
if (inString) {
if (ch === '\\') i++;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === '(' || ch === '[' || ch === '<') depth++;
else if (ch === ')' || ch === ']' || ch === '>') depth--;
else if (ch === '.' && depth === 0) last = i;
}
return last;
}
type ReceiverBoundProviderSubset = Pick<
ScopeResolver,
| 'isSuperReceiver'
@ -135,6 +184,9 @@ type ReceiverBoundProviderSubset = Pick<
| 'constraintCompatibility'
| 'isStaticOnly'
| 'normalizeTypeArgument'
| 'markConstructionSites'
| 'namespaceExportsIncludeImportedNames'
| 'resolveNamespaceChains'
>;
/** A bare, undecorated identifier and nothing else — see {@link isBareTypeName}. */
@ -329,9 +381,145 @@ 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);
// A class-like member `name` unique across `files`, or nothing — two
// same-named classes behind one handle would mint a confident wrong edge.
const uniqueClassAcross = (
files: readonly string[],
name: string,
): SymbolDefinition | undefined => {
let picked: SymbolDefinition | undefined;
for (const file of files) {
const def = lookupNamespaceMember(file, name);
if (def === undefined || !isClassLike(def.type)) continue;
if (picked !== undefined && picked.nodeId !== def.nodeId) return undefined;
picked = def;
}
return picked;
};
// A class-like def NESTED in `owner` (`A.Item` inside `A`): its qualified
// name is the owner's plus the segment — the identity the structure phase
// and `populateClassOwnedMembers` agree on — so the qualified-name index
// answers directly; same file as the owner, unique or nothing. Only the
// chain walk reads this: `findOwnedMember` knows methods and fields, and a
// nested type is neither.
const findNestedClass = (owner: SymbolDefinition, name: string): SymbolDefinition | undefined => {
if (owner.qualifiedName === undefined || owner.qualifiedName.length === 0) return undefined;
let picked: SymbolDefinition | undefined;
for (const id of scopes.qualifiedNames.get(`${owner.qualifiedName}.${name}`)) {
const def = scopes.defs.get(id);
if (def === undefined || !isClassLike(def.type) || def.filePath !== owner.filePath) continue;
if (picked !== undefined && picked.nodeId !== def.nodeId) return undefined;
picked = def;
}
return picked;
};
// Namespace CHAIN walk (`ScopeResolver.resolveNamespaceChains`): resolve
// every segment of a qualified prefix from its verified namespace root —
// or, failing a namespace, from a class binding in scope (`Outer.Inner`).
// The cursor is either "these module files" or "this class"; a hop from a
// module is a class-like member of it (→ class) or a namespace-import edge
// its module scope binds under the segment — a republished module,
// `pub const sub = @import("sub.zig");` (→ files); a hop from a class is a
// nested class-like. Anything ambiguous resolves nothing.
const walkChains = provider.resolveNamespaceChains === true;
const namespaceImportTargetsOf = (file: string, name: string): readonly string[] => {
const moduleScope = index.moduleScopeByFile.get(file);
if (moduleScope === undefined) return [];
const out: string[] = [];
for (const edge of scopes.imports.get(moduleScope.id) ?? []) {
if (edge.kind !== 'namespace' || edge.localName !== name || edge.targetFile === null)
continue;
if (!out.includes(edge.targetFile)) out.push(edge.targetFile);
}
return out;
};
type ChainCursor =
| { readonly files: readonly string[] }
| { readonly classDef: SymbolDefinition };
const resolveNamespaceChain = (
prefix: string,
inScope: ScopeId,
namespaceTargets: ReadonlyMap<string, readonly string[]>,
): ChainCursor | undefined => {
const segments = splitTopLevelDots(prefix);
if (segments.length === 0) return undefined;
let cursor: ChainCursor | undefined;
let rest: readonly string[] = [];
// The LONGEST namespace key wins: a provider may bind dotted handles
// (`namespaceReceiverPaths`) and an inline `@import("x.zig")` handle
// carries a dot of its own inside the quotes.
for (let k = segments.length; k >= 1; k--) {
const key = segments.slice(0, k).join('.');
const files = namespaceTargets.get(key);
if (files === undefined) continue;
if (isNamespaceNameShadowed(key, inScope, scopes)) return undefined;
cursor = { files };
rest = segments.slice(k);
break;
}
if (cursor === undefined) {
const head = findClassBindingInScope(inScope, segments[0]!, scopes);
if (head === undefined || !isClassLike(head.type)) return undefined;
cursor = { classDef: head };
rest = segments.slice(1);
}
for (const segment of rest) {
if (segment.includes('(') || segment.includes('[')) return undefined;
if ('files' in cursor) {
const asClass = uniqueClassAcross(cursor.files, segment);
const asModule: string[] = [];
for (const file of cursor.files) {
for (const target of namespaceImportTargetsOf(file, segment)) {
if (!asModule.includes(target)) asModule.push(target);
}
}
if (asClass !== undefined && asModule.length > 0) return undefined; // both — refuse
if (asClass !== undefined) cursor = { classDef: asClass };
else if (asModule.length > 0) cursor = { files: asModule };
else return undefined;
} else {
const nested = findNestedClass(cursor.classDef, segment);
if (nested === undefined) return undefined;
cursor = { classDef: nested };
}
}
return cursor;
};
// `ns.Type` as a receiver, where `ns` is a verified namespace of the current
// file and `Type` a class-like member of it — or, with the chain walk, any
// `a.b.c.Type` whose prefix resolves. Unique or nothing.
const resolveNamespaceQualifiedClass = (
receiverName: string,
inScope: ScopeId,
namespaceTargets: ReadonlyMap<string, readonly string[]>,
): SymbolDefinition | undefined => {
const dot = walkChains ? lastTopLevelDot(receiverName) : 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;
if (walkChains) {
const cursor = resolveNamespaceChain(head, inScope, namespaceTargets);
if (cursor === undefined) return undefined;
return 'classDef' in cursor
? findNestedClass(cursor.classDef, tail)
: uniqueClassAcross(cursor.files, tail);
}
const files = namespaceTargets.get(head);
if (files === undefined || isNamespaceNameShadowed(head, inScope, scopes)) return undefined;
return uniqueClassAcross(files, tail);
};
const compoundOpts = {
fieldFallback,
elementTypeOf: provider.elementTypeOf,
namespaceExportsIncludeImportedNames: provider.namespaceExportsIncludeImportedNames === true,
hoistTypeBindingsToModule,
stripReceiverCastExpressions: provider.stripReceiverCastExpressions === true,
constructionSyntax: provider.constructionSyntax,
@ -778,7 +966,16 @@ export function emitReceiverBoundCalls(
receiverPaths: provider.namespaceReceiverPaths,
moduleFileExists: (filePath) => index.moduleScopeByFile.has(filePath),
});
const fileCompoundOpts = { ...compoundOpts, namespaceTargets };
const fileCompoundOpts = {
...compoundOpts,
namespaceTargets,
...(walkChains
? {
resolveQualifiedClass: (qualifiedName: string, inScope: ScopeId) =>
resolveNamespaceQualifiedClass(qualifiedName, inScope, namespaceTargets),
}
: {}),
};
// Per-file resolved-callee-id capture context (#2227 U2). Built once per
// file; `undefined` when the sink is absent (pdg off) so the `tryEmitEdge`
// capture is a no-op and emission stays byte-identical (R4).
@ -1265,15 +1462,22 @@ export function emitReceiverBoundCalls(
// that is usually empty. Mirrors the order the compound-receiver
// construction path already uses.
const namespaceCandidates = namespaceTargets.get(receiverName);
const targetFiles =
let targetFiles: readonly string[] | undefined =
namespaceCandidates !== undefined &&
!isNamespaceNameShadowed(receiverName, site.inScope, scopes)
? namespaceCandidates
: undefined;
// Chain walk: `hub.sub.helper()` / `hub.sub.Thing{}` — the receiver is
// no handle of this file, but its segments reach a module (see
// `resolveNamespaceChain`). A prefix that ends in a CLASS is Case 2's.
if (targetFiles === undefined && walkChains && lastTopLevelDot(receiverName) > 0) {
const cursor = resolveNamespaceChain(receiverName, site.inScope, namespaceTargets);
if (cursor !== undefined && 'files' in cursor) targetFiles = cursor.files;
}
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(
@ -1293,7 +1497,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,
@ -1369,7 +1581,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;
@ -1413,6 +1634,45 @@ export function emitReceiverBoundCalls(
handledSites.add(siteKey);
continue;
}
// `A.Item{}` / `mod.Outer.Inner{}` — a construction whose member is a
// type NESTED in the class the receiver names. Neither a method nor a
// field, so the owner walk above cannot see it; the chain walk's
// nested-class lookup can (`resolveNamespaceChains`).
if (memberDef === undefined && walkChains && site.callForm === 'constructor') {
const nested = findNestedClass(classDef, memberName);
if (nested !== undefined) {
if (
suppressDeletedCallTarget(
options.recordResolutionOutcome,
parsed.filePath,
site,
nested,
)
) {
handledSites.add(siteKey);
continue;
}
const ok = tryEmitEdge(
graph,
scopes,
nodeLookup,
site,
nested,
constructionSiteReason(
nested.filePath !== parsed.filePath ? 'import-resolved' : 'global',
site,
provider.markConstructionSites,
),
seen,
0.85,
collapse,
calleeCapture,
);
if (ok) emitted++;
handledSites.add(siteKey);
continue;
}
}
if (memberDef !== undefined) {
if (
suppressDeletedCallTarget(
@ -1455,11 +1715,23 @@ export function emitReceiverBoundCalls(
if (typeRef !== undefined && typeRef.rawName.includes('.')) {
const [nsName, ...classNameParts] = typeRef.rawName.split('.');
const className = classNameParts.join('.');
const targetFiles3 = namespaceTargets.get(nsName);
// With the chain walk the dotted type is resolved as a whole
// (`x: mod.Outer.Inner`, `t: hub.sub.Thing`); the candidate list then
// has one entry or none. Without it: the historical one-hop split.
const chainDef3 = walkChains
? resolveNamespaceQualifiedClass(typeRef.rawName, site.inScope, namespaceTargets)
: undefined;
const targetFiles3 = walkChains
? chainDef3 === undefined
? undefined
: [chainDef3.filePath]
: namespaceTargets.get(nsName);
if (targetFiles3 !== undefined && className.length > 0) {
let found3 = false;
for (const targetFile3 of targetFiles3) {
const classDef3 = findExportedDef(targetFile3, className, index);
const classDef3 = walkChains
? chainDef3
: lookupNamespaceMember(targetFile3, className);
if (classDef3 !== undefined) {
const picked =
site.kind === 'call'
@ -1499,7 +1771,15 @@ export function emitReceiverBoundCalls(
nodeLookup,
site,
memberDef,
memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global',
// Same marker rule as Case 1 / Case 2: a constructor-form site
// reached through a dotted type binding keeps its
// ` (constructor)` suffix when the provider opted in; for
// every other provider the string is unchanged.
constructionSiteReason(
memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global',
site,
provider.markConstructionSites,
),
seen,
// Explicit defaults so the trailing capture ctx (#2227 U2) can
// be threaded without changing dedup/confidence behavior.

View file

@ -27,6 +27,7 @@ import { cobolScopeResolver } from '../../languages/cobol/scope-resolver.js';
import { swiftScopeResolver } from '../../languages/swift/scope-resolver.js';
import { dartScopeResolver } from '../../languages/dart/scope-resolver.js';
import { vueScopeResolver } from '../../languages/vue/scope-resolver.js';
import { zigScopeResolver } from '../../languages/zig/scope-resolver.js';
/** Map of `SupportedLanguages` → `ScopeResolver`. The scope-resolution phase
* iterates this map directly — every registered resolver runs. This is the
@ -51,4 +52,5 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
[SupportedLanguages.Swift, swiftScopeResolver],
[SupportedLanguages.Dart, dartScopeResolver],
[SupportedLanguages.Vue, vueScopeResolver],
[SupportedLanguages.Zig, zigScopeResolver],
]);

View file

@ -1081,6 +1081,7 @@ export function runScopeResolution(
{
allowGlobalFallback: provider.allowGlobalFreeCallFallback === true,
constructorCallTargetsClass: provider.constructorCallTargetsClass === true,
markConstructionSites: provider.markConstructionSites === true,
isFileLocalDef: provider.isFileLocalDef,
isBuiltInName: provider.languageProvider.isBuiltInName,
freeCallsRequireInstanceOwnership: provider.freeCallsRequireInstanceOwnership === true,
@ -1111,6 +1112,7 @@ export function runScopeResolution(
// both correctly emit. See the build site above for why the earlier
// allowlist could not be made safe this way.
functionLocalValueDefIds,
{ markConstructionSites: provider.markConstructionSites === true },
);
// Last-resort property resolution by workspace-unique name (A1/A5). Runs
// after every precise pass and only sees what they left behind, so a

View file

@ -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'
);
}
@ -208,12 +210,19 @@ export function isClassLike(t: string): boolean {
* `resolveInheritanceBaseInScope` and `resolveQualifiedInheritanceBase` are
* (2); receiver typing is (1).
*
* NOT YET INCLUDED, deliberately: `Typedef` and `Union`. They belong here
* conceptually — the `union_item` note on `MEMBER_OWNER_NODE_TYPES` records
* the same gap, that a union owns fields captured as `Property` yet is not a
* recognized owner — but neither is wired as a member container today, so
* adding them would widen a predicate nothing exercises. They join when their
* containers do, with fixtures.
* `Union` IS included, via `isClassLike`: Zig wires `union(enum)` as a member
* container (methods dispatched on a union receiver — see the `main → isEnergy`
* case in `test/integration/resolvers/zig.test.ts`), so it is a shape. It
* lives in `isClassLike` because that is the label set the ownership walkers
* consult, NOT because unions inherit: Zig has no inheritance and its scope
* resolver supplies no heritage hooks, so a `Union` never has supertypes and
* its MRO is just itself. C/C++ unions still do not emit `Union` defs on the
* scope side, so nothing changes for them.
*
* NOT YET INCLUDED, deliberately: `Typedef`. It belongs here conceptually but
* is not wired as a member container today, so adding it would widen a
* predicate nothing exercises. It joins when its container does, with
* fixtures.
*/
export function isShapeLike(t: string): boolean {
return isClassLike(t) || t === 'TypeAlias';
@ -2104,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

@ -2447,6 +2447,167 @@ export const DART_QUERIES = `
right: (_)) @assignment
`;
// ── Zig ──────────────────────────────────────────────────────────────────────
// Verified against @tree-sitter-grammars/tree-sitter-zig 1.1.2.
// Container declarations (struct/enum/union) are anonymous in the grammar; the
// binding name lives on the parent variable_declaration's first identifier
// child. Heritage queries are intentionally absent — Zig has no inheritance.
export const ZIG_QUERIES = `
; Functions (top-level + methods inside struct/enum/union containers)
(function_declaration
name: (identifier) @name) @definition.function
; Struct: const Foo = struct { ... }
(variable_declaration
(identifier) @name
(struct_declaration)) @definition.struct
; Enum: const Foo = enum { ... }
(variable_declaration
(identifier) @name
(enum_declaration)) @definition.enum
; Union: const Foo = union { ... } (and tagged-union union(enum) { ... })
(variable_declaration
(identifier) @name
(union_declaration)) @definition.union
; File-struct: a file whose top level declares a container field IS a struct
; named after the file (\`Page.zig\` declares \`Page\`; \`@typeName\` agrees).
; The anchor is the whole file; the name comes from the class extractor
; (\`zigContainerName(source_file, filePath)\` — the file stem), not from a
; capture, since no node spells it. One match per top-level field — the
; definition phase dedupes by (node, name). Namespace-only files (no fields)
; never match and keep their Function ids.
((source_file (container_field name: (identifier) @_field)) @definition.struct
(#not-eq? @_field ""))
; A FIELDLESS file-struct — \`Empty.zig\`: no field, but a top-level fn whose
; first parameter is typed as the file's own type (\`self: *@This()\`, or
; \`self: *Self\` beside \`const Self = @This();\`). Zero-sized types are still
; constructed (\`Empty{}\`) and dispatched on, and keyed on fields alone the
; file lost its Struct node and every \`e.ping()\` edge (PR #1432 review,
; 8.12). The two rules over-match on purpose — any \`@This\` in a first
; parameter, any top-level \`@This()\` alias — and the provider's
; \`shouldSkipDefinitionCapture\` keeps only what \`isZigFileStruct\` (the
; single predicate the owner walk and the scope side use) admits.
((source_file (function_declaration (parameters . (parameter type: (_) @_recv))))
@definition.struct
(#match? @_recv "@This"))
((source_file (variable_declaration (identifier) (builtin_function (builtin_identifier) @_this)))
@definition.struct
(#eq? @_this "@This"))
; Opaque: const Handle = opaque { ... } — the FFI handle type. It is a
; container (it may declare methods, never fields), so it is labelled Struct:
; the owner of a HAS_METHOD edge must be class-like, and there is no closer
; label. It is NOT a TypeAlias — an opaque type is a distinct nominal type,
; deliberately incompatible with whatever it wraps.
(variable_declaration
(identifier) @name
(opaque_declaration)) @definition.struct
; Generic type constructors: \`pub fn List(comptime T: type) type { return
; struct { … }; }\` — Zig's only spelling of a generic type. The returned
; container is anonymous in the grammar; the definition anchor is the
; container node and its name is the enclosing function's (\`List\`), which
; is what every caller writes (\`List(u8)\`). Only the direct \`return
; <container>\` of a fn whose return type is \`type\` qualifies (see
; \`zigTypeConstructorOf\`). The Function node \`List\` coexists: \`List\` is
; both a callable and a type.
((function_declaration
name: (identifier) @name
type: (builtin_type) @_ret
body: (block (expression_statement (return_expression
(struct_declaration) @definition.struct))))
(#eq? @_ret "type"))
((function_declaration
name: (identifier) @name
type: (builtin_type) @_ret
body: (block (expression_statement (return_expression
(union_declaration) @definition.union))))
(#eq? @_ret "type"))
((function_declaration
name: (identifier) @name
type: (builtin_type) @_ret
body: (block (expression_statement (return_expression
(enum_declaration) @definition.enum))))
(#eq? @_ret "type"))
; Function-local and anonymous containers (F8): \`fn string() { const R =
; struct { fn get … }; }\` (Lightpanda's reflection.zig declares one \`R\` per
; builder fn), \`std.sort.pdq(T, items, {}, struct { fn lessThan … }.lessThan)\`,
; \`const cmp = struct { fn lt … }.lt;\`, a field typed \`?struct { min: u32 }\`.
; No name child spells their identity, so these rules match EVERY container
; and the class extractor names the node from \`zigContainerName\` (\`string$R\`,
; \`build$1\`) — the same function the owner walk uses for their fns, which
; were ownerless, colliding Methods before. The bound shapes above match too;
; the provider's \`shouldSkipDefinitionCapture\` keeps exactly one rule per
; container (\`zigContainerAnchor\`).
(struct_declaration) @definition.struct
(enum_declaration) @definition.enum
(union_declaration) @definition.union
(opaque_declaration) @definition.struct
; Container fields (struct fields, enum variants, union variants) — all are
; \`container_field\` in the grammar and all become Property (C labels its
; enumerators Const; Rust captures no variants; Zig's own vocabulary is
; "field" for all three, so one label keeps the query honest).
; #not-eq? guard: tree-sitter-zig 1.1.2 recovers an EMPTY container body
; (\`struct {}\`, \`opaque {}\`) as a container_field whose identifier is a
; zero-width MISSING placeholder — a parser artefact, not a field, and
; without the guard it minted a Property with an empty name.
((container_field
name: (identifier) @name) @definition.property
(#not-eq? @name ""))
; Named tests: test "description" { ... }. The name is the string node WITH
; its quotes, so \`test "add"\` next to \`fn add\` (the idiomatic layout) does
; not collide on Function:<file>:add. Anonymous \`test {}\` and decl-tests
; \`test add {}\` have no name of their own and are not graph nodes; their
; bodies' calls attribute to the File.
(test_declaration
(string) @name) @definition.function
; const / var bindings that are neither a container nor an @import (those two
; are skipped by the provider's \`shouldSkipDefinitionCapture\` so the Struct /
; import binding is the only node for that name). The literal keyword is
; load-bearing: tree-sitter-zig 1.1.2 parses statement assignments (\`x = 5;\`,
; \`x += 1;\`, \`_ = expr;\`) as \`variable_declaration\` WITHOUT a keyword
; child, and a keyword-less rule would mint a Const per assignment and a
; Variable named \`_\` per discard.
(variable_declaration
"const" . (identifier) @name) @definition.const
(variable_declaration
"var" . (identifier) @name) @definition.variable
; @import("path") — capture the string argument as @import.source, in
; EVERY position: the value of a const/var (\`const std = @import("std")\`),
; a member chain (\`const X = @import("x.zig").X\`), \`pub usingnamespace
; @import("path")\`, a tuple element (\`pub const Interfaces = .{
; @import("a.zig"), @import("b.zig") }\`), a call argument, a comparison
; operand, the receiver of a member call (\`try @import("dump.zig").root(...)\`).
; Zig has no import statement — the builtin IS the import, wherever it sits,
; and every occurrence is a file dependency. The #eq? predicate keeps the
; other builtins (@sizeOf, @TypeOf, @as, …) out. One rule, one match per
; builtin: the structure phase only skips import matches (IMPORTS edges come
; from the scope phase — \`emitZigScopeCaptures\`, whose \`@import.inline\`
; rule is this rule's twin, decides which occurrences bind a name).
((builtin_function
(builtin_identifier) @builtin
(arguments
(string) @import.source))
(#eq? @builtin "@import")) @import
; Free calls: foo(...)
(call_expression
function: (identifier) @call.name) @call
; Member calls: obj.method(...) and namespace.fn(...) (e.g. std.debug.print).
(call_expression
function: (field_expression
member: (identifier) @call.name)) @call
`;
import { SupportedLanguages } from 'gitnexus-shared';
export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
@ -2466,4 +2627,5 @@ export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
[SupportedLanguages.Dart]: DART_QUERIES,
[SupportedLanguages.Vue]: TYPESCRIPT_QUERIES, // Vue <script> blocks are parsed as TypeScript
[SupportedLanguages.Cobol]: '', // Standalone regex processor — no tree-sitter queries
[SupportedLanguages.Zig]: ZIG_QUERIES,
};

View file

@ -0,0 +1,21 @@
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js';
/**
* Zig type extraction — minimal v1 stub.
*
* Type-flow inference (constructor binding, for-loop element types, pattern
* binding, etc.) is intentionally out of scope for v1. The provider supplies
* an empty `declarationNodeTypes` set and no-op extractors to satisfy the
* `LanguageTypeConfig` contract; the central type-env builder will simply
* record nothing for Zig files. Receiver resolution today operates without
* type-env signal for languages that omit it.
*/
const noopDeclaration: TypeBindingExtractor = () => {};
const noopParameter: ParameterExtractor = () => {};
export const zigTypeConfig: LanguageTypeConfig = {
declarationNodeTypes: new Set(),
extractDeclaration: noopDeclaration,
extractParameter: noopParameter,
};

View file

@ -321,6 +321,9 @@ export const FUNCTION_NODE_TYPES = new Set([
// Dart
'function_signature',
'method_signature',
// Zig: `test "…" { }` bodies are callable scopes — calls inside attribute
// to the test, not the file. Named via methodExtractor.extractFunctionName.
'test_declaration',
]);
/**
@ -370,6 +373,9 @@ export const CLASS_CONTAINER_TYPES = new Set([
// Go
'struct_type',
'interface_type',
// Zig
'union_declaration',
'opaque_declaration',
]);
/**
@ -438,6 +444,12 @@ export const CONTAINER_TYPE_TO_LABEL: Record<string, string> = {
companion_object: 'Class',
struct_type: 'Struct',
interface_type: 'Interface',
// Zig: tagged and untagged unions are class-like containers, and so is
// the fieldless `opaque {}` (may own methods; labelled Struct, see
// ZIG_QUERIES). `struct_declaration` and `enum_declaration` are already
// present (Dart / generic).
union_declaration: 'Union',
opaque_declaration: 'Struct',
};
/**
@ -581,14 +593,17 @@ export function getLabelFromCaptures(
const hasDefaultExportHocNameSeed =
captureMap['definition.function'] !== undefined &&
(captureMap['hoc'] !== undefined || captureMap['callee'] !== undefined);
// Nameless `definition.class` passes through: a class extractor may
// synthesize the name (Java anonymous class bodies → `Worker$N`, #2550).
// Downstream stays safe — parse-worker skips any nameless definition the
// extractor could not name (its `!nameNode && !extractedClassSymbol` gate).
// Nameless `definition.class` / `definition.struct` pass through: a class
// extractor may synthesize the name (Java anonymous class bodies →
// `Worker$N`, #2550; a file-level type named after its file — the
// extractor receives the file path for exactly this). Downstream stays
// safe — parse-worker skips any nameless definition the extractor could
// not name (its `!nameNode && !extractedClassSymbol` gate).
if (
!captureMap['name'] &&
!captureMap['definition.constructor'] &&
!captureMap['definition.class'] &&
!captureMap['definition.struct'] &&
!hasDefaultExportHocNameSeed
)
return null;
@ -805,6 +820,26 @@ const javaBinaryNameOfType = (node: SyntaxNode): string | undefined => {
return enclosingName === undefined ? undefined : `${enclosingName}$${simpleName}`;
};
/**
* For a container node that is the direct `return` value of a function whose
* declared return type is the literal `type` (`fn List(comptime T: type) type
* { return struct {…}; }`), the function's `name` node; undefined otherwise.
* Language-agnostic by shape — today only tree-sitter-zig produces it.
*/
function typeConstructorNameNode(container: SyntaxNode): SyntaxNode | undefined {
const ret = container.parent;
if (ret?.type !== 'return_expression') return undefined;
const stmt = ret.parent;
if (stmt?.type !== 'expression_statement') return undefined;
const block = stmt.parent;
if (block?.type !== 'block') return undefined;
const fn = block.parent;
if (fn?.type !== 'function_declaration') return undefined;
if (fn.childForFieldName?.('body')?.id !== block.id) return undefined;
if (fn.childForFieldName?.('type')?.text !== 'type') return undefined;
return fn.childForFieldName?.('name') ?? undefined;
}
/**
* Authoritative Java local/anonymous type identity.
*
@ -879,6 +914,29 @@ export const findEnclosingClassInfo = (
* the node-id is built from, guaranteeing owner-id == node-id by construction.
*/
getQualifiedOwnerName?: (node: SyntaxNode, simpleName: string) => string | null,
/**
* Optional: the type the whole FILE declares (`LanguageProvider.resolveFileTypeOwner`).
* Consulted only when the walk reaches the tree root without meeting a
* container, so a member declared at file level can be owned by the file's
* own type (Zig file-structs). The name is what the definition phase names
* the class-like node, so owner id == node id by construction.
*/
resolveFileTypeOwner?: (
root: SyntaxNode,
filePath: string,
) => { readonly name: string; readonly label: NodeLabel } | null,
/**
* Optional: the type a CONTAINER node declares
* (`LanguageProvider.resolveContainerTypeOwner`). Consulted for every
* `CLASS_CONTAINER_TYPES` node the walk meets, before the generic name-child
* derivation, for languages whose containers are named from context (a
* binding wrapper, an enclosing callable, an anonymous ordinal). Null falls
* through to the generic derivation.
*/
resolveContainerTypeOwner?: (
container: SyntaxNode,
filePath: string,
) => { readonly name: string; readonly label: NodeLabel } | null,
): EnclosingClassInfo | null => {
let current = node.parent;
let iterations = 0;
@ -976,6 +1034,19 @@ export const findEnclosingClassInfo = (
}
}
// A container the PROVIDER names from context (binding wrapper,
// enclosing callable, anonymous ordinal — Zig). The name is what the
// class-like node is minted under, so owner id == node id.
if (resolveContainerTypeOwner !== undefined) {
const containerOwner = resolveContainerTypeOwner(current, filePath);
if (containerOwner !== null) {
return {
classId: generateId(containerOwner.label, `${filePath}:${containerOwner.name}`),
className: containerOwner.name,
};
}
}
// Rust impl_item: for `impl Trait for Struct {}`, pick the type after `for`
// NOTE: This impl_item ownership logic is mirrored in
// method-extractors/configs/rust.ts (extractOwnerName, metadata only).
@ -1064,7 +1135,27 @@ export const findEnclosingClassInfo = (
c.type === 'identifier' ||
c.type === 'name' ||
c.type === 'constant',
);
) ??
// An ANONYMOUS container bound by the enclosing declaration —
// `const Point = struct { … }` (tree-sitter-zig: struct/enum/union/
// opaque nodes carry no name; the binding identifier is the first
// named child of the parent `variable_declaration`). Same shape as the
// Go `type_spec` branch above: the name lives one level up. Without it
// the walk climbed past every Zig container and no member ever got a
// HAS_METHOD / HAS_PROPERTY owner. The definition phase names the
// container node from the same binding (`@name` on the wrapper), so
// the owner id and the node id agree by construction.
(current.parent?.type === 'variable_declaration'
? current.parent.namedChildren?.find((c: SyntaxNode) => c.type === 'identifier')
: undefined) ??
// An anonymous container RETURNED by a type-constructor function —
// `pub fn List(comptime T: type) type { return struct { … }; }`
// (Zig's only spelling of a generic type). The container's name is
// the function's, which is what the definition phase uses too
// (`@name` on the fn identifier, anchor on the container), so the
// owner id and the node id agree by construction. Only the literal
// `return <container>` of a fn returning `type` qualifies.
typeConstructorNameNode(current);
if (nameNode) {
let label = CONTAINER_TYPE_TO_LABEL[current.type] || 'Class';
// Kotlin: class_declaration with an anonymous "interface" keyword child
@ -1121,6 +1212,17 @@ export const findEnclosingClassInfo = (
};
}
}
if (current.parent === null && resolveFileTypeOwner !== undefined) {
// Tree root reached with no container on the way: ask the provider
// whether the file itself is the owner.
const fileOwner = resolveFileTypeOwner(current, filePath);
if (fileOwner !== null) {
return {
classId: generateId(fileOwner.label, `${filePath}:${fileOwner.name}`),
className: fileOwner.name,
};
}
}
current = current.parent;
}
return null;

View file

@ -120,6 +120,15 @@ export interface CallableFlowCaptureOptions {
| readonly { readonly destination: SyntaxNode; readonly source: SyntaxNode }[]
| undefined;
readonly extractFunctionParameters?: (node: SyntaxNode) => readonly SyntaxNode[] | undefined;
/**
* Provider-owned call-argument extraction, for grammars whose call node
* carries its arguments as DIRECT children with no argument-list wrapper
* (tree-sitter-zig's `call_expression`). Without a wrapper node the shared
* `arguments`/`parameterListNodeTypes` lookup finds nothing, so every
* `argument` fact is lost. Returning `undefined` falls back to the shared
* path (mirrors `extractFunctionParameters`).
*/
readonly extractCallArguments?: (call: SyntaxNode) => readonly SyntaxNode[] | undefined;
readonly extractCallCallee?: (node: SyntaxNode) => SyntaxNode | undefined;
readonly isCallNode?: (node: SyntaxNode) => boolean;
/**
@ -173,6 +182,14 @@ interface FunctionInfo {
interface ValueBindingIndex {
readonly assignmentRegionIdsByName: ReadonlyMap<string, ReadonlySet<number>>;
/**
* Regions holding a store whose destination is a MEMBER path (`o->run =
* handler`, `self.f = target`), keyed by the member name. Only these gate a
* member call as a field-stored-callable invoke: a plain-name binding
* (`const release = deinit;` next to `self.slot.release()`) is not a store
* into anybody's member cell.
*/
readonly memberStoreRegionIdsByName: ReadonlyMap<string, ReadonlySet<number>>;
readonly formalByOwner: ReadonlyMap<number | undefined, ReadonlySet<string>>;
readonly signatureByNameAndRegion: ReadonlyMap<
string,
@ -313,6 +330,7 @@ function buildValueBindingIndex(
options: CallableFlowCaptureOptions,
): ValueBindingIndex {
const assignmentRegionIdsByName = new Map<string, Set<number>>();
const memberStoreRegionIdsByName = new Map<string, Set<number>>();
const formalByOwner = new Map<number | undefined, Set<string>>();
const signatureByNameAndRegion = new Map<string, Map<number, CallableCaptureSignature>>();
const add = (
@ -338,19 +356,27 @@ function buildValueBindingIndex(
// it (#2522 review, M3 ops-vtable pattern).
const terminal = terminalIdentifier(assignment.destination, options);
if (terminal !== undefined) destinationNames.add(terminal.text);
for (const name of destinationNames) {
const region = nearestLexicalRegion(assignment.container, options);
let regionIds = assignmentRegionIdsByName.get(name);
// A destination whose binding identifier and terminal identifier are two
// different nodes spans a member path (`o->run`, `self.slot.f`); a bare
// name or a declarator (`void (*fp)(int)`) resolves both to the same leaf.
const memberStoreName =
terminal !== undefined && terminal.id !== destination?.node.id ? terminal.text : undefined;
const region = nearestLexicalRegion(assignment.container, options);
const functionOwner =
options.functionScopedValueBindings === true
? nearestFunctionOwner(assignment.container, options)
: undefined;
const record = (index: Map<string, Set<number>>, name: string): void => {
let regionIds = index.get(name);
if (regionIds === undefined) {
regionIds = new Set();
assignmentRegionIdsByName.set(name, regionIds);
index.set(name, regionIds);
}
regionIds.add(region.id);
if (options.functionScopedValueBindings === true) {
const functionOwner = nearestFunctionOwner(assignment.container, options);
if (functionOwner !== undefined) regionIds.add(functionOwner.id);
}
}
if (functionOwner !== undefined) regionIds.add(functionOwner.id);
};
for (const name of destinationNames) record(assignmentRegionIdsByName, name);
if (memberStoreName !== undefined) record(memberStoreRegionIdsByName, memberStoreName);
}
for (const fn of functions) {
for (const parameter of fn.parameters) {
@ -386,7 +412,12 @@ function buildValueBindingIndex(
if (functionOwner !== undefined) byRegion.set(functionOwner.id, signature);
}
}
return { assignmentRegionIdsByName, formalByOwner, signatureByNameAndRegion };
return {
assignmentRegionIdsByName,
memberStoreRegionIdsByName,
formalByOwner,
signatureByNameAndRegion,
};
}
/** True when a pointer/parenthesized declarator sits between the declaration
@ -470,6 +501,34 @@ function isVisibleValueBinding(
return visibleCallableSignature(input, name, bindings, options) !== undefined;
}
/**
* Member-call gate: the member's name-cell was written by a visible MEMBER
* store, or is a declared callable-typed binding (C struct field
* `void (*cb)(int);`, whose stores may live in other functions). Plain-name
* bindings and formals are deliberately NOT consulted — `x.f()` reads the
* member `f` of `x`, not a same-named local, and gating on the local minted an
* invoke through the wrong cell (a Zig `pub const release = deinit;` alias
* turned `self.slot.release()` into a `deinit → deinit` self-loop).
*/
function isVisibleMemberStore(
input: SyntaxNode,
name: string,
bindings: ValueBindingIndex,
options: CallableFlowCaptureOptions,
): boolean {
const regionIds = bindings.memberStoreRegionIdsByName.get(name);
if (regionIds !== undefined) {
const providerOwner = options.lexicalFunctionOwner?.(input);
if (providerOwner !== undefined && regionIds.has(providerOwner.id)) return true;
let node: SyntaxNode | null = input;
while (node !== null) {
if (regionIds.has(node.id)) return true;
node = node.parent;
}
}
return visibleCallableSignature(input, name, bindings, options) !== undefined;
}
function visibleCallableSignature(
input: SyntaxNode,
name: string,
@ -700,10 +759,16 @@ function emitCallFacts(
const callee = operandSyntax(calleeNode, options);
const calleeIsValueBinding =
callee !== undefined && isVisibleValueBinding(call, callee.name, valueBindings, options);
// A direct callee NAME exists only when the call spells its callee as a
// designator (`f(x)`, `ns.f(x)`). A receiver-less member (Zig's decl
// literal `.init(x)`, whose receiver is an inferred type) or a computed
// callee names nothing the solver may seed by simple name — doing so
// fanned each argument out to every same-named callable in the repo.
const directCalleeName =
member === undefined &&
callee !== undefined &&
callee.indirection === 0 &&
callee.directDesignator &&
!calleeIsValueBinding
? callee.name
: undefined;
@ -816,7 +881,7 @@ function emitCallFacts(
// name-keyed field collapse matches the solver's store/load model.
// ponytail: same-region joins only — cross-function vtable installs need
// a field-sensitive cell model.
if (isVisibleValueBinding(call, member.member.name, valueBindings, options)) {
if (isVisibleMemberStore(call, member.member.name, valueBindings, options)) {
emitInvoke(callSite, member.member, 'indirect', args.length, out, options, member.receiver);
}
return;
@ -891,6 +956,8 @@ function callArguments(
call: SyntaxNode,
options: CallableFlowCaptureOptions,
): readonly SyntaxNode[] {
const providerArguments = options.extractCallArguments?.(call);
if (providerArguments !== undefined) return providerArguments;
const list =
call.childForFieldName('arguments') ??
call.childForFieldName('argument') ??
@ -947,10 +1014,18 @@ function memberParts(
node.childForFieldName('object') ??
node.childForFieldName('argument') ??
node.childForFieldName('receiver');
// `member` is the field name tree-sitter grammars use when the receiver
// field is `object` (Zig `field_expression`). It is only read once a
// receiver field matched: the other grammars that expose a `member` field
// (C/C++ `offsetof_expression`, JS `class_body`) carry no receiver field and
// stay unaffected. Without it every `x.f(arg)` in such a grammar collapsed
// to a DIRECT call named `f` and the flow solver fanned the argument out to
// every same-named callable.
const memberNode =
node.childForFieldName('property') ??
node.childForFieldName('field') ??
node.childForFieldName('method');
node.childForFieldName('method') ??
node.childForFieldName('member');
if (receiverNode === null || memberNode === null) return undefined;
const receiver = operandSyntax(receiverNode, options);
const member = operandSyntax(memberNode, options);
@ -1062,7 +1137,13 @@ function wrappedExpression(node: SyntaxNode): SyntaxNode | null {
node.childForFieldName('expression') ??
node.childForFieldName('value');
if (field !== null && field.id !== node.id && node.namedChildCount === 1) return field;
// `await` is a wrapper, not a callee: tree-sitter-typescript parses
// `await f<T>(x)` as `call_expression(function: await_expression(f), …)`,
// and `await_expression` carries its operand without a field name, so the
// field-based unwrap above misses it. Unwrapping keeps `f` a direct
// designator (`direct-callee-name`), as it is for the un-awaited spelling.
if (
node.type.includes('await_expression') ||
node.type.includes('parenthesized') ||
node.type.includes('reference_expression') ||
node.type.includes('pointer_expression') ||

View file

@ -0,0 +1,81 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { VariableExtractionConfig, VariableVisibility } from '../../variable-types.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { hasZigPubKeyword } from '../../export-detection.js';
import {
isZigKeywordDeclaration,
isZigTypeShadowingBinding,
} from '../../languages/zig/captures.js';
/**
* Zig variable extraction — enriches the Const / Variable nodes minted by
* ZIG_QUERIES' `@definition.const` / `@definition.variable` rules.
*
* tree-sitter-zig uses a single `variable_declaration` node for both `const`
* and `var` bindings — AND for statement assignments (`x = 5;`), which carry
* no keyword. The keyword is exposed as an unnamed token child
* (`type === 'const'` or `type === 'var'`).
*
* Excludes variable_declarations whose value is a struct/enum/union or an
* `@import(...)` builtin — those are handled by the class extractor / import
* pipeline, and a Variable record beside the Struct/Enum/Union node (or the
* import edge) would be a duplicate. `extractName` returns undefined for them,
* which is the generic extractor's skip signal (see `generic.ts`, and Python's
* broad `expression_statement` config for the same pattern).
*/
const isVarKeyword = (node: SyntaxNode): boolean => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'var') return true;
}
return false;
};
export const zigVariableConfig: VariableExtractionConfig = {
language: SupportedLanguages.Zig,
constNodeTypes: ['variable_declaration'],
staticNodeTypes: [],
variableNodeTypes: [],
extractName(node) {
if (isZigTypeShadowingBinding(node)) return undefined;
// Statement assignments share the node type and have no keyword — not a
// declaration (belt to the query's literal `"const"` / `"var"` brace).
if (!isZigKeywordDeclaration(node)) return undefined;
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'identifier') return child.text;
}
return undefined;
},
extractType(node) {
// tree-sitter-zig 1.1.2 exposes the annotation as the `type:` field of
// variable_declaration (its only field; verified by AST dump: `const p:
// *Foo = …` → `pointer_type [type]`, `extern var f: T;` → `identifier
// [type]`). Reading the field covers every annotation shape (builtin,
// identifier, pointer/optional/slice/array/fn types) and, just as
// importantly, never mistakes an initializer for a type: `const f =
// target;` has no `type:` field, so it is untyped — the old positional
// fallback returned `target` as its type.
return node.childForFieldName('type')?.text?.trim();
},
extractVisibility(node): VariableVisibility {
// `pub` only — `export var` is C linkage, private to other Zig files.
return hasZigPubKeyword(node) ? 'public' : 'private';
},
isConst(node) {
return !isVarKeyword(node);
},
isStatic() {
return false;
},
isMutable(node) {
return isVarKeyword(node);
},
};

View file

@ -1,4 +1,5 @@
import { parentPort, threadId, workerData } from 'node:worker_threads';
import { createRequire } from 'node:module';
import {
boundCallableStartPosition,
localIdentity,
@ -82,6 +83,13 @@ let C: TreeSitterLanguage | null = null;
try {
C = requireVendoredGrammar('tree-sitter-c') as TreeSitterLanguage;
} catch {}
// @tree-sitter-grammars/tree-sitter-zig is an optionalDependency — may not be installed
const _require = createRequire(import.meta.url);
let Zig: TreeSitterLanguage | null = null;
try {
Zig = _require('@tree-sitter-grammars/tree-sitter-zig');
} catch {}
import { getLanguageFromFilename } from 'gitnexus-shared';
import {
buildDefinitionPreScan,
@ -561,6 +569,7 @@ const languageMap: Record<string, TreeSitterLanguage> = {
[SupportedLanguages.Vue]: TypeScript.typescript,
...(Dart ? { [SupportedLanguages.Dart]: Dart } : {}),
...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}),
...(Zig ? { [SupportedLanguages.Zig]: Zig } : {}),
};
/**
@ -631,6 +640,27 @@ function findEnclosingClassNode(node: SyntaxNode): SyntaxNode | null {
return null;
}
/**
* `findEnclosingClassNode`, extended with the provider's file-level owner:
* when no container encloses `node` but the language says the FILE itself is
* a type (`resolveFileTypeOwner`, e.g. a Zig file-struct), the tree root is
* the owner node the method/field extractors should read members from. Same
* root, same name as `findEnclosingClassInfo`'s root branch, so member ids and
* owner ids agree.
*/
function findEnclosingClassNodeOrFileOwner(
node: SyntaxNode,
provider: LanguageProvider,
filePath: string,
): SyntaxNode | null {
const container = findEnclosingClassNode(node);
if (container !== null) return container;
if (provider.resolveFileTypeOwner === undefined) return null;
let root: SyntaxNode = node;
while (root.parent) root = root.parent;
return provider.resolveFileTypeOwner(root, filePath) !== null ? root : null;
}
/**
* For C++ out-of-class method definitions (e.g. `void Foo::bar() {}`), extract the
* class name from the qualified_identifier scope and find the class declaration in the
@ -931,7 +961,14 @@ const callableOwnQualifiedName = (
const prefix = enclosingCallablePrefix(fnNode, filePath, provider);
const classInfo =
prefix === undefined
? cachedFindEnclosingClassInfo(fnNode, filePath, provider.resolveEnclosingOwner)
? cachedFindEnclosingClassInfo(
fnNode,
filePath,
provider.resolveEnclosingOwner,
undefined,
provider.resolveFileTypeOwner,
provider.resolveContainerTypeOwner,
)
: null;
const objectOwner =
prefix === undefined && classInfo === null && shouldObjectOwnerQualifyCallable(finalLabel)
@ -989,6 +1026,9 @@ const findEnclosingFunctionId = (
current,
filePath,
provider.resolveEnclosingOwner,
undefined,
provider.resolveFileTypeOwner,
provider.resolveContainerTypeOwner,
);
const encLang = getLanguageFromFilename(filePath);
const standaloneMethodInfo =
@ -1043,8 +1083,12 @@ const findEnclosingFunctionId = (
? undefined
: standaloneMethodInfo.parameters.length;
} else {
// Same owner lookup as the definition-phase Method id builder: a Zig
// file-struct's top-level fn is owned by the file root, and its
// id carries the `#<arity>` suffix only if that owner is found.
const classNode =
findEnclosingClassNode(current) ?? findClassNodeByQualifiedName(current);
findEnclosingClassNodeOrFileOwner(current, provider, filePath) ??
findClassNodeByQualifiedName(current);
if (classNode && encLang) {
const methodMap = getMethodInfo(classNode, provider, {
filePath,
@ -1090,6 +1134,9 @@ const findEnclosingFunctionId = (
current.previousSibling ?? current,
filePath,
provider.resolveEnclosingOwner,
undefined,
provider.resolveFileTypeOwner,
provider.resolveContainerTypeOwner,
);
// Same nesting rule as the generic branch above (#2699). Anchored on
// `sigNode`-equivalent (`current.previousSibling ?? current`) so Dart,
@ -1154,6 +1201,8 @@ const cachedFindEnclosingClassInfo = (
filePath: string,
resolveEnclosingOwner?: (node: SyntaxNode) => SyntaxNode | null,
getQualifiedOwnerName?: (node: SyntaxNode, simpleName: string) => string | null,
resolveFileTypeOwner?: LanguageProvider['resolveFileTypeOwner'],
resolveContainerTypeOwner?: LanguageProvider['resolveContainerTypeOwner'],
): EnclosingClassInfo | null => {
const cached = classIdCache.get(node);
if (cached !== undefined) return cached;
@ -1163,6 +1212,8 @@ const cachedFindEnclosingClassInfo = (
filePath,
resolveEnclosingOwner,
getQualifiedOwnerName,
resolveFileTypeOwner,
resolveContainerTypeOwner,
);
classIdCache.set(node, result);
return result;
@ -2020,6 +2071,8 @@ const processFileGroup = (
file.path,
provider.resolveEnclosingOwner,
propGetQualifiedOwnerName,
provider.resolveFileTypeOwner,
provider.resolveContainerTypeOwner,
);
const propEnclosingClassId =
propEnclosingInfo?.qualifiedClassId ?? propEnclosingInfo?.classId ?? null;
@ -2191,6 +2244,7 @@ const processFileGroup = (
? provider.classExtractor.extract(definitionNode, {
name: nameNode?.text,
type: defaultNodeLabel,
filePath: file.path,
})
: null;
const nodeLabel = extractedClassSymbol?.type ?? defaultNodeLabel;
@ -2199,7 +2253,8 @@ const processFileGroup = (
nodeLabel === 'Struct' ||
nodeLabel === 'Interface' ||
nodeLabel === 'Enum' ||
nodeLabel === 'Record';
nodeLabel === 'Record' ||
nodeLabel === 'Union';
if (
isClassLikeLabel &&
provider.classExtractor?.shouldSkipClassCapture?.({
@ -2426,6 +2481,8 @@ const processFileGroup = (
file.path,
provider.resolveEnclosingOwner,
getQualifiedOwnerName,
provider.resolveFileTypeOwner,
provider.resolveContainerTypeOwner,
)
: null;
const enclosingClassId =
@ -2641,7 +2698,8 @@ const processFileGroup = (
let enrichedByMethodExtractor = false;
if (provider.methodExtractor && definitionNode) {
const classNode =
findEnclosingClassNode(definitionNode) ?? findClassNodeByQualifiedName(definitionNode);
findEnclosingClassNodeOrFileOwner(definitionNode, provider, file.path) ??
findClassNodeByQualifiedName(definitionNode);
if (classNode) {
const methodMap = getMethodInfo(classNode, provider, {
filePath: file.path,
@ -2840,7 +2898,7 @@ const processFileGroup = (
if (nodeLabel === 'Property' && definitionNode) {
// FieldExtractor is the single source of truth when available
if (provider.fieldExtractor && typeEnv) {
const classNode = findEnclosingClassNode(definitionNode);
const classNode = findEnclosingClassNodeOrFileOwner(definitionNode, provider, file.path);
if (classNode) {
const fieldMap = getFieldInfo(classNode, provider, {
typeEnv,

View file

@ -494,14 +494,14 @@ const ATTACHMENT_TARGET_LABELS: readonly NodeTableName[] = [
];
/**
* The 69 pairs NEITHER rule above generates — everything left after the two
* The 66 pairs NEITHER rule above generates — everything left after the two
* cross products are subtracted. Carried by CONTAINMENT, inheritance, imports
* and DI: a container label crossed with a contained label. No predicate
* describes that surface (any container can hold any definition).
*
* What survives here is characteristic, not arbitrary. Almost all of it is a
* TARGET no rule reaches — `CodeElement`, `Impl`, `Namespace`, `Template`,
* `Typedef`, `Union`, `Static`, `Section`, `Folder` are in neither
* `Typedef`, `Static`, `Section`, `Folder` are in neither
* `SCOPE_BRIDGE_TARGET_LABELS` nor {@link ATTACHMENT_TARGET_LABELS} — plus the
* `Impl|*` and `Template|*` member rows (Rust `impl`/`trait` bodies, C++
* templates), the two `Route|Process` / `Tool|Process` entry points whose
@ -529,7 +529,7 @@ const ATTACHMENT_TARGET_LABELS: readonly NodeTableName[] = [
* from it and so moves with any change to it.
*
* Folding this remainder into a third cross product
* (`DEFINITION_ANCHOR_LABELS × {CodeElement, Section, Typedef, Union,
* (`DEFINITION_ANCHOR_LABELS × {CodeElement, Section, Typedef,
* Namespace, Impl, TypeAlias, Static, Template}`) would take the table to 641
* pairs and leave only ~29 lines here. `bench/schema-pairs` measures 641 at
* 1.22–1.43× on the historical reference box; current production's 461 pairs
@ -546,7 +546,6 @@ const ATTACHMENT_TARGET_LABELS: readonly NodeTableName[] = [
export const STRUCTURAL_PAIR_DDL = ` FROM File TO Folder,
FROM File TO CodeElement,
FROM File TO \`Typedef\`,
FROM File TO \`Union\`,
FROM File TO \`Namespace\`,
FROM File TO \`Impl\`,
FROM File TO \`Static\`,
@ -558,11 +557,9 @@ export const STRUCTURAL_PAIR_DDL = ` FROM File TO Folder,
FROM Function TO \`Namespace\`,
FROM Function TO \`Impl\`,
FROM Function TO \`Typedef\`,
FROM Function TO \`Union\`,
FROM Function TO CodeElement,
FROM Class TO \`Template\`,
FROM Class TO \`Impl\`,
FROM Class TO \`Union\`,
FROM Class TO \`Namespace\`,
FROM Class TO \`Typedef\`,
FROM Class TO CodeElement,

View file

@ -177,6 +177,23 @@ const SOURCES: Record<string, GrammarSource> = {
'Likely cause: no prebuilt `.node` for this platform/architecture. ' +
`See ${ISSUES_URL}/2107.`,
},
// Zig grammar declares peerOptional `tree-sitter@^0.22.1` but its native
// binding is ABI-compatible with the bundled `tree-sitter@0.21.x` runtime
// (verified by load-time smoke test). The peer-dep mismatch is suppressed
// via the `overrides` block in package.json. Listed as `optional: true`
// because the package is in `optionalDependencies` — users on platforms
// without a prebuild may not have it — and `userSkippable` because it is
// a genuinely-optional grammar: `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` must be
// able to disable it at analyze time like swift/dart/kotlin.
[SupportedLanguages.Zig]: {
load: () => _require('@tree-sitter-grammars/tree-sitter-zig'),
optional: true,
userSkippable: true,
unavailableNote:
'Zig parsing disabled: `@tree-sitter-grammars/tree-sitter-zig` is an ' +
'optionalDependency and is not installed (or its native binding failed ' +
'to load on this platform).',
},
};
/**

View file

@ -720,7 +720,19 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
// ModuleConstants shadow metadata. A warm v90 cache would replay unchanged
// Kotlin files with neither route candidates nor the constant declarations
// needed to fold them, leaving the new ingestion path silently inert.
const SCHEMA_BUMP = 91;
// 91 -> 92 (#1432): the shared callable-flow reader (`callable-flow-captures.ts`)
// no longer names a callee by simple name for a MEMBER call and gates a
// field-stored-callable invoke on a visible member store — parse-time capture
// facts for Kotlin / C++ / C# / TypeScript member calls change (the
// scope-capture bench re-baselined all four), and Zig files are captured for
// the first time, with rules that changed within the PR (qualified struct
// literals, enum-variant field bindings, receiver tagging). A warm v91 cache
// replays the old facts verbatim, `--force` included: a reviewer re-testing a
// later head of this PR on an index built from an earlier one measured a
// byte-identical graph until `parse-cache/` and `parsedfile-cache/` were
// deleted by hand. 92 is the next free value above origin/main (91) at merge
// time. RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING.
const SCHEMA_BUMP = 92;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -697,7 +697,7 @@
},
"csharp-variadic-resolution/Utils/Logger.cs": {
"captureGroups": 10,
"digest": "46905fc6f0d59035df05fe60d29c57217d321c5a8d3c3223466061b56c7f785d"
"digest": "4907421baf3bbbcb46447b57beb2b1fb29f4ecd1555e79ea08d1b653c636666e"
},
"csharp-write-access/Models.cs": {
"captureGroups": 9,

View file

@ -0,0 +1,36 @@
const std = @import("std");
const main_mod = @import("./main.zig");
// C-ABI export: no `pub`, still the most externally visible symbol in the file.
export fn c_add(a: i32, b: i32) i32 {
return a + b;
}
// Fieldless container: FFI handle type. May own methods, never fields.
pub const Handle = opaque {
pub fn close(self: *Handle) void {
_ = self;
}
};
// Empty container body — tree-sitter-zig recovers it with a MISSING
// placeholder field; it must not become a nameless Property.
pub const Empty = struct {};
pub fn release(h: *Handle) void {
h.close();
}
test "c_add adds" {
_ = c_add(1, 2);
}
// The idiomatic layout: a test named after the function it exercises.
test "release" {
var h: *Handle = undefined;
release(h);
}
test {
_ = c_add(3, 4);
}

View file

@ -0,0 +1,14 @@
const std = @import("std");
const pioneer = @import("./pioneer.zig");
pub fn main() void {
var p = pioneer.Pioneer{ .energy = 0 };
p.tick();
helper();
const t = pioneer.Tag{ .energy = 5 };
_ = t.isEnergy();
}
fn helper() void {
_ = 1;
}

View file

@ -0,0 +1,22 @@
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 {
energy: u32,
pub fn tick(self: *Pioneer) void {
self.energy += 1;
}
pub fn reset(self: *Pioneer) void {
self.energy = 0;
}
};

View file

@ -0,0 +1,34 @@
const std = @import("std");
// Two executables that each bind the alias "config" to THEIR OWN config.zig —
// the ordinary multi-target layout. A single first-wins map of aliases sends
// tool/main.zig's `@import("config")` to app/config.zig.
pub fn build(b: *std.Build) void {
const corelib = b.dependency("corelib", .{});
const app_config = b.createModule(.{ .root_source_file = b.path("src/app/config.zig") });
const tool_config = b.createModule(.{ .root_source_file = b.path("src/tool/config.zig") });
const app = b.addExecutable(.{ .name = "app", .root_source_file = b.path("src/app/main.zig") });
app.root_module.addImport("config", app_config);
// A path dep's NAMED module (declared by libs/corelib/build.zig).
app.root_module.addImport("api", corelib.module("core"));
const tool_mod = b.createModule(.{
.root_source_file = b.path("src/tool/main.zig"),
.imports = &.{ .{ .name = "config", .module = tool_config } },
});
const tool = b.addExecutable(.{ .name = "tool", .root_module = tool_mod });
b.installArtifact(app);
b.installArtifact(tool);
// Two modules rooted in ONE directory that disagree on "clash": a file of
// that directory which is neither root cannot be attributed, and must
// resolve nothing rather than the first declaration.
const clash_a = b.createModule(.{ .root_source_file = b.path("src/shared/clash_a.zig") });
const clash_b = b.createModule(.{ .root_source_file = b.path("src/shared/clash_b.zig") });
const shared_a = b.addModule("shared_a", .{ .root_source_file = b.path("src/shared/a.zig") });
shared_a.addImport("clash", clash_a);
const shared_b = b.addModule("shared_b", .{ .root_source_file = b.path("src/shared/b.zig") });
shared_b.addImport("clash", clash_b);
}

View file

@ -0,0 +1,8 @@
.{
.name = .buildmodules,
.version = "0.1.0",
.dependencies = .{
.corelib = .{ .path = "libs/corelib" },
},
.paths = .{ "build.zig", "build.zig.zon", "src" },
}

View file

@ -0,0 +1,4 @@
const std = @import("std");
pub fn build(b: *std.Build) void {
_ = b.addModule("core", .{ .root_source_file = b.path("src/core.zig") });
}

View file

@ -0,0 +1 @@
pub fn ping() void {}

View file

@ -0,0 +1 @@
pub fn load_app() void {}

View file

@ -0,0 +1,7 @@
const config = @import("config");
const api = @import("api");
pub fn main() void {
config.load_app();
api.ping();
}

View file

@ -0,0 +1,4 @@
const clash = @import("clash");
pub fn use_a() void {
clash.hit_a();
}

View file

@ -0,0 +1,4 @@
const clash = @import("clash");
pub fn use_b() void {
clash.hit_b();
}

View file

@ -0,0 +1 @@
pub fn hit_a() void {}

View file

@ -0,0 +1 @@
pub fn hit_b() void {}

View file

@ -0,0 +1,5 @@
// Neither module root: "clash" is ambiguous here and must resolve nothing.
const clash = @import("clash");
pub fn use_helper() void {
clash.hit_a();
}

View file

@ -0,0 +1 @@
pub fn load_tool() void {}

View file

@ -0,0 +1,5 @@
const config = @import("config");
pub fn run_tool() void {
config.load_tool();
}

View file

@ -0,0 +1,4 @@
const Self = @This();
pub fn ping(self: *Self) void {
_ = self;
}

View file

@ -0,0 +1,10 @@
n: u32 = 0,
pub const Inner = struct {
pub fn m(self: Inner) void {
_ = self;
}
};
pub fn touch(self: *Host) void {
_ = self;
}
const Host = @This();

View file

@ -0,0 +1,9 @@
pub const Counter = struct {
n: u32 = 0,
pub fn init(n: u32) Counter {
return .{ .n = n };
}
pub fn get(self: Counter) u32 {
return self.n;
}
};

View file

@ -0,0 +1 @@
pub const sub = @import("sub.zig");

View file

@ -0,0 +1,6 @@
pub const A = struct {
pub fn work() void {}
};
pub const B = struct {
pub fn work() void {}
};

View file

@ -0,0 +1,8 @@
pub fn List(comptime T: type) type {
return struct {
items: []T = &.{},
pub fn push(self: *@This()) void {
_ = self;
}
};
}

View file

@ -0,0 +1,112 @@
const counter = @import("counter.zig");
const Counter = counter.Counter;
const lib = @import("lib.zig");
const hub = @import("hub.zig");
const nested = @import("nested.zig");
const lists = @import("lists.zig");
const Empty = @import("Empty.zig");
const runner = @import("runner.zig");
const Runner = runner.Runner;
const opmod = @import("op.zig");
const List = lists.List;
var global_runner = Runner{};
var global_runner2: Runner = undefined;
fn target_global() void {}
fn target_global2() void {}
fn target_local() void {}
fn f_module_receiver() void {
global_runner.run(target_global);
global_runner2.run(target_global2);
}
fn f_local_receiver() void {
var r = Runner{};
r.run(target_local);
}
const chosen = @import("lib.zig").B.work;
const chosen2 = lib.B.work;
fn f_deep_alias() void {
chosen();
chosen2();
}
fn f_nested() void {
var a = nested.A.Item{};
a.run();
var b = nested.B.Item{};
b.run();
}
fn f_result_location() u32 {
const a: Counter = .init(1);
const b: Counter = .{ .n = 2 };
return a.get() + b.get();
}
fn f_return_decl_literal() Counter {
return .init(3);
}
fn f_sib_a() void {
const m = @import("qa.zig");
var t = m.Thing{};
t.qa_only();
m.hello();
}
fn f_sib_b() void {
const m = @import("qb.zig");
var t = m.Thing{};
t.qb_only();
m.hello();
}
fn f_multihop() u32 {
var s = hub.sub.Thing{};
s.sub_m();
var i = nested.Outer.Inner{};
i.inner_m();
_ = hub.sub.Thing.make();
return opmod.Op.lookup.event_max();
}
fn f_inline_generic() void {
var t = @import("qa.zig").Thing{};
t.qa_only();
var l = lists.List(u8){};
l.push();
var l2 = List(u8){};
l2.push();
}
fn f_fieldless() void {
var e = Empty{};
e.ping();
}
pub fn main() void {
f_module_receiver();
f_local_receiver();
f_deep_alias();
f_nested();
_ = f_result_location();
_ = f_return_decl_literal();
f_sib_a();
f_sib_b();
_ = f_multihop();
f_inline_generic();
f_fieldless();
}
const Host = @import("Host.zig");
fn f_filestruct_nested() void {
var x = Host.Inner{};
x.m();
var h = Host{};
h.touch();
_ = hub.sub.Thing.make();
var e2: Empty = .{};
e2.ping();
}
fn f_calls_more() void {
f_filestruct_nested();
}

View file

@ -0,0 +1,21 @@
pub const A = struct {
pub const Item = struct {
pub fn run(self: Item) void {
_ = self;
}
};
};
pub const B = struct {
pub const Item = struct {
pub fn run(self: Item) void {
_ = self;
}
};
};
pub const Outer = struct {
pub const Inner = struct {
pub fn inner_m(self: Inner) void {
_ = self;
}
};
};

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,6 @@
pub const Thing = struct {
pub fn qa_only(self: Thing) void {
_ = self;
}
};
pub fn hello() void {}

View file

@ -0,0 +1,6 @@
pub const Thing = struct {
pub fn qb_only(self: Thing) void {
_ = self;
}
};
pub fn hello() void {}

View file

@ -0,0 +1,7 @@
pub const Runner = struct {
x: u32 = 0,
pub fn run(self: *Runner, cb: *const fn () void) void {
_ = self;
cb();
}
};

View file

@ -0,0 +1,8 @@
pub const Thing = struct {
pub fn sub_m(self: Thing) void {
_ = self;
}
pub fn make() Thing {
return .{};
}
};

View file

@ -0,0 +1,51 @@
// A FILE-STRUCT (Lightpanda `Page.zig` shape): the file has top-level fields,
// so it IS a struct named after the file — `Page` — and its top-level fns
// taking `self` are its methods. `const Page = @This();` is the idiomatic
// self-alias, not a second declaration.
const std = @import("std");
const Page = @This();
const Session = @import("Session.zig");
session: *Session,
count: u32 = 0,
pub fn init(session: *Session) Page {
return .{ .session = session };
}
pub fn getArena(self: *Page) u32 {
return self.count;
}
pub fn bump(self: *Page) void {
self.count += 1;
_ = self.getArena();
}
// Field-typed receivers (F5): `session: *Session` types the member, so a
// call through it dispatches into Session.zig — directly (`self.session.name()`)
// or through a local alias of the field (`const s = self.session; s.name()`).
pub fn sessionName(self: *Page) []const u8 {
return self.session.name();
}
pub fn sessionLabel(self: *Page) []const u8 {
const s = self.session;
return s.name();
}
// F6 — value flow: a fallible constructor, member calls whose RETURN types
// type the local, optionals for `if` / `while` payloads.
pub fn make(session: *Session) !*Page {
_ = session;
return error.Nope;
}
pub fn getSession(self: *Page) *Session {
return self.session;
}
pub fn maybeSession(self: *Page) ?*Session {
return self.session;
}

View file

@ -0,0 +1,19 @@
const Session = @This();
const Page = @import("Page.zig");
label: []const u8,
pub fn name(self: *Session) []const u8 {
return self.label;
}
pub fn findFrame(self: *Session, page: *Page) u32 {
_ = self;
// Method call on a parameter typed by ANOTHER file-struct.
return page.getArena();
}
// F6 — an iterator-shaped method: `while (s.next()) |p| p.bump()`.
pub fn next(self: *Session) ?*Page {
_ = self;
return null;
}

View file

@ -0,0 +1,17 @@
// File-struct whose `@This()` alias is spelled DIFFERENTLY from the file stem
// (Lightpanda `Sighandler.zig` / `const SigHandler = @This();`). The type is
// still `Sighandler` (what importers write, what `@typeName` says); the alias
// is a second name for it inside the file, so `self: *SigHandler` must
// resolve to the same Struct.
const SigHandler = @This();
armed: bool = false,
pub fn arm(self: *SigHandler) void {
self.armed = true;
self.check();
}
fn check(self: *SigHandler) void {
_ = self;
}

View file

@ -0,0 +1,50 @@
// A file-struct hosting ANONYMOUS containers (F8):
// - `std.sort.pdq(T, items, {}, struct { fn lessThan … }.lessThan)` — the
// comparator idiom (Lightpanda's ImportMap.zig has three in one file, all
// named `lessThan`: one ownerless `Method:…:lessThan` node for all three);
// - `const byteSize = struct { fn it … }.it;` — build.zig's shape;
// - a field typed `?struct { min: u32, max: u32 }`.
// Each gets an identity (`Sorter.sortBoth$1`, `Sorter.sortBoth$2`,
// `Sorter$1`, `Sorter$2`) so its fns are Methods WITH an owner and never
// collide.
const std = @import("std");
const Sorter = @This();
items: []u32,
bounds: ?struct { min: u32, max: u32 } = null,
const byteSize = struct {
fn it(n: u32) bool {
return before(n, n);
}
}.it;
pub fn sortBoth(self: *Sorter) void {
std.sort.pdq(u32, self.items, {}, struct {
fn lessThan(_: void, a: u32, b: u32) bool {
return before(a, b);
}
}.lessThan);
std.sort.pdq(u32, self.items, {}, struct {
fn lessThan(_: void, a: u32, b: u32) bool {
return before(b, a);
}
}.lessThan);
}
fn before(a: u32, b: u32) bool {
return a < b;
}
// A container local to a TEST block (Function.zig / HttpClient.zig tests
// declare a `const State = struct {…}` per test): host is the quoted test name.
test "Sorter: local state" {
const State = struct {
n: u32 = 0,
fn kill(self: *@This()) void {
self.n = if (before(self.n, 1)) 1 else 0;
}
};
var state = State{};
state.kill();
}

View file

@ -0,0 +1,85 @@
// Type aliases (review finding F7). `const X = <type expr>;` is Zig's only
// alias syntax; every one of these is a Const in the graph, and every receiver
// below is typed THROUGH the alias. Lightpanda: `pub const Proto = HtmlElement;`,
// `const Allocator = std.mem.Allocator;`, `pub const bridge = js.Bridge(T);`.
const generic = @import("generic.zig");
const Page = @import("Page.zig");
const Thing = generic.Thing;
const Local = struct {
pub fn mk() Local {
return .{};
}
pub fn go(self: *Local) void {
_ = self;
}
};
// b1/b2 — alias of a same-file struct
const LocalAlias = Local;
// b3/b4 — alias of an alias (the promoted namespace-member import `Thing`)
const T2 = Thing;
// b5..b7 — alias of an INSTANTIATED generic type constructor
const B = generic.List(u8);
// alias of a namespace import that is also a file-struct type
const P = Page;
// a value const and a value call: NOT type aliases
const max = 5;
const helperResult = generic.Thing.make();
fn b1() void {
_ = LocalAlias.mk();
}
fn b2() void {
var l = LocalAlias.mk();
l.go();
}
fn b3() void {
_ = T2.make();
}
fn b4() void {
var t = T2.make();
t.run();
}
fn b5() void {
_ = B.init();
}
fn b6() void {
var b = B{};
b.push(1);
}
fn b7() void {
var x: B = .{};
x.push(2);
}
// b8 — the same alias inside a fn body (Lightpanda `const R = …;` locals)
fn b8() void {
const R = generic.List(u16);
var r = R.init();
r.push(3);
}
fn b10() void {
var q: P = undefined;
_ = q.getArena();
}
// b11 — a VALUE alias (`var cur = orig;`, the cursor idiom) is Rust's
// `let x = y`: the same binding, chained to the parameter's type.
fn b11(orig: *Local) void {
var cur = orig;
cur.go();
}
// b9 — Lightpanda's `JsApi` shape: a container-level alias of a forwarding
// type constructor, used by every member declaration of the container.
pub const JsApi = struct {
pub const bridge = generic.Bridge(Local);
pub const Meta = struct {
pub const prototype_chain = bridge.prototypeChain();
};
pub const value = bridge.accessor("value");
};
pub fn main() void {
_ = max;
_ = helperResult;
}

View file

@ -0,0 +1,117 @@
// F6 — the value-flow shapes that bind a local WITHOUT an annotation. Each fn
// is one shape; the CALLS edge out of it (`… → bump`, `… → name`) only exists
// when the local was typed. Lightpanda: `const page = try Page.init(…)`
// (2,551 `try T.f()` sites), `for (self.frames) |*f|`, `if (self.doc) |doc|`,
// `while (it.next()) |node|`, `const el = node.asElement()`.
const Page = @import("Page.zig");
const Session = @import("Session.zig");
// `try` / `catch` / `orelse` / parens around a constructor call — the
// receiver names the type exactly as in the bare `Page.init(…)` shape.
fn viaTry(s: *Session) !void {
const p = try Page.make(s);
p.bump();
}
fn viaCatch(s: *Session) void {
const p = Page.make(s) catch return;
p.bump();
}
fn viaOrelse(p: *Page) void {
const s = p.maybeSession() orelse return;
_ = s.name();
}
fn viaParens(s: *Session) !void {
const p = (try Page.make(s));
p.bump();
}
fn viaTryLiteral(s: *Session) !void {
const p = try Page{ .session = s };
p.bump();
}
// A free call: the callee's RETURN type types the local.
fn viaFreeCall() void {
const p = makeLocal();
p.bump();
}
fn makeLocal() Page {
return undefined;
}
// A member call on a fn-local receiver: the METHOD's return type, not the
// receiver's — `s` is a Session, not a Page.
fn viaMemberReturn(p: *Page) void {
const s = p.getSession();
_ = s.name();
}
// The same on `self`, inside a container (`const el = self.asElement()`).
const Runner = struct {
page: *Page,
pub fn current(self: *Runner) *Page {
return self.page;
}
pub fn go(self: *Runner) void {
const p = self.current();
p.bump();
}
};
// Payload captures.
fn forSlice(pages: []Page) void {
for (pages) |*p| p.bump();
for (pages) |p| {
_ = p.getArena();
}
}
fn forIndexed(pages: []Page) void {
for (pages, 0..) |p, i| {
_ = i;
p.bump();
}
}
fn ifOptional(o: ?*Page) void {
if (o) |p| p.bump();
}
fn ifCallOptional(p: *Page) void {
if (p.maybeSession()) |s| {
_ = s.name();
}
}
fn whileNext(s: *Session) void {
while (s.next()) |p| p.bump();
}
// One-layer projections bound to a local.
fn viaIndex(pages: []Page) void {
const p = pages[0];
p.bump();
}
fn viaUnwrap(o: ?Page) void {
const p = o.?;
p.bump();
}
fn viaDeref(ptr: *Page) void {
const p = ptr.*;
p.bump();
}
fn viaPtrCaptureDeref(pages: []Page) void {
// `|*p|` captures a POINTER: the recorded type must keep the `*`
// (`*Page`), or the deref projection below has no layer to remove.
for (pages) |*p| {
const q = p.*;
q.bump();
}
}
// Guards: nothing typed here, no edge may appear.
fn viaTypeConstructor() void {
// A TitleCase callee is a type constructor (an alias, F7), not a value.
const L = List(u8);
_ = L;
}
fn List(comptime T: type) type {
return struct {
x: T,
};
}

View file

@ -0,0 +1,38 @@
// A NAMESPACE file exporting a generic type constructor (`List(u8)`) and the
// Lightpanda `js.Bridge(T)` shape: a type constructor that FORWARDS to
// another one (`return Builder(T);`) instead of returning a container.
pub const Thing = struct {
pub fn make() Thing {
return .{};
}
pub fn run(self: *Thing) void {
_ = self;
}
};
pub fn List(comptime T: type) type {
return struct {
items: []T = &.{},
pub fn init() @This() {
return .{};
}
pub fn push(self: *@This(), v: T) void {
_ = self;
_ = v;
}
};
}
pub fn Bridge(comptime T: type) type {
return Builder(T);
}
pub fn Builder(comptime T: type) type {
return struct {
pub fn accessor(comptime name: []const u8) void {
_ = name;
_ = T;
}
pub fn prototypeChain() void {}
};
}

View file

@ -0,0 +1,5 @@
// A re-export hub (Lightpanda `lightpanda.zig`): `pub const X = @import(...)`
// at file scope republishes X, so a third file reaches the TYPE as `lp.X`.
pub const Page = @import("Page.zig");
pub const Session = @import("Session.zig");
const private_util = @import("util.zig");

View file

@ -0,0 +1,33 @@
const std = @import("std");
const Page = @import("Page.zig");
const Session = @import("Session.zig");
const Sighandler = @import("Sighandler.zig");
const util = @import("util.zig");
pub fn main() void {
var s = Session{ .label = "x" };
// namespace-member (static) call — must keep working alongside the type
var p = Page.init(&s);
// receiver typed by the call-return rule (`Page.init` → `Page`)
p.bump();
_ = p.getArena();
_ = s.findFrame(&p);
// receiver typed by annotation
var q: Page = undefined;
_ = q.getArena();
var h: Sighandler = .{};
h.arm();
_ = util.helper();
useParam(&p);
}
fn useParam(page: *Page) void {
_ = page.getArena();
}
const lp = @import("lp.zig");
// The type behind a re-exported name, taken through the hub.
const PageViaHub = lp.Page;
fn viaHubAlias(page: *PageViaHub) void {
_ = page.getArena();
}

View file

@ -0,0 +1,62 @@
// Lightpanda's `webapi/element/reflection.zig` shape (F8): a generic type
// constructor whose builder fns each declare a FUNCTION-LOCAL helper container
// named `R`. By binding name alone every `R` collapsed onto ONE `Struct:…:R`
// with one `R.get` / one `R.set`; each must be its own type
// (`Reflect.string$R`, `Reflect.url$R`) owning its own methods.
const std = @import("std");
pub const Accessor = struct {
get: *const anyopaque,
set: *const anyopaque,
};
pub fn Reflect(comptime T: type) type {
return struct {
pub fn string(comptime attr: []const u8) Accessor {
const R = struct {
fn get(self: *const T) []const u8 {
return readAttr(self, attr);
}
fn set(self: *T, value: []const u8) void {
writeAttr(self, attr, value);
}
};
return Accessor{ .get = R.get, .set = R.set };
}
pub fn url(comptime attr: []const u8) Accessor {
const R = struct {
// A `@This()` alias inside a function-local container names
// THAT container (`R`), not the enclosing type constructor:
// `self: *const Self` below must dispatch `self.get()` to
// `Reflect.url$R.get`.
const Self = @This();
fn get(self: *const T) []const u8 {
return normalize(readAttr(self, attr));
}
fn set(self: *T, value: []const u8) void {
writeAttr(self, attr, value);
}
fn check(self: *const Self) []const u8 {
return self.get();
}
};
return Accessor{ .get = R.get, .set = R.set };
}
};
}
fn readAttr(self: anytype, attr: []const u8) []const u8 {
_ = self;
return attr;
}
fn writeAttr(self: anytype, attr: []const u8, value: []const u8) void {
_ = self;
_ = attr;
_ = value;
}
fn normalize(s: []const u8) []const u8 {
return s;
}

View file

@ -0,0 +1,8 @@
// A NAMESPACE file: no top-level fields, so it is not a type. `const util =
// @This();` here is a plain Const (nothing to alias), `helper` stays a
// top-level Function, and `util.helper()` is a namespace-member call.
const util = @This();
pub fn helper() u32 {
return 1;
}

Some files were not shown because too many files have changed in this diff Show more