From 12763a40c8a9f62d9f3024f2d268c7ca55953e69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Thu, 3 Sep 2026 12:25:51 +0100 Subject: [PATCH 1/2] fix(watch): await a watcher re-arm barrier so gitignore reloads cannot drop events (#3159) * Increase CI timeout budget for flaky watch-filesystem test * test(watch): include elapsed budget in waitFor timeout errors (#3156) Make CI flake timeouts self-describing without raising the 90s ceiling, and cite the mcp/server-startup 15s/5s convention in the helper comment. * fix(watch): await a watcher re-arm barrier after an ignore-rule reload An ignore-rule reload re-armed the watcher with `watcher.add(repoPath)`, which returns before the rescan it starts has finished and offers no signal for that completion. A file the reload had just unignored was therefore still unregistered when the call returned, and since `ignoreInitial` suppresses the `add` that the in-flight rescan would emit, an immediate rewrite of that file was dropped permanently. A standalone reproduction missed the rewrite 40/40 times on both chokidar 4.0.3 and 5.0.0. Re-arm by arming a replacement watcher and awaiting its `ready` instead, which is the only completion signal chokidar exposes (`ready` never fires twice on one instance). The re-arm runs before the refresh, so a write that lands while the replacement arms is still read by that refresh; the outgoing instance keeps reporting until the swap, so no event window is dropped; and a replacement that fails to arm leaves the working instance in place for the queue to retry. The transient-watcher-error path now requests the same awaited re-arm rather than re-arming inline ahead of its catch-up refresh. This replaces the CI timeout increase from #3156, which treated the symptom: the test was not slow, it was waiting for an event that never came. Co-authored-by: Cursor * refactor(watch): drop restating comments and duplicated waitFor state The re-arm error now uses the same cause-wrapping shape as ignore-control reload, and the instant-rewrite test waits for both paths in one poll. Co-authored-by: Cursor --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Gergo Magyar Co-authored-by: Cursor --- gitnexus/src/cli/analyze-watch.ts | 115 +++++++++++++----- .../test/integration/watch-filesystem.test.ts | 37 +++++- 2 files changed, 119 insertions(+), 33 deletions(-) diff --git a/gitnexus/src/cli/analyze-watch.ts b/gitnexus/src/cli/analyze-watch.ts index 0dcf232d0..172a3a090 100644 --- a/gitnexus/src/cli/analyze-watch.ts +++ b/gitnexus/src/cli/analyze-watch.ts @@ -257,6 +257,8 @@ export async function startWatchFileLoop( ): Promise { let ignorePath = await createWatchIgnorePredicate(repoPath); let ignoreControlValid = true; + let rearmPending = false; + let closed = false; const queue = new WatchRefreshQueue( async (paths) => { if (paths.some(isIgnoreControlPath) || !ignoreControlValid) { @@ -264,7 +266,7 @@ export async function startWatchFileLoop( try { ignorePath = await createWatchIgnorePredicate(repoPath); ignoreControlValid = true; - watcher.add(repoPath); + rearmPending = true; } catch (error) { ignoreControlValid = false; throw new WatchControlReloadError( @@ -279,6 +281,11 @@ export async function startWatchFileLoop( ); } } + // Re-arm before refreshing, never after: the refresh reads the whole + // repository, so a write that lands while the replacement watcher is + // arming is still picked up by this refresh, and a write that lands + // afterwards is reported by the armed watcher. + if (rearmPending) await rearmWatcher(); await refresh(paths); }, onError, @@ -291,44 +298,87 @@ export async function startWatchFileLoop( }, ); - const watcher: FSWatcher = watch(repoPath, { - ignoreInitial: true, - atomic: true, - followSymlinks: false, - awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 }, - ignored: (candidate, stats) => { - const relative = repoRelativeWatchPath(repoPath, candidate); - if (relative !== null && isAnalyzerOwnedWatchPath(relative)) return true; - if (relative !== null && (isIgnoreControlPath(relative) || isConfigControlPath(relative))) { - return false; + const createWatcher = (): FSWatcher => { + const created: FSWatcher = watch(repoPath, { + ignoreInitial: true, + atomic: true, + followSymlinks: false, + awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 }, + ignored: (candidate, stats) => { + const relative = repoRelativeWatchPath(repoPath, candidate); + if (relative !== null && isAnalyzerOwnedWatchPath(relative)) return true; + if (relative !== null && (isIgnoreControlPath(relative) || isConfigControlPath(relative))) { + return false; + } + return ignorePath(candidate, stats?.isDirectory() ?? false); + }, + }); + // Events from an instance being retired are kept: they overlap with the + // replacement's coverage and the queue coalesces the duplicates. + created.on('all', (event, changedPath) => { + if (event !== 'add' && event !== 'change' && event !== 'unlink') return; + const relative = repoRelativeWatchPath(repoPath, changedPath); + if (relative && isRelevantWatchPath(relative) && !isAnalyzerOwnedWatchPath(relative)) { + queue.enqueue(relative); } - return ignorePath(candidate, stats?.isDirectory() ?? false); - }, - }); - watcher.on('all', (event, changedPath) => { - if (event !== 'add' && event !== 'change' && event !== 'unlink') return; - const relative = repoRelativeWatchPath(repoPath, changedPath); - if (relative && isRelevantWatchPath(relative) && !isAnalyzerOwnedWatchPath(relative)) { - queue.enqueue(relative); + }); + created.on('error', (error) => { + // Replacement failures before the swap are reported through `waitUntilReady`. + if (created !== watcher) return; + // Chokidar can surface a transient EPERM on Windows while an ignored + // analyzer-owned path is replaced. Re-arm the watcher and force one + // bounded catch-up refresh so a missed event cannot leave the graph + // stale. Other watcher errors may mean coverage was lost and stay fatal. + if (TRANSIENT_WATCH_ERROR_CODES.has((error as NodeJS.ErrnoException).code ?? '')) { + rearmPending = true; + queue.enqueue(WATCH_FULL_REFRESH_PATH); + return; + } + onWatcherError(error); + }); + return created; + }; + + let watcher: FSWatcher = createWatcher(); + + // Chokidar emits `ready` once per instance and `add()` returns before the + // rescan it starts has finished, with no signal for that completion. A file + // an ignore-rule reload has just unignored is therefore still unregistered + // when `add()` returns, and because `ignoreInitial` suppresses the `add` the + // rescan would emit, an immediate rewrite of that file is dropped for good + // (reproduced on chokidar 4 and 5). So re-arm by arming a replacement + // watcher and awaiting its `ready` instead. The outgoing instance keeps + // reporting until the replacement is armed, so the swap has no blind window, + // and a replacement that fails to arm leaves the working instance in place. + const rearmWatcher = async (): Promise => { + rearmPending = false; + if (closed) return; + const replacement = createWatcher(); + try { + await waitUntilReady(replacement); + } catch (error) { + rearmPending = true; + try { + await replacement.close(); + } catch { + // The instance never became live; the arm error is the one to report. + } + throw new WatchControlReloadError( + new Error('Unable to re-arm the filesystem watcher', { cause: error }), + ); } - }); - watcher.on('error', (error) => { - // Chokidar can surface a transient EPERM on Windows while an ignored - // analyzer-owned path is replaced. Re-arm the root and force one bounded - // catch-up refresh so a missed event cannot leave the graph stale. Other - // watcher errors may mean coverage was lost and remain fatal. - if (TRANSIENT_WATCH_ERROR_CODES.has((error as NodeJS.ErrnoException).code ?? '')) { - watcher.add(repoPath); - queue.enqueue(WATCH_FULL_REFRESH_PATH); - return; - } - onWatcherError(error); - }); + const retired = watcher; + watcher = replacement; + await retired.close(); + // `close()` can land between arming the replacement and the swap above. + if (closed) await replacement.close(); + }; try { await waitUntilReady(watcher); await queue.runInitial(); } catch (error) { + closed = true; await watcher.close(); await queue.close(); throw error; @@ -337,6 +387,7 @@ export async function startWatchFileLoop( return { waitForIdle: () => queue.waitForIdle(), close: async () => { + closed = true; await watcher.close(); await queue.close(); }, diff --git a/gitnexus/test/integration/watch-filesystem.test.ts b/gitnexus/test/integration/watch-filesystem.test.ts index 51325e1ff..f1e9aae8b 100644 --- a/gitnexus/test/integration/watch-filesystem.test.ts +++ b/gitnexus/test/integration/watch-filesystem.test.ts @@ -12,7 +12,9 @@ const loops: WatchFileLoop[] = []; async function waitFor(predicate: () => boolean, timeoutMs = 5_000): Promise { const deadline = Date.now() + timeoutMs; while (!predicate()) { - if (Date.now() >= deadline) throw new Error('timed out waiting for watcher event'); + if (Date.now() >= deadline) { + throw new Error(`timed out waiting for watcher event after ${timeoutMs}ms`); + } await new Promise((resolve) => setTimeout(resolve, 25)); } } @@ -181,6 +183,39 @@ describe('watch filesystem integration', () => { await waitFor(() => batches.flat().includes('blocked.ts')); }); + it('reports writes issued the instant a gitignore reload re-arms the watcher', async () => { + const repo = await makeRepo(); + await fs.writeFile(path.join(repo, '.gitignore'), 'blocked.ts\n', 'utf8'); + await fs.writeFile(path.join(repo, 'blocked.ts'), 'export const blocked = 1;', 'utf8'); + await fs.writeFile(path.join(repo, 'tracked.ts'), 'export const tracked = 1;', 'utf8'); + const batches: string[][] = []; + let rewritten = false; + const loop = await startWatchFileLoop( + repo, + 25, + async (paths) => { + batches.push([...paths]); + // Writing from inside the refresh puts these rewrites right after the + // re-arm returns. Polling from the test body instead would leave enough + // slack for a watcher that is not armed yet to look armed. + if (paths.includes('.gitignore') && !rewritten) { + rewritten = true; + await fs.writeFile(path.join(repo, 'blocked.ts'), 'export const blocked = 2;', 'utf8'); + await fs.writeFile(path.join(repo, 'tracked.ts'), 'export const tracked = 2;', 'utf8'); + } + }, + (error) => { + throw error; + }, + ); + loops.push(loop); + + await fs.writeFile(path.join(repo, '.gitignore'), '', 'utf8'); + await waitFor( + () => batches.flat().includes('blocked.ts') && batches.flat().includes('tracked.ts'), + ); + }); + it('keeps the last valid ignore predicate after an oversized reload and later recovers', async () => { const repo = await makeRepo(); await fs.writeFile(path.join(repo, '.gitignore'), 'blocked.ts\n', 'utf8'); From 932d937085e14664f4ef97b06506bf01034497ab Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Thu, 3 Sep 2026 14:29:42 +0200 Subject: [PATCH 2/2] feat: add Zig language support (#1432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 `. = .{` 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::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/.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("", .{ .root_source_file = b.path("…") })`, name-matched module first), then src/root.zig, src/.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 ` = @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 ` = @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("", …root_source_file = b.path("

.zig")…)`, and `createModule`/`addModule` bindings named via `addImport("", 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::Page.getArena#0` / `Property::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 = ;` 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::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::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::lessThan#3`) that collided across a file. `zigContainerName` now yields the graph IDENTITY on both phases: - function-local named: `$` — `Reflect.string$R` (Java local-class `$` chain; `populateClassOwnedMembers` leaves it whole); - anonymous: `$` — `build$1`, `Outer$1`, `Page$1` (javac's `Outer$1` numbering per host, in source order); - a `test` host is keyed `test@L` (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::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.` 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("", .{ … })` 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("")`. 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 `#` 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(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(x)` (member) and `initializer()(cb)` (call-of-call) drop the name as intended. But `await verifyToken(x)` — a DIRECT call — lost it too, because tree-sitter-typescript parses `await f(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>` 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 * Address PR review feedback (#1432) Attach the paren-matcher doc comment to findZigParenEnd instead of zigTopLevelStaticRoot. Co-authored-by: Cursor * 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 * 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 Co-authored-by: Gergő Magyar Co-authored-by: Gergo Magyar Co-authored-by: Cursor --- .github/workflows/ci-tests.yml | 12 + README.md | 1 + gitnexus-shared/src/language-detection.ts | 2 + gitnexus-shared/src/languages.ts | 1 + .../scope-resolution/finalize-algorithm.ts | 1 + .../language-classification.ts | 4 + gitnexus-shared/src/scope-resolution/types.ts | 12 + gitnexus/README.md | 5 +- gitnexus/bench/import-target/baselines.json | 67 +- gitnexus/bench/import-target/measure.mjs | 107 +- .../python-scope/baseline-fingerprint.txt | 2 +- .../bench/receiver-resolution/baseline.json | 13 +- gitnexus/bench/scope-capture/baselines.json | 24 +- gitnexus/package-lock.json | 21 + gitnexus/package.json | 4 + gitnexus/src/cli/optional-grammars.ts | 28 +- .../ingestion/call-extractors/configs/zig.ts | 6 + .../ingestion/class-extractors/configs/zig.ts | 42 + .../ingestion/class-extractors/generic.ts | 7 +- gitnexus/src/core/ingestion/class-types.ts | 8 +- .../src/core/ingestion/export-detection.ts | 61 + .../ingestion/field-extractors/configs/zig.ts | 58 + .../ingestion/field-extractors/generic.ts | 16 +- .../ingestion/import-resolvers/configs/zig.ts | 36 + .../core/ingestion/import-resolvers/zig.ts | 210 ++ .../src/core/ingestion/language-config.ts | 764 ++++++ .../src/core/ingestion/language-provider.ts | 37 + .../src/core/ingestion/languages/index.ts | 2 + .../core/ingestion/languages/rust/query.ts | 16 +- gitnexus/src/core/ingestion/languages/zig.ts | 145 ++ .../core/ingestion/languages/zig/captures.ts | 2175 +++++++++++++++++ .../src/core/ingestion/languages/zig/index.ts | 36 + .../core/ingestion/languages/zig/interpret.ts | 176 ++ .../src/core/ingestion/languages/zig/query.ts | 432 ++++ .../ingestion/languages/zig/range-binding.ts | 375 +++ .../ingestion/languages/zig/scope-resolver.ts | 79 + .../ingestion/languages/zig/simple-hooks.ts | 164 ++ .../method-extractors/configs/zig.ts | 141 ++ .../ingestion/method-extractors/generic.ts | 21 +- gitnexus/src/core/ingestion/method-types.ts | 14 +- .../src/core/ingestion/resolve-references.ts | 4 + .../contract/scope-resolver.ts | 65 + .../graph-bridge/node-lookup.ts | 13 +- .../graph-bridge/references-to-edges.ts | 35 +- .../passes/compound-receiver.ts | 45 +- .../passes/free-call-fallback.ts | 26 +- .../passes/receiver-bound-calls.ts | 298 ++- .../scope-resolution/pipeline/registry.ts | 2 + .../scope-resolution/pipeline/run.ts | 2 + .../scope-resolution/scope/walkers.ts | 83 +- .../src/core/ingestion/tree-sitter-queries.ts | 162 ++ .../src/core/ingestion/type-extractors/zig.ts | 21 + .../src/core/ingestion/utils/ast-helpers.ts | 112 +- .../ingestion/utils/callable-flow-captures.ts | 105 +- .../variable-extractors/configs/zig.ts | 81 + .../core/ingestion/workers/parse-worker.ts | 68 +- gitnexus/src/core/lbug/schema.ts | 9 +- .../src/core/tree-sitter/parser-loader.ts | 17 + gitnexus/src/storage/parse-cache.ts | 14 +- .../expected-captures.json | 2 +- .../lang-resolution/zig-basic/src/ffi.zig | 36 + .../lang-resolution/zig-basic/src/main.zig | 14 + .../lang-resolution/zig-basic/src/pioneer.zig | 22 + .../zig-buildmodules/build.zig | 34 + .../zig-buildmodules/build.zig.zon | 8 + .../zig-buildmodules/libs/corelib/build.zig | 4 + .../libs/corelib/src/core.zig | 1 + .../zig-buildmodules/src/app/config.zig | 1 + .../zig-buildmodules/src/app/main.zig | 7 + .../zig-buildmodules/src/shared/a.zig | 4 + .../zig-buildmodules/src/shared/b.zig | 4 + .../zig-buildmodules/src/shared/clash_a.zig | 1 + .../zig-buildmodules/src/shared/clash_b.zig | 1 + .../zig-buildmodules/src/shared/helper.zig | 5 + .../zig-buildmodules/src/tool/config.zig | 1 + .../zig-buildmodules/src/tool/main.zig | 5 + .../lang-resolution/zig-chains/src/Empty.zig | 4 + .../lang-resolution/zig-chains/src/Host.zig | 10 + .../zig-chains/src/counter.zig | 9 + .../lang-resolution/zig-chains/src/hub.zig | 1 + .../lang-resolution/zig-chains/src/lib.zig | 6 + .../lang-resolution/zig-chains/src/lists.zig | 8 + .../lang-resolution/zig-chains/src/main.zig | 112 + .../lang-resolution/zig-chains/src/nested.zig | 21 + .../lang-resolution/zig-chains/src/op.zig | 7 + .../lang-resolution/zig-chains/src/qa.zig | 6 + .../lang-resolution/zig-chains/src/qb.zig | 6 + .../lang-resolution/zig-chains/src/runner.zig | 7 + .../lang-resolution/zig-chains/src/sub.zig | 8 + .../zig-filestruct/src/Page.zig | 51 + .../zig-filestruct/src/Session.zig | 19 + .../zig-filestruct/src/Sighandler.zig | 17 + .../zig-filestruct/src/Sorter.zig | 50 + .../zig-filestruct/src/aliases.zig | 85 + .../zig-filestruct/src/flow.zig | 117 + .../zig-filestruct/src/generic.zig | 38 + .../lang-resolution/zig-filestruct/src/lp.zig | 5 + .../zig-filestruct/src/main.zig | 33 + .../zig-filestruct/src/reflect.zig | 62 + .../zig-filestruct/src/util.zig | 8 + .../lang-resolution/zig-hub/src/main.zig | 56 + .../lang-resolution/zig-hub/src/op.zig | 7 + .../zig-hub/src/stdx/bounded_array.zig | 9 + .../lang-resolution/zig-hub/src/stdx/prng.zig | 9 + .../lang-resolution/zig-hub/src/stdx/stdx.zig | 6 + .../zig-hub/src/stdx/thing.zig | 9 + .../lang-resolution/zig-hub/src/stdx/util.zig | 3 + .../lang-resolution/zig-idioms/build.zig | 24 + .../lang-resolution/zig-idioms/build.zig.zon | 17 + .../zig-idioms/libs/geo/build.zig | 4 + .../zig-idioms/libs/geo/src/root.zig | 9 + .../zig-idioms/libs/oldlib/src/oldlib.zig | 1 + .../zig-idioms/src/counter.zig | 48 + .../lang-resolution/zig-idioms/src/dump.zig | 4 + .../lang-resolution/zig-idioms/src/holder.zig | 27 + .../lang-resolution/zig-idioms/src/idioms.zig | 11 + .../lang-resolution/zig-idioms/src/main.zig | 65 + .../lang-resolution/zig-idioms/src/mixin.zig | 1 + .../zig-idioms/src/webapi/AbortController.zig | 8 + .../zig-idioms/src/webapi/AbortSignal.zig | 6 + .../zig-qualified-literal/src/a.zig | 3 + .../zig-qualified-literal/src/b.zig | 3 + .../zig-qualified-literal/src/c.zig | 3 + .../zig-qualified-literal/src/d.zig | 3 + .../zig-qualified-literal/src/main.zig | 43 + .../zig-receivers/src/Ledger.zig | 12 + .../zig-receivers/src/counter.zig | 32 + .../zig-receivers/src/main.zig | 47 + .../zig-receivers/src/stdx/bounded_array.zig | 9 + .../zig-receivers/src/stdx/prng.zig | 9 + .../zig-receivers/src/stdx/stdx.zig | 2 + .../lang-resolution/zig-rootmodule/build.zig | 17 + .../zig-rootmodule/src/core.zig | 1 + .../zig-rootmodule/src/main.zig | 7 + .../expected-captures.json | 4 +- gitnexus/test/fixtures/sample-code/simple.zig | 49 + .../test/helpers/grammar-introspection.ts | 6 + gitnexus/test/helpers/literal-collectors.ts | 3 + gitnexus/test/helpers/optional-grammar.ts | 80 + gitnexus/test/integration/parsing.test.ts | 1 + .../resolvers/callable-value-flow.test.ts | 98 + .../resolvers/zig-build-modules.test.ts | 70 + .../test/integration/resolvers/zig.test.ts | 1209 +++++++++ .../structural-pair-coverage.test.ts | 47 +- .../integration/tree-sitter-languages.test.ts | 119 +- .../test/unit/incremental-parse-cache.test.ts | 10 +- gitnexus/test/unit/ingestion-utils.test.ts | 8 +- .../test/unit/optional-grammar-gate.test.ts | 79 + gitnexus/test/unit/parser-loader-abi.test.ts | 11 +- .../unit/parser-loader-skip-optional.test.ts | 11 + .../external-import-conformance.test.ts | 16 + ...import-target-index-reuse.contract.test.ts | 19 + .../references-to-edges-call-form.test.ts | 237 ++ .../typescript/typescript-captures.test.ts | 45 + gitnexus/test/unit/zig-extractors.test.ts | 2129 ++++++++++++++++ .../test/unit/zig-import-resolver.test.ts | 818 +++++++ 156 files changed, 12786 insertions(+), 125 deletions(-) create mode 100644 gitnexus/src/core/ingestion/call-extractors/configs/zig.ts create mode 100644 gitnexus/src/core/ingestion/class-extractors/configs/zig.ts create mode 100644 gitnexus/src/core/ingestion/field-extractors/configs/zig.ts create mode 100644 gitnexus/src/core/ingestion/import-resolvers/configs/zig.ts create mode 100644 gitnexus/src/core/ingestion/import-resolvers/zig.ts create mode 100644 gitnexus/src/core/ingestion/languages/zig.ts create mode 100644 gitnexus/src/core/ingestion/languages/zig/captures.ts create mode 100644 gitnexus/src/core/ingestion/languages/zig/index.ts create mode 100644 gitnexus/src/core/ingestion/languages/zig/interpret.ts create mode 100644 gitnexus/src/core/ingestion/languages/zig/query.ts create mode 100644 gitnexus/src/core/ingestion/languages/zig/range-binding.ts create mode 100644 gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts create mode 100644 gitnexus/src/core/ingestion/languages/zig/simple-hooks.ts create mode 100644 gitnexus/src/core/ingestion/method-extractors/configs/zig.ts create mode 100644 gitnexus/src/core/ingestion/type-extractors/zig.ts create mode 100644 gitnexus/src/core/ingestion/variable-extractors/configs/zig.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-basic/src/ffi.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-basic/src/main.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-basic/src/pioneer.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-buildmodules/build.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-buildmodules/build.zig.zon create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-buildmodules/libs/corelib/build.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-buildmodules/libs/corelib/src/core.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/app/config.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/app/main.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/a.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/b.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/clash_a.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/clash_b.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/helper.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/tool/config.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/tool/main.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-chains/src/Empty.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-chains/src/Host.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-chains/src/counter.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-chains/src/hub.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-chains/src/lib.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-chains/src/lists.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-chains/src/main.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-chains/src/nested.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-chains/src/op.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-chains/src/qa.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-chains/src/qb.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-chains/src/runner.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-chains/src/sub.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/Page.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/Session.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/Sighandler.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/Sorter.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/aliases.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/flow.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/generic.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/lp.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/main.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/reflect.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-filestruct/src/util.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-hub/src/main.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-hub/src/op.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/bounded_array.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/prng.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/stdx.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/thing.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-hub/src/stdx/util.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-idioms/build.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-idioms/build.zig.zon create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-idioms/libs/geo/build.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-idioms/libs/geo/src/root.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-idioms/libs/oldlib/src/oldlib.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-idioms/src/counter.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-idioms/src/dump.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-idioms/src/holder.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-idioms/src/idioms.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-idioms/src/main.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-idioms/src/mixin.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/AbortController.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/AbortSignal.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-qualified-literal/src/a.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-qualified-literal/src/b.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-qualified-literal/src/c.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-qualified-literal/src/d.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-qualified-literal/src/main.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-receivers/src/Ledger.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-receivers/src/counter.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-receivers/src/main.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-receivers/src/stdx/bounded_array.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-receivers/src/stdx/prng.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-receivers/src/stdx/stdx.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-rootmodule/build.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-rootmodule/src/core.zig create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-rootmodule/src/main.zig create mode 100644 gitnexus/test/fixtures/sample-code/simple.zig create mode 100644 gitnexus/test/helpers/optional-grammar.ts create mode 100644 gitnexus/test/integration/resolvers/zig-build-modules.test.ts create mode 100644 gitnexus/test/integration/resolvers/zig.test.ts create mode 100644 gitnexus/test/unit/optional-grammar-gate.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/references-to-edges-call-form.test.ts create mode 100644 gitnexus/test/unit/zig-extractors.test.ts create mode 100644 gitnexus/test/unit/zig-import-resolver.test.ts diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index be7b4d813..4ac6b8845 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -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 diff --git a/README.md b/README.md index a4361584a..8b25f8e09 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/gitnexus-shared/src/language-detection.ts b/gitnexus-shared/src/language-detection.ts index 6d031d761..e805d4e4c 100644 --- a/gitnexus-shared/src/language-detection.ts +++ b/gitnexus-shared/src/language-detection.ts @@ -53,6 +53,7 @@ const EXTENSION_MAP: Record = { [SupportedLanguages.Dart]: ['.dart'], [SupportedLanguages.Vue]: ['.vue'], [SupportedLanguages.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'], + [SupportedLanguages.Zig]: ['.zig'], } satisfies Record; // Ensure exhaustiveness /** Pre-built reverse lookup: extension → language (built once at module load). */ @@ -121,6 +122,7 @@ const SYNTAX_MAP: Record = { [SupportedLanguages.Dart]: 'dart', [SupportedLanguages.Vue]: 'typescript', [SupportedLanguages.Cobol]: 'cobol', + [SupportedLanguages.Zig]: 'zig', } satisfies Record; // Ensure exhaustiveness /** Non-code file extensions → Prism-compatible syntax identifiers */ diff --git a/gitnexus-shared/src/languages.ts b/gitnexus-shared/src/languages.ts index 29edb8d1e..d16adbd1a 100644 --- a/gitnexus-shared/src/languages.ts +++ b/gitnexus-shared/src/languages.ts @@ -22,4 +22,5 @@ export enum SupportedLanguages { Vue = 'vue', /** Standalone regex processor — no tree-sitter, no LanguageProvider. */ Cobol = 'cobol', + Zig = 'zig', } diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts index e2a90c253..f6038b3f0 100644 --- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -1072,6 +1072,7 @@ const CALLABLE_OR_TYPE_LIKE: ReadonlySet = new Set([ 'Interface', 'Enum', 'Struct', + 'Union', 'Record', 'Trait', 'Namespace', diff --git a/gitnexus-shared/src/scope-resolution/language-classification.ts b/gitnexus-shared/src/scope-resolution/language-classification.ts index 10c556cda..20059c3e9 100644 --- a/gitnexus-shared/src/scope-resolution/language-classification.ts +++ b/gitnexus-shared/src/scope-resolution/language-classification.ts @@ -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 resolved target. A change here is a BEHAVIOUR change: the resolver returned a different target set, and IMPORTS/CALLS edges moved. Explain it, never re-baseline to make CI green. For the languages these PRs changed, the pre-change implementations produce these same values on this corpus at both 400 and 1600 files \u2014 that is what makes the index hoist a performance change. The tie-break-level proof lives in test/unit/scope-resolution/import-target-index-parity.test.ts (verbatim copies of the pre-change code, diffed) for Kotlin's current declared-package behavior in test/unit/kotlin-module-resolution.test.ts, and for the four resolvers added there in test/unit/scope-resolution/{php,java,cobol}-import-target-parity.test.ts and test/unit/import-resolvers/csharp-csproj-parity.test.ts, and for JavaScript in test/unit/scope-resolution/javascript-import-target-parity.test.ts (a differential over 211200 old-vs-new pairs, PR #2911). The eight languages added last have no per-language parity harness against a pre-change implementation and do NOT need one: nothing about their resolution changed, so there is no before to diff against. Their fingerprints are pure forward guards, minted from the current implementations, and their adapter-boundary index reuse is covered for every registered language at once by test/unit/scope-resolution/import-target-index-reuse.contract.test.ts. NOTE for csharp_csproj: on this corpus the #2902 indexed leg (step 3 of resolveCSharpImportInternal) is reached by 2221 of the 3200 small-arm imports but answers null for every one of them \u2014 the 979 that resolve do so at step 2 \u2014 so this fingerprint pins that legs cost and its null answers, while its positive tie-breaks (unanchored substring, iteration order) are pinned by csharp-csproj-parity.test.ts. NOTE for kotlin, go, csharp and java: twenty fingerprints across these four languages were re-baselined in #2881, the one deliberate behaviour change any language in this file has had. It landed in two steps and the second is the reason the first is not a special case: Kotlin first, then the shared package-dir-index (go, java, csharp) and the csproj namespace index once the same rule was found live there. `getKotlinFileIndex` no longer requires a file's package directory to be the FIRST occurrence of that name in its own path, so the unique arm's `d % 7` nested slice (`mod{d}/src/main/kotlin/com/example/pkg{d}/inner/pkg{d}`) now belongs to package `pkg{d}` and its wildcard imports resolve: resolved 1100 -> 1153 small and deep, 4456 -> 4681 large. The collide arm needed a CORPUS edit alongside it, not just a new number \u2014 its `d % 7` slice deliberately imported `com.example.vendor{d}`, a package that exists nowhere, purely to mirror the unique arm's nested-slice MISS, so leaving it would have left collide at 1100 against small's 1153 and broken the same-workload invariant the arm is built on (that assertion is what caught it). It now uses the same `com.example.models.*` spelling as the rest of the arm, which is why its distinct_outcomes fell (2775 -> 2744, 11087 -> 10961): one shared target instead of one per d. The record-level evidence for the resolver change \u2014 235 of 19968 records moved, 54 null -> resolved, 0 buckets losing a member \u2014 is in bench/kotlin-import-target/baselines.json `_provenance`. The kotlin heap_reading_bytes and heap_ceiling_bytes moved with it, together as `_heap_reading_note` requires: 48073096 -> 48200224 bytes_large (+127128, +0.264%), ceiling still exactly 1.5x. Small, and it is worth saying WHY it is small rather than reading the number as evidence that the change is cheap. `dirChildren` grows by one entry per component-suffix the old rule used to skip, and this arm can only see part of that: the heap corpus is built with HEAP_PAD 8, which prefixes every path with `d0/\u2026/d7/`, so no path can begin with a suffix of its own directory and the leading-segment half of the old rule is structurally invisible here. What moves the reading is the `d % 7` nested slice alone. Read +0.264% as this arm's ceiling on the effect, not as the effect. GO NEEDED A CORPUS EDIT TO BE GATED AT ALL. Its nested slice was `src/pkg{d}/internal/pkg{d}`, repeating only the LAST segment, while a Go query addresses the whole package path `src/pkg{d}` \u2014 so the directory never even ended with the query and the first-occurrence rule was never reached. Every go arm sat unchanged through the resolver fix. `uniqueDir`/`collideDir` now repeat the shape at the granularity Go actually queries (`src/pkg{d}/internal/src/pkg{d}`, `svc{d}/internal/sub/svc{d}/internal`), which is what moved go from 979 to 1153 resolved and bumped `languages.go.heap.path_segments` 13 -> 14. The general lesson: a corpus that carries a shape the QUERY cannot express does not gate that shape. CSHARP AND JAVA HIT THE SAME COLLIDE-ARM TRAP AS KOTLIN. Both collide arms sent their `d % 7` slice to a namespace that exists nowhere (`App.Src{d}.Vendor`, `com.svc{d}.vendor`) purely to MIRROR the unique arm's nested-slice miss; once that miss became a hit, collide sat at 979/1100 against small's 1153 and the same-workload assertion failed. Both now use the same spelling as the rest of their arm. HEAP: no reading here moved for the resolver change. An earlier revision of this branch re-recorded `csharp_csproj` 73703384 -> 73116520 as a -0.79% effect of the step-2 filter; review measured base and branch three times each and got the same 73.10e6 on BOTH sides \u2014 the recorded 73703384 was simply not reproducible on this box, and re-recording it would have dropped that language's derived floor by 0.8% for no reason belonging to this change. Reverted. Everything else sat within +/-0.03%. Note that `_heap_reading_note`'s claim that these readings 'reproduce to the byte across processes on one box' did NOT hold on the box this was measured on: go, dart, ruby, python, php and cpp all wandered by a few hundred to a few thousand bytes between processes with no code change touching them. Treat sub-0.05% movement as jitter, not signal. HEAP, kotlin, second movement: 48200224 -> 42802456 (-11.20%), re-recorded with its ceiling. `getKotlinFileIndex` now compacts each `dirChildren` bucket as it freezes it. `addChild` mints a bucket as `[raw]` and pushes the rest, and V8 grows a backing store by `old + old/2 + 16`, so the second child takes a 1-slot store to 17: 61144 buckets, 52.9% of their slots empty, 88 B each. Same fix and same accounting as the python `byBasename` sentence above. Note what this means for the gate: a memory WIN of this size passes every arm \u2014 it is under the ceiling and over the 0.5x floor \u2014 so it is recorded because the convention says a reading and its ceiling move together, not because anything went red. kotlin now reads 40.82 MiB. The prose in measure.mjs calling it '45.85 MiB, the second-largest reading in this file' is corrected with it \u2014 and was already wrong on the ranking before this change, since csharp_csproj (69.73) and php (47.28) both read higher; kotlin was third. A measurement written into prose is not re-taken, which is the finding `_heap_bound_note` records about this very file. One further corpus edit, made in review and MEASURED rather than assumed: kotlin's collide layout repeated only the `models` leaf (`\u2026/com/example/models/inner/models`) while a Kotlin query addresses the whole dotted path, so a full revert of the Kotlin guards left both collide fingerprints UNMOVED \u2014 the arm was blind to the rule it was re-baselined for. Deepening it to `\u2026/models/inner/com/example/models` makes the revert move both, and those two fingerprints are the only ones that changed for it. The same deepening was applied to the java and kotlin UNIQUE arms and REVERTED: it moved ten more fingerprints, grew java's heap reading 43%, and bought nothing \u2014 progressive stripping lands those queries on the same file with or without the rule, so the control still failed only on go.", "_shape_note": "files/imports/resolved/distinct_outcomes AND the fingerprint are asserted exactly, per scale. A fingerprint alone cannot tell a legitimate resolution change from a corpus quietly shrunk below the size at which the timing arms can see anything; conversely the counts alone cannot see a defect confined to one arm, because the arms differ only in path padding and directory layout and both of those are count-neutral by design. Two cross-arm assertions close the remaining hole: the deep and collide arms must resolve exactly what small resolves (they are the same workload), and each of their fingerprints must DIFFER from small's (they are not the same corpus). Without the second, setting DEEP_PAD to 0 \u2014 which deletes the entire depth arm \u2014 moves no asserted number and prints PASS; the same is true of a collideDir that forwards to uniqueDir. THE HEAP ARM IS ASSERTED THE SAME WAY, by the same loop, and was not before: files_small, files_large, path_segments and probe decide WHAT it measures, and every one of them was reported and compared to nothing. Swapping HEAP_PROBE_TARGET.csharp_csproj for a target matching no CSPROJ_CONFIGS rootNamespace skips the whole config loop, so the getFilesInDir and getInsensitive legs never run and the arm the header calls the witness that the read pattern IS the footprint quietly becomes a two-map arm \u2014 73703384 -> 59921216 B, ratio 1.017 -> 1.011, ceiling and floor both still passing and --check still exiting 0. Setting HEAP_SMALL equal to HEAP_LARGE is the same hole from the other side: ratio goes to ~1.0 by construction and bytes_large never moves. bytes_small and bytes_large are deliberately NOT asserted for equality \u2014 heap_ceiling_bytes and the heap_reading_bytes floor bound them with ~50% either way, because heapUsed accounting moves across platforms and Node majors and an exact byte assertion would be a re-baseline per runner. THE CONTEXT ARM IS ASSERTED THE SAME WAY, by the same loop, and more strictly than either: target, with_context and without_context are exact strings with no tolerance at all, because the arm resolves one import over a three-file corpus and has no measurement noise to tolerate. A separate check requires the last two to DIFFER, for the same reason deep.fingerprint must differ from small.fingerprint \u2014 a probe on which both call shapes agree asserts one number twice. Both halves run through resolveOne, so what the arm gates is this bench threading run.ts's fifth argument, not the resolvers' behaviour.", @@ -26,7 +27,8 @@ "typescript": 1.8, "vue": 1.8, "c": 3.8, - "cpp": 4 + "cpp": 4, + "zig": 1.8 }, "depth_budget": { "go": 1.4, @@ -45,7 +47,8 @@ "typescript": 2.6, "vue": 2.3, "c": 3, - "cpp": 3 + "cpp": 3, + "zig": 2.4 }, "small_ms_ceiling": { "go": 7, @@ -64,7 +67,8 @@ "typescript": 85, "vue": 81, "c": 7, - "cpp": 7 + "cpp": 7, + "zig": 4 }, "collide_ms_ceiling": { "go": 28, @@ -83,7 +87,8 @@ "typescript": 86, "vue": 93, "c": 11, - "cpp": 12 + "cpp": 12, + "zig": 7 }, "heap_ceiling_bytes": { "kotlin": 6800000, @@ -120,7 +125,8 @@ "rust": 1048576, "javascript": 1048576, "typescript": 1048576, - "vue": 1048576 + "vue": 1048576, + "zig": 1048576 }, "heap_floor_fraction": 0.5, "heap_ratio_budget": 1.25, @@ -1014,6 +1020,57 @@ "scaling_ratio": 1.167, "small_ms": 1.626 } + }, + "zig": { + "small": { + "files": 400, + "imports": 3200, + "resolved": 979, + "distinct_outcomes": 2998, + "fingerprint": "d2e6e88606d3498ac58f1f099221a278c774d2078c9d8fc92e01085cb38b8f69" + }, + "large": { + "files": 1600, + "imports": 12800, + "resolved": 4064, + "distinct_outcomes": 11920, + "fingerprint": "f5d833f2449f3375349d37621360a817c9fba574bb33ad2b7dbc8ed51fe46462" + }, + "deep": { + "files": 400, + "imports": 3200, + "resolved": 979, + "distinct_outcomes": 2998, + "fingerprint": "8f1e9e149684d5a7f75b5424a2757162f4f92dd440fcb5839fcc4ab851d67914" + }, + "collide": { + "files": 400, + "imports": 3200, + "resolved": 979, + "distinct_outcomes": 2998, + "fingerprint": "7d456e9bbf5b53acdc2aaad0a558a8513e969928f1cf687f2d3f8a8b957ff653" + }, + "collide_large": { + "files": 1600, + "imports": 12800, + "resolved": 4064, + "distinct_outcomes": 11920, + "fingerprint": "91f6ef7a1f5886f284361488a7e908f0bba27124df0683085baf98fa1a52bdc3" + }, + "fingerprint": "f5d833f2449f3375349d37621360a817c9fba574bb33ad2b7dbc8ed51fe46462", + "heap": { + "files_small": 8000, + "files_large": 32000, + "path_segments": 11, + "probe": "../vendor0/missing.zig" + }, + "_measured": { + "collide_ms": 1.7, + "collide_scaling_ratio": 1.011, + "depth_ratio": 1.573, + "scaling_ratio": 1.05, + "small_ms": 1.036 + } } }, "_blind_spot": "MEASURED, so nobody has to rediscover it: a full workspace scan reintroduced on 1-in-32 imports passes EVERY arm here \u2014 dart scored 1.458 scaling and 1.736 ms against the 1.8 budget and 4 ms ceiling of an earlier revision. At 1-in-8 the scaling arm catches it (2.414). The gate that NARROWS this is not a timing gate at all: test/unit/scope-resolution/import-target-index-parity.test.ts counts iterations of the file-set Set and reads 14 instead of 1 for that same 1-in-32 mutation, deterministically and for all five languages. It does NOT close it. The counter watches the Set, and the resolvers no longer read the Set \u2014 they read materialized copies of the same file list: WorkspaceFileIndex.normalized and .all (C#, Ruby), Dart's byBasename buckets, and PackageDirIndex.filesByDir (Go, C#). A 1-in-32 scan over any of those three touches the Set zero extra times, so it passes the parity test AND passes --check. Closing it would take an iteration counter on the materialized arrays themselves. Read the two gates together; tightening these ceilings toward the noise floor to chase that case would only buy flaky CI. CONFIRMED THE HARD WAY by PR #2911: JavaScript resolution was scanning ImportPassCache.normalizedFileList on every import \u2014 a materialized array, not the Set \u2014 at 25972 us per import at 8000 files, and no instrument on the #2901-#2909 branch could see it. It took a differential parity test over 211200 old-vs-new pairs to find. The arms added here would have caught THAT one on absolute ms (85 ms budget against a 20 ms arm; the unindexed resolver costs ~83000 ms on the same corpus), which is the argument for gating every registered language rather than only the ones a PR happens to touch. THE SECOND BLIND SPOT IS CLOSED, and this records what closing it changed. This harness used to call the inner resolvers with the NO-CONTEXT shape: run.ts calls provider.resolveImportTarget with five arguments, the fifth being { parsedFiles, parsedImport }, and resolveOne supplied three. resolveOne now makes the production call, newPass mints the ParsedFile[] FIRST and derives the path set from it exactly as run.ts does, and both legs behind the argument run on every import of their arms \u2014 PHP's named/alias function-or-const leg over filesByDirectory(context.parsedFiles), whose memo defeated measures 197.0 us -> 9976.2 us per import (50.6x), and Python's from-import submodule-precedence branch, the only spelling that reads context.parsedFiles at all. Fifteen of the seventeen arms cannot observe a context (their hooks declare three or four parameters) and are handed none, so their numbers did not move; which two CAN is now reconciled against SCOPE_RESOLVERS' hook arity rather than asserted in prose. NOTHING ELSE IN THIS FILE COULD HAVE GATED IT, which is why the context arm exists: fingerprints and shape can remain unchanged while dropping context only makes timing faster. The deterministic context arm is therefore the guard for this wiring. The arm is one import per language resolved through resolveOne twice, with and without the pass's parsedFiles, whose two answers must DIFFER and must both match what is recorded. WHAT REMAINS UNMEASURED, narrowed rather than deleted: Python's parsedFileByPath memo is exercised by the five timing arms and cannot be reached by the heap arm at all, because retainedPassBytes requires a probe that MISSES while every path that builds that memo returns a non-null packageTarget \u2014 so no ceiling bounds that Map (one pointer per parsed file, O(files), no depth term) and the contract test's count gate is what holds it to one build per pass. PHP's sole arm carries a representative Composer PSR-4 map, so mapped hits and authoritative misses exercise that production branch directly. And the const tail of PHP's leg is a different ANSWER at the same cost \u2014 it runs the identical candidate gather and localDefs filter and diverges in the last two lines \u2014 so it is gated by count in test/unit/scope-resolution/import-target-index-reuse.contract.test.ts, which stays the gate to read alongside this file.", diff --git a/gitnexus/bench/import-target/measure.mjs b/gitnexus/bench/import-target/measure.mjs index c7ea7cc0e..68193154d 100644 --- a/gitnexus/bench/import-target/measure.mjs +++ b/gitnexus/bench/import-target/measure.mjs @@ -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 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 diff --git a/gitnexus/bench/python-scope/baseline-fingerprint.txt b/gitnexus/bench/python-scope/baseline-fingerprint.txt index aff56e0d5..0a4bb9019 100644 --- a/gitnexus/bench/python-scope/baseline-fingerprint.txt +++ b/gitnexus/bench/python-scope/baseline-fingerprint.txt @@ -1 +1 @@ -2600a1f6f8a042eb4f520a7870c34d9ca292765824537c3bc861b40dac8769a8 +7412edd9db56b4626c77fc09363e0d49a2f95763453756dceb82db6606280c28 diff --git a/gitnexus/bench/receiver-resolution/baseline.json b/gitnexus/bench/receiver-resolution/baseline.json index cf8b8abeb..c89c2c6e3 100644 --- a/gitnexus/bench/receiver-resolution/baseline.json +++ b/gitnexus/bench/receiver-resolution/baseline.json @@ -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 } } } diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 7dcd85c15..272d081c0 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -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` (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 repo;` (bare template_type) and `std::vector items;` (qualified_identifier wrapping a template_type) \u2014 plus the header declaring `template 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(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(token, ...)` (admin.ts/auth.ts) KEEPS `direct-callee-name|verifyToken`: tree-sitter-typescript parses `await f(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." } } diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index c000bce5b..924776110 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -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", diff --git a/gitnexus/package.json b/gitnexus/package.json index 1e15ad9b4..d99734523 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -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": { diff --git a/gitnexus/src/cli/optional-grammars.ts b/gitnexus/src/cli/optional-grammars.ts index 736ee6628..5c6eadf68 100644 --- a/gitnexus/src/cli/optional-grammars.ts +++ b/gitnexus/src/cli/optional-grammars.ts @@ -7,19 +7,23 @@ * prebuilds activated via node-gyp-build. All can be skipped via * GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (postinstall scripts), or can silently * soft-fail when no prebuild matches the host platform (and a source build was - * unavailable / not attempted). + * unavailable / not attempted). tree-sitter-zig is the one npm-installed + * optionalDependency in the list; its `probe` overrides the vendored load. * * Either path produces the same observable: the .node binding is absent * at runtime. This helper detects that condition and surfaces a single - * stderr line per missing grammar so users learn why .dart/.proto/.swift/.kt + * stderr line per missing grammar so users learn why .dart/.proto/.swift/.kt/.zig * support is unavailable instead of silently getting a degraded index. */ +import { createRequire } from 'node:module'; import { SupportedLanguages } from 'gitnexus-shared'; import { isGrammarRuntimeSkipped } from '../core/tree-sitter/parser-loader.js'; import { requireVendoredGrammar } from '../core/tree-sitter/vendored-grammars.js'; import { cliWarn } from './cli-message.js'; +const _require = createRequire(import.meta.url); + interface OptionalGrammar { /** Display name in warnings */ name: string; @@ -34,6 +38,12 @@ interface OptionalGrammar { * `.proto`, which is a gRPC-extractor concern, not a SupportedLanguages. */ language?: SupportedLanguages; + /** + * Availability probe. Defaults to `requireVendoredGrammar(pkg)`; grammars + * that install from npm as an optionalDependency (zig) override it with a + * plain `require` of the package. + */ + probe?: () => unknown; } const OPTIONAL_GRAMMARS: OptionalGrammar[] = [ @@ -56,6 +66,14 @@ const OPTIONAL_GRAMMARS: OptionalGrammar[] = [ extensions: ['.kt', '.kts'], language: SupportedLanguages.Kotlin, }, + { + name: 'tree-sitter-zig', + pkg: '@tree-sitter-grammars/tree-sitter-zig', + extensions: ['.zig'], + language: SupportedLanguages.Zig, + // npm optionalDependency, not vendored — probe via plain require. + probe: () => _require('@tree-sitter-grammars/tree-sitter-zig'), + }, ]; /** @@ -105,7 +123,11 @@ export function detectMissingOptionalGrammars(): MissingGrammar[] { continue; } try { - requireVendoredGrammar(g.pkg); + if (g.probe !== undefined) { + g.probe(); + } else { + requireVendoredGrammar(g.pkg); + } } catch (err) { const code = (err as NodeJS.ErrnoException | undefined)?.code; const msg = err instanceof Error ? err.message : String(err); diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/zig.ts b/gitnexus/src/core/ingestion/call-extractors/configs/zig.ts new file mode 100644 index 000000000..93f3621ff --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/configs/zig.ts @@ -0,0 +1,6 @@ +import { SupportedLanguages } from 'gitnexus-shared'; +import type { CallExtractionConfig } from '../../call-types.js'; + +export const zigCallConfig: CallExtractionConfig = { + language: SupportedLanguages.Zig, +}; diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/zig.ts b/gitnexus/src/core/ingestion/class-extractors/configs/zig.ts new file mode 100644 index 000000000..690a9ff56 --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/configs/zig.ts @@ -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, +}; diff --git a/gitnexus/src/core/ingestion/class-extractors/generic.ts b/gitnexus/src/core/ingestion/class-extractors/generic.ts index 39e060e00..e20d84829 100644 --- a/gitnexus/src/core/ingestion/class-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/class-extractors/generic.ts @@ -57,6 +57,7 @@ const CLASS_LIKE_LABELS = new Set([ '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] ?? diff --git a/gitnexus/src/core/ingestion/class-types.ts b/gitnexus/src/core/ingestion/class-types.ts index f71fa5531..037bb198d 100644 --- a/gitnexus/src/core/ingestion/class-types.ts +++ b/gitnexus/src/core/ingestion/class-types.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/export-detection.ts b/gitnexus/src/core/ingestion/export-detection.ts index 17494e7bb..143358c6f 100644 --- a/gitnexus/src/core/ingestion/export-detection.ts +++ b/gitnexus/src/core/ingestion/export-detection.ts @@ -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; +} diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/zig.ts b/gitnexus/src/core/ingestion/field-extractors/configs/zig.ts new file mode 100644 index 000000000..e842d154d --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/configs/zig.ts @@ -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; + }, +}; diff --git a/gitnexus/src/core/ingestion/field-extractors/generic.ts b/gitnexus/src/core/ingestion/field-extractors/generic.ts index d102ca1a8..7e22053ee 100644 --- a/gitnexus/src/core/ingestion/field-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/field-extractors/generic.ts @@ -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; } diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/zig.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/zig.ts new file mode 100644 index 000000000..a191df9b6 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/zig.ts @@ -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], +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/zig.ts b/gitnexus/src/core/ingestion/import-resolvers/zig.ts new file mode 100644 index 000000000..8b66a2626 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/zig.ts @@ -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 + * `/src/root.zig`, `/src/.zig`, + * `/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("")` 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, + modules: readonly ZigBuildModule[], +): string | null | undefined { + const targets = new Set(); + 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, + 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/.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; +} diff --git a/gitnexus/src/core/ingestion/language-config.ts b/gitnexus/src/core/ingestion/language-config.ts index 11d50a9b0..5d8fc9061 100644 --- a/gitnexus/src/core/ingestion/language-config.ts +++ b/gitnexus/src/core/ingestion/language-config.ts @@ -179,6 +179,69 @@ export interface SwiftPackageConfig { targets: Map; } +/** 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; + /** + * 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("")` 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; + /** + * 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; + /** + * 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("", …)` 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; 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; +} + // ============================================================================ // LANGUAGE-SPECIFIC CONFIG LOADERS // ============================================================================ @@ -548,6 +611,703 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise")`. 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: `. = .{ ... }` + * where `` 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// or ~/.cache/zig/p//) 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 { + 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("")` is how single-package repos refer + // to their root file from every other file. + let rootModules: Map | 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(); + // 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>(); + 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(); + 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 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(); + 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("", .{ .root_source_file = b.path("

.zig"), … })` + * names the module directly; + * - `const m = b.createModule(.{ .root_source_file = b.path("

.zig"), … })` + * (or `const m = b.addModule(…)`) bound to an identifier and later named + * by `x.addImport("", m)` or `.imports = &.{ .{ .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("", 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 { + const text = stripZonComments(buildZig); + const mask = zonStringMask(text); + const modules = new Map(); + // identifier → repo-relative root, for `const m = b.createModule(…)` / + // `const m = b.addModule(…)` bindings later named via addImport. + const bindings = new Map(); + 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("", .{ .root_source_file = b.path("

.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("

.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; + * - `.addImport("", )`, `.root_module.addImport(…)` + * and the `.imports = &.{ .{ .name = "", .module = } }` + * 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("")` with `const dep = b.dependency("", …)`, + * 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>, +): 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; + } + const drafts: Draft[] = []; + const bindings = new Map(); // 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 = ` 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("", …)` bindings, for `dep.module("…")`. + const dependencyBindings = new Map(); + 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 — `.addImport("", )` / `.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 = "", .module = }, … }` + // 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(); + // Walk each `. = .{ ... }` 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 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::`), 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 `$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 + * (`