From fda651dd75e6f73a86ff64ddacd96ef36eb0b6d1 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Tue, 8 Sep 2026 05:55:33 +0200 Subject: [PATCH] fix(zig): emit `value-ref` for a callable named in value position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mapReferenceKindToEdgeType` has handled the registration-vs-invocation case since #2437, and TypeScript, JavaScript and C++ all emit `value-ref`. Zig emitted zero — so a Zig function handed somewhere as a VALUE was absent from the graph entirely. That is not a corner: Zig's JS bridge is built out of this one shape, pub const namespaceURI = bridge.accessor(Element.getNamespaceUri, null, .{}); and `bridge.{accessor,function,indexed,…}` appears 2,047 times across 257 files in lightpanda-io/browser — the project's whole JS<->Zig surface, none of it reaching the graph. `impact` on `Element.getNamespaceUri`, which IS the DOM `Element.namespaceURI` accessor, answered with its two in-file callers. Three query rules, tagging `@reference.value-ref` on: a bare identifier in argument position (`bridge.accessor(_tagName, …)`), a qualified one (`bridge.accessor(Element.getNamespaceUri, …)`), and a const binding initialiser (`pub const defaultHandler = onReset;`). Everything downstream already existed — no new edge type, no schema change, no capture-machinery change, no baseline edited. One grammar detail is load-bearing: in tree-sitter-zig, call arguments are DIRECT children of `call_expression` — there is no `arguments` node, only builtins have one — so the callee has to be consumed explicitly by `function:`. Without that binding the same rule also matches the callee of `foo(bar)` and mints a USES edge shadowing the call's own CALLS edge. There is a test for it. The rules are deliberately broad — `js.Bridge(Element)` and `register(count)` match too — because the callable gate in the property-dispatch pass (Function/Method/Constructor only) is the filter, the same design that keeps TypeScript's `{ port: DEFAULT_PORT }` from registering anything. Measured on the real corpus: all 3,169 emitted edges land on a Method (3,146) or Function (23). Deliberately NOT modelled: the terminal invoke. The value reaches `Accessor.init` -> a struct field -> `Factory.zig` reflection (`inline for`, `@typeInfo`) -> `Caller.zig`'s `@call(.auto, func, args)` over `func: anytype`. Resolving that needs comptime evaluation. The point is to stop dropping the reference; the shortfall is now reported as `epistemic: "lower-bound"` by the preceding commit instead of being papered over. Measured on lightpanda-io/browser (698 .zig files), wiped index both times: 30,222 nodes / 71,070 edges -> 30,222 nodes / 74,229 edges. The delta is entirely `USES` (3,169 after vs 10 before, and every one is a value-ref); CALLS, ACCESSES, IMPORTS, HAS_METHOD, DEFINES and the rest are unchanged — purely additive, no node invented. The fixture joins the existing `zig-idioms` corpus rather than adding a new one, because `bench/receiver-resolution` uses `test/fixtures/lang-resolution` as its `--check` corpus. That gate, `scope-capture` (15 languages), `zig-cross-file-resolution` and every other bench `--check` pass unchanged. --- DECISIONS.md | 132 ++++++++++++++++++ .../src/core/ingestion/languages/zig/query.ts | 54 +++++++ .../lang-resolution/zig-idioms/src/main.zig | 1 + .../zig-idioms/src/webapi/Element.zig | 99 +++++++++++++ .../test/integration/resolvers/zig.test.ts | 88 ++++++++++++ 5 files changed, 374 insertions(+) create mode 100644 gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/Element.zig diff --git a/DECISIONS.md b/DECISIONS.md index 28d88bb90..c2e08a29f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -96,3 +96,135 @@ Test: `test/integration/impact-callable-value-references.test.ts` (5 cases, including three controls: an ordinary CALLS-only target, a non-value-ref `USES` edge, and the downstream direction). + +### D1 — the missing edge (Zig emits `value-ref`) + +**D1-1. Query rules only; no new capture machinery.** Three rules added to +`ZIG_SCOPE_QUERY`, tagging `@reference.name @reference.value-ref`. Everything +downstream already existed: `referenceKindFromAnchor` → `'value-ref'` → +`mapReferenceKindToEdgeType` → `USES`, resolved by the property-dispatch pass. +Zero changes to `captures.ts`, to `callable-flow-captures.ts`, to the schema, or +to any edge kind. + +*Rejected:* extending `zigCallableCaptureOptions` / `synthesizeCallableFlowCaptures` +(the cell/site model). That machinery exists to trace a value to its INVOKE site +and emit CALLS — which is the explicit non-goal here, needs comptime evaluation, +and already reports its own all-or-nothing failure (`callable-value-flow: +candidate set exceeded the cap`). The task is to stop dropping the reference, +and a reference is exactly what `value-ref` is for. + +**D1-2. The callee must be consumed by `function:`.** In tree-sitter-zig, +call arguments are DIRECT children of `call_expression` — there is no `arguments` +node (only builtins have one; verified against the grammar). A naive +`(call_expression (identifier) @x)` therefore also matches the callee of +`foo(bar)`, minting a USES edge that shadows the call's own CALLS edge. Binding +`function: (_)` consumes it. Pinned by a test +(`does not mint a value reference for the CALLEE of an ordinary call`). + +**D1-3. Both the bare and the qualified argument shape.** The motivating line is +qualified (`bridge.accessor(Element.getNamespaceUri, …)`) and its sibling is bare +(`bridge.accessor(_tagName, …)`); the real table uses both. For the qualified +form `@reference.name` is the MEMBER and the object is captured as +`@reference.receiver`, because the member is the name the scope walk resolves. +*Trade-off accepted:* the property-dispatch pass ignores the receiver, so a +qualified reference resolves by tail name and could in principle bind a +same-named local callable. Measured on the real corpus this does not bite: all +3,169 emitted edges land on `Method` (3,146) or `Function` (23), and the +94 registrations in `Element.zig`'s `JsApi` read as the DOM Element API surface +one for one. *Rejected:* resolving through the receiver — that is the +receiver-bound-call path, a much larger change, and the callable gate already +carries the precision. + +**D1-4. Rules are deliberately broad; the CALLABLE GATE is the filter.** +`js.Bridge(Element)` and `register(count)` match too. `findCallableBindingInScope` +keeps only Function/Method/Constructor, so they emit nothing — the same design +that stops TypeScript's `{ port: DEFAULT_PORT }` from registering anything +(tie-breaker 2: consistency with how TS/JS already emit `value-ref`). + +**D1-5. No `@reference.property-key`.** Zig has no object-literal key to +dispatch through, so these register a reference and never synthesize CALLS. +`emitPropertyDispatchCalls` already skips the registration index when +`propertyKey` is undefined, and still emits the USES edge. + +**D1-6. Fixture lives in the EXISTING `zig-idioms` corpus**, as +`src/webapi/Element.zig`, alongside the `AbortController`/`AbortSignal` JsApi +files already there. *Rejected:* a new `zig-callable-values/` fixture directory — +`bench/receiver-resolution` uses `test/fixtures/lang-resolution` as its `--check` +corpus, so every added fixture risks moving `countArm`. Measured after the fact: +it did not move (`[receiver-resolution] OK — shape states and call-drop counts +match baseline`), so no rebaseline was needed either way. + +--- + +## Measured result — D1 + D2 together + +Re-analyzed from scratch (`rm -rf ~/code/browser/.gitnexus`) with the same +command as the baseline. + +| | baseline | after | delta | +| --- | --- | --- | --- | +| index | 30,222 nodes / 71,070 edges | 30,222 nodes / 74,229 edges | **+3,159 edges, 0 nodes** | +| `getNamespaceUri` impactedCount | 5 | 7 | +2 | +| `getNamespaceUri` direct | 2 | 3 | +1 | +| `getNamespaceUri` risk | LOW | LOW | — | +| `getNamespaceUri` **epistemic** | **`exact`** | **`lower-bound`** | the fix | +| `getTagNameLower` impactedCount | 30 | 31 | +1 (explained below) | +| `getTagNameLower` direct / risk / epistemic | 10 / HIGH / `exact` | 10 / HIGH / `exact` | unchanged | + +**The +3,159 edges are fully accounted for.** Per-type counts in the new index: +`USES` = 3,169 and value-ref edges = 3,169 — i.e. every USES edge in this repo is +a value reference. The baseline had 10 (all from the single `.js` file, +`src/browser/tests/testing.js`), which still has exactly 10. `CALLS` (25,649), +`ACCESSES`, `IMPORTS`, `HAS_METHOD`, `DEFINES`, … did not move. The change is +purely additive and confined to `USES`. + +**`getTagNameLower` +1, explained.** The single added entry is at depth 2: +`USES Struct:src/browser/webapi/Element.zig:JsApi`. `JsApi` registers 94 +accessors, three of which (`Element.getLocalName`, `Element._prefix`, +`Element.getTagNameDump`) are depth-1 callers of `getTagNameLower`. So the +binding table genuinely does sit two hops upstream of it, and one node is the +correct amount to add. `direct` (10), `risk` (HIGH) and `epistemic` (`exact`) +are all unchanged — and `exact` is the meaningful half of the control: the hedge +is targeted at symbols with an INBOUND value reference, not sprayed over +everything the change touches. `getTagNameLower` is registered nowhere, so it +keeps its certainty. I am recording the +1 rather than calling the control +"unchanged", because it is a real new fact of exactly the kind D1 exists to +record. + +## Gates + +- `npm run build` — clean; `tsc --noEmit -p tsconfig.json` — clean. + (`tsconfig.test.json` has ~1,194 pre-existing errors across the fixture tree + and test helpers on `origin/main`; none are in the files this branch touches, + and no npm script or CI job runs it.) +- Bench `--check` gates, all PASS with **no baseline edited**: `scope-capture` + (15 languages), `receiver-resolution`, `zig-cross-file-resolution`, + `scope-emission`, `python-scope`, `callable-value-flow`, `import-target`, + `emit-persistence`, `finalize-reexport`, `cpp-qualified-ns`, + `parse-dispatch-rounds`, `spring-config-bindings`. +- `npx vitest run` (full suite): **20,552 passed / 10 failed / 78 skipped** + (20,640 tests, 1,005 files, ~17 min). + + All 10 failures are **pre-existing on `origin/main` and machine-specific**. + Verified, not assumed: I stashed the branch, checked out `0d1aed94` + (`origin/main`), rebuilt, and re-ran the six affected files — the **same 10 + tests fail, identically**. They are: + + | file | tests | cause | + | --- | --- | --- | + | `analyzer-identity` | 4 | macOS realpath `/var` vs `/private/var` | + | `analyzer-identity-in-process-guards` | 2 | EACCES cases, #3092 | + | `hooks-e2e` | 2 | "prefers pnpm dlx" — local `pnpm` shim is 1.40.0, below the ≥10.2 gate | + | `evidence-provenance-helper` | 1 | golden digest | + | `review-agent-workflow` | 1 | pinned-install retry helper | + + None is in a file or dependency cone this branch touches. +- Targeted re-run of every suite that exercises `value-ref`, after the final + build: `resolvers/zig`, `impact-callable-value-references`, + `resolvers/typescript-value-refs`, `resolvers/value-ref-locality`, + `resolvers/cpp`, `resolvers/typescript` — **734 / 734 passed**. + +## Nothing marked UNRESOLVED or BLOCKED + +Every fork above was decided and recorded. No gate failed three times; no gate +needed a baseline edit. diff --git a/gitnexus/src/core/ingestion/languages/zig/query.ts b/gitnexus/src/core/ingestion/languages/zig/query.ts index 9aa8e106a..0cf5f0b08 100644 --- a/gitnexus/src/core/ingestion/languages/zig/query.ts +++ b/gitnexus/src/core/ingestion/languages/zig/query.ts @@ -359,6 +359,60 @@ const ZIG_SCOPE_QUERY = ` "const" . (identifier) @type-binding.name (call_expression) @type-binding.type .) @type-binding.alias +;; References — VALUE positions (#3399): a callable named where a value is +;; expected rather than where a callee is. Zig's JS bridge is built entirely +;; out of this shape — +;; +;; pub const namespaceURI = bridge.accessor(Element.getNamespaceUri, null, .{}); +;; +;; — 2,047 such declarations across 257 files in lightpanda-io/browser, the +;; project's whole JS↔Zig surface, and NONE of them reached the graph: Zig +;; emitted no \`value-ref\` capture at all, so a public DOM accessor's only +;; recorded callers were the two internal ones and \`impact\` called that \`exact\`. +;; +;; These become reference-class USES edges through the existing +;; \`mapReferenceKindToEdgeType\` mapping — a registration is not an invocation +;; (Kythe \`ref\` vs \`ref/call\`; Joern \`METHOD_REF\`) — resolved by the +;; property-dispatch pass, which keeps ONLY callable targets. That callable gate +;; is what makes these deliberately broad rules safe: \`js.Bridge(Element)\` and +;; \`register(count)\` match too, and emit nothing, exactly as TypeScript's +;; \`{ port: DEFAULT_PORT }\` does. +;; +;; No \`@reference.property-key\` is attached: Zig has no object-literal key to +;; dispatch through, so these register a reference and never synthesize CALLS. +;; The terminal invoke — \`Accessor.init\` → a struct field → \`Factory.zig\`'s +;; \`inline for\`/\`@typeInfo\` → \`@call(.auto, func, args)\` — needs comptime +;; evaluation and is deliberately NOT modelled; \`impact\` reports the shortfall +;; as \`epistemic: "lower-bound"\` instead of pretending to certainty. + +;; Call ARGUMENTS. In tree-sitter-zig arguments are direct children of +;; \`call_expression\`, NOT wrapped in an \`arguments\` node (only builtins have +;; one), so the callee has to be consumed explicitly by \`function:\` — without +;; that binding the same rule also matches the callee of \`foo(bar)\` and mints a +;; USES edge duplicating the call. +(call_expression + function: (_) + (identifier) @reference.name @reference.value-ref) + +;; Qualified argument — \`bridge.accessor(Element.getNamespaceUri, …)\`. The +;; RECEIVER is captured alongside the member so the site carries the owner it +;; was written with; \`@reference.name\` stays the member, which is the name the +;; scope walk resolves. +(call_expression + function: (_) + (field_expression + object: (_) @reference.receiver + member: (identifier) @reference.name) @reference.value-ref) + +;; Const binding initialiser — \`pub const defaultHandler = onReset;\`. Both +;; anchors are load-bearing: the leading \`.\` pins the bound name to the first +;; named child (see the declaration rules above), and the trailing \`.\` keeps the +;; initializer as the LAST child, so a \`const x: T = y\` annotation shape cannot +;; put the TYPE in value position. +(variable_declaration + "const" . (identifier) + (identifier) @reference.name @reference.value-ref .) + ;; References — free calls: foo(...) (call_expression function: (identifier) @reference.name) @reference.call.free diff --git a/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/main.zig b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/main.zig index 1cb77e2ee..15c44f34a 100644 --- a/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/main.zig +++ b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/main.zig @@ -21,6 +21,7 @@ pub usingnamespace @import("mixin.zig"); pub const Interfaces = .{ @import("webapi/AbortController.zig"), @import("webapi/AbortSignal.zig"), + @import("webapi/Element.zig"), }; pub fn main() void { diff --git a/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/Element.zig b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/Element.zig new file mode 100644 index 000000000..9f384e459 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/zig-idioms/src/webapi/Element.zig @@ -0,0 +1,99 @@ +// The JS-API binding-table idiom, as Lightpanda writes it (#3399). +// +// Every accessor below hands a Zig function to `bridge.accessor` AS A VALUE: +// the function is REGISTERED here, never called here. The eventual invocation +// runs through comptime reflection (`@call(.auto, func, args)` over a +// `func: anytype` field), which no static walk can follow — that terminal hop +// is out of scope. What was NOT acceptable is dropping the reference entirely: +// `impact` then reported the accessor as having only its two in-file callers +// and called that answer `exact`. +// +// A file-as-struct, like every webapi module in the real tree. +const Element = @This(); + +_namespace: u8 = 0, + +// ── Registered accessors ──────────────────────────────────────────────────── + +pub fn getNamespaceUri(self: *Element) u8 { + return self._namespace; +} + +// An ordinary in-file caller. The point of the defect is that the REGISTRATION +// was missing, not that the symbol looked like a leaf: a plausible-but-short +// caller list is exactly what makes `epistemic: "exact"` dangerous. +pub fn lookupNamespaceUri(self: *Element) u8 { + return self.getNamespaceUri(); +} + +// ── The control ───────────────────────────────────────────────────────────── + +// Called normally and registered NOWHERE. Its edges must not move: a change +// that hedges or re-links every method would be indistinguishable from one +// that models value references, and only the second is correct. +pub fn getTagNameLower(self: *Element) u8 { + return self._namespace; +} + +pub fn describe(self: *Element) u8 { + return self.getTagNameLower(); +} + +// ── The binding table ─────────────────────────────────────────────────────── + +pub const JsApi = struct { + pub const bridge = Bridge(Element); + + // QUALIFIED value reference — the accessor names its container explicitly. + // This is the exact line from Element.zig:2296 that #3399 was filed over. + pub const namespaceURI = bridge.accessor(Element.getNamespaceUri, null, .{}); + + // BARE value reference to a sibling declared in this same container. + pub const tagName = bridge.accessor(_tagName, null, .{}); + + fn _tagName(self: *Element) u8 { + return self.getTagNameLower(); + } +}; + +// ── Const binding initialiser ─────────────────────────────────────────────── + +fn onReset(self: *Element) u8 { + return self._namespace; +} + +// A `const` whose initialiser IS a function value. Second value position, same +// class of drop. +pub const defaultHandler = onReset; + +// ── comptime anytype sink ─────────────────────────────────────────────────── + +var registered: ?*const fn (*Element) u8 = null; + +// Takes a callable by value into a `comptime … anytype` parameter and STORES +// it. Nothing in this file calls `f`. +pub fn register(comptime f: anytype) void { + registered = f; +} + +fn onTick(self: *Element) u8 { + return self._namespace; +} + +pub fn boot() void { + // `onTick` is never called in this file — it is handed over as a value and + // invoked later through `registered`. + register(onTick); +} + +fn Bridge(comptime T: type) type { + _ = T; + return struct { + pub fn accessor(comptime getter: anytype, comptime setter: anytype, comptime opts: anytype) u8 { + _ = getter; + _ = setter; + _ = opts; + return 0; + } + }; +} diff --git a/gitnexus/test/integration/resolvers/zig.test.ts b/gitnexus/test/integration/resolvers/zig.test.ts index 724bd5368..1931ac2f0 100644 --- a/gitnexus/test/integration/resolvers/zig.test.ts +++ b/gitnexus/test/integration/resolvers/zig.test.ts @@ -229,6 +229,94 @@ describe.skipIf(!zigAvailable)('Zig idioms (zig-idioms fixture)', () => { expect(calls).toContain('main → incr'); }); + /** + * #3399 — a callable named in VALUE position. + * + * `src/webapi/Element.zig` is the JS-API binding-table idiom verbatim: + * `pub const namespaceURI = bridge.accessor(Element.getNamespaceUri, null, .{});` + * registers a Zig function with the JS bridge instead of calling it. Zig + * emitted NO `value-ref` capture at all, so every one of those references was + * dropped — 2,047 of them across 257 files in lightpanda-io/browser, the whole + * JS<->Zig surface — and `impact` on a public DOM accessor answered with its + * two in-file callers and the verdict `epistemic: "exact"`. + * + * These assert USES and not CALLS on purpose. A registration is not an + * invocation (Kythe `ref` vs `ref/call`; Joern METHOD_REF), and the call that + * eventually happens goes through comptime reflection this analyzer cannot + * follow. The claim being pinned is the reference, not the dispatch. + */ + describe('callable values (#3399)', () => { + let uses: string[]; + let valueRefs: string[]; + beforeAll(() => { + const edges = getRelationships(result, 'USES'); + uses = edgeSet(edges); + // The reason text is spelled out rather than imported from + // `VALUE_REF_EDGE_REASON`, deliberately and as `typescript-value-refs.test.ts` + // already does: `impact`'s epistemic probe matches this exact string in + // the stored graph, so a change to the constant's VALUE (as opposed to + // its name) must fail a test rather than quietly agree with itself on + // both sides. + valueRefs = edgeSet(edges.filter((e) => e.rel.reason === 'scope-resolution: value-ref')); + }); + + it('records a QUALIFIED function value handed to a call (`bridge.accessor(Element.getNamespaceUri, …)`)', () => { + expect(valueRefs).toContain('JsApi → getNamespaceUri'); + }); + + it('records a BARE function value handed to a call (`bridge.accessor(_tagName, …)`)', () => { + expect(valueRefs).toContain('JsApi → _tagName'); + }); + + it('records a function value in a const initialiser (`pub const defaultHandler = onReset;`)', () => { + expect(valueRefs).toContain('Element → onReset'); + }); + + it('records a function passed into a `comptime f: anytype` parameter and stored', () => { + // The shape the non-goal is about: `register(onTick)` stores the value in + // a module-level field and nothing in the file ever calls `onTick`. The + // terminal invoke needs comptime evaluation and is NOT modelled — but the + // reference must survive, or `onTick` reads as dead code. + expect(valueRefs).toContain('boot → onTick'); + expect(calls).not.toContain('boot → onTick'); + }); + + it('emits USES, never CALLS, for a registration', () => { + // The whole distinction: if these became CALLS, `impact` would claim the + // accessor is invoked from the binding table, which is not a fact the + // analyzer has. + expect(calls).not.toContain('JsApi → getNamespaceUri'); + expect(calls).not.toContain('JsApi → _tagName'); + }); + + it('does not mint a value reference for a non-callable argument', () => { + // `Bridge(Element)` passes a TYPE. The property-dispatch pass keeps only + // Function/Method/Constructor targets, which is what makes the broad + // capture rules safe — the same gate that stops TypeScript's + // `{ port: DEFAULT_PORT }` from registering anything. + expect(valueRefs).not.toContain('JsApi → Element'); + expect(uses.filter((u) => u === 'JsApi → Element')).toHaveLength(0); + }); + + it('leaves a method that is only ever CALLED untouched', () => { + // The control. `getTagNameLower` is called twice and registered nowhere; + // a change that sprayed USES edges over every method would satisfy every + // assertion above and still be wrong. + expect(calls).toContain('describe → getTagNameLower'); + expect(calls).toContain('_tagName → getTagNameLower'); + expect(valueRefs.filter((v) => v.endsWith(' → getTagNameLower'))).toEqual([]); + }); + + it('does not mint a value reference for the CALLEE of an ordinary call', () => { + // `register(onTick)` must produce ONE value reference (the argument), not + // two: without binding the callee to the `function:` field the same rule + // also matches `register` itself and every call in the repo would emit a + // USES edge shadowing its own CALLS edge. + expect(valueRefs).not.toContain('boot → register'); + expect(calls).toContain('boot → register'); + }); + }); + it('types a receiver from its ANNOTATION (`var b: Counter = undefined; b.twice()`, `const c: Counter = .init(); c.get()`)', () => { // The declared type is the ONLY type source for `= undefined` and for // 0.14+ decl literals (`.init`, `.empty`), which current std uses for