fix(zig): emit value-ref for a callable named in value position

`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.
This commit is contained in:
Navid EMAD 2026-09-08 05:55:33 +02:00
parent ea2372e453
commit fda651dd75
No known key found for this signature in database
5 changed files with 374 additions and 0 deletions

View file

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

View file

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

View file

@ -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 {

View file

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

View file

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