Skip to content

Commit 39b4bab

Browse files
python: a call through a value makes path answer unknown, not independent
path said 'the two are independent' when a closure called a parameter, a loop or comprehension variable, table[k](), a getattr result or a function stored in a field. The python engine now emits unresolved_value_callee as the javascript one does; cls() in a classmethod is left out. path --json lists the sites as value_calls. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com>
1 parent 637532a commit 39b4bab

7 files changed

Lines changed: 354 additions & 1 deletion

File tree

‎graph/python/engine/call-edge-generation/call_chain.dl‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,6 +760,59 @@ call_site_unresolved(site, caller, r, kind) :-
760760
site_class(site, "ambiguous_unknown"), site_caller(site, caller),
761761
site_reason(site, r), invocation_site(site, kind).
762762

763+
// ── unresolved_value_callee(SiteKey, Binding) ────────────────────────────────
764+
// An unresolved call whose callee is a VALUE, not a name: `cb()` on a parameter, `f()`
765+
// on a loop or comprehension variable, `table[k]()`, `hook()` on what getattr returned,
766+
// `self.cb()` on a function stored in a field. Nothing about the name narrows what it
767+
// calls, so a question that finds no chain from its caller must answer "unknown", never
768+
// "independent" (the same relation, and the same Binding words, as the JavaScript rules).
769+
// A call on an untyped receiver (`obj.save()`) is not one: its name is the lead the
770+
// by-name search follows.
771+
// `cls(...)` in a classmethod constructs the class or a subclass of it, never an arbitrary
772+
// function, so it is not one.
773+
value_callee_site(site, c) :-
774+
site_class(site, "ambiguous_unknown"), call_callee_is_value(site), call_callee_expr(site, c),
775+
!expr_node("client", "CLS_REFERENCE", _, _, c).
776+
value_callee_binding(site, origin, kind, b2) :-
777+
value_callee_site(site, c), expr_binding("client", b, ctx, c), ctx != "STORE",
778+
binding_lookup("client", b, b2), binding_decl("client", _, _, kind, origin, b2).
779+
// what a local was assigned, when that is a getattr call: the attribute is picked by a
780+
// name the program computes
781+
value_callee_from_getattr(site) :-
782+
value_callee_binding(site, _, _, b2), expr_binding("client", b2, "STORE", tgt),
783+
assign_pair("client", tgt, val), call_of_expr(val, g), call_name(g, "getattr").
784+
value_callee_from_getattr(site) :-
785+
value_callee_site(site, c), call_of_expr(c, g), call_name(g, "getattr").
786+
unresolved_value_callee(site, "getattr") :- value_callee_from_getattr(site).
787+
unresolved_value_callee(site, "parameter") :-
788+
value_callee_binding(site, origin, _, _), (origin = "PARAMETER" ; origin = "LAMBDA_PARAM").
789+
unresolved_value_callee(site, "loop_variable") :-
790+
value_callee_binding(site, origin, _, _), (origin = "FOR_TARGET" ; origin = "COMPREHENSION_TARGET").
791+
value_callee_names_value(origin) :- binding_decl("client", _, _, _, origin, _),
792+
origin != "PARAMETER", origin != "LAMBDA_PARAM", origin != "FOR_TARGET", origin != "COMPREHENSION_TARGET",
793+
origin != "FUNCTION_DEF", origin != "CLASS_DEF", origin != "IMPORT", origin != "TYPE_PARAM",
794+
origin != "TYPE_ALIAS", origin != "MULTIPLE".
795+
unresolved_value_callee(site, "local") :-
796+
value_callee_binding(site, origin, "LOCAL", _), value_callee_names_value(origin),
797+
!value_callee_from_getattr(site).
798+
unresolved_value_callee(site, "module_variable") :-
799+
value_callee_binding(site, origin, kind, _), kind != "LOCAL", value_callee_names_value(origin),
800+
!value_callee_from_getattr(site).
801+
// `table[k]()`: an element of a collection
802+
unresolved_value_callee(site, "computed") :-
803+
value_callee_site(site, c), expr_node("client", "SUBSCRIPT", _, _, c).
804+
// `make()()`, `(a or b)()`: the callee is what an expression evaluates to
805+
unresolved_value_callee(site, "expression") :-
806+
value_callee_site(site, c), expr_node("client", k, _, _, c),
807+
k != "NAME_REFERENCE", k != "SUBSCRIPT", k != "CLS_REFERENCE", k != "ATTRIBUTE_ACCESS",
808+
!value_callee_from_getattr(site).
809+
// `self.cb()` where the class gives `cb` a field and no method: the function stored
810+
// there is whatever the constructor or a setter was handed
811+
unresolved_value_callee(site, "field") :-
812+
site_class(site, "ambiguous_unknown"), invocation_site(site, "SELF_CALL"),
813+
call_name(site, n), call_enclosing_type(site, t),
814+
type_defines_field("client", t, n, _), !mro_lookup("client", t, n, _).
815+
763816
// ── method_has_unknown_call(CallerMethodHash, SiteKey) ───────────────────────
764817
method_has_unknown_call(caller, site) :-
765818
site_class(site, "ambiguous_unknown"), site_caller(site, caller).

