diff --git a/gitnexus/src/core/ingestion/languages/zig/captures.ts b/gitnexus/src/core/ingestion/languages/zig/captures.ts index 5ac958e24..75f2506ab 100644 --- a/gitnexus/src/core/ingestion/languages/zig/captures.ts +++ b/gitnexus/src/core/ingestion/languages/zig/captures.ts @@ -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( diff --git a/gitnexus/src/core/ingestion/utils/callable-flow-captures.ts b/gitnexus/src/core/ingestion/utils/callable-flow-captures.ts index b7260a3d9..dd7f53c1a 100644 --- a/gitnexus/src/core/ingestion/utils/callable-flow-captures.ts +++ b/gitnexus/src/core/ingestion/utils/callable-flow-captures.ts @@ -182,6 +182,14 @@ interface FunctionInfo { interface ValueBindingIndex { readonly assignmentRegionIdsByName: ReadonlyMap>; + /** + * 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>; readonly formalByOwner: ReadonlyMap>; readonly signatureByNameAndRegion: ReadonlyMap< string, @@ -322,6 +330,7 @@ function buildValueBindingIndex( options: CallableFlowCaptureOptions, ): ValueBindingIndex { const assignmentRegionIdsByName = new Map>(); + const memberStoreRegionIdsByName = new Map>(); const formalByOwner = new Map>(); const signatureByNameAndRegion = new Map>(); 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>, 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); diff --git a/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json index 86dc818ce..427509c04 100644 --- a/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json @@ -697,7 +697,7 @@ }, "csharp-variadic-resolution/Utils/Logger.cs": { "captureGroups": 10, - "digest": "46905fc6f0d59035df05fe60d29c57217d321c5a8d3c3223466061b56c7f785d" + "digest": "4907421baf3bbbcb46447b57beb2b1fb29f4ecd1555e79ea08d1b653c636666e" }, "csharp-write-access/Models.cs": { "captureGroups": 9, diff --git a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json index 808b2c3dd..99aececf7 100644 --- a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json @@ -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, diff --git a/gitnexus/test/integration/resolvers/callable-value-flow.test.ts b/gitnexus/test/integration/resolvers/callable-value-flow.test.ts index 820ece290..38b972059 100644 --- a/gitnexus/test/integration/resolvers/callable-value-flow.test.ts +++ b/gitnexus/test/integration/resolvers/callable-value-flow.test.ts @@ -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); }); diff --git a/gitnexus/test/unit/zig-extractors.test.ts b/gitnexus/test/unit/zig-extractors.test.ts index 31c3db46c..40493c8ee 100644 --- a/gitnexus/test/unit/zig-extractors.test.ts +++ b/gitnexus/test/unit/zig-extractors.test.ts @@ -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; }