diff --git a/gitnexus/bench/import-target/baselines.json b/gitnexus/bench/import-target/baselines.json index d8d50b015..a02f23e0a 100644 --- a/gitnexus/bench/import-target/baselines.json +++ b/gitnexus/bench/import-target/baselines.json @@ -1,6 +1,7 @@ { "_what": "Baselines for bench/import-target/measure.mjs cover every import-target resolver registered in SCOPE_RESOLVERS on a shared corpus, plus a configured C# arm for the branch the default call cannot reach. measure.mjs derives its arm inventory from LANG_REGISTRY and --check reconciles the registered languages in both directions. The single PHP arm supplies its production PSR-4 Composer mapping; csharp_csproj supplies csproj configuration. C and C++ also receive their production resolutionConfig header corpus. The timing, shape, fingerprint, context, and retained-heap gates therefore cover each production resolver path without splitting PHP into configured and unconfigured identities.", "_rebaselined_2960_kotlin_declared_packages": "Kotlin now resolves only from parsed package facts and local module bindings. This deliberately changes its five fingerprints, removes path-depth sensitivity, adds the context probe, and reduces the 32000-file retained index from 40.82 MiB to 4.31 MiB. External same-name path decoys now remain unresolved.", + "_zig_arm_1432": "zig was registered in SCOPE_RESOLVERS by PR #1432 with no arm here, which is the exact hole the inventory arm exists for, and it reported it. The arm passes NO build config: resolveZigImportInternal walks an importer-relative @import path component by component and probes allFiles.has() twice (as spelled, then + '.zig'), so its fingerprints pin that walk alone and do not move when build.zig / build.zig.zon parsing (the bare-name legs, gated by test/unit/zig-import-resolver.test.ts) changes. Same corpus proportions as rust — 979 of 3200 resolve at 400 files — and rust's collide design, a deep tree whose spellings carry thirteen components against the unique arm's three, because component count is the only axis the cost has. Measured on one box: small 1.036 ms, collide 1.700 ms, scaling 1.050, collide scaling 1.011, depth 1.573; budgets take the file's usual ~1.5x on ratios (2.4 depth) and ~4x on absolute ms (4 / 7). Heap reads 16 B at both 8000 and 32000 files — it builds nothing, exactly rust's reading — so it sits in heap_bound_bytes at rust's absolute 1048576 B.", "_php_composer_gate_2962": "The canonical PHP arm supplies an authoritative App PSR-4 mapping. A deterministic Vendor0 suffix decoy makes deletion of the external gate change every timing fingerprint, while the heap arm separately pins a mapped miss, the rendered mapping, and the external null result. Three serial samples measured depth ratios 1.058, 1.114, and 1.158; the 1.8 budget is 1.55x the observed maximum. The mapped-miss heap reading peaked at 39607216 retained bytes at 32000 files.", "_fingerprint_note": "Per-language sha256 over every distinct fromFile|target -> 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/src/core/ingestion/import-resolvers/zig.ts b/gitnexus/src/core/ingestion/import-resolvers/zig.ts index 8a36571ca..418d849ea 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/zig.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/zig.ts @@ -14,19 +14,81 @@ * 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 root build.zig's own named modules - * come first (`rootModules`, name → root file — a repo with no build.zig.zon - * still resolves them). `.url`-based deps unpack into a build cache outside + * 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 ZigBuildZonConfig } from '../language-config.js'; +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. That is fail-closed on purpose: picking either would mint a + * confident wrong `IMPORTS` edge for half the files, which is the + * first-wins defect this table exists to remove. + */ +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 && allFiles.has(target)) 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. @@ -78,6 +140,22 @@ export function resolveZigImportInternal( // 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 diff --git a/gitnexus/src/core/ingestion/language-config.ts b/gitnexus/src/core/ingestion/language-config.ts index b1f53c1a9..e4359bcbb 100644 --- a/gitnexus/src/core/ingestion/language-config.ts +++ b/gitnexus/src/core/ingestion/language-config.ts @@ -212,6 +212,34 @@ export interface ZigBuildZonConfig { * 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; } // ============================================================================ @@ -631,8 +659,9 @@ export async function loadZigBuildConfig(repoRoot: string): Promise")` is how single-package repos refer // to their root file from every other file. let rootModules: Map | undefined; + let rootBuildZig: string | null = null; try { - const rootBuildZig = await fs.readFile(path.join(repoRoot, 'build.zig'), 'utf-8'); + rootBuildZig = await fs.readFile(path.join(repoRoot, 'build.zig'), 'utf-8'); const parsed = parseZigRootModules(rootBuildZig); if (parsed.size > 0) rootModules = parsed; } catch { @@ -640,7 +669,15 @@ export async function loadZigBuildConfig(repoRoot: string): Promise 0 ? { buildModules } : {}), + }; } // A path dep's importable root is whatever ITS build.zig declares, not a @@ -648,6 +685,9 @@ export async function loadZigBuildConfig(repoRoot: string): Promise(); + // 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; @@ -657,15 +697,21 @@ export async function loadZigBuildConfig(repoRoot: string): Promise - rel === '' ? r : `${rel}/${r}`, - ); + 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 } : {}), }; } @@ -805,6 +851,205 @@ export function parseZigRootModules(buildZig: string): Map { 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); + const rootRe = /\.root_source_file\s*=\s*b\.path\(\s*"([^"\n]+)"\s*\)/; + const bindingRe = /(?:const|var)\s+([A-Za-z_]\w*)\s*=\s*(?:[A-Za-z_]\w*\.)*$/; + const staticRoot = (args: string): string | null => { + const rootMatch = rootRe.exec(args); + const root = rootMatch ? normalizeZigDepPath(rootMatch[1]!) : null; + return root === null || root === '' || !root.endsWith('.zig') ? null : root; + }; + + // 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 = bindingRe.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 = staticRoot(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 = bindingRe.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 = bindingRe.exec(text.slice(0, m.index)); + if (binding) pendingArtifactAliases.push({ ident: binding[1]!, module: rootModule[2]! }); + } + continue; + } + const root = staticRoot(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, + })); +} + /** * Index of the `)` matching the `(` that precedes `start`, skipping parens * inside `"…"` literals. -1 when unbalanced. Call on comment-stripped text. diff --git a/gitnexus/src/core/ingestion/languages/zig.ts b/gitnexus/src/core/ingestion/languages/zig.ts index 40261478c..63d70f217 100644 --- a/gitnexus/src/core/ingestion/languages/zig.ts +++ b/gitnexus/src/core/ingestion/languages/zig.ts @@ -96,7 +96,11 @@ export const zigProvider = defineLanguage({ captureMap['definition.struct'] ?? captureMap['definition.enum'] ?? captureMap['definition.union']; - return decl !== undefined && isZigRedundantContainerCapture(decl, captureMap['name']); + if (decl === undefined) return false; + // The file-struct rules over-match (a `@This` first parameter, a + // top-level `@This()` alias — see ZIG_QUERIES); the one predicate decides. + if (decl.type === 'source_file') return !isZigFileStruct(decl); + return isZigRedundantContainerCapture(decl, captureMap['name']); } return false; }, diff --git a/gitnexus/src/core/ingestion/languages/zig/captures.ts b/gitnexus/src/core/ingestion/languages/zig/captures.ts index 002567fc3..51bd11cac 100644 --- a/gitnexus/src/core/ingestion/languages/zig/captures.ts +++ b/gitnexus/src/core/ingestion/languages/zig/captures.ts @@ -5,6 +5,7 @@ import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; import { hasZigPubKeyword } from '../../export-detection.js'; +import { normalizeZigTypeName } from './interpret.js'; /** Zig container node types: `struct`, `enum`, `union` and the fieldless * `opaque` all bind through `const T = {…}` and may own methods. @@ -33,16 +34,25 @@ export function zigImportRootOf(value: SyntaxNode | null): SyntaxNode | null { } /** Is this `@import(…)` builtin the receiver of a member call — - * `@import("dump.zig").root(...)` — i.e. the `object` of a `field_expression` - * that is the `function` of a `call_expression`? A deeper chain - * (`@import("x.zig").Foo.init()`) is not: its receiver is `….Foo`, and the - * builtin is only the module the chain starts from. */ + * `@import("dump.zig").root(...)` — or of a qualified construction — + * `@import("a.zig").Thing{…}` — i.e. the `object` of a `field_expression` + * that is the `function` of a `call_expression` or the type of a + * `struct_initializer`? A deeper chain (`@import("x.zig").Foo.init()`) is + * not: its receiver is `….Foo`, and the builtin is only the module the chain + * starts from (the namespace chain walk resolves it from there). */ export function isZigInlineImportReceiver(importNode: SyntaxNode): boolean { const field = importNode.parent; if (field?.type !== 'field_expression') return false; if (field.childForFieldName('object')?.id !== importNode.id) return false; - const call = field.parent; - return call?.type === 'call_expression' && call.childForFieldName('function')?.id === field.id; + const use = field.parent; + if (use === null || use === undefined) return false; + if (use.type === 'call_expression') return use.childForFieldName('function')?.id === field.id; + // `@import("a.zig").Thing{…}` — the module is the receiver of a qualified + // CONSTRUCTION (the `@reference.call.constructor` rule with a receiver, see + // the query), exactly as it is of a member call. Without the namespace + // binding the site had a receiver nothing was bound to, so the aggregate + // event had no target (PR #1432 review, 8.11). + return use.type === 'struct_initializer' && use.namedChild(0)?.id === field.id; } /** Is this variable_declaration a container binding (`const T = struct {…}`) @@ -106,14 +116,26 @@ export function isZigKeywordDeclaration(declNode: SyntaxNode): boolean { return false; } -/** Is `root` a FILE-STRUCT — a `.zig` file whose top level declares at least - * one container field? In Zig every file is a struct; one with fields is an - * instantiable type whose name is the file stem (`Page.zig` declares `Page`, - * `@typeName(Page)` is `"Page"`), and its top-level `fn`s taking `self` are - * its methods. A file WITHOUT fields is only a namespace and stays a Module: - * its fns keep their `Function` ids. The MISSING-identifier placeholder - * tree-sitter-zig recovers for an empty body is not a field (see - * `zigFieldConfig.extractName`). */ +/** Is `root` a FILE-STRUCT — a `.zig` file that IS a type? In Zig every file + * is a struct; the ones that matter as types are named after the file stem + * (`Page.zig` declares `Page`, `@typeName(Page)` is `"Page"`) and their + * top-level `fn`s taking a receiver are its methods. Two signals, either one + * suffices: + * - the top level declares at least one container field (an instantiable + * type with state); the MISSING-identifier placeholder tree-sitter-zig + * recovers for an empty body is not a field (see + * `zigFieldConfig.extractName`); + * - a top-level `fn` takes the file's OWN type as its first parameter — + * `self: *@This()`, or `self: *Self` with `const Self = @This();` at + * file level. A zero-sized file type (`Empty.zig`: no field, a `Self` + * alias and `pub fn ping(self: *Self)`) is still constructed (`Empty{}`) + * and dispatched on by importers; keyed on fields alone it lost its + * `Struct`, its `HAS_METHOD`s and every `e.ping()` edge (PR #1432 + * review, 8.12). + * A file with neither is a namespace and stays a Module: its fns keep their + * `Function` ids, and a `const js = @This();` there stays a Const. Only the + * receiver TYPE decides, never the parameter name: `fn f(self: Foo)` in a + * utility file is a free function that happens to call its argument `self`. */ export function isZigFileStruct(root: SyntaxNode | null | undefined): boolean { if (root?.type !== 'source_file') return false; for (let i = 0; i < root.namedChildCount; i++) { @@ -122,6 +144,20 @@ export function isZigFileStruct(root: SyntaxNode | null | undefined): boolean { const name = child.childForFieldName('name'); if (name !== null && name.text.length > 0) return true; } + let aliases: Set | undefined; + for (let i = 0; i < root.namedChildCount; i++) { + const fn = root.namedChild(i); + if (fn?.type !== 'function_declaration') continue; + const first = fn.namedChildren + .find((c): c is SyntaxNode => c?.type === 'parameters') + ?.namedChild(0); + const typeNode = first?.type === 'parameter' ? first.childForFieldName('type') : null; + if (typeNode === null || typeNode === undefined) continue; + const nominal = zigParameterNominalType(typeNode.text); + if (nominal === '@This()') return true; + aliases ??= zigThisAliasNamesIn(root); + if (aliases.has(nominal)) return true; + } return false; } @@ -284,8 +320,18 @@ export function zigReceiverParameter(fn: SyntaxNode, filePath?: string): SyntaxN * structure phase's owner walk) and `emitZigScopeCaptures`, so the two * phases agree by construction. * - * - file-struct / `const T = struct {…}` at file or container level / - * generic type constructor: the binding name (`zigContainerBindingName`). + * - file-struct / `const T = struct {…}` at file level / generic type + * constructor: the binding name (`zigContainerBindingName`). + * - CONTAINER-HOSTED named container — `pub const Item = struct {…}` + * inside `const A = struct {…}`: owner-qualified, `A.Item` (recursively: + * `A.B.Item`). By binding name alone `A.Item` and `B.Item` — two + * distinct types, each with its own `run` — shared ONE `Struct:: + * Item` node and one `Item.run` (PR #1432 review, 8.5). The scope side + * still binds the lexical spelling `Item` inside `A` (the + * `@declaration.binding-name` split in `emitZigScopeCaptures`), and the + * shared `populateClassOwnedMembers` leaves a dotted qualified name + * alone, so the def and the graph node agree on `A.Item` and its members + * qualify as `A.Item.run` — the same shape Java's `Outer.Inner` takes. * - FUNCTION-LOCAL named container (F8) — `const R = struct {…}` inside a * `fn` or `test` body: `$`, e.g. `string$R`, * `Reflect.string$R`. Zig code declares such helper containers per @@ -313,8 +359,12 @@ export function zigContainerName(containerNode: SyntaxNode, filePath?: string): const host = zigIdentityHost(containerNode); if (binding !== undefined) { if (containerNode.parent?.type !== 'variable_declaration' || host === null) return binding; - if (!isZigCallableNode(host)) return binding; // file / container level: unchanged - return `${zigCallableQualifiedName(host, filePath)}$${binding}`; + if (isZigCallableNode(host)) return `${zigCallableQualifiedName(host, filePath)}$${binding}`; + if (ZIG_CONTAINER_TYPES.has(host.type)) { + const hostName = zigContainerName(host, filePath); + return hostName === undefined ? binding : `${hostName}.${binding}`; + } + return binding; // file level: the binding name is the identity } const ordinal = zigAnonymousContainerOrdinal(containerNode); const prefix = host === null ? undefined : zigAnonymousHostPrefix(host, filePath); @@ -340,14 +390,15 @@ export function zigContainerLabel( } /** Which ZIG_QUERIES rule mints a container's graph node (F8): - * - 'wrapper': `const T = struct {…}` at file or container level — the + * - 'wrapper': `const T = struct {…}` at FILE level — the * `variable_declaration … @definition.struct` rule (name from `@name`); * - 'constructor': the container a generic type constructor returns — * the `fn … type { return struct {…}; }` rule; * - 'container': everything the bare `(struct_declaration) - * @definition.struct` rule owns — FUNCTION-LOCAL named containers (their - * identity `string$R` is not a capture, so the class extractor names - * them via `zigContainerName`) and ANONYMOUS containers. + * @definition.struct` rule owns — FUNCTION-LOCAL and CONTAINER-HOSTED + * named containers (their identities `string$R` / `A.Item` are not + * captures, so the class extractor names them via `zigContainerName`) + * and ANONYMOUS containers. * The provider's `shouldSkipClassCapture` drops the other rules' matches for * the same node so each container is minted exactly once. */ export function zigContainerAnchor( @@ -360,7 +411,7 @@ export function zigContainerAnchor( zigContainerBindingName(containerNode) !== undefined ) { const host = zigIdentityHost(containerNode); - return host !== null && isZigCallableNode(host) ? 'container' : 'wrapper'; + return host === null || host.type === 'source_file' ? 'wrapper' : 'container'; } return 'container'; } @@ -654,16 +705,22 @@ function rewriteZigThisAlias( * or type receiver (`Runner.init(cb)`, `std.sort.pdq(…)`, `List(u8).init`, * `@import("x.zig").f(cb)`) passes nothing implicitly and gets no prepend. * Value-vs-type is F6's rule: the chain head is a fn-local name (param / - * local / payload) that is not TitleCase. Known residual gap: a - * MODULE-level value receiver (`global_runner.run(cb)`) is not fn-local and - * gets no prepend, so its callback misses the formal. + * local / payload) that is not TitleCase, or a MODULE-level value — a + * file- or container-level `var` / `const` whose declaration shape says + * "value" (`zigHostValueNames`: annotated, a struct literal, a non-type + * call, a literal), so `global_runner.run(target)` prepends `global_runner` + * exactly like `r.run(target)` does and `target` joins formal `cb@1` + * instead of `self@0` (PR #1432 review, 8.3). * * `builtin_function` (`@import`, `@sizeOf`, …) is deliberately not a call node: * builtins never take user callables as flow arguments. */ -/** Per file: the callable-flow options close over the file's fn-local name - * cache (shared with F6's `zigCallReturnTypeOf`). */ -function zigCallableCaptureOptions(fnLocalNames: Map>) { +/** Per file: the callable-flow options close over the file's fn-local and + * host-value name caches (shared with F6's `zigCallReturnTypeOf`). */ +function zigCallableCaptureOptions( + fnLocalNames: Map>, + hostValueNames: Map>, +) { return { functionNodeTypes: new Set(['function_declaration']), callNodeTypes: new Set(['call_expression']), @@ -698,7 +755,7 @@ function zigCallableCaptureOptions(fnLocalNames: Map>) { (child): child is SyntaxNode => child !== null && child.id !== callee?.id && child.type !== 'comment', ); - const receiver = zigImplicitReceiver(call, fnLocalNames); + const receiver = zigImplicitReceiver(call, fnLocalNames, hostValueNames); return receiver === undefined ? explicit : [receiver, ...explicit]; }, } as const; @@ -709,10 +766,12 @@ function zigCallableCaptureOptions(fnLocalNames: Map>) { * (`Runner.init(…)`, `std.mem.eql(…)`, `List(u8).init(…)`, `@import(…).f(…)`). * Same value-vs-type rule as `zigCallReturnTypeOf` (F6): the chain head is a * name declared in the enclosing fn and is not TitleCase (a fn-local - * TitleCase name is a type alias, F7). */ + * TitleCase name is a type alias, F7), or a module-level value + * (`zigIsHostValueName`). */ function zigImplicitReceiver( call: SyntaxNode, fnLocalNames: Map>, + hostValueNames: Map>, ): SyntaxNode | undefined { const callee = call.childForFieldName('function'); if (callee === null || callee.type !== 'field_expression') return undefined; @@ -720,9 +779,73 @@ function zigImplicitReceiver( if (object === null) return undefined; // `.init(…)` decl literal const head = zigChainHead(object); if (head === null || isZigTitleCase(head.text)) return undefined; - const fn = zigEnclosingFunction(call); - if (fn === null || !zigFunctionLocalNames(fn, fnLocalNames).has(head.text)) return undefined; - return object; + return zigIsValueName(call, head.text, fnLocalNames, hostValueNames) ? object : undefined; +} + +/** Is `name`, read at `at`, a VALUE — a fn-local (param / local / payload) of + * the enclosing fn, or a module-level value declared by the file or by a + * container enclosing `at`? Zig forbids shadowing, so the first declaration + * found walking outwards is the only one. Namespaces (`std`, an `@import` + * handle, `const mem = std.mem`) and types are not values. */ +function zigIsValueName( + at: SyntaxNode, + name: string, + fnLocalNames: Map>, + hostValueNames: Map>, +): boolean { + const fn = zigEnclosingFunction(at); + if (fn !== null && zigFunctionLocalNames(fn, fnLocalNames).has(name)) return true; + let host = zigIdentityHost(at); + while (host !== null) { + if (!isZigCallableNode(host) && zigHostValueNames(host, hostValueNames).has(name)) return true; + host = zigIdentityHost(host); + } + return false; +} + +/** The names a HOST (the file root or a container node) declares DIRECTLY as + * values — module-level state such as `var global_runner = Runner{};`, + * `var pool: Pool = undefined;`, `const default_config = Config.load();`, + * `const max = 16;`. Decided from the declaration's shape, the only evidence + * available before finalization: + * - annotated (`var x: T = …`, `const x: T;`) or initialized with a struct + * literal, a literal, or a call that is not a generic type instantiation + * (`isZigTypeConstructorCall`) → value; + * - container / `@import` bindings, TitleCase names (types, F7), and + * values that merely ALIAS another name (`const mem = std.mem;`, + * `var cur = orig;`) → not a value here: an alias is whatever it aliases, + * and a namespace alias prepended as a receiver would shift every + * callback index the other way. + * Lazily computed once per host node. */ +function zigHostValueNames(host: SyntaxNode, cache: Map>): Set { + const cached = cache.get(host.id); + if (cached !== undefined) return cached; + const names = new Set(); + for (let i = 0; i < host.namedChildCount; i++) { + const decl = host.namedChild(i); + if (decl === null || decl.type !== 'variable_declaration' || !isZigKeywordDeclaration(decl)) { + continue; + } + const named = decl.namedChildren.filter((c): c is SyntaxNode => c !== null); + const name = named[0]; + if (name === undefined || name.type !== 'identifier' || isZigTitleCase(name.text)) continue; + if (named.length < 2 || isZigContainerOrImportBinding(decl)) continue; + const last = named[named.length - 1]!; + if (last.id !== decl.childForFieldName('type')?.id) { + const value = zigUnwrapValue(last); + if ( + value.type === 'identifier' || + value.type === 'field_expression' || + value.type === 'builtin_function' || + (value.type === 'call_expression' && isZigTypeConstructorCall(value)) + ) { + continue; + } + } + names.add(name.text); + } + cache.set(host.id, names); + return names; } // ─── F6: value-inferred and return-type bindings ───────────────────────────── @@ -838,6 +961,7 @@ function zigEnclosingFunction(node: SyntaxNode): SyntaxNode | null { export function zigCallReturnTypeOf( value: SyntaxNode, localNamesCache: Map>, + hostValueNames: Map> = new Map(), ): | { readonly type: string; readonly memberCall?: true; readonly structLiteral?: true } | undefined { @@ -870,16 +994,15 @@ export function zigCallReturnTypeOf( if (isZigTitleCase(member.text)) return undefined; const head = zigChainHead(object); if (head !== null) { - const fn = zigEnclosingFunction(value); - // A fn-local name is a VALUE receiver — unless it is TitleCase: `const R - // = generic.List(u8); var l = R.init();` binds a type alias inside the fn - // (F7), and `R.init()` names the type `R` exactly like `Counter.init()` - // does at module level. Zig's naming convention (types TitleCase, values - // snake_case) is the same signal `isZigTypeConstructorCall` relies on. + // A fn-local or module-level value name is a VALUE receiver — unless it + // is TitleCase: `const R = generic.List(u8); var l = R.init();` binds a + // type alias inside the fn (F7), and `R.init()` names the type `R` + // exactly like `Counter.init()` does at module level. Zig's naming + // convention (types TitleCase, values snake_case) is the same signal + // `isZigTypeConstructorCall` relies on. if ( - fn !== null && - zigFunctionLocalNames(fn, localNamesCache).has(head.text) && - !isZigTitleCase(head.text) + !isZigTitleCase(head.text) && + zigIsValueName(value, head.text, localNamesCache, hostValueNames) ) { return { type: `${object.text}.${member.text}()`, memberCall: true }; } @@ -1049,10 +1172,62 @@ export function emitZigScopeCaptures( const byName = new Map(m.captures.map((c) => [c.name, c.node] as const)); const stmt = byName.get('alias.statement'); const ns = byName.get('alias.namespace'); - if (stmt !== undefined && ns !== undefined && importSources.has(ns.text)) { + // Only a ONE-level member (`const Counter = counter.Counter;`) is the + // named-import fact. A deeper chain (`const chosen = lib.B.work;`) names a + // member OF a member: promoting it to a named import of `work` from + // `lib.zig` discarded the written owner `B`, and `findExportedDef` then + // answered with the first `work` in the file — `A.work` (PR #1432 review, + // 8.4). Those stay Consts and are rewritten at their use sites instead + // (`collectZigDeepAliases`). + if ( + stmt !== undefined && + ns !== undefined && + importSources.has(ns.text) && + zigMemberChainOf(stmt)?.members.length === 1 + ) { aliasDeclIds.add(stmt.id); } } + // Function-local `@import` bindings, keyed per enclosing callable (PR #1432 + // review, 8.9). Finalization flattens every import of a file onto its + // Module scope, so two sibling fns each binding `const m = @import(…)` to a + // different file became ONE `m → [a.zig, b.zig]` namespace bucket and both + // `m.Thing{}` sites took the first target. The binding and every use of the + // name inside that fn are rewritten to `m$` — a spelling no Zig + // identifier can take — so each fn's handle is its own bucket and resolves + // through its own lexical import (`rewriteZigFunctionLocalImportNames`). + const fnLocalImports = new Map< + number, + { readonly fn: SyntaxNode; readonly names: Map } + >(); + for (const m of rawMatches) { + const byName = new Map(m.captures.map((c) => [c.name, c.node] as const)); + const importName = byName.get('import.name'); + const importStmt = byName.get('import.statement'); + const importSource = byName.get('import.source'); + if (importName === undefined || importStmt === undefined || importSource === undefined) { + continue; + } + if (!isZigKeywordDeclaration(importStmt) || isZigTypePositionImport(importStmt, importSource)) { + continue; + } + const fn = zigEnclosingFunction(importStmt); + if (fn === null) continue; + let entry = fnLocalImports.get(fn.id); + if (entry === undefined) { + entry = { fn, names: new Map() }; + fnLocalImports.set(fn.id, entry); + } + if (!entry.names.has(importName.text)) { + entry.names.set(importName.text, zigFunctionLocalImportKey(importName.text, fn, _filePath)); + } + } + // Deep member aliases — `const chosen = @import("lib.zig").B.work;`, + // `const chosen2 = lib.B.work;`, `const Inner = nested.Outer.Inner;` (PR + // #1432 review, 8.4): the owner path is kept and the alias's use sites are + // rewritten to qualified references (`chosen()` → `lib.B` . `work`), which + // the namespace chain walk resolves segment by segment. + const deepAliases = collectZigDeepAliases(tree.rootNode, importSources, fnLocalImports); // File-struct (top-level fields): the file IS a type named after the file. // Emit a Class scope over the whole file (nested under the Module scope — @@ -1075,8 +1250,11 @@ export function emitZigScopeCaptures( // `@This()` aliases: alias name ↦ the container it names (file stem for the // file-struct, binding name for `const Self = @This();` inside a container). const thisAliases = collectZigThisAliases(root, fileStructName, _filePath); - // F6: fn-local names per function node, for `zigCallReturnTypeOf`. + // F6: fn-local names per function node, for `zigCallReturnTypeOf`; module- + // level value names per host node (file / container), for the value-vs- + // namespace receiver rule (`zigHostValueNames`). const fnLocalNames = new Map>(); + const hostValueNames = new Map>(); for (const m of rawMatches) { const grouped: Record = {}; @@ -1123,7 +1301,7 @@ export function emitZigScopeCaptures( const source = nodeMap['@import.source']!; if (claimedImportSourceIds.has(source.id)) continue; const sourceCapture = grouped['@import.source']!; - if (isZigInlineImportReceiver(inlineImport)) { + if (isZigInlineImportReceiver(inlineImport) || deepAliases.inlineRoots.has(inlineImport.id)) { const key = `receiver:${source.text}`; if (importedSourceTexts.has(key)) continue; importedSourceTexts.add(key); @@ -1143,6 +1321,35 @@ export function emitZigScopeCaptures( continue; } + // `const chosen = @import("lib.zig").B.work;` — the two-member import + // rule matched a DEEP chain. Binding `chosen` as a named import of the + // innermost member `work` lost the owner `B` (8.4); bind the module under + // the builtin's own text instead — the same namespace binding a member- + // call receiver gets — and let the rewritten use sites walk `B.work`. + const deepImportStmt = nodeMap['@import.statement']; + if ( + deepImportStmt !== undefined && + nodeMap['@import.imported'] !== undefined && + deepAliases.declIds.has(deepImportStmt.id) + ) { + const importRoot = zigImportRootOf( + deepImportStmt.namedChildren.filter((c): c is SyntaxNode => c !== null).pop() ?? null, + ); + const source = nodeMap['@import.source']; + if (importRoot !== null && source !== undefined) { + const key = `receiver:${source.text}`; + if (!importedSourceTexts.has(key)) { + importedSourceTexts.add(key); + out.push({ + '@import.statement': nodeToCapture('@import.statement', importRoot), + '@import.name': nodeToCapture('@import.name', importRoot), + '@import.source': grouped['@import.source']!, + }); + } + } + continue; + } + // Member aliases: promote to a named import when the object is one of // this file's @import bindings; otherwise the group is inert (the same // node is also matched by the plain-variable rule). @@ -1178,7 +1385,15 @@ export function emitZigScopeCaptures( // An @import binding (`const Stack = @import("counter.zig").Stack;`) is // an import, and a promoted member alias a named import: both already // carry the type through the import binding. - if (aliasDeclIds.has(aliasAnchor.id) || isZigContainerOrImportBinding(aliasAnchor)) continue; + // A deep alias off an inline import (`const Inner = + // @import("n.zig").Outer.Inner;`) keeps its type binding: the written + // path is the type (8.4). + if ( + aliasDeclIds.has(aliasAnchor.id) || + (isZigContainerOrImportBinding(aliasAnchor) && !deepAliases.declIds.has(aliasAnchor.id)) + ) { + continue; + } const value = nodeMap['@type-binding.type']; if (value === undefined) continue; // `var b: Counter = undefined;` — `undefined` / `null` are anonymous @@ -1249,7 +1464,7 @@ export function emitZigScopeCaptures( if (valueNode.id === nodeMap['@type-binding.call-return'].childForFieldName('type')?.id) { continue; } - const inferred = zigCallReturnTypeOf(valueNode, fnLocalNames); + const inferred = zigCallReturnTypeOf(valueNode, fnLocalNames, hostValueNames); if (inferred === undefined) continue; grouped['@type-binding.type'] = syntheticCapture( '@type-binding.type', @@ -1358,16 +1573,14 @@ export function emitZigScopeCaptures( ); } // F8 — FUNCTION-LOCAL named container (`const R = struct {…}` inside a - // fn body): the def's qualified name is its identity (`string$R`, - // matching the graph node the structure phase mints via - // `zigContainerName`), while the scope still binds the spelling code - // uses (`R.get()`, `self: *R`) — the Java local-class split - // (`@declaration.binding-name`, java/captures.ts). - if ( - containerAnchor !== undefined && - nameNode !== undefined && - zigContainerAnchor(containerAnchor) === 'container' - ) { + // fn body) and (8.5) CONTAINER-HOSTED named container (`pub const Item + // = struct {…}` inside `A`): the def's qualified name is its identity + // (`string$R`, `A.Item` — matching the graph node the structure phase + // mints via `zigContainerName`), while the scope still binds the + // spelling code uses (`R.get()`, `self: *R`, `Item{}`) — the Java + // local / nested class split (`@declaration.binding-name`, + // java/captures.ts). + if (containerAnchor !== undefined && nameNode !== undefined) { const identity = zigContainerName(containerAnchor, _filePath); if (identity !== undefined && identity !== nameNode.text) { grouped['@declaration.binding-name'] = grouped['@declaration.name']!; @@ -1534,13 +1747,400 @@ export function emitZigScopeCaptures( // the enclosing scope, where a `const Self = @This();` rewrite can find it. out.push(...synthesizeZigAnonymousContainerDeclarations(root, _filePath)); + // Result-location sites — `const a: Counter = .init(1);`, `return .init(3);`, + // `const b: Counter = .{ .n = 2 };` (8.6): the call / construction the query + // cannot see because the type is written on the LEFT. + out.push(...synthesizeZigResultLocationReferences(root, thisAliases, fileStructName)); + out.push( - ...synthesizeCallableFlowCaptures(tree.rootNode, zigCallableCaptureOptions(fnLocalNames)), + ...synthesizeCallableFlowCaptures( + tree.rootNode, + zigCallableCaptureOptions(fnLocalNames, hostValueNames), + ), ); + // Use-site rewrites, in this order: a deep alias's receiver may itself name + // a fn-local import (`const m = @import(…); const w = m.B.work;`), and the + // second pass rewrites that name inside the receiver text it just minted. + rewriteZigDeepAliasReferences(out, deepAliases.aliases); + rewriteZigFunctionLocalImportNames(out, fnLocalImports); + return out; } +// ─── Use-site rewrites (8.4 / 8.9) and result-location sites (8.6) ──────────── + +/** The unique spelling a function-local import binding gets: `m$` + * — `m$f_sib_a`, `m$Reflect$string`, `m$test$L12`. `$` cannot appear in a + * Zig identifier, so the key collides with nothing the source declares; + * every non-word character of the callable's qualified name becomes `$` so + * the key stays a single receiver segment (a `.` would split it). */ +function zigFunctionLocalImportKey(name: string, fn: SyntaxNode, filePath: string): string { + return `${name}$${zigCallableQualifiedName(fn, filePath).replace(/[^\w]/g, '$')}`; +} + +type ZigRange = Capture['range']; + +/** The capture-side range of `node` (1-based lines, as `nodeToCapture`). */ +function zigNodeRange(node: SyntaxNode): ZigRange { + return { + startLine: node.startPosition.row + 1, + startCol: node.startPosition.column, + endLine: node.endPosition.row + 1, + endCol: node.endPosition.column, + }; +} + +function zigRangeWithin(inner: ZigRange, outer: ZigRange): boolean { + const startsAfter = + inner.startLine > outer.startLine || + (inner.startLine === outer.startLine && inner.startCol >= outer.startCol); + const endsBefore = + inner.endLine < outer.endLine || + (inner.endLine === outer.endLine && inner.endCol <= outer.endCol); + return startsAfter && endsBefore; +} + +/** Replace every bare identifier token `name` in `text` — outside string + * literals, and not the member of a `.name` access or the tail of a + * `@builtin` — with `replacement`. Zig forbids shadowing, so inside the + * region a rewrite applies to, every such token is the same binding. */ +function zigReplaceIdentifier(text: string, name: string, replacement: string): string { + let out = ''; + let i = 0; + let inString = false; + while (i < text.length) { + const ch = text[i]!; + if (inString) { + out += ch; + if (ch === '\\') { + out += text[i + 1] ?? ''; + i += 2; + continue; + } + if (ch === '"') inString = false; + i++; + continue; + } + if (ch === '"') { + inString = true; + out += ch; + i++; + continue; + } + if (/[A-Za-z_]/.test(ch)) { + let j = i; + while (j < text.length && /\w/.test(text[j]!)) j++; + const word = text.slice(i, j); + const prev = i > 0 ? text[i - 1] : ''; + out += word === name && prev !== '.' && prev !== '@' ? replacement : word; + i = j; + continue; + } + out += ch; + i++; + } + return out; +} + +/** Every capture whose text can spell a receiver, a type, a bound name or a + * callable-flow cell — the ones a fn-local import name can appear in. */ +const ZIG_NAME_BEARING_TAGS: readonly string[] = [ + '@import.name', + '@reference.receiver', + '@reference.name', + '@type-binding.type', + '@callable-flow.target-name', + '@callable-flow.target-qualified-name', + '@callable-flow.receiver', + '@callable-flow.source', + '@callable-flow.destination', + '@callable-flow.callee', + '@callable-flow.direct-callee-name', +]; + +/** 8.9 — rewrite a fn-local import's binding and its uses to its unique key + * (`zigFunctionLocalImportKey`), within that fn's range only. */ +function rewriteZigFunctionLocalImportNames( + out: CaptureMatch[], + fnLocalImports: ReadonlyMap< + number, + { readonly fn: SyntaxNode; readonly names: Map } + >, +): void { + if (fnLocalImports.size === 0) return; + for (const { fn, names } of fnLocalImports.values()) { + const fnRange = zigNodeRange(fn); + for (let i = 0; i < out.length; i++) { + const group = out[i]!; + let next: Record | undefined; + for (const tag of ZIG_NAME_BEARING_TAGS) { + const cap = group[tag]; + if (cap === undefined || !zigRangeWithin(cap.range, fnRange)) continue; + let text = cap.text; + for (const [name, key] of names) text = zigReplaceIdentifier(text, name, key); + if (text === cap.text) continue; + next ??= { ...group }; + next[tag] = { ...cap, text }; + } + if (next !== undefined) out[i] = next; + } + } +} + +/** A `const X = ..…;` alias (8.4): `X` stands for member `` + * of the receiver `.`, inside `range` (null: the whole + * file). */ +interface ZigDeepAlias { + readonly name: string; + readonly receiver: string; + readonly member: string; + readonly range: ZigRange | null; +} + +/** The member chain a declaration's value spells — `lib.B.work` → + * `{ root: lib, members: [B, work] }`; undefined for anything but a + * `field_expression` chain rooted in an identifier or an `@import(…)`. */ +function zigMemberChainOf( + decl: SyntaxNode, +): { readonly root: SyntaxNode; readonly members: readonly SyntaxNode[] } | undefined { + const named = decl.namedChildren.filter((c): c is SyntaxNode => c !== null); + if (named.length !== 2 || named[0]!.type !== 'identifier') return undefined; + let cur: SyntaxNode | null = named[1]!; + const members: SyntaxNode[] = []; + while (cur !== null && cur.type === 'field_expression') { + const member = cur.childForFieldName('member'); + const object = cur.childForFieldName('object'); + if (member === null || object === null) return undefined; // `.init` literal + members.unshift(member); + cur = object; + } + if (cur === null || members.length === 0) return undefined; + if (cur.type !== 'identifier' && !isZigImportBuiltin(cur)) return undefined; + return { root: cur, members }; +} + +/** 8.4 — every deep member alias in the tree whose root is a module handle: + * a file-level `@import` binding of this file, a fn-local one (its key is + * applied by the later rewrite), or an inline `@import(…)`. Returns the + * aliases, the declaring nodes (so their import / alias groups are handled + * as deep aliases) and the inline-import roots (bound as namespaces). */ +function collectZigDeepAliases( + root: SyntaxNode, + importSources: ReadonlyMap, + fnLocalImports: ReadonlyMap< + number, + { readonly fn: SyntaxNode; readonly names: Map } + >, +): { + readonly aliases: readonly ZigDeepAlias[]; + readonly declIds: ReadonlySet; + readonly inlineRoots: ReadonlySet; +} { + const aliases: ZigDeepAlias[] = []; + const declIds = new Set(); + const inlineRoots = new Set(); + const visit = (node: SyntaxNode): void => { + if (node.type === 'variable_declaration' && isZigKeywordDeclaration(node)) { + const chain = zigMemberChainOf(node); + if (chain !== undefined && chain.members.length >= 2) { + const fn = zigEnclosingFunction(node); + const isHandle = + isZigImportBuiltin(chain.root) || + importSources.has(chain.root.text) || + (fn !== null && fnLocalImports.get(fn.id)?.names.has(chain.root.text) === true); + if (isHandle) { + const host = zigIdentityHost(node); + const receiver = [chain.root.text, ...chain.members.slice(0, -1).map((m) => m.text)].join( + '.', + ); + aliases.push({ + name: node.namedChild(0)!.text, + receiver, + member: chain.members[chain.members.length - 1]!.text, + range: host === null || host.type === 'source_file' ? null : zigNodeRange(host), + }); + declIds.add(node.id); + if (isZigImportBuiltin(chain.root)) inlineRoots.add(chain.root.id); + } + } + } + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child !== null) visit(child); + } + }; + visit(root); + return { aliases, declIds, inlineRoots }; +} + +/** 8.4 — turn a free call / bare construction of a deep alias (`chosen()`, + * `Inner{}`) into the qualified reference it stands for (receiver `lib.B`, + * member `work`), so it resolves through the alias's written owner path and + * never through a same-named member elsewhere in the module. */ +function rewriteZigDeepAliasReferences( + out: CaptureMatch[], + aliases: readonly ZigDeepAlias[], +): void { + if (aliases.length === 0) return; + for (let i = 0; i < out.length; i++) { + const group = out[i]!; + const free = group['@reference.call.free']; + const ctor = group['@reference.call.constructor']; + if ((free === undefined && ctor === undefined) || group['@reference.receiver'] !== undefined) { + continue; + } + const nameCap = group['@reference.name']; + if (nameCap === undefined) continue; + const alias = aliases.find( + (a) => + a.name === nameCap.text && (a.range === null || zigRangeWithin(nameCap.range, a.range)), + ); + if (alias === undefined) continue; + const next: Record = { ...group }; + if (free !== undefined) { + delete next['@reference.call.free']; + next['@reference.call.member'] = { ...free, name: '@reference.call.member' }; + } + next['@reference.receiver'] = { + name: '@reference.receiver', + range: nameCap.range, + text: alias.receiver, + }; + next['@reference.name'] = { ...nameCap, text: alias.member }; + out[i] = next; + } +} + +/** 8.6 — reference sites for RESULT-LOCATION expressions: a decl literal + * `.init(…)` or an anonymous literal `.{…}` whose type comes from where the + * value lands — a declared variable (`const a: Counter = .init(1);`), a + * function's return (`fn make() Counter { return .init(3); }`), a container + * field's default (`n: Counter = .init(0),`). The query cannot see these: + * its member-call rule needs an object and its constructor rule a type + * identifier, and the annotation only typed the VARIABLE — the `init` call + * and the `Counter{…}` construction event were absent from the graph. Each + * site is emitted with the expected type as its receiver (`Counter`, + * `stdx.Thing`, `@This()` and `Self` rewritten to the container), so it + * resolves exactly as `Counter.init(1)` / `Counter{…}` would — through the + * class binding or the namespace chain, never by simple name workspace-wide. + * Arguments (`f(.init(1))`) are out: the expected type is the callee's + * parameter, which needs the resolved callee. */ +function synthesizeZigResultLocationReferences( + root: SyntaxNode, + thisAliases: ReadonlyMap, + fileStructName: string | undefined, +): CaptureMatch[] { + const out: CaptureMatch[] = []; + const emit = (expected: SyntaxNode, value: SyntaxNode): void => { + const inner = zigUnwrapValue(value); + let isDeclLiteral = false; + let member: SyntaxNode | null = null; + if (inner.type === 'call_expression') { + const callee = inner.childForFieldName('function'); + if (callee?.type !== 'field_expression' || callee.childForFieldName('object') !== null) + return; + member = callee.childForFieldName('member'); + if (member === null) return; + isDeclLiteral = true; + } else if (inner.type !== 'anonymous_struct_initializer') { + return; + } + let text = expected.text; + if (text.includes('@This()')) { + const target = zigThisTargetFor(expected, fileStructName); + if (target === undefined) return; + text = text.replace('@This()', target); + } else { + text = rewriteZigThisAlias(expected, thisAliases) ?? text; + } + const nominal = normalizeZigTypeName(text); + // A builtin / primitive (`u32`, `void`, `anyerror`) constructs nothing. + if (!/^[A-Z@]/.test(nominal) && !nominal.includes('.')) return; + if (isDeclLiteral) { + out.push({ + '@reference.call.member': nodeToCapture('@reference.call.member', inner), + '@reference.receiver': syntheticCapture('@reference.receiver', inner, nominal), + '@reference.name': nodeToCapture('@reference.name', member!), + }); + return; + } + const dot = zigLastTopLevelDot(nominal); + out.push( + dot === -1 + ? { + '@reference.call.constructor': nodeToCapture('@reference.call.constructor', inner), + '@reference.name': syntheticCapture('@reference.name', inner, nominal), + } + : { + '@reference.call.constructor': nodeToCapture('@reference.call.constructor', inner), + '@reference.receiver': syntheticCapture( + '@reference.receiver', + inner, + nominal.slice(0, dot), + ), + '@reference.name': syntheticCapture('@reference.name', inner, nominal.slice(dot + 1)), + }, + ); + }; + const visit = (node: SyntaxNode): void => { + if (node.type === 'variable_declaration' && isZigKeywordDeclaration(node)) { + const typeNode = node.childForFieldName('type'); + const named = node.namedChildren.filter((c): c is SyntaxNode => c !== null); + const last = named[named.length - 1]; + if (typeNode !== null && last !== undefined && last.id !== typeNode.id) emit(typeNode, last); + } else if (node.type === 'return_expression') { + const fn = zigEnclosingFunction(node); + const ret = fn?.type === 'function_declaration' ? fn.childForFieldName('type') : null; + const value = node.namedChild(0); + if (ret !== null && ret !== undefined && value !== null && zigReturnTypeIsNominal(ret)) { + emit(ret, value); + } + } else if (node.type === 'container_field') { + const typeNode = node.childForFieldName('type'); + const nameNode = node.childForFieldName('name'); + const named = node.namedChildren.filter((c): c is SyntaxNode => c !== null); + const last = named[named.length - 1]; + if ( + typeNode !== null && + last !== undefined && + last.id !== typeNode.id && + last.id !== nameNode?.id + ) { + emit(typeNode, last); + } + } + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child !== null) visit(child); + } + }; + visit(root); + return out; +} + +/** Index of the last `.` at nesting depth 0 and outside string literals — + * the one that separates `@import("a.zig").Thing` or `stdx.List(u8).Node` + * into receiver and member. -1 when there is none. */ +function zigLastTopLevelDot(text: string): number { + let depth = 0; + let inString = false; + let last = -1; + for (let i = 0; i < text.length; i++) { + const ch = text[i]!; + if (inString) { + if (ch === '\\') i++; + else if (ch === '"') inString = false; + continue; + } + if (ch === '"') inString = true; + else if (ch === '(' || ch === '[') depth++; + else if (ch === ')' || ch === ']') depth--; + else if (ch === '.' && depth === 0) last = i; + } + return last; +} + /** One `@declaration.` group per anonymous container in the tree — * see the call site in `emitZigScopeCaptures` (F8). Named after * `synthesizeJavaAnonymousClassDeclarations`, which does the same for diff --git a/gitnexus/src/core/ingestion/languages/zig/query.ts b/gitnexus/src/core/ingestion/languages/zig/query.ts index 72edaac0b..f8a4098af 100644 --- a/gitnexus/src/core/ingestion/languages/zig/query.ts +++ b/gitnexus/src/core/ingestion/languages/zig/query.ts @@ -136,10 +136,13 @@ const ZIG_SCOPE_QUERY = ` ;; Imports — const X = @import("...").X : a NAMED import of one member. The ;; local name is whatever the user chose (\`const Alloc = @import("std").mem;\` -;; is a rename), the imported name is the member. Deeper chains -;; (\`@import("std").mem.Allocator\`) bind the innermost member — the file -;; edge is what matters; a member-of-a-member resolves through the namespace -;; later or not at all. +;; is a rename), the imported name is the member. A deeper chain +;; (\`@import("std").mem.Allocator\`, \`@import("lib.zig").B.work\`) is matched +;; by the second rule below but is NOT bound as a named import of the +;; innermost member: that discarded the written owner (\`B\`) and let a +;; same-named \`A.work\` answer first. \`emitZigScopeCaptures\` binds the module +;; under the builtin's text instead and rewrites the alias's use sites to the +;; full path (\`collectZigDeepAliases\`, PR #1432 review 8.4). (variable_declaration "const" . (identifier) @import.name (field_expression @@ -179,8 +182,9 @@ const ZIG_SCOPE_QUERY = ` ;; alias and \`emitZigScopeCaptures\` promotes the ones whose object is a ;; known @import to a named import (same fact as \`const Counter = ;; @import("counter.zig").Counter;\`); the rest stay ordinary variables. -;; Deeper chains (\`std.mem.Allocator\`) bind the innermost member off the -;; leftmost namespace. +;; Only the ONE-level shape is promoted; a deeper chain (\`lib.B.work\`, +;; \`std.mem.Allocator\`) is a deep alias — a Const whose use sites are +;; rewritten to the written owner path (see the import rule above, 8.4). (variable_declaration "const" . (identifier) @alias.name (field_expression @@ -371,6 +375,24 @@ const ZIG_SCOPE_QUERY = ` (field_expression object: (_) @reference.receiver member: (identifier) @reference.name)) @reference.call.constructor + +;; References — generic instantiation literals: List(u8){ ... } / +;; lists.List(u8){ ... }. The type head is a call_expression — the +;; instantiation of the type constructor — which neither constructor rule +;; above matches, so the OUTER aggregate event had no site: only the inner +;; \`List(u8)\` call (a free / member call reference on the call node) reached +;; the graph (PR #1432 review, 8.11). Two sites on two anchors: the call +;; (an invocation of \`List\`) and this initializer (a construction of the +;; container \`List\` returns, marked \`(constructor)\`). The receiver form goes +;; through the same namespace path as \`mod.T{}\`. +(struct_initializer + (call_expression + function: (identifier) @reference.name)) @reference.call.constructor +(struct_initializer + (call_expression + function: (field_expression + object: (_) @reference.receiver + member: (identifier) @reference.name))) @reference.call.constructor `; let _parser: Parser | null = null; diff --git a/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts index 93f140720..229d91cf3 100644 --- a/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/zig/scope-resolver.ts @@ -38,6 +38,12 @@ export const zigScopeResolver: ScopeResolver = { // `src/terminal/` from outside it 46 → 253; into tigerbeetle's `stdx` hub // from outside it 837 → 1500 (136 `stdx.Type.fn(` sites, 289 annotations). namespaceExportsIncludeImportedNames: true, + // A qualified receiver is a chain of `const` handles — hub modules + // republishing modules (`hub.sub.Thing{}`), types nested in types + // (`mod.Outer.Inner{}`), enum variants through the module + // (`opmod.Op.lookup.event_max()`) — walked hop by hop from the verified + // import; a one-hop split at the last dot resolved none of them. + resolveNamespaceChains: true, loadResolutionConfig: (repoPath: string) => loadZigBuildConfig(repoPath), diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 4af1ca4ea..aa82da251 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -922,6 +922,24 @@ export interface ScopeResolver { */ readonly namespaceExportsIncludeImportedNames?: boolean; + /** + * When true, a qualified receiver is walked SEGMENT BY SEGMENT from its + * verified namespace root instead of being split once at the last dot: + * `hub.sub.Thing{}` (a namespace republished by a hub — `pub const sub = + * @import("sub.zig");`), `mod.Outer.Inner{}` (a type nested in a type), + * `opmod.Op.lookup` (an enum variant reached through the module), and the + * typed forms `x: mod.Outer.Inner`. Each hop is either a class-like member + * of the current module(s) / the current class, or a namespace import + * edge the current module's scope binds under that name; a hop that is + * ambiguous — two files behind one handle disagree, or a name is both a + * type and a republished module — resolves nothing rather than picking a + * first match. Off, the receiver-bound paths (Case 1, Case 2's + * namespace-qualified class, Case 3) keep their one-hop lookups exactly + * as they are, so no existing edge moves; Zig opts in (PR #1432 review, + * 8.10), whose module system is nothing but nested `const` handles. + */ + readonly resolveNamespaceChains?: boolean; + /** * How this language spells a construction expression, so the compound * receiver resolver can type an INLINE constructor receiver — the diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts index e96e31903..a057baff4 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -132,6 +132,17 @@ interface ResolveCompoundReceiverOptions { /** A namespace member may be a name the target module imported and * publishes (hub modules). See `ScopeResolver.namespaceExportsIncludeImportedNames`. */ readonly namespaceExportsIncludeImportedNames?: boolean; + /** Resolve a qualified CLASS name (`opmod.Op`, `hub.sub.Thing`, + * `mod.Outer.Inner`) through the language's namespace chain walk + * (`ScopeResolver.resolveNamespaceChains`). Seeds the dotted-chain walk + * when its head is a namespace rather than a value: `opmod.Op.lookup` is + * the enum `Op` reached through the module `opmod`, then its variant + * `lookup` — a value of `Op` — and only then a method (PR #1432 review, + * 8.10). Absent ⇒ the head must bind in scope, exactly as before. */ + readonly resolveQualifiedClass?: ( + qualifiedName: string, + inScope: ScopeId, + ) => SymbolDefinition | undefined; /** Compact receiver chain for THIS site (`ReferenceSite.receiverChain`), when * the language's capture emitter produced one. Present ⇒ the structural fold * is tried before the text cascade; absent ⇒ behaviour is exactly as before. @@ -1187,8 +1198,28 @@ export function resolveCompoundReceiverClass( options, ); } + // Namespace-qualified chain head — `opmod.Op.lookup` / `hub.sub.Thing.x`: + // no binding and no class named `opmod`, but the LONGEST prefix the + // language's chain walk accepts as a class seeds the walk (the class + // itself, so a variant / static member hop is read off the class scope), + // and the remaining segments are walked as members. Longest first: the + // prefix is a class, not a value, and `a.B.C` must seed at `C`, not stop + // at `B` and read `C` as a member of it. + let firstHop = 1; + if (currentClass === undefined && headType === undefined && options.resolveQualifiedClass) { + for (let k = parts.length - 1; k >= 2; k--) { + const prefix = parts.slice(0, k).join('.'); + if (prefix.includes('(')) continue; + const seeded = options.resolveQualifiedClass(prefix, inScope); + if (seeded === undefined) continue; + currentClass = seeded; + currentIsClassConstant = true; + firstHop = k; + break; + } + } - for (let i = 1; i < parts.length && currentClass !== undefined; i++) { + for (let i = firstHop; i < parts.length && currentClass !== undefined; i++) { const segment = parts[i]; if (segment === undefined) break; const memberName = stripCallParens(segment); diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 37f2cda3f..241793e1b 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -117,6 +117,53 @@ import type { DecodedReceiverChain } from '../../utils/receiver-chain-codec.js'; /** Subset of `ScopeResolver` consumed by this pass. Accepting the * subset rather than the full provider keeps tests and partial * refactors lighter — callers only need to populate what we read. */ +/** Split `text` at the dots that sit at nesting depth 0 and outside string + * literals — `@import("a.zig").Outer.Inner` → three segments, not four; + * `List(u8).Node` → two. The chain walk's segmenter. */ +function splitTopLevelDots(text: string): string[] { + const out: string[] = []; + let depth = 0; + let inString = false; + let start = 0; + for (let i = 0; i < text.length; i++) { + const ch = text[i]!; + if (inString) { + if (ch === '\\') i++; + else if (ch === '"') inString = false; + continue; + } + if (ch === '"') inString = true; + else if (ch === '(' || ch === '[' || ch === '<') depth++; + else if (ch === ')' || ch === ']' || ch === '>') depth--; + else if (ch === '.' && depth === 0) { + out.push(text.slice(start, i)); + start = i + 1; + } + } + out.push(text.slice(start)); + return out.filter((s) => s.length > 0); +} + +/** Index of the last depth-0, outside-string dot of `text`, or -1. */ +function lastTopLevelDot(text: string): number { + let depth = 0; + let inString = false; + let last = -1; + for (let i = 0; i < text.length; i++) { + const ch = text[i]!; + if (inString) { + if (ch === '\\') i++; + else if (ch === '"') inString = false; + continue; + } + if (ch === '"') inString = true; + else if (ch === '(' || ch === '[' || ch === '<') depth++; + else if (ch === ')' || ch === ']' || ch === '>') depth--; + else if (ch === '.' && depth === 0) last = i; + } + return last; +} + type ReceiverBoundProviderSubset = Pick< ScopeResolver, | 'isSuperReceiver' @@ -139,6 +186,7 @@ type ReceiverBoundProviderSubset = Pick< | 'normalizeTypeArgument' | 'markConstructionSites' | 'namespaceExportsIncludeImportedNames' + | 'resolveNamespaceChains' >; /** A bare, undecorated identifier and nothing else — see {@link isBareTypeName}. */ @@ -340,31 +388,134 @@ export function emitReceiverBoundCalls( provider.namespaceExportsIncludeImportedNames === true ? findExportedDefIncludingImportedNames(targetFile, name, index, scopes) : findExportedDef(targetFile, name, index); - // `ns.Type` as a receiver, where `ns` is a verified namespace of the current - // file and `Type` a class-like member of it. Unique across the namespace's - // target files or nothing — two same-named classes behind one handle would - // mint a confident wrong edge. - const resolveNamespaceQualifiedClass = ( - receiverName: string, - inScope: ScopeId, - namespaceTargets: ReadonlyMap, + // A class-like member `name` unique across `files`, or nothing — two + // same-named classes behind one handle would mint a confident wrong edge. + const uniqueClassAcross = ( + files: readonly string[], + name: string, ): SymbolDefinition | undefined => { - const dot = receiverName.lastIndexOf('.'); - if (dot <= 0 || dot === receiverName.length - 1) return undefined; - const head = receiverName.slice(0, dot); - const tail = receiverName.slice(dot + 1); - if (tail.includes('(') || tail.includes('[')) return undefined; - const files = namespaceTargets.get(head); - if (files === undefined || isNamespaceNameShadowed(head, inScope, scopes)) return undefined; let picked: SymbolDefinition | undefined; for (const file of files) { - const def = lookupNamespaceMember(file, tail); + const def = lookupNamespaceMember(file, name); if (def === undefined || !isClassLike(def.type)) continue; if (picked !== undefined && picked.nodeId !== def.nodeId) return undefined; picked = def; } return picked; }; + // A class-like def NESTED in `owner` (`A.Item` inside `A`): its qualified + // name is the owner's plus the segment — the identity the structure phase + // and `populateClassOwnedMembers` agree on — so the qualified-name index + // answers directly; same file as the owner, unique or nothing. Only the + // chain walk reads this: `findOwnedMember` knows methods and fields, and a + // nested type is neither. + const findNestedClass = (owner: SymbolDefinition, name: string): SymbolDefinition | undefined => { + if (owner.qualifiedName === undefined || owner.qualifiedName.length === 0) return undefined; + let picked: SymbolDefinition | undefined; + for (const id of scopes.qualifiedNames.get(`${owner.qualifiedName}.${name}`)) { + const def = scopes.defs.get(id); + if (def === undefined || !isClassLike(def.type) || def.filePath !== owner.filePath) continue; + if (picked !== undefined && picked.nodeId !== def.nodeId) return undefined; + picked = def; + } + return picked; + }; + // Namespace CHAIN walk (`ScopeResolver.resolveNamespaceChains`): resolve + // every segment of a qualified prefix from its verified namespace root — + // or, failing a namespace, from a class binding in scope (`Outer.Inner`). + // The cursor is either "these module files" or "this class"; a hop from a + // module is a class-like member of it (→ class) or a namespace-import edge + // its module scope binds under the segment — a republished module, + // `pub const sub = @import("sub.zig");` (→ files); a hop from a class is a + // nested class-like. Anything ambiguous resolves nothing. + const walkChains = provider.resolveNamespaceChains === true; + const namespaceImportTargetsOf = (file: string, name: string): readonly string[] => { + const moduleScope = index.moduleScopeByFile.get(file); + if (moduleScope === undefined) return []; + const out: string[] = []; + for (const edge of scopes.imports.get(moduleScope.id) ?? []) { + if (edge.kind !== 'namespace' || edge.localName !== name || edge.targetFile === null) + continue; + if (!out.includes(edge.targetFile)) out.push(edge.targetFile); + } + return out; + }; + type ChainCursor = + | { readonly files: readonly string[] } + | { readonly classDef: SymbolDefinition }; + const resolveNamespaceChain = ( + prefix: string, + inScope: ScopeId, + namespaceTargets: ReadonlyMap, + ): ChainCursor | undefined => { + const segments = splitTopLevelDots(prefix); + if (segments.length === 0) return undefined; + let cursor: ChainCursor | undefined; + let rest: readonly string[] = []; + // The LONGEST namespace key wins: a provider may bind dotted handles + // (`namespaceReceiverPaths`) and an inline `@import("x.zig")` handle + // carries a dot of its own inside the quotes. + for (let k = segments.length; k >= 1; k--) { + const key = segments.slice(0, k).join('.'); + const files = namespaceTargets.get(key); + if (files === undefined) continue; + if (isNamespaceNameShadowed(key, inScope, scopes)) return undefined; + cursor = { files }; + rest = segments.slice(k); + break; + } + if (cursor === undefined) { + const head = findClassBindingInScope(inScope, segments[0]!, scopes); + if (head === undefined || !isClassLike(head.type)) return undefined; + cursor = { classDef: head }; + rest = segments.slice(1); + } + for (const segment of rest) { + if (segment.includes('(') || segment.includes('[')) return undefined; + if ('files' in cursor) { + const asClass = uniqueClassAcross(cursor.files, segment); + const asModule: string[] = []; + for (const file of cursor.files) { + for (const target of namespaceImportTargetsOf(file, segment)) { + if (!asModule.includes(target)) asModule.push(target); + } + } + if (asClass !== undefined && asModule.length > 0) return undefined; // both — refuse + if (asClass !== undefined) cursor = { classDef: asClass }; + else if (asModule.length > 0) cursor = { files: asModule }; + else return undefined; + } else { + const nested = findNestedClass(cursor.classDef, segment); + if (nested === undefined) return undefined; + cursor = { classDef: nested }; + } + } + return cursor; + }; + // `ns.Type` as a receiver, where `ns` is a verified namespace of the current + // file and `Type` a class-like member of it — or, with the chain walk, any + // `a.b.c.Type` whose prefix resolves. Unique or nothing. + const resolveNamespaceQualifiedClass = ( + receiverName: string, + inScope: ScopeId, + namespaceTargets: ReadonlyMap, + ): SymbolDefinition | undefined => { + const dot = walkChains ? lastTopLevelDot(receiverName) : receiverName.lastIndexOf('.'); + if (dot <= 0 || dot === receiverName.length - 1) return undefined; + const head = receiverName.slice(0, dot); + const tail = receiverName.slice(dot + 1); + if (tail.includes('(') || tail.includes('[')) return undefined; + if (walkChains) { + const cursor = resolveNamespaceChain(head, inScope, namespaceTargets); + if (cursor === undefined) return undefined; + return 'classDef' in cursor + ? findNestedClass(cursor.classDef, tail) + : uniqueClassAcross(cursor.files, tail); + } + const files = namespaceTargets.get(head); + if (files === undefined || isNamespaceNameShadowed(head, inScope, scopes)) return undefined; + return uniqueClassAcross(files, tail); + }; const compoundOpts = { fieldFallback, elementTypeOf: provider.elementTypeOf, @@ -815,7 +966,16 @@ export function emitReceiverBoundCalls( receiverPaths: provider.namespaceReceiverPaths, moduleFileExists: (filePath) => index.moduleScopeByFile.has(filePath), }); - const fileCompoundOpts = { ...compoundOpts, namespaceTargets }; + const fileCompoundOpts = { + ...compoundOpts, + namespaceTargets, + ...(walkChains + ? { + resolveQualifiedClass: (qualifiedName: string, inScope: ScopeId) => + resolveNamespaceQualifiedClass(qualifiedName, inScope, namespaceTargets), + } + : {}), + }; // Per-file resolved-callee-id capture context (#2227 U2). Built once per // file; `undefined` when the sink is absent (pdg off) so the `tryEmitEdge` // capture is a no-op and emission stays byte-identical (R4). @@ -1302,11 +1462,18 @@ export function emitReceiverBoundCalls( // that is usually empty. Mirrors the order the compound-receiver // construction path already uses. const namespaceCandidates = namespaceTargets.get(receiverName); - const targetFiles = + let targetFiles: readonly string[] | undefined = namespaceCandidates !== undefined && !isNamespaceNameShadowed(receiverName, site.inScope, scopes) ? namespaceCandidates : undefined; + // Chain walk: `hub.sub.helper()` / `hub.sub.Thing{}` — the receiver is + // no handle of this file, but its segments reach a module (see + // `resolveNamespaceChain`). A prefix that ends in a CLASS is Case 2's. + if (targetFiles === undefined && walkChains && lastTopLevelDot(receiverName) > 0) { + const cursor = resolveNamespaceChain(receiverName, site.inScope, namespaceTargets); + if (cursor !== undefined && 'files' in cursor) targetFiles = cursor.files; + } if (targetFiles !== undefined && provider.resolveQualifiedReceiverMember === undefined) { let found = false; for (const targetFile of targetFiles) { @@ -1467,6 +1634,45 @@ export function emitReceiverBoundCalls( handledSites.add(siteKey); continue; } + // `A.Item{}` / `mod.Outer.Inner{}` — a construction whose member is a + // type NESTED in the class the receiver names. Neither a method nor a + // field, so the owner walk above cannot see it; the chain walk's + // nested-class lookup can (`resolveNamespaceChains`). + if (memberDef === undefined && walkChains && site.callForm === 'constructor') { + const nested = findNestedClass(classDef, memberName); + if (nested !== undefined) { + if ( + suppressDeletedCallTarget( + options.recordResolutionOutcome, + parsed.filePath, + site, + nested, + ) + ) { + handledSites.add(siteKey); + continue; + } + const ok = tryEmitEdge( + graph, + scopes, + nodeLookup, + site, + nested, + constructionSiteReason( + nested.filePath !== parsed.filePath ? 'import-resolved' : 'global', + site, + provider.markConstructionSites, + ), + seen, + 0.85, + collapse, + calleeCapture, + ); + if (ok) emitted++; + handledSites.add(siteKey); + continue; + } + } if (memberDef !== undefined) { if ( suppressDeletedCallTarget( @@ -1509,11 +1715,23 @@ export function emitReceiverBoundCalls( if (typeRef !== undefined && typeRef.rawName.includes('.')) { const [nsName, ...classNameParts] = typeRef.rawName.split('.'); const className = classNameParts.join('.'); - const targetFiles3 = namespaceTargets.get(nsName); + // With the chain walk the dotted type is resolved as a whole + // (`x: mod.Outer.Inner`, `t: hub.sub.Thing`); the candidate list then + // has one entry or none. Without it: the historical one-hop split. + const chainDef3 = walkChains + ? resolveNamespaceQualifiedClass(typeRef.rawName, site.inScope, namespaceTargets) + : undefined; + const targetFiles3 = walkChains + ? chainDef3 === undefined + ? undefined + : [chainDef3.filePath] + : namespaceTargets.get(nsName); if (targetFiles3 !== undefined && className.length > 0) { let found3 = false; for (const targetFile3 of targetFiles3) { - const classDef3 = lookupNamespaceMember(targetFile3, className); + const classDef3 = walkChains + ? chainDef3 + : lookupNamespaceMember(targetFile3, className); if (classDef3 !== undefined) { const picked = site.kind === 'call' diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 02dc6f82e..2a157dea6 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -2481,6 +2481,21 @@ export const ZIG_QUERIES = ` ; never match and keep their Function ids. ((source_file (container_field name: (identifier) @_field)) @definition.struct (#not-eq? @_field "")) +; A FIELDLESS file-struct — \`Empty.zig\`: no field, but a top-level fn whose +; first parameter is typed as the file's own type (\`self: *@This()\`, or +; \`self: *Self\` beside \`const Self = @This();\`). Zero-sized types are still +; constructed (\`Empty{}\`) and dispatched on, and keyed on fields alone the +; file lost its Struct node and every \`e.ping()\` edge (PR #1432 review, +; 8.12). The two rules over-match on purpose — any \`@This\` in a first +; parameter, any top-level \`@This()\` alias — and the provider's +; \`shouldSkipDefinitionCapture\` keeps only what \`isZigFileStruct\` (the +; single predicate the owner walk and the scope side use) admits. +((source_file (function_declaration (parameters . (parameter type: (_) @_recv)))) + @definition.struct + (#match? @_recv "@This")) +((source_file (variable_declaration (identifier) (builtin_function (builtin_identifier) @_this))) + @definition.struct + (#eq? @_this "@This")) ; Opaque: const Handle = opaque { ... } — the FFI handle type. It is a ; container (it may declare methods, never fields), so it is labelled Struct: diff --git a/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/build.zig b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/build.zig new file mode 100644 index 000000000..cec93e96b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/build.zig @@ -0,0 +1,34 @@ +const std = @import("std"); + +// Two executables that each bind the alias "config" to THEIR OWN config.zig — +// the ordinary multi-target layout. A single first-wins map of aliases sends +// tool/main.zig's `@import("config")` to app/config.zig. +pub fn build(b: *std.Build) void { + const corelib = b.dependency("corelib", .{}); + + const app_config = b.createModule(.{ .root_source_file = b.path("src/app/config.zig") }); + const tool_config = b.createModule(.{ .root_source_file = b.path("src/tool/config.zig") }); + + const app = b.addExecutable(.{ .name = "app", .root_source_file = b.path("src/app/main.zig") }); + app.root_module.addImport("config", app_config); + // A path dep's NAMED module (declared by libs/corelib/build.zig). + app.root_module.addImport("api", corelib.module("core")); + + const tool_mod = b.createModule(.{ + .root_source_file = b.path("src/tool/main.zig"), + .imports = &.{ .{ .name = "config", .module = tool_config } }, + }); + const tool = b.addExecutable(.{ .name = "tool", .root_module = tool_mod }); + b.installArtifact(app); + b.installArtifact(tool); + + // Two modules rooted in ONE directory that disagree on "clash": a file of + // that directory which is neither root cannot be attributed, and must + // resolve nothing rather than the first declaration. + const clash_a = b.createModule(.{ .root_source_file = b.path("src/shared/clash_a.zig") }); + const clash_b = b.createModule(.{ .root_source_file = b.path("src/shared/clash_b.zig") }); + const shared_a = b.addModule("shared_a", .{ .root_source_file = b.path("src/shared/a.zig") }); + shared_a.addImport("clash", clash_a); + const shared_b = b.addModule("shared_b", .{ .root_source_file = b.path("src/shared/b.zig") }); + shared_b.addImport("clash", clash_b); +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/build.zig.zon b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/build.zig.zon new file mode 100644 index 000000000..3c28e63c5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/build.zig.zon @@ -0,0 +1,8 @@ +.{ + .name = .buildmodules, + .version = "0.1.0", + .dependencies = .{ + .corelib = .{ .path = "libs/corelib" }, + }, + .paths = .{ "build.zig", "build.zig.zon", "src" }, +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/libs/corelib/build.zig b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/libs/corelib/build.zig new file mode 100644 index 000000000..db65843c0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/libs/corelib/build.zig @@ -0,0 +1,4 @@ +const std = @import("std"); +pub fn build(b: *std.Build) void { + _ = b.addModule("core", .{ .root_source_file = b.path("src/core.zig") }); +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/libs/corelib/src/core.zig b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/libs/corelib/src/core.zig new file mode 100644 index 000000000..f049ef0a5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/libs/corelib/src/core.zig @@ -0,0 +1 @@ +pub fn ping() void {} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/app/config.zig b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/app/config.zig new file mode 100644 index 000000000..7eaaa270c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/app/config.zig @@ -0,0 +1 @@ +pub fn load_app() void {} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/app/main.zig b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/app/main.zig new file mode 100644 index 000000000..65cb51abf --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/app/main.zig @@ -0,0 +1,7 @@ +const config = @import("config"); +const api = @import("api"); + +pub fn main() void { + config.load_app(); + api.ping(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/a.zig b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/a.zig new file mode 100644 index 000000000..ad8ce6271 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/a.zig @@ -0,0 +1,4 @@ +const clash = @import("clash"); +pub fn use_a() void { + clash.hit_a(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/b.zig b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/b.zig new file mode 100644 index 000000000..b80aa8765 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/b.zig @@ -0,0 +1,4 @@ +const clash = @import("clash"); +pub fn use_b() void { + clash.hit_b(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/clash_a.zig b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/clash_a.zig new file mode 100644 index 000000000..9afa9d26a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/clash_a.zig @@ -0,0 +1 @@ +pub fn hit_a() void {} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/clash_b.zig b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/clash_b.zig new file mode 100644 index 000000000..48584bf67 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/clash_b.zig @@ -0,0 +1 @@ +pub fn hit_b() void {} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/helper.zig b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/helper.zig new file mode 100644 index 000000000..13a218087 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/shared/helper.zig @@ -0,0 +1,5 @@ +// Neither module root: "clash" is ambiguous here and must resolve nothing. +const clash = @import("clash"); +pub fn use_helper() void { + clash.hit_a(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/tool/config.zig b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/tool/config.zig new file mode 100644 index 000000000..4e622c1d7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/tool/config.zig @@ -0,0 +1 @@ +pub fn load_tool() void {} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/tool/main.zig b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/tool/main.zig new file mode 100644 index 000000000..da4bc00cc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-buildmodules/src/tool/main.zig @@ -0,0 +1,5 @@ +const config = @import("config"); + +pub fn run_tool() void { + config.load_tool(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-chains/src/Empty.zig b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/Empty.zig new file mode 100644 index 000000000..483f6c1cb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/Empty.zig @@ -0,0 +1,4 @@ +const Self = @This(); +pub fn ping(self: *Self) void { + _ = self; +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-chains/src/Host.zig b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/Host.zig new file mode 100644 index 000000000..1aa49a9d8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/Host.zig @@ -0,0 +1,10 @@ +n: u32 = 0, +pub const Inner = struct { + pub fn m(self: Inner) void { + _ = self; + } +}; +pub fn touch(self: *Host) void { + _ = self; +} +const Host = @This(); diff --git a/gitnexus/test/fixtures/lang-resolution/zig-chains/src/counter.zig b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/counter.zig new file mode 100644 index 000000000..1e515e9f3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/counter.zig @@ -0,0 +1,9 @@ +pub const Counter = struct { + n: u32 = 0, + pub fn init(n: u32) Counter { + return .{ .n = n }; + } + pub fn get(self: Counter) u32 { + return self.n; + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/zig-chains/src/hub.zig b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/hub.zig new file mode 100644 index 000000000..e140a33fa --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/hub.zig @@ -0,0 +1 @@ +pub const sub = @import("sub.zig"); diff --git a/gitnexus/test/fixtures/lang-resolution/zig-chains/src/lib.zig b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/lib.zig new file mode 100644 index 000000000..6e3c36d65 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/lib.zig @@ -0,0 +1,6 @@ +pub const A = struct { + pub fn work() void {} +}; +pub const B = struct { + pub fn work() void {} +}; diff --git a/gitnexus/test/fixtures/lang-resolution/zig-chains/src/lists.zig b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/lists.zig new file mode 100644 index 000000000..9930af61d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/lists.zig @@ -0,0 +1,8 @@ +pub fn List(comptime T: type) type { + return struct { + items: []T = &.{}, + pub fn push(self: *@This()) void { + _ = self; + } + }; +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-chains/src/main.zig b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/main.zig new file mode 100644 index 000000000..5747e9ab3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/main.zig @@ -0,0 +1,112 @@ +const counter = @import("counter.zig"); +const Counter = counter.Counter; +const lib = @import("lib.zig"); +const hub = @import("hub.zig"); +const nested = @import("nested.zig"); +const lists = @import("lists.zig"); +const Empty = @import("Empty.zig"); +const runner = @import("runner.zig"); +const Runner = runner.Runner; +const opmod = @import("op.zig"); +const List = lists.List; + +var global_runner = Runner{}; +var global_runner2: Runner = undefined; + +fn target_global() void {} +fn target_global2() void {} +fn target_local() void {} +fn f_module_receiver() void { + global_runner.run(target_global); + global_runner2.run(target_global2); +} +fn f_local_receiver() void { + var r = Runner{}; + r.run(target_local); +} + +const chosen = @import("lib.zig").B.work; +const chosen2 = lib.B.work; +fn f_deep_alias() void { + chosen(); + chosen2(); +} + +fn f_nested() void { + var a = nested.A.Item{}; + a.run(); + var b = nested.B.Item{}; + b.run(); +} + +fn f_result_location() u32 { + const a: Counter = .init(1); + const b: Counter = .{ .n = 2 }; + return a.get() + b.get(); +} +fn f_return_decl_literal() Counter { + return .init(3); +} + +fn f_sib_a() void { + const m = @import("qa.zig"); + var t = m.Thing{}; + t.qa_only(); + m.hello(); +} +fn f_sib_b() void { + const m = @import("qb.zig"); + var t = m.Thing{}; + t.qb_only(); + m.hello(); +} + +fn f_multihop() u32 { + var s = hub.sub.Thing{}; + s.sub_m(); + var i = nested.Outer.Inner{}; + i.inner_m(); + _ = hub.sub.Thing.make(); + return opmod.Op.lookup.event_max(); +} + +fn f_inline_generic() void { + var t = @import("qa.zig").Thing{}; + t.qa_only(); + var l = lists.List(u8){}; + l.push(); + var l2 = List(u8){}; + l2.push(); +} + +fn f_fieldless() void { + var e = Empty{}; + e.ping(); +} + +pub fn main() void { + f_module_receiver(); + f_local_receiver(); + f_deep_alias(); + f_nested(); + _ = f_result_location(); + _ = f_return_decl_literal(); + f_sib_a(); + f_sib_b(); + _ = f_multihop(); + f_inline_generic(); + f_fieldless(); +} +const Host = @import("Host.zig"); +fn f_filestruct_nested() void { + var x = Host.Inner{}; + x.m(); + var h = Host{}; + h.touch(); + _ = hub.sub.Thing.make(); + var e2: Empty = .{}; + e2.ping(); +} +fn f_calls_more() void { + f_filestruct_nested(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-chains/src/nested.zig b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/nested.zig new file mode 100644 index 000000000..0abc9f1d7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/nested.zig @@ -0,0 +1,21 @@ +pub const A = struct { + pub const Item = struct { + pub fn run(self: Item) void { + _ = self; + } + }; +}; +pub const B = struct { + pub const Item = struct { + pub fn run(self: Item) void { + _ = self; + } + }; +}; +pub const Outer = struct { + pub const Inner = struct { + pub fn inner_m(self: Inner) void { + _ = self; + } + }; +}; diff --git a/gitnexus/test/fixtures/lang-resolution/zig-chains/src/op.zig b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/op.zig new file mode 100644 index 000000000..7b18a27d1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/op.zig @@ -0,0 +1,7 @@ +pub const Op = enum(u8) { + create = 1, + lookup = 2, + pub fn event_max(self: Op) u32 { + return @intFromEnum(self); + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/zig-chains/src/qa.zig b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/qa.zig new file mode 100644 index 000000000..43aa977a0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/qa.zig @@ -0,0 +1,6 @@ +pub const Thing = struct { + pub fn qa_only(self: Thing) void { + _ = self; + } +}; +pub fn hello() void {} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-chains/src/qb.zig b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/qb.zig new file mode 100644 index 000000000..30c1b9a00 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/qb.zig @@ -0,0 +1,6 @@ +pub const Thing = struct { + pub fn qb_only(self: Thing) void { + _ = self; + } +}; +pub fn hello() void {} diff --git a/gitnexus/test/fixtures/lang-resolution/zig-chains/src/runner.zig b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/runner.zig new file mode 100644 index 000000000..9c015a0eb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/runner.zig @@ -0,0 +1,7 @@ +pub const Runner = struct { + x: u32 = 0, + pub fn run(self: *Runner, cb: *const fn () void) void { + _ = self; + cb(); + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/zig-chains/src/sub.zig b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/sub.zig new file mode 100644 index 000000000..290fa7fb6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-chains/src/sub.zig @@ -0,0 +1,8 @@ +pub const Thing = struct { + pub fn sub_m(self: Thing) void { + _ = self; + } + pub fn make() Thing { + return .{}; + } +}; diff --git a/gitnexus/test/integration/resolvers/zig-build-modules.test.ts b/gitnexus/test/integration/resolvers/zig-build-modules.test.ts new file mode 100644 index 000000000..33df2da36 --- /dev/null +++ b/gitnexus/test/integration/resolvers/zig-build-modules.test.ts @@ -0,0 +1,70 @@ +/** + * Zig: per-build-module import tables (PR #1432 review, finding 8.2). + * + * `build.zig` binds bare-name aliases PER MODULE (`exe.root_module.addImport + * ("config", …)`); flattening every alias into one repo-wide first-wins map + * sent the second module's `@import("config")` to the first module's file. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + edgeSet, + FIXTURES, + getRelationships, + runPipelineFromRepo, + type PipelineResult, +} from './helpers.js'; +import { SupportedLanguages } from '../../../src/config/supported-languages.js'; +import { describeGrammarPresence, optionalGrammarGate } from '../../helpers/optional-grammar.js'; + +const zig = optionalGrammarGate(SupportedLanguages.Zig); +const zigAvailable = zig.available; + +describeGrammarPresence(zig); + +describe.skipIf(!zigAvailable)('Zig per-module build imports (zig-buildmodules fixture)', () => { + let result: PipelineResult; + let imports: string[]; + let calls: Set; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-buildmodules'), () => {}); + imports = getRelationships(result, 'IMPORTS').map( + (e) => `${e.sourceFilePath.replace(/\\/g, '/')} → ${e.targetFilePath.replace(/\\/g, '/')}`, + ); + calls = edgeSet(getRelationships(result, 'CALLS')); + }, 60000); + + it('resolves each module’s `@import("config")` through ITS OWN import table, not the first-declared alias', () => { + // Before: one `config → src/app/config.zig` entry for the whole repo, so + // tool/main.zig imported app's config and `config.load_tool()` resolved + // nothing (while `impact` on app's `load_app` gained a phantom caller). + expect(imports).toContain('src/app/main.zig → src/app/config.zig'); + expect(imports).toContain('src/tool/main.zig → src/tool/config.zig'); + expect(imports).not.toContain('src/tool/main.zig → src/app/config.zig'); + expect(calls).toContain('main → load_app'); + expect(calls).toContain('run_tool → load_tool'); + }); + + it('resolves `addImport("api", dep.module("core"))` through the path dep’s own `addModule("core")`', () => { + // `dep.module(…)` operands were deliberately unmodelled; the alias stayed + // unresolved even though the dep's build.zig names the module statically. + expect(imports).toContain('src/app/main.zig → libs/corelib/src/core.zig'); + expect(calls).toContain('main → ping'); + }); + + it('resolves a module ROOT through its own table even when it shares a directory with a disagreeing module', () => { + expect(imports).toContain('src/shared/a.zig → src/shared/clash_a.zig'); + expect(imports).toContain('src/shared/b.zig → src/shared/clash_b.zig'); + expect(calls).toContain('use_a → hit_a'); + expect(calls).toContain('use_b → hit_b'); + }); + + it('fails closed for a non-root file whose same-directory modules disagree on the alias', () => { + // helper.zig belongs to neither root; `shared_a` says clash_a.zig and + // `shared_b` says clash_b.zig. First-wins would emit helper → clash_a — + // a confident wrong edge — so the import resolves nothing instead. + expect(imports.some((e) => e.startsWith('src/shared/helper.zig → '))).toBe(false); + expect(calls).not.toContain('use_helper → hit_a'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/zig.test.ts b/gitnexus/test/integration/resolvers/zig.test.ts index 23d51aff6..6218aa6f1 100644 --- a/gitnexus/test/integration/resolvers/zig.test.ts +++ b/gitnexus/test/integration/resolvers/zig.test.ts @@ -930,6 +930,17 @@ describe.skipIf(!zigAvailable)( it('dispatches a method on an enum-typed parameter (`op: Op`)', () => { expect(calls).toContain('c8_enum_param_receiver → event_max'); }); + + it('dispatches on a variant reached THROUGH THE MODULE (`opmod.Op.lookup.event_max()`)', () => { + // The receiver `opmod.Op.lookup` is three hops: the module handle, the + // enum inside it, the variant (a value of the enum). Split once at the + // last dot, `opmod.Op` was looked up as a namespace key that does not + // exist and the site resolved to nothing — the fixture line was + // committed but never asserted (PR #1432 review, 8.10). The chain walk + // seeds the compound resolver at the class `Op` and reads `lookup` as + // its variant. + expect(calls).toContain('c9_enum_qualified_variant_receiver → event_max'); + }); }, ); @@ -1000,3 +1011,199 @@ describe.skipIf(!zigAvailable)( }); }, ); + +describe.skipIf(!zigAvailable)( + 'Zig qualified chains, deep aliases and result-location sites (PR #1432 review, 8.3–8.12)', + () => { + // One fixture per finding of the adversarial review, each with the decoy + // that made the old answer WRONG rather than merely missing: two nested + // `Item`s, `A.work` declared before `B.work`, two sibling fns binding `m` + // to different files, a hub republishing a module, a fieldless file type. + let result: PipelineResult; + /** `caller → targetId reason`, callers in main.zig only. */ + let calls: string[]; + let nodeIds: Set; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'zig-chains'), () => {}); + calls = getRelationships(result, 'CALLS') + .filter((e) => e.sourceFilePath.endsWith('main.zig')) + .map((e) => `${e.source} → ${e.rel.targetId} ${e.rel.reason ?? ''}`); + nodeIds = new Set(); + result.graph.forEachNode((n) => nodeIds.add(n.id)); + }, 60000); + + it('8.5 — keys a container nested in a container by its owner (`A.Item` ≠ `B.Item`)', () => { + // `nested.zig` declares `A.Item` and `B.Item`, each with `run`. By + // binding name alone both types and both methods collapsed onto ONE + // `Struct:…:Item` / `Item.run` — ownership and call targets were + // irrecoverably false. The identity is owner-qualified, like Java's + // `Outer.Inner`; the scope side still binds the lexical `Item`. + expect(nodeIds.has('Struct:src/nested.zig:A.Item')).toBe(true); + expect(nodeIds.has('Struct:src/nested.zig:B.Item')).toBe(true); + expect(nodeIds.has('Struct:src/nested.zig:Outer.Inner')).toBe(true); + expect(nodeIds.has('Struct:src/nested.zig:Item')).toBe(false); + expect(nodeIds.has('Method:src/nested.zig:A.Item.run#0')).toBe(true); + expect(nodeIds.has('Method:src/nested.zig:B.Item.run#0')).toBe(true); + expect(nodeIds.has('Method:src/nested.zig:Item.run#0')).toBe(false); + const owners = getRelationships(result, 'HAS_METHOD') + .filter((e) => e.targetFilePath.endsWith('nested.zig')) + .map((e) => `${e.source} → ${e.target}`) + .sort(); + expect(owners).toEqual(['A.Item → run', 'B.Item → run', 'Outer.Inner → inner_m']); + // …and each construction / call lands on ITS type. + expect(calls).toContain( + 'f_nested → Struct:src/nested.zig:A.Item import-resolved (constructor)', + ); + expect(calls).toContain( + 'f_nested → Struct:src/nested.zig:B.Item import-resolved (constructor)', + ); + expect(calls).toContain('f_nested → Method:src/nested.zig:A.Item.run#0 import-resolved'); + expect(calls).toContain('f_nested → Method:src/nested.zig:B.Item.run#0 import-resolved'); + }); + + it('8.3 — a MODULE-level value receiver passes itself as `self`, so the callback joins `cb`, not `self`', () => { + // `global_runner.run(target_global)` against `run(self, cb)`: only a + // fn-local head got the implicit receiver prepended, so `target_global` + // sat at actual 0, joined `self@0`, and the `run → target_global` edge + // was missing while the fn-local spelling `r.run(target_local)` had its + // edge. Same for the annotated `var global_runner2: Runner = undefined`. + const flow = getRelationships(result, 'CALLS') + .filter((e) => e.rel.reason === 'callable-value-flow') + .map((e) => `${e.source} → ${e.target}`); + expect(flow).toEqual( + expect.arrayContaining([ + 'run → target_local', + 'run → target_global', + 'run → target_global2', + ]), + ); + }); + + it('8.4 — a deep alias keeps its written owner (`@import("lib.zig").B.work` is `B.work`, not the first `work`)', () => { + // `lib.zig` declares `A.work` BEFORE `B.work`. The alias used to become a + // named import of the tail `work`, and the first `work` in the module — + // `A.work` — answered with exact confidence. Both the inline-import + // spelling (`chosen`) and the handle spelling (`chosen2 = lib.B.work`) + // must land on `B.work`; nothing may land on `A.work`. + const deep = calls.filter((c) => c.startsWith('f_deep_alias → ')); + expect(deep).toContain('f_deep_alias → Method:src/lib.zig:B.work#0 import-resolved'); + expect(deep.some((c) => c.includes('A.work'))).toBe(false); + }); + + it('8.6 — result-location `.init(…)` and `.{…}` emit the call and the construction the annotation implies', () => { + // `const a: Counter = .init(1);` typed `a` but the `init` CALL was absent + // from the graph; `const b: Counter = .{ .n = 2 };` had no construction + // event. `return .init(3)` in a fn returning `Counter` likewise. + expect(calls).toContain( + 'f_result_location → Method:src/counter.zig:Counter.init#1 import-resolved', + ); + expect(calls).toContain( + 'f_result_location → Struct:src/counter.zig:Counter import-resolved (constructor)', + ); + expect(calls).toContain( + 'f_return_decl_literal → Method:src/counter.zig:Counter.init#1 import-resolved', + ); + // The variables themselves stay typed by the annotation. + expect(calls).toContain( + 'f_result_location → Method:src/counter.zig:Counter.get#0 import-resolved', + ); + }); + + it('8.9 — sibling fns binding the same local `m` to different files each resolve through their own import', () => { + // `f_sib_a` binds `const m = @import("qa.zig")`, `f_sib_b` binds + // `@import("qb.zig")`. Finalization flattens both onto the module scope: + // `m → [qa.zig, qb.zig]`, and Case 1 took the first target for BOTH — + // `f_sib_b → qa.zig`'s `Thing` and `hello` (wrong edges, not missing). + const a = calls.filter((c) => c.startsWith('f_sib_a → ')); + const b = calls.filter((c) => c.startsWith('f_sib_b → ')); + expect(a).toEqual( + expect.arrayContaining([ + 'f_sib_a → Struct:src/qa.zig:Thing import-resolved (constructor)', + 'f_sib_a → Function:src/qa.zig:hello import-resolved', + 'f_sib_a → Method:src/qa.zig:Thing.qa_only#0 import-resolved', + ]), + ); + expect(b).toEqual( + expect.arrayContaining([ + 'f_sib_b → Struct:src/qb.zig:Thing import-resolved (constructor)', + 'f_sib_b → Function:src/qb.zig:hello import-resolved', + 'f_sib_b → Method:src/qb.zig:Thing.qb_only#0 import-resolved', + ]), + ); + expect(b.some((c) => c.includes('src/qa.zig'))).toBe(false); + expect(a.some((c) => c.includes('src/qb.zig'))).toBe(false); + }); + + it('8.10 — qualified chains are walked segment by segment: hub-republished module, nested type', () => { + // `hub.sub.Thing{}` (`hub.zig`: `pub const sub = @import("sub.zig");`), + // `nested.Outer.Inner{}` and `hub.sub.Thing.make()` all arrive with a + // receiver whose prefix is not a namespace KEY of main.zig; the one-hop + // split at the last dot asked for `hub.sub` / `nested.Outer` as exact + // keys and resolved nothing. + const hop = calls.filter((c) => c.startsWith('f_multihop → ')); + expect(hop).toEqual( + expect.arrayContaining([ + 'f_multihop → Struct:src/sub.zig:Thing import-resolved (constructor)', + 'f_multihop → Method:src/sub.zig:Thing.sub_m#0 import-resolved', + 'f_multihop → Method:src/sub.zig:Thing.make#0 import-resolved', + 'f_multihop → Struct:src/nested.zig:Outer.Inner import-resolved (constructor)', + 'f_multihop → Method:src/nested.zig:Outer.Inner.inner_m#0 import-resolved', + 'f_multihop → Method:src/op.zig:Op.event_max#0 import-resolved', + ]), + ); + }); + + it('8.11 — inline-import and generic-instantiation literals are construction events', () => { + // `@import("qa.zig").Thing{}`: the module is the receiver of a + // construction exactly as of a member call, but only the call shape was + // bound as a namespace. `List(u8){}` / `lists.List(u8){}`: the type + // head is a call_expression, which neither constructor rule matched, so + // only the inner `List(u8)` invocation reached the graph and the outer + // aggregate event had no site. + const gen = calls.filter((c) => c.startsWith('f_inline_generic → ')); + expect(gen).toEqual( + expect.arrayContaining([ + 'f_inline_generic → Struct:src/qa.zig:Thing import-resolved (constructor)', + 'f_inline_generic → Method:src/qa.zig:Thing.qa_only#0 import-resolved', + 'f_inline_generic → Struct:src/lists.zig:List import-resolved (constructor)', + 'f_inline_generic → Method:src/lists.zig:List.push#0 import-resolved', + ]), + ); + }); + + it('8.12 — a FIELDLESS file type (`Empty.zig`: `const Self = @This(); fn ping(self: *Self)`) keeps its Struct', () => { + // Keyed on top-level fields alone, `Empty.zig` was a namespace: no + // `Struct`, `ping` a free `Function`, and `Empty{}` / `e.ping()` from an + // importer resolved nothing. The receiver typed as the file's own type + // is the second signal. + expect(nodeIds.has('Struct:src/Empty.zig:Empty')).toBe(true); + expect(nodeIds.has('Method:src/Empty.zig:Empty.ping#0')).toBe(true); + expect(nodeIds.has('Function:src/Empty.zig:ping')).toBe(false); + expect(nodeIds.has('Const:src/Empty.zig:Self')).toBe(false); + expect(calls).toContain( + 'f_fieldless → Struct:src/Empty.zig:Empty import-resolved (constructor)', + ); + expect(calls).toContain('f_fieldless → Method:src/Empty.zig:Empty.ping#0 import-resolved'); + // A namespace-only file (no field, no self-typed receiver) is still not a type. + expect(nodeIds.has('Struct:src/qa.zig:qa')).toBe(false); + expect(nodeIds.has('Struct:src/sub.zig:sub')).toBe(false); + expect(nodeIds.has('Function:src/qa.zig:hello')).toBe(true); + }); + + it('keeps a type nested in a FILE-struct reachable through the file handle (`Host.Inner{}`)', () => { + // A file-level container is already namespaced by its file, so its + // identity stays the binding name; the chain `Host.Inner` walks the + // file-struct's class to the nested type. + expect(calls).toContain( + 'f_filestruct_nested → Struct:src/Host.zig:Inner import-resolved (constructor)', + ); + expect(calls).toContain( + 'f_filestruct_nested → Method:src/Host.zig:Inner.m#0 import-resolved', + ); + expect(calls).toContain( + 'f_filestruct_nested → Method:src/Host.zig:Host.touch#0 import-resolved', + ); + }); + }, +); diff --git a/gitnexus/test/unit/zig-extractors.test.ts b/gitnexus/test/unit/zig-extractors.test.ts index 1378b202d..9da8575da 100644 --- a/gitnexus/test/unit/zig-extractors.test.ts +++ b/gitnexus/test/unit/zig-extractors.test.ts @@ -833,11 +833,15 @@ test { targetRaw: 'counter.zig', }, { kind: 'named', localName: 'Same', importedName: 'Same', targetRaw: 'counter.zig' }, + // A DEEP chain (`@import("std").mem.Allocator`) is not a named import + // of the innermost member: that lost the owner `mem` (review 8.4). The + // module is bound under the builtin's own text — the namespace handle + // the rewritten use sites of `Deep` (`@import("std").mem` . `Allocator`) + // resolve through — and `Deep` itself stays a deep alias. { - kind: 'alias', - localName: 'Deep', - importedName: 'Allocator', - alias: 'Deep', + kind: 'namespace', + localName: '@import("std")', + importedName: 'std', targetRaw: 'std', }, { kind: 'wildcard', targetRaw: 'mixin.zig' }, @@ -1200,6 +1204,29 @@ pub fn helper() u32 { return 1; } expect(isZigFileStruct(parse('').rootNode)).toBe(false); }); + it("detects a FIELDLESS file-struct by a top-level fn whose receiver is the file's own type (review 8.12)", () => { + // `Empty.zig`: no field, but `ping` takes the file type. Constructed + // (`Empty{}`) and dispatched on by importers, it lost its Struct when + // fields were the only signal. + expect( + isZigFileStruct( + parse('const Self = @This();\npub fn ping(self: *Self) void { _ = self; }\n').rootNode, + ), + ).toBe(true); + expect( + isZigFileStruct(parse('pub fn ping(self: *@This()) void { _ = self; }\n').rootNode), + ).toBe(true); + // Only the receiver's TYPE decides — the parameter name `self` on some + // other type is a free function in a utility file… + expect( + isZigFileStruct( + parse('const Foo = struct {};\npub fn f(self: Foo) void { _ = self; }\n').rootNode, + ), + ).toBe(false); + // …and a `@This()` alias with no receiver use is still a namespace. + expect(isZigFileStruct(parse(NAMESPACE).rootNode)).toBe(false); + }); + it('names the type after the FILE STEM, on every platform spelling', () => { expect(zigFileStructName('src/browser/Page.zig')).toBe('Page'); expect(zigFileStructName('src\\browser\\Sighandler.zig')).toBe('Sighandler'); @@ -1303,7 +1330,9 @@ fn f() void { ['hidden', false], // no `pub` ['ns', false], ['Bar', true], // pub alias of a namespace member - ['Local', false], // fn-local: binds locally, publishes nothing + // fn-local: binds locally under its per-callable key (`Local$f`, see + // `zigFunctionLocalImportKey` — review 8.9), publishes nothing + ['Local$f', false], ]); }); @@ -1853,3 +1882,209 @@ pub fn string() void { expect(selfParam?.['@type-binding.type']?.text).toBe('*const R'); }); }); + +describeZig('Zig review findings 8.4 / 8.5 / 8.6 / 8.9 — capture-side contracts', () => { + it('8.5 — a container nested in a container is identified `Owner.Name` and bound as `Name`', () => { + const src = ` +pub const A = struct { + pub const Item = struct { + pub fn run(self: Item) void { _ = self; } + }; +}; +pub const B = struct { + pub const Item = struct { + pub fn run(self: Item) void { _ = self; } + }; +}; +`; + const root = parse(src).rootNode; + const containers: SyntaxNode[] = []; + const visit = (n: SyntaxNode): void => { + if (n.type === 'struct_declaration') containers.push(n); + for (let i = 0; i < n.namedChildCount; i++) visit(n.namedChild(i)!); + }; + visit(root); + expect(containers.map((c) => zigContainerName(c, 'src/nested.zig'))).toEqual([ + 'A', + 'A.Item', + 'B', + 'B.Item', + ]); + // The lexical binding is unchanged: `Item{}` inside `A` still spells `Item`. + expect(containers.map((c) => zigContainerBindingName(c))).toEqual(['A', 'Item', 'B', 'Item']); + // Nested containers are minted by the bare-container rule (identity from + // `zigContainerName`), not by the wrapper rule whose `@name` is `Item`. + expect(containers.map((c) => zigContainerAnchor(c))).toEqual([ + 'wrapper', + 'container', + 'wrapper', + 'container', + ]); + const decls = emitZigScopeCaptures(src, 'src/nested.zig').filter( + (m) => m['@declaration.struct'] !== undefined, + ); + expect( + decls.map((m) => [m['@declaration.name']?.text, m['@declaration.binding-name']?.text]), + ).toEqual([ + ['A', undefined], + ['A.Item', 'Item'], + ['B', undefined], + ['B.Item', 'Item'], + ]); + }); + + it('8.9 — a fn-local `@import` binding and its uses are keyed per callable', () => { + const src = ` +const std = @import("std"); +fn f_sib_a() void { + const m = @import("qa.zig"); + var t = m.Thing{}; + t.go(); + m.hello(); +} +fn f_sib_b() void { + const m = @import("qb.zig"); + m.hello(); +} +`; + const matches = emitZigScopeCaptures(src, 'src/main.zig'); + const importNames = matches + .filter((m) => m['@import.statement'] !== undefined && m['@import.imported'] === undefined) + .map((m) => m['@import.name']!.text); + // The module-level handle is untouched; each fn-local `m` gets its own key. + expect(importNames).toEqual(['std', 'm$f_sib_a', 'm$f_sib_b']); + const receivers = matches + .filter((m) => m['@reference.receiver'] !== undefined) + .map((m) => `${m['@reference.receiver']!.text}.${m['@reference.name']!.text}`); + expect(receivers).toEqual(['m$f_sib_a.Thing', 't.go', 'm$f_sib_a.hello', 'm$f_sib_b.hello']); + // The constructor type binding follows the same key. + const tBinding = matches.find( + (m) => m['@type-binding.constructor'] !== undefined && m['@type-binding.name']?.text === 't', + ); + expect(tBinding?.['@type-binding.type']?.text).toBe('m$f_sib_a.Thing'); + // The string literal inside `@import("m.zig")` is never touched. + const inString = emitZigScopeCaptures( + 'fn g() void {\n const m = @import("m.zig");\n m.run();\n}\n', + 'src/x.zig', + ); + expect(inString.find((m) => m['@import.source'] !== undefined)?.['@import.source']?.text).toBe( + '"m.zig"', + ); + }); + + it('8.4 — a deep member alias rewrites its use sites to the written owner path', () => { + const src = ` +const lib = @import("lib.zig"); +const chosen = @import("lib.zig").B.work; +const chosen2 = lib.B.work; +const Inner = lib.Outer.Inner; +fn f() void { + chosen(); + chosen2(); + var i = Inner{}; + _ = i; +} +`; + const matches = emitZigScopeCaptures(src, 'src/main.zig'); + // The inline import is bound as a namespace under its own text — the + // handle the rewritten sites resolve through — never as a named import + // of the tail `work`. + const imports = matches + .filter((m) => m['@import.source'] !== undefined) + .map((m) => interpretZigImport(m)); + expect(imports).toEqual([ + { kind: 'namespace', localName: 'lib', importedName: 'lib', targetRaw: 'lib.zig' }, + { kind: 'named', localName: 'lib', importedName: 'lib', targetRaw: 'lib.zig' }, + { + kind: 'namespace', + localName: '@import("lib.zig")', + importedName: 'lib', + targetRaw: 'lib.zig', + }, + ]); + const sites = matches + .filter((m) => m['@reference.receiver'] !== undefined) + .map( + (m) => + `${m['@reference.call.member'] !== undefined ? 'call' : 'ctor'} ${m['@reference.receiver']!.text} . ${m['@reference.name']!.text}`, + ); + expect(sites).toEqual([ + 'call @import("lib.zig").B . work', + 'call lib.B . work', + 'ctor lib.Outer . Inner', + ]); + // No free-call site named `chosen` survives to be resolved by simple name. + expect( + matches.some( + (m) => m['@reference.call.free'] !== undefined && m['@reference.name']?.text === 'chosen', + ), + ).toBe(false); + // The two-level alias `lib.B.work` is NOT promoted to a named import; it + // stays a Const with a type alias binding carrying the path. + expect( + matches.some( + (m) => + m['@declaration.variable'] !== undefined && m['@declaration.name']?.text === 'chosen2', + ), + ).toBe(true); + }); + + it('8.6 — result-location `.init(…)` / `.{…}` sites carry the expected type as their receiver', () => { + const src = ` +const stdx = @import("stdx.zig"); +const Counter = @import("counter.zig").Counter; +const a: Counter = .init(1); +const b: Counter = .{ .n = 2 }; +var t: stdx.Thing = .{}; +const n: u32 = 5; +fn mk() !Counter { + return .init(2); +} +fn arg() void { + use(.init(3)); +} +const S = struct { + const Self = @This(); + n: Counter = .init(0), + pub fn make() Self { + return .{}; + } +}; +`; + const sites = emitZigScopeCaptures(src, 'src/main.zig') + .filter( + (m) => + (m['@reference.call.member'] !== undefined || + m['@reference.call.constructor'] !== undefined) && + m['@reference.receiver'] !== undefined, + ) + .map( + (m) => + `${m['@reference.call.member'] !== undefined ? 'call' : 'ctor'} ${m['@reference.receiver']!.text} . ${m['@reference.name']!.text} @${m['@reference.call.member']?.range.startLine ?? m['@reference.call.constructor']?.range.startLine}`, + ); + expect(sites).toEqual( + expect.arrayContaining([ + 'call Counter . init @4', // const a: Counter = .init(1) + 'call Counter . init @9', // return .init(2) in `fn mk() !Counter` + 'call Counter . init @16', // field default `n: Counter = .init(0)` + 'ctor stdx . Thing @6', // var t: stdx.Thing = .{} + ]), + ); + // `.{}` under a bare annotation is a free construction of that type… + const bare = emitZigScopeCaptures(src, 'src/main.zig').filter( + (m) => + m['@reference.call.constructor'] !== undefined && m['@reference.receiver'] === undefined, + ); + expect( + bare.map( + (m) => + `${m['@reference.name']!.text} @${m['@reference.call.constructor']!.range.startLine}`, + ), + ).toEqual( + expect.arrayContaining(['Counter @5', 'S @18']), // `return .{}` in `fn make() Self` → the container + ); + // …while a primitive annotation and an argument position emit nothing. + expect(sites.some((s) => s.includes('u32'))).toBe(false); + expect(sites.some((s) => s.endsWith('@12'))).toBe(false); + }); +}); diff --git a/gitnexus/test/unit/zig-import-resolver.test.ts b/gitnexus/test/unit/zig-import-resolver.test.ts index 2ddf4e799..fd479d563 100644 --- a/gitnexus/test/unit/zig-import-resolver.test.ts +++ b/gitnexus/test/unit/zig-import-resolver.test.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'; import { resolveZigImportInternal } from '../../src/core/ingestion/import-resolvers/zig.js'; import { loadZigBuildConfig, + parseZigBuildModules, parseZigRootModules, parseZigBuildModuleRoots, parseZigBuildZon, @@ -227,6 +228,98 @@ describe('resolveZigImportInternal', () => { ).toBe('src/root.zig'); }); + it('resolves a bare name through the importer’s OWN build module table, not the first-declared alias', () => { + // Two executables each `addImport("config", …)` their own config.zig. The + // flat `rootModules` map keeps the first, so every tool/ file imported + // app's config. The per-module table is consulted first, by membership. + const files = new Set([ + 'src/app/main.zig', + 'src/app/config.zig', + 'src/app/util.zig', + 'src/tool/main.zig', + 'src/tool/config.zig', + ]); + const config = { + pathDeps: new Map(), + rootModules: new Map([['config', 'src/app/config.zig']]), + buildModules: [ + { root: 'src/app/main.zig', imports: new Map([['config', 'src/app/config.zig']]) }, + { root: 'src/tool/main.zig', imports: new Map([['config', 'src/tool/config.zig']]) }, + ], + }; + expect(resolveZigImportInternal('src/tool/main.zig', 'config', files, config)).toBe( + 'src/tool/config.zig', + ); + expect(resolveZigImportInternal('src/app/main.zig', 'config', files, config)).toBe( + 'src/app/config.zig', + ); + // A non-root file is attributed to the module whose root shares its + // directory (deepest prefix). + expect(resolveZigImportInternal('src/app/util.zig', 'config', files, config)).toBe( + 'src/app/config.zig', + ); + // A file outside every module directory falls back to the flat map. + expect(resolveZigImportInternal('examples/demo.zig', 'config', files, config)).toBe( + 'src/app/config.zig', + ); + }); + + it('fails closed when same-directory modules disagree on an alias, instead of taking the first', () => { + // `src/main.zig` (exe) and `src/root.zig` (lib) share a directory — the + // `zig init` layout. A third file there cannot be attributed; resolving + // through either would be a confident wrong edge, and the flat fallback + // would only restore the first-wins answer, so the chain stops at null. + const files = new Set([ + 'src/main.zig', + 'src/root.zig', + 'src/util.zig', + 'src/cfg_exe.zig', + 'src/cfg_lib.zig', + ]); + const config = { + pathDeps: new Map(), + rootModules: new Map([['cfg', 'src/cfg_exe.zig']]), + buildModules: [ + { root: 'src/main.zig', imports: new Map([['cfg', 'src/cfg_exe.zig']]) }, + { root: 'src/root.zig', imports: new Map([['cfg', 'src/cfg_lib.zig']]) }, + ], + }; + expect(resolveZigImportInternal('src/util.zig', 'cfg', files, config)).toBeNull(); + // The roots themselves are unambiguous: each is its own module. + expect(resolveZigImportInternal('src/main.zig', 'cfg', files, config)).toBe('src/cfg_exe.zig'); + expect(resolveZigImportInternal('src/root.zig', 'cfg', files, config)).toBe('src/cfg_lib.zig'); + // Agreement is not a conflict: two modules binding one alias to one root + // resolve it for their shared directory. + const agreeing = { + ...config, + buildModules: [ + { root: 'src/main.zig', imports: new Map([['cfg', 'src/cfg_lib.zig']]) }, + { root: 'src/root.zig', imports: new Map([['cfg', 'src/cfg_lib.zig']]) }, + ], + }; + expect(resolveZigImportInternal('src/util.zig', 'cfg', files, agreeing)).toBe( + 'src/cfg_lib.zig', + ); + }); + + it('falls through to the flat root-module map when the containing module does not bind the name', () => { + // A module table answers only for the aliases it declares; the package's + // own `addModule` name stays importable from everywhere, as before. + const files = new Set(['src/main.zig', 'src/lib.zig', 'src/cfg.zig']); + const config = { + pathDeps: new Map(), + rootModules: new Map([['mylib', 'src/lib.zig']]), + buildModules: [{ root: 'src/main.zig', imports: new Map([['cfg', 'src/cfg.zig']]) }], + }; + expect(resolveZigImportInternal('src/main.zig', 'mylib', files, config)).toBe('src/lib.zig'); + // A table entry whose root is not indexed is not an answer either. + const stale = { + ...config, + buildModules: [{ root: 'src/main.zig', imports: new Map([['cfg', 'src/gone.zig']]) }], + }; + expect(resolveZigImportInternal('src/main.zig', 'cfg', files, stale)).toBeNull(); + }); + it('returns null for an unknown bare name not in build.zig.zon', () => { const files = new Set(['src/main.zig']); const buildZon = { pathDeps: new Map([['ziggit', 'vendor/ziggit']]) }; @@ -529,6 +622,114 @@ _ = b.addModule("abs", .{ .root_source_file = .{ .cwd_relative = "/abs/x.zig" } }); }); +describe('parseZigBuildModules', () => { + it('keeps each module’s addImport aliases in ITS OWN table (two modules, one alias, two roots)', () => { + // The review trigger: `app` and `tool` each bind "config". One flat map + // kept app's; the per-module tables keep both, each on its module. + const buildZig = ` +pub fn build(b: *std.Build) void { + const app_config = b.createModule(.{ .root_source_file = b.path("src/app/config.zig") }); + const tool_config = b.createModule(.{ .root_source_file = b.path("src/tool/config.zig") }); + const app = b.addExecutable(.{ .name = "app", .root_source_file = b.path("src/app/main.zig") }); + app.root_module.addImport("config", app_config); + const tool_mod = b.createModule(.{ + .root_source_file = b.path("src/tool/main.zig"), + .imports = &.{ .{ .name = "config", .module = tool_config } }, + }); + const tool = b.addExecutable(.{ .name = "tool", .root_module = tool_mod }); + tool.root_module.addImport("extra", app_config); +} +`; + expect(parseZigBuildModules(buildZig)).toEqual([ + { root: 'src/app/config.zig', imports: new Map() }, + { root: 'src/tool/config.zig', imports: new Map() }, + { root: 'src/app/main.zig', imports: new Map([['config', 'src/app/config.zig']]) }, + { + root: 'src/tool/main.zig', + imports: new Map([ + ['config', 'src/tool/config.zig'], + // `tool.root_module` IS `tool_mod`: the artifact alias lands on it. + ['extra', 'src/app/config.zig'], + ]), + }, + ]); + }); + + it('names addModule modules, binds an inline `.root_module = b.createModule(…)` to its artifact, and keeps the first alias', () => { + const buildZig = ` +pub fn build(b: *std.Build) void { + const lib = b.addModule("mylib", .{ .root_source_file = b.path("./src/lib.zig") }); + lib.addImport("mylib", lib); + const exe = b.addExecutable(.{ + .name = "app", + .root_module = b.createModule(.{ .root_source_file = b.path("src/main.zig") }), + }); + exe.root_module.addImport("mylib", lib); + exe.root_module.addImport("mylib", exe.root_module); // second binding of a name: ignored + exe.root_module.addImport("gen", opts.createModule()); // generated: no file + unbound.addImport("x", lib); // receiver bound to nothing: ignored +} +`; + expect(parseZigBuildModules(buildZig)).toEqual([ + { name: 'mylib', root: 'src/lib.zig', imports: new Map([['mylib', 'src/lib.zig']]) }, + { root: 'src/main.zig', imports: new Map([['mylib', 'src/lib.zig']]) }, + ]); + }); + + it('resolves `addImport("api", dep.module("core"))` through the dep’s declared modules, and nothing without them', () => { + const buildZig = ` +pub fn build(b: *std.Build) void { + const corelib = b.dependency("corelib", .{ .target = target }); + const exe = b.addExecutable(.{ .name = "app", .root_source_file = b.path("src/main.zig") }); + exe.root_module.addImport("api", corelib.module("core")); + exe.root_module.addImport("other", corelib.module("missing")); + exe.root_module.addImport("v8", v8.module("v8")); // not a b.dependency binding +} +`; + const depModules = new Map([['corelib', new Map([['core', 'libs/corelib/src/core.zig']])]]); + expect(parseZigBuildModules(buildZig, depModules)).toEqual([ + { root: 'src/main.zig', imports: new Map([['api', 'libs/corelib/src/core.zig']]) }, + ]); + // The zon dep (or its build.zig) unknown: the alias is left unresolved + // rather than guessed. + expect(parseZigBuildModules(buildZig)).toEqual([{ root: 'src/main.zig', imports: new Map() }]); + }); + + it('ignores calls, aliases and roots spelled in comments or strings', () => { + const buildZig = ` +pub fn build(b: *std.Build) void { + const m = b.createModule(.{ .root_source_file = b.path("src/m.zig") }); + // const decoy = b.createModule(.{ .root_source_file = b.path("src/decoy.zig") }); + // m.addImport("commented", m); + const s = "m.addImport(\\"quoted\\", m)"; + _ = s; +} +`; + expect(parseZigBuildModules(buildZig)).toEqual([{ root: 'src/m.zig', imports: new Map() }]); + expect(parseZigBuildModules('pub fn build(b: *std.Build) void { _ = b; }')).toEqual([]); + }); +}); + +describe('loadZigBuildConfig (zig-buildmodules fixture)', () => { + it('carries per-module tables, with dep.module(…) aliases resolved through the dep’s build.zig', async () => { + const config = await loadZigBuildConfig(path.join(FIXTURES, 'zig-buildmodules')); + expect(config).not.toBeNull(); + const byRoot = new Map(config!.buildModules!.map((m) => [m.root, m.imports])); + expect(byRoot.get('src/app/main.zig')).toEqual( + new Map([ + ['config', 'src/app/config.zig'], + ['api', 'libs/corelib/src/core.zig'], + ]), + ); + expect(byRoot.get('src/tool/main.zig')).toEqual(new Map([['config', 'src/tool/config.zig']])); + expect(byRoot.get('src/shared/a.zig')).toEqual(new Map([['clash', 'src/shared/clash_a.zig']])); + expect(byRoot.get('src/shared/b.zig')).toEqual(new Map([['clash', 'src/shared/clash_b.zig']])); + // The flat map is still there as the repo-wide fallback — and shows the + // first-wins collapse the per-module tables exist to avoid. + expect(config!.rootModules?.get('config')).toBe('src/app/config.zig'); + }); +}); + describe('loadZigBuildConfig (zig-rootmodule fixture: build.zig, no build.zig.zon)', () => { it('still yields a config carrying the root build.zig’s modules', async () => { // Before: no build.zig.zon → null → every bare-name import in the repo