‎graph/python/souffle/decls_all.dl‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,11 @@
596596
.decl site_is_decorator(c0:symbol)
597597
.decl call_chain_edge(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol)
598598
.decl call_site_unresolved(c0:symbol,c1:symbol,c2:symbol,c3:symbol)
599+
.decl value_callee_site(c0:symbol,c1:symbol)
600+
.decl value_callee_binding(c0:symbol,c1:symbol,c2:symbol,c3:symbol)
601+
.decl value_callee_from_getattr(c0:symbol)
602+
.decl value_callee_names_value(c0:symbol)
603+
.decl unresolved_value_callee(c0:symbol,c1:symbol)
599604
.decl method_has_unknown_call(c0:symbol,c1:symbol)
600605
.decl call_chain_summary(c0:symbol,c1:number)
601606
.decl module_is_stub(c0:symbol,c1:symbol)

‎graph/python/souffle/export_manifest.tsv‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
call_chain_edge call-chain-edges.csv
22
call_site_unresolved call-site-unresolved.csv
3+
unresolved_value_callee unresolved-value-callee.csv
34
call_chain_summary call-chain-summary.csv
45
mro_position resolution-mro-position.csv
56
mro_tie resolution-mro-tie.csv

‎graph/test/python/tools/literal_gate.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,11 @@
7979
# framework hop -- the registration decorator, the dispatch method, the
8080
# route table, the marker -- are catalogues in config-resolution/knobs.dl,
8181
# which is the one file a reviewer reads to see every name assumed.
82-
"framework_edge(", "framework_unjoined(")
82+
"framework_edge(", "framework_unjoined(",
83+
# unresolved_value_callee's second column is `binding`: WHAT the callee of an
84+
# unresolved call is ("parameter", "loop_variable", "field", ...), the same words
85+
# the JavaScript rules write. Written into the output and never joined on.
86+
"unresolved_value_callee(")
8387

8488

8589
GROUND_FACT = re.compile(r'^[a-z_]+\((?:\s*"[^"]*"\s*,?)+\)\.\s*$')

