mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-11 03:38:07 +00:00
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:
parent
b9358184fa
commit
b527266ce8
6 changed files with 255 additions and 15 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -697,7 +697,7 @@
|
|||
},
|
||||
"csharp-variadic-resolution/Utils/Logger.cs": {
|
||||
"captureGroups": 10,
|
||||
"digest": "46905fc6f0d59035df05fe60d29c57217d321c5a8d3c3223466061b56c7f785d"
|
||||
"digest": "4907421baf3bbbcb46447b57beb2b1fb29f4ecd1555e79ea08d1b653c636666e"
|
||||
},
|
||||
"csharp-write-access/Models.cs": {
|
||||
"captureGroups": 9,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue