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

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

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

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

Lightpanda: cap-warnings 4761 -> 2, cvf self-loops 10 -> 0,
CALLS 13885 -> 13857 (28 removed, all callable-value-flow: 10 self-loops,
17 same-name fan-out, 1 lost `on -> TypeErased.start`; +1 correct
`Arena.alloc -> allocator`).
This commit is contained in:
Navid EMAD 2026-08-18 14:25:52 +02:00
parent b9358184fa
commit b527266ce8
No known key found for this signature in database
6 changed files with 255 additions and 15 deletions

View file

@ -288,6 +288,14 @@ function rewriteZigThisAlias(
* every named child other than the `function` field, hence
* `extractCallArguments`.
*
* - Zig's receiver is an EXPLICIT first parameter named `self` (the same
* convention `interpretZigTypeBinding` keys receiver typing on), while a
* method call `x.f(cb)` passes `x` implicitly. Formals are therefore
* numbered without the leading `self`, so the actual at index 0 of
* `r.run(target)` joins `cb` and not `self` — hence
* `extractFunctionParameters`. The explicit-receiver spelling
* `Runner.run(&r, target)` is the one shape that no longer lines up.
*
* `builtin_function` (`@import`, `@sizeOf`, …) is deliberately not a call node:
* builtins never take user callables as flow arguments.
*/
@ -308,6 +316,17 @@ const ZIG_CALLABLE_CAPTURE_OPTIONS = {
if (source.id === node.childForFieldName('type')?.id) return undefined;
return { destination: named[0]!, source };
},
extractFunctionParameters: (fn: SyntaxNode) => {
// `parameters` is a fieldless child of `function_declaration`.
const list = fn.namedChildren.find((child) => child?.type === 'parameters');
if (list === undefined || list === null) return undefined;
const parameters = list.namedChildren.filter(
(child): child is SyntaxNode => child !== null && child.type === 'parameter',
);
return parameters[0]?.childForFieldName('name')?.text === 'self'
? parameters.slice(1)
: parameters;
},
extractCallArguments: (call: SyntaxNode) => {
const callee = call.childForFieldName('function');
return call.namedChildren.filter(

View file

@ -182,6 +182,14 @@ interface FunctionInfo {
interface ValueBindingIndex {
readonly assignmentRegionIdsByName: ReadonlyMap<string, ReadonlySet<number>>;
/**
* Regions holding a store whose destination is a MEMBER path (`o->run =
* handler`, `self.f = target`), keyed by the member name. Only these gate a
* member call as a field-stored-callable invoke: a plain-name binding
* (`const release = deinit;` next to `self.slot.release()`) is not a store
* into anybody's member cell.
*/
readonly memberStoreRegionIdsByName: ReadonlyMap<string, ReadonlySet<number>>;
readonly formalByOwner: ReadonlyMap<number | undefined, ReadonlySet<string>>;
readonly signatureByNameAndRegion: ReadonlyMap<
string,
@ -322,6 +330,7 @@ function buildValueBindingIndex(
options: CallableFlowCaptureOptions,
): ValueBindingIndex {
const assignmentRegionIdsByName = new Map<string, Set<number>>();
const memberStoreRegionIdsByName = new Map<string, Set<number>>();
const formalByOwner = new Map<number | undefined, Set<string>>();
const signatureByNameAndRegion = new Map<string, Map<number, CallableCaptureSignature>>();
const add = (
@ -347,19 +356,27 @@ function buildValueBindingIndex(
// it (#2522 review, M3 ops-vtable pattern).
const terminal = terminalIdentifier(assignment.destination, options);
if (terminal !== undefined) destinationNames.add(terminal.text);
for (const name of destinationNames) {
const region = nearestLexicalRegion(assignment.container, options);
let regionIds = assignmentRegionIdsByName.get(name);
// A destination whose binding identifier and terminal identifier are two
// different nodes spans a member path (`o->run`, `self.slot.f`); a bare
// name or a declarator (`void (*fp)(int)`) resolves both to the same leaf.
const memberStoreName =
terminal !== undefined && terminal.id !== destination?.node.id ? terminal.text : undefined;
const region = nearestLexicalRegion(assignment.container, options);
const functionOwner =
options.functionScopedValueBindings === true
? nearestFunctionOwner(assignment.container, options)
: undefined;
const record = (index: Map<string, Set<number>>, name: string): void => {
let regionIds = index.get(name);
if (regionIds === undefined) {
regionIds = new Set();
assignmentRegionIdsByName.set(name, regionIds);
index.set(name, regionIds);
}
regionIds.add(region.id);
if (options.functionScopedValueBindings === true) {
const functionOwner = nearestFunctionOwner(assignment.container, options);
if (functionOwner !== undefined) regionIds.add(functionOwner.id);
}
}
if (functionOwner !== undefined) regionIds.add(functionOwner.id);
};
for (const name of destinationNames) record(assignmentRegionIdsByName, name);
if (memberStoreName !== undefined) record(memberStoreRegionIdsByName, memberStoreName);
}
for (const fn of functions) {
for (const parameter of fn.parameters) {
@ -395,7 +412,12 @@ function buildValueBindingIndex(
if (functionOwner !== undefined) byRegion.set(functionOwner.id, signature);
}
}
return { assignmentRegionIdsByName, formalByOwner, signatureByNameAndRegion };
return {
assignmentRegionIdsByName,
memberStoreRegionIdsByName,
formalByOwner,
signatureByNameAndRegion,
};
}
/** True when a pointer/parenthesized declarator sits between the declaration
@ -479,6 +501,34 @@ function isVisibleValueBinding(
return visibleCallableSignature(input, name, bindings, options) !== undefined;
}
/**
* Member-call gate: the member's name-cell was written by a visible MEMBER
* store, or is a declared callable-typed binding (C struct field
* `void (*cb)(int);`, whose stores may live in other functions). Plain-name
* bindings and formals are deliberately NOT consulted — `x.f()` reads the
* member `f` of `x`, not a same-named local, and gating on the local minted an
* invoke through the wrong cell (a Zig `pub const release = deinit;` alias
* turned `self.slot.release()` into a `deinit → deinit` self-loop).
*/
function isVisibleMemberStore(
input: SyntaxNode,
name: string,
bindings: ValueBindingIndex,
options: CallableFlowCaptureOptions,
): boolean {
const regionIds = bindings.memberStoreRegionIdsByName.get(name);
if (regionIds !== undefined) {
const providerOwner = options.lexicalFunctionOwner?.(input);
if (providerOwner !== undefined && regionIds.has(providerOwner.id)) return true;
let node: SyntaxNode | null = input;
while (node !== null) {
if (regionIds.has(node.id)) return true;
node = node.parent;
}
}
return visibleCallableSignature(input, name, bindings, options) !== undefined;
}
function visibleCallableSignature(
input: SyntaxNode,
name: string,
@ -709,10 +759,16 @@ function emitCallFacts(
const callee = operandSyntax(calleeNode, options);
const calleeIsValueBinding =
callee !== undefined && isVisibleValueBinding(call, callee.name, valueBindings, options);
// A direct callee NAME exists only when the call spells its callee as a
// designator (`f(x)`, `ns.f(x)`). A receiver-less member (Zig's decl
// literal `.init(x)`, whose receiver is an inferred type) or a computed
// callee names nothing the solver may seed by simple name — doing so
// fanned each argument out to every same-named callable in the repo.
const directCalleeName =
member === undefined &&
callee !== undefined &&
callee.indirection === 0 &&
callee.directDesignator &&
!calleeIsValueBinding
? callee.name
: undefined;
@ -825,7 +881,7 @@ function emitCallFacts(
// name-keyed field collapse matches the solver's store/load model.
// ponytail: same-region joins only — cross-function vtable installs need
// a field-sensitive cell model.
if (isVisibleValueBinding(call, member.member.name, valueBindings, options)) {
if (isVisibleMemberStore(call, member.member.name, valueBindings, options)) {
emitInvoke(callSite, member.member, 'indirect', args.length, out, options, member.receiver);
}
return;
@ -958,10 +1014,18 @@ function memberParts(
node.childForFieldName('object') ??
node.childForFieldName('argument') ??
node.childForFieldName('receiver');
// `member` is the field name tree-sitter grammars use when the receiver
// field is `object` (Zig `field_expression`). It is only read once a
// receiver field matched: the other grammars that expose a `member` field
// (C/C++ `offsetof_expression`, JS `class_body`) carry no receiver field and
// stay unaffected. Without it every `x.f(arg)` in such a grammar collapsed
// to a DIRECT call named `f` and the flow solver fanned the argument out to
// every same-named callable.
const memberNode =
node.childForFieldName('property') ??
node.childForFieldName('field') ??
node.childForFieldName('method');
node.childForFieldName('method') ??
node.childForFieldName('member');
if (receiverNode === null || memberNode === null) return undefined;
const receiver = operandSyntax(receiverNode, options);
const member = operandSyntax(memberNode, options);

View file

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

View file

@ -621,7 +621,7 @@
},
"python-overload-dispatch/service.py": {
"captureGroups": 37,
"digest": "fd23d8926c3debb335ab3ae7126d4314ec2f5d81f5e81f47c12a5a7d8b25537d"
"digest": "b661e8965b168f16124d4682906516527e79d4c9de6f36fa1e7cf7aff9211c28"
},
"python-parent-resolution/models/__init__.py": {
"captureGroups": 0,
@ -781,7 +781,7 @@
},
"python-variadic-resolution/logger.py": {
"captureGroups": 7,
"digest": "7d460bb9d28620ce178db652e15427a7cf7839cdf9ef651a9c30209ed3d22d82"
"digest": "e539f618f46b19d1f7674321c6b994c773b57753c19d7764da83900143d71c0e"
},
"python-walrus-chain/app.py": {
"captureGroups": 37,

View file

@ -1510,4 +1510,66 @@ invoke(assigned);
),
).toBe(true);
}, 90_000);
it('does not read a Zig member call `x.f(arg)` as a direct call named `f` (Zig PR review, F2)', async () => {
// tree-sitter-zig spells `field_expression` as `object:`/`member:`. Read as
// a direct call, `self.slot.release()` next to the container alias
// `pub const release = deinit;` minted a `deinit → deinit` self-loop, and
// every `.init(...)` fanned its arguments out to all same-named callables
// (4,761 cap warnings on Lightpanda). A receiver-typed member call must
// still carry its actual into the formal AFTER the implicit `self`.
const result = await runSource(
'zig',
`
pub const Slot = struct {
n: u32 = 0,
pub fn release(self: *Slot) void { self.n = 0; }
};
pub const Global = struct {
slot: *Slot,
pub fn deinit(self: Global) void {
self.slot.release();
}
pub const release = deinit;
};
fn target(x: u32) void { _ = x; }
pub const Runner = struct {
pub fn run(self: *Runner, cb: *const fn (u32) void) void { _ = self; cb(2); }
pub fn init(cb: *const fn (u32) void) Runner { cb(3); return .{}; }
};
pub const Decoy = struct {
pub fn init(cb: *const fn (u32) void) Decoy { cb(4); return .{}; }
};
pub fn main() void {
var r: Runner = undefined;
r.run(target);
const made: Runner = .init(target);
_ = made;
}
`,
);
// (i) no self-loop through the alias: `self.slot.release()` is a call on
// Slot, not an invoke through Global's `release` name-cell.
expect(callsFrom(result, 'deinit')).not.toContainEqual({
target: 'deinit',
reason: 'callable-value-flow',
});
// (ii) receiver-typed member call: `r.run(target)` joins formal `cb`
// (index 0 once the leading `self` is dropped), so `run` invokes `target`.
expect(callsFrom(result, 'run')).toContainEqual({
target: 'target',
reason: 'callable-value-flow',
});
// (iii) decl literal `.init(target)` names no callee by simple name — the
// unrelated `Decoy.init` must not gain a flow edge to `target`.
expect(
getRelationships(result, 'CALLS').filter(
(edge) =>
edge.rel.sourceId.includes('Decoy.init') &&
edge.target === 'target' &&
edge.rel.reason === 'callable-value-flow',
),
).toEqual([]);
}, 60_000);
});

View file

@ -237,6 +237,101 @@ describeZig('Zig scope captures — receiver is the FIRST parameter named self',
});
});
describeZig('Zig callable-flow captures — member calls, receiver formals, decl literals', () => {
const src = `
const Slot = struct {
n: u32 = 0,
pub fn release(self: *Slot) void { self.n = 0; }
};
const Global = struct {
slot: *Slot,
pub fn deinit(self: Global) void { self.slot.release(); }
pub const release = deinit;
};
const Runner = struct {
pub fn run(self: *Runner, cb: *const fn (u32) void) void { _ = self; cb(2); }
};
fn target(x: u32) void { _ = x; }
fn invoke(cb: *const fn (u32) void) void { cb(1); }
pub fn main() void {
var r: Runner = undefined;
r.run(target);
invoke(target);
const reg: Runner = .init(target);
_ = reg;
}
`;
const flow = () =>
emitZigScopeCaptures(src, 'test.zig').filter((m) =>
Object.keys(m).some((k) => k.startsWith('@callable-flow.')),
);
const argumentFacts = () =>
flow()
.filter((m) => m['@callable-flow.argument'] !== undefined)
.map((m) => ({
call: m['@callable-flow.argument']!.text,
index: m['@callable-flow.parameter-index']!.text,
directCallee: m['@callable-flow.direct-callee-name']?.text,
}));
it('a member call `r.run(target)` is NOT a direct call named `run` (no direct-callee-name)', () => {
// With the name attached, the solver seeded the argument into EVERY
// callable named `run` in the repo (thousands of cap warnings on Lightpanda,
// `deinit → deinit` self-loops). tree-sitter-zig spells the member field
// `member:`; the shared reader must see it.
expect(argumentFacts()).toContainEqual({
call: 'r.run(target)',
index: '0',
directCallee: undefined,
});
});
it('a free call keeps its direct-callee-name so `invoke(target)` still joins `invoke`', () => {
expect(argumentFacts()).toContainEqual({
call: 'invoke(target)',
index: '0',
directCallee: 'invoke',
});
});
it('a decl-literal call `.init(target)` (inferred-type receiver) has no direct-callee-name', () => {
// `.init` names no callee the solver may look up by simple name — Lightpanda
// has hundreds of `init`s, and each such site fanned out to all of them.
expect(argumentFacts()).toContainEqual({
call: '.init(target)',
index: '0',
directCallee: undefined,
});
});
it('formals skip the leading `self` receiver so the member-call actual at 0 joins `cb`', () => {
const runFormals = flow()
.filter(
(m) => m['@callable-flow.formal'] !== undefined && m['@callable-flow.owner']!.text === 'run',
)
.map((m) => `${m['@callable-flow.binding']!.text}@${m['@callable-flow.parameter-index']!.text}`);
expect(runFormals).toEqual(['cb@0']);
});
it('a container-level alias `pub const release = deinit;` does not turn `self.slot.release()` into an invoke through it', () => {
// The alias is a plain-name binding, not a store into Slot's `release`
// member; gating on it produced the `Global.deinit → Global.deinit` self-loop.
const invokes = flow()
.filter((m) => m['@callable-flow.invoke'] !== undefined)
.map((m) => m['@callable-flow.invoke']!.text);
expect(invokes).not.toContain('self.slot.release()');
// The alias itself is still a seed (`Global.release` really is `deinit`).
expect(
flow().some(
(m) =>
m['@callable-flow.seed'] !== undefined &&
m['@callable-flow.destination']!.text === 'release' &&
m['@callable-flow.target']!.text === 'deinit',
),
).toBe(true);
});
});
describeZig('Zig `export` (C-ABI) visibility', () => {
const src = `
export fn c_add(a: i32, b: i32) i32 { return a + b; }