‎plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1589,8 +1589,13 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20):
15891589
'field': 'a function stored in a field',
15901590
'instance_member': 'a function stored on the instance from outside the class',
15911591
'parameter_member': 'a member of a parameter', 'expression': 'computed by an expression',
1592+
'getattr': 'an attribute looked up by a name computed at run time',
15921593
'imported_variable': "another module's exported variable, which that module reassigns"}
15931594
for lx, rows in opaque:
1595+
# the sites that make the answer unknown, in --json too: a consumer reading only `answers` saw "no chain"
1596+
RESULT['value_calls'] = RESULT.get('value_calls', []) + [
1597+
{'side': lx, 'caller': g.disp(r['c']), 'callee': r['n'] or '', 'at': f"{g.site_file(r['f'])}:{r['ln']}",
1598+
'callee_is': WHY.get(r['why'], r['why'])} for r in rows[:50]]
15941599
print(f" {lx} reaches {len(rows)} call(s) through a value:")
15951600
for r in rows[:4]:
15961601
print(f" `{r['n'] or '[…]'}()` in {g.disp(r['c'])} at {g.site_file(r['f'])}:{r['ln']} — the callee is {WHY.get(r['why'], r['why'])}")
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
{
2+
"lang": "python",
3+
"src": "src",
4+
"checks": [
5+
{
6+
"why": "invoke calls a parameter: the callee can be any function handed to it, so path says unknown and names the site",
7+
"run": [
8+
"path",
9+
"invoke",
10+
"alpha"
11+
],
12+
"expect_error": true,
13+
"want": [
14+
"connection is UNKNOWN, not absent",
15+
"`cb()` in invoke at src/handlers.py:23 — the callee is a parameter"
16+
],
17+
"avoid": [
18+
"the two are independent in this graph"
19+
]
20+
},
21+
{
22+
"why": "run_list calls a loop variable",
23+
"run": [
24+
"path",
25+
"run_list",
26+
"alpha"
27+
],
28+
"expect_error": true,
29+
"want": [
30+
"connection is UNKNOWN, not absent",
31+
"`f()` in run_list at src/handlers.py:28 — the callee is a loop variable"
32+
],
33+
"avoid": [
34+
"the two are independent in this graph"
35+
]
36+
},
37+
{
38+
"why": "dispatch calls an element of a collection (table[key]())",
39+
"run": [
40+
"path",
41+
"dispatch",
42+
"alpha"
43+
],
44+
"expect_error": true,
45+
"want": [
46+
"connection is UNKNOWN, not absent",
47+
"in dispatch at src/handlers.py:32 — the callee is a computed member"
48+
],
49+
"avoid": [
50+
"the two are independent in this graph"
51+
]
52+
},
53+
{
54+
"why": "via_local calls a local that holds whatever a lookup returned",
55+
"run": [
56+
"path",
57+
"via_local",
58+
"alpha"
59+
],
60+
"expect_error": true,
61+
"want": [
62+
"connection is UNKNOWN, not absent",
63+
"`fn()` in via_local at src/handlers.py:37 — the callee is a local"
64+
],
65+
"avoid": [
66+
"the two are independent in this graph"
67+
]
68+
},
69+
{
70+
"why": "fire calls a local holding getattr(self, f'on_{event}'): the site is named, and the target is not called independent",
71+
"run": [
72+
"path",
73+
"Machine.fire",
74+
"Machine.on_go"
75+
],
76+
"expect_error": true,
77+
"want": [
78+
"connection is UNKNOWN, not absent",
79+
"`hook()` in Machine.fire at src/handlers.py:44 — the callee is an attribute looked up by a name computed at run time"
80+
],
81+
"avoid": [
82+
"the two are independent in this graph"
83+
]
84+
},
85+
{
86+
"why": "control: getattr(self, 'on_' + event)() is resolved through its literal prefix, so it is a chain, not an unknown",
87+
"run": [
88+
"path",
89+
"Machine.fire_now",
90+
"Machine.on_go"
91+
],
92+
"want": [
93+
"Machine.fire_now → Machine.on_go: 1 of 1 target(s) reached"
94+
],
95+
"avoid": [
96+
"connection is UNKNOWN, not absent"
97+
]
98+
},
99+
{
100+
"why": "assess calls each element of a stored collection of callables inside a comprehension",
101+
"run": [
102+
"path",
103+
"Scoring.assess",
104+
"alpha"
105+
],
106+
"expect_error": true,
107+
"want": [
108+
"connection is UNKNOWN, not absent",
109+
"`scorer()` in Scoring.assess at src/handlers.py:58 — the callee is a loop variable"
110+
],
111+
"avoid": [
112+
"the two are independent in this graph"
113+
]
114+
},
115+
{
116+
"why": "run calls a function stored in a field by the constructor",
117+
"run": [
118+
"path",
119+
"Hooked.run",
120+
"alpha"
121+
],
122+
"expect_error": true,
123+
"want": [
124+
"connection is UNKNOWN, not absent",
125+
"`cb()` in Hooked.run at src/handlers.py:66 — the callee is a function stored in a field"
126+
],
127+
"avoid": [
128+
"the two are independent in this graph"
129+
]
130+
},
131+
{
132+
"why": "the reverse question is the same connection: the value call is on the other side",
133+
"run": [
134+
"path",
135+
"alpha",
136+
"invoke"
137+
],
138+
"expect_error": true,
139+
"want": [
140+
"connection is UNKNOWN, not absent",
141+
"`cb()` in invoke"
142+
],
143+
"avoid": [
144+
"the two are independent in this graph"
145+
]
146+
},
147+
{
148+
"why": "control: a function that calls nothing stays independent",
149+
"run": [
150+
"path",
151+
"pure",
152+
"alpha"
153+
],
154+
"expect_error": true,
155+
"want": [
156+
"the two are independent in this graph"
157+
],
158+
"avoid": [
159+
"connection is UNKNOWN, not absent",
160+
"through a value"
161+
]
162+
},
163+
{
164+
"why": "control: a call on an imported module's member names a library function, not a value",
165+
"run": [
166+
"path",
167+
"joins",
168+
"alpha"
169+
],
170+
"expect_error": true,
171+
"want": [
172+
"the two are independent in this graph"
173+
],
174+
"avoid": [
175+
"connection is UNKNOWN, not absent",
176+
"through a value"
177+
]
178+
},
179+
{
180+
"why": "control: cls(...) in a classmethod constructs the class, so it is not a call through a value even when the class has a library base",
181+
"run": [
182+
"path",
183+
"Money.of",
184+
"alpha"
185+
],
186+
"expect_error": true,
187+
"want": [
188+
"no chain of resolved calls connects Money.of and alpha"
189+
],
190+
"avoid": [
191+
"connection is UNKNOWN, not absent",
192+
"through a value",
193+
"`cls()`"
194+
]
195+
},
196+
{
197+
"why": "control: a resolved call is still a chain",
198+
"run": [
199+
"path",
200+
"direct",
201+
"alpha"
202+
],
203+
"want": [
204+
"direct → alpha: 1 of 1 target(s) reached"
205+
],
206+
"avoid": [
207+
"connection is UNKNOWN, not absent"
208+
]
209+
}
210+
]
211+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import os
2+
3+
from pydantic import BaseModel
4+
5+
6+
def alpha():
7+
return 1
8+
9+
10+
def pure():
11+
return 2
12+
13+
14+
def direct():
15+
return alpha()
16+
17+
18+
def joins(parts):
19+
return os.path.join(*parts)
20+
21+
22+
def invoke(cb):
23+
return cb()
24+
25+
26+
def run_list(fns):
27+
for f in fns:
28+
f()
29+
30+
31+
def dispatch(table, key):
32+
return table[key]()
33+
34+
35+
def via_local(name):
36+
fn = globals().get(name)
37+
return fn()
38+
39+
40+
class Machine:
41+
def fire(self, event):
42+
hook = getattr(self, f"on_{event}", None)
43+
if hook is not None:
44+
hook()
45+
46+
def fire_now(self, event):
47+
return getattr(self, "on_" + event)()
48+
49+
def on_go(self):
50+
return 3
51+
52+
53+
class Scoring:
54+
def __init__(self, scorers):
55+
self.scorers = scorers
56+
57+
def assess(self, ctx):
58+
return [scorer(ctx) for scorer in self.scorers]
59+
60+
61+
class Hooked:
62+
def __init__(self, cb):
63+
self.cb = cb
64+
65+
def run(self):
66+
return self.cb()
67+
68+
69+
class Money(BaseModel):
70+
amount: int
71+
72+
@classmethod
73+
def of(cls, amount: int) -> "Money":
74+
return cls(amount=amount)

0 commit comments

Comments
 (0)