From 221e3b64480ddbcc22a99108d8c475665dac5d91 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:17:29 -0700 Subject: [PATCH 01/16] fix(python): type a test parameter by the fixture the runner hands it pytest fills a test's parameter with whatever the fixture of that name returned or yielded, and no call site spells that call, so the parameter stayed untyped: every method called on it was ambiguous_unknown, and everything derived from it (a local assigned from one of those calls, a with block) went with it. Across ten public suites 1,529 such calls were unresolved; a suite whose fixtures are annotated already resolved its own. value-flow.dl now treats the runner's call as one more argument reaching a parameter: param_arg_type from the fixture's value type (its return, its yield, or its declared return). The fixture serving a parameter comes from a syntax-only copy of the runner's lookup (class, own module, nearest conftest): the existing py_fixture_injection reads module_member_method for star-imported fixtures, which is resolution, and its nearest-conftest MAX would then be a cyclic aggregate. Star-imported and pytest_plugins fixtures still reach their tests through the injection edge; they only stay untyped. Measured with a behavioural oracle (break each of 600 sampled functions, record which test files fail) on ten held-in repositories: impact --tests recall 0.668 -> 0.698, every-failing-file-selected 46.7% -> 50.5%, precision unchanged; the three repositories carrying the pattern move, the seven without it are byte-identical. Engine suite 43/43 with identical case results; query cases 310/310. New case fixture-value-types-the-parameter fails on the base engine on its derived-local check. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/framework-behavior/dispatch.dl | 47 +++++++++++++++++++ graph/python/engine/resolution/value-flow.dl | 15 ++++++ .../app/__init__.py | 0 .../app/core.py | 26 ++++++++++ .../case.json | 17 +++++++ .../tests/conftest.py | 15 ++++++ .../tests/test_ledger.py | 2 + .../tests/test_tx.py | 3 ++ 8 files changed, 125 insertions(+) create mode 100644 tests/cases/python/fixture-value-types-the-parameter/app/__init__.py create mode 100644 tests/cases/python/fixture-value-types-the-parameter/app/core.py create mode 100644 tests/cases/python/fixture-value-types-the-parameter/case.json create mode 100644 tests/cases/python/fixture-value-types-the-parameter/tests/conftest.py create mode 100644 tests/cases/python/fixture-value-types-the-parameter/tests/test_ledger.py create mode 100644 tests/cases/python/fixture-value-types-the-parameter/tests/test_tx.py diff --git a/graph/python/engine/framework-behavior/dispatch.dl b/graph/python/engine/framework-behavior/dispatch.dl index 0ede5dbf..c2c8dc38 100644 --- a/graph/python/engine/framework-behavior/dispatch.dl +++ b/graph/python/engine/framework-behavior/dispatch.dl @@ -412,6 +412,53 @@ py_fixture_requested(m) :- py_fixture_injection(_, m, _). framework_edge(from, to, "fixture_injection", name, "by_name") :- py_fixture_injection(from, to, name). +// ── 2a. THE SAME LOOKUP, FROM SYNTAX ALONE ────────────────────────────────────── +// resolution/value-flow.dl types a parameter by the fixture serving it, so it needs the +// runner's lookup INSIDE the resolution fixpoint. py_fixture_injection cannot be read +// there: a fixture a conftest star-imports is found through module_member_method, which +// is resolution, and the nearest-conftest MAX over it would then be a cyclic aggregate +// souffle refuses to stratify. This chain keeps the runner's order (class, own module, +// nearest conftest by its declaring file) and leaves out the two clauses that need +// resolution — a star-imported fixture and a pytest_plugins one. Those still reach their +// tests through the injection edge; they only go untyped. +.decl py_fixs_in_file(fix:symbol, file:symbol) +py_fixs_in_file(fix, p) :- py_fixture_decl(fix, _), method_file("client", p, fix). +.decl py_fixs_same_module(req:symbol, name:symbol, fix:symbol) +py_fixs_same_module(req, name, fix) :- py_fixture_request(req, name), + py_fixture_decl(fix, name), fix != req, !py_fixture_class(fix, _), + !py_fixture_class_shadowed(req, name), + method_file("client", p, req), py_fixs_in_file(fix, p). +.decl py_fixs_conftest_cand(req:symbol, name:symbol, fix:symbol, depth:number) +py_fixs_conftest_cand(req, name, fix, dl) :- py_fixture_request(req, name), + py_fixture_decl(fix, name), fix != req, !py_fixture_class(fix, _), + py_fixs_in_file(fix, cp), py_fixture_scope_file(c), + strlen(cp) >= strlen(c), substr(cp, strlen(cp) - strlen(c), strlen(c)) = c, + d = substr(cp, 0, strlen(cp) - strlen(c)), + method_file("client", p, req), + strlen(p) > strlen(d), substr(p, 0, strlen(d)) = d, dl = strlen(d). +.decl py_fixs_nearest(req:symbol, name:symbol, depth:number) +py_fixs_nearest(req, name, m) :- py_fixs_conftest_cand(req, name, _, _), + m = max dl : { py_fixs_conftest_cand(req, name, _, dl) }. +.decl py_fixs_injection(from:symbol, to:symbol, name:symbol) +py_fixs_injection(req, fix, name) :- py_fixture_in_class(req, name, fix). +py_fixs_injection(req, fix, name) :- py_fixs_same_module(req, name, fix). +py_fixs_injection(req, fix, name) :- py_fixs_conftest_cand(req, name, fix, dl), + !py_fixture_class_shadowed(req, name), !py_fixs_same_module(req, name, _), + py_fixs_nearest(req, name, dl). + +// ── 2b. FIXTURE VALUE (what the runner hands the parameter) ──────────────────── +// py_fixture_value_type(Fixture, Type) — the value a fixture hands over: its return or +// yield value's type, or its declared return. Consumed by resolution/value-flow.dl, where +// the runner's call `test(fixture_value)` is one more argument reaching a parameter. +.decl py_fixture_value_type(fix:symbol, t:symbol) +py_fixture_value_type(fix, t) :- py_fixture_decl(fix, _), + method_return_value_expr("client", fix, e), expr_type("client", e, t). +py_fixture_value_type(fix, t) :- py_fixture_decl(fix, _), + method_declared_return_type("client", fix, d), declared_dispatch("client", d, t). +py_fixture_value_type(fix, t) :- py_fixture_decl(fix, _), + expr_node("client", "YIELD", _, _, y), expr_ultimate_method("client", y, fix), + expr_parent("client", y, "YIELD_VALUE", _, v), expr_type("client", v, t). + // ───────────────────────────────────────────────────────────────────────────── // 3. URL DISPATCH (route table -> view) // ───────────────────────────────────────────────────────────────────────────── diff --git a/graph/python/engine/resolution/value-flow.dl b/graph/python/engine/resolution/value-flow.dl index 14ef74b5..69fb4ae4 100644 --- a/graph/python/engine/resolution/value-flow.dl +++ b/graph/python/engine/resolution/value-flow.dl @@ -97,6 +97,21 @@ param_arg_type(ph, t) :- param_decl("client", kw, _, _, m, ph), expr_type("client", a, t). +// ── a parameter the TEST RUNNER fills ───────────────────────────────────────── +// pytest calls `test_commit(session=)`: the call is the runner's, +// so no call site spells it, but it is an argument reaching a parameter all the same, and +// the parameter is typed exactly as one passed at a call site would be. Which fixture +// serves the parameter is framework-behavior/dispatch.dl's py_fixs_injection (2a), the +// runner's own lookup (same class, same module, nearest conftest, a pytest_plugins +// module), so this is as deterministic as a name the LEGB walk resolves. +// MEASURED across ten public suites: 1,529 method calls on such parameters were +// ambiguous_unknown, and everything derived from them (`tx = session.begin(); tx.commit()`) +// with them; a suite whose fixtures carry annotations resolved its own. +param_arg_type(ph, t) :- + py_fixs_injection(req, fix, pn), + param_decl("client", pn, _, _, req, ph), + py_fixture_value_type(fix, t). + // ── param_arg_method(ParamHash, MethodHash) — a CALLABLE reaching a parameter ── // `Delegator(target)` puts the FUNCTION `target` into the parameter `fn`. Java would // need a functional interface for this; in Python it is an ordinary assignment. diff --git a/tests/cases/python/fixture-value-types-the-parameter/app/__init__.py b/tests/cases/python/fixture-value-types-the-parameter/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/fixture-value-types-the-parameter/app/core.py b/tests/cases/python/fixture-value-types-the-parameter/app/core.py new file mode 100644 index 00000000..687b3245 --- /dev/null +++ b/tests/cases/python/fixture-value-types-the-parameter/app/core.py @@ -0,0 +1,26 @@ +class Session: + def __init__(self, name): + self.name = name + + def begin(self): + return Transaction(self) + + def close(self): + return None + + +class Transaction: + def __init__(self, session): + self.session = session + + def commit(self): + return validate(self.session.name) + + +def validate(name): + return bool(name) + + +class Ledger: + def post(self, amount): + return amount diff --git a/tests/cases/python/fixture-value-types-the-parameter/case.json b/tests/cases/python/fixture-value-types-the-parameter/case.json new file mode 100644 index 00000000..828a6f45 --- /dev/null +++ b/tests/cases/python/fixture-value-types-the-parameter/case.json @@ -0,0 +1,17 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a test parameter the runner fills from a yield fixture is typed by what the fixture yields, so a method called on it resolves and the test reaches the change", + "run": ["impact", "Session.begin", "--tests"], + "want": ["test_tx.py"], + "avoid": ["test_ledger.py"]}, + {"why": "the type carries through a local derived from the parameter (`tx = session.begin(); tx.commit()`), two calls below the fixture", + "run": ["impact", "validate", "--tests"], + "want": ["test_tx.py"], + "avoid": ["test_ledger.py"]}, + {"why": "a return fixture types its parameter the same way", + "run": ["impact", "Ledger.post", "--tests"], + "want": ["test_ledger.py"], + "avoid": ["test_tx.py"]}, + {"why": "CONTROL: a method no test calls on a fixture value reaches no test file through it", + "run": ["impact", "Session.close", "--tests"], + "avoid": ["test_ledger.py"]}]} diff --git a/tests/cases/python/fixture-value-types-the-parameter/tests/conftest.py b/tests/cases/python/fixture-value-types-the-parameter/tests/conftest.py new file mode 100644 index 00000000..12d7146d --- /dev/null +++ b/tests/cases/python/fixture-value-types-the-parameter/tests/conftest.py @@ -0,0 +1,15 @@ +import pytest + +from app.core import Ledger, Session + + +@pytest.fixture +def session(): + s = Session("db") + yield s + s.close() + + +@pytest.fixture +def ledger(): + return Ledger() diff --git a/tests/cases/python/fixture-value-types-the-parameter/tests/test_ledger.py b/tests/cases/python/fixture-value-types-the-parameter/tests/test_ledger.py new file mode 100644 index 00000000..54302900 --- /dev/null +++ b/tests/cases/python/fixture-value-types-the-parameter/tests/test_ledger.py @@ -0,0 +1,2 @@ +def test_post(ledger): + assert ledger.post(3) == 3 diff --git a/tests/cases/python/fixture-value-types-the-parameter/tests/test_tx.py b/tests/cases/python/fixture-value-types-the-parameter/tests/test_tx.py new file mode 100644 index 00000000..54cfaa2e --- /dev/null +++ b/tests/cases/python/fixture-value-types-the-parameter/tests/test_tx.py @@ -0,0 +1,3 @@ +def test_commit(session): + tx = session.begin() + assert tx.commit() From 6bc6ce614c6340c0a0a82e85c94f3a28889d92dd Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:34:49 -0700 Subject: [PATCH 02/16] fix(python): a decorator returning update_wrapper(wrapper, f) or cast(T, wrapper) returns wrapper functools.update_wrapper(wrapper, wrapped) hands back `wrapper`, and typing.cast(T, x) hands back `x`; both are documented, neither module is staged in a client-only run. A decorator written `return update_wrapper(wrapper, f)`, or typed as `return t.cast(F, update_wrapper(wrapper, f))`, was therefore opaque: every method it decorates was decorator_replaced_target and no call through the attribute reached anything. That shape occurs in five of sixteen public repositories measured. A catalogue, py_returns_arg(DottedName, Position) in builtins.dl, lists the two functions and the argument each returns; call_chain.dl follows such calls (to any depth) to the returned name, matched through the import that binds the callee: a module import, its alias (`import typing as t` is MODULE_IMPORT_ALIAS, which the copy.copy rule beside it also misses), or a from-import. On ten held-in repositories, path's found rate on runtime-proven chains goes 0.720 -> 0.744 (the repository with the shape: 0.52 -> 0.76); test selection and the other verbs unchanged. Engine suite 43/43 with identical case results; query cases 313/313; case decorator-returning-update-wrapper fails on the base engine on both decorator checks. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/call-edge-generation/call_chain.dl | 25 +++++++++++ graph/python/engine/resolution/builtins.dl | 12 +++++ graph/python/souffle/decls_all.dl | 3 ++ .../case.json | 13 ++++++ .../pkg/__init__.py | 0 .../pkg/scaffold.py | 45 +++++++++++++++++++ 6 files changed, 98 insertions(+) create mode 100644 tests/cases/python/decorator-returning-update-wrapper/case.json create mode 100644 tests/cases/python/decorator-returning-update-wrapper/pkg/__init__.py create mode 100644 tests/cases/python/decorator-returning-update-wrapper/pkg/scaffold.py diff --git a/graph/python/engine/call-edge-generation/call_chain.dl b/graph/python/engine/call-edge-generation/call_chain.dl index b1c334c5..c6bf5e07 100644 --- a/graph/python/engine/call-edge-generation/call_chain.dl +++ b/graph/python/engine/call-edge-generation/call_chain.dl @@ -156,6 +156,31 @@ method_returns_method("client", m, r) :- expr_call_candidate(site, g), method_returns_method("client", g, r). +// ── a call that hands back one of its arguments (py_returns_arg, resolution/builtins.dl) ── +// Matched through the import that binds the callee, never by the bare name: +// `functools.update_wrapper(...)` / `t.cast(...)` where the receiver is the name an `import` +// bound, or `update_wrapper(...)` bound by `from functools import update_wrapper`. A local +// function that happens to share the name does neither. +py_passthrough_arg(site, pos) :- + call_name(site, fn), call_receiver_object(site, obj), + expr_binding("client", rb, ctx, obj), ctx != "STORE", binding_lookup("client", rb, rb2), + import_binding("client", rb2, i), import_decl("client", k, mod, _, i), + (k = "MODULE_IMPORT" ; k = "MODULE_IMPORT_ALIAS"), // `import typing as t` is the alias kind + py_returns_arg(path, pos), cat(mod, cat(".", fn)) = path. +py_passthrough_arg(site, pos) :- + call_callee_is_value(site), call_callee_expr(site, callee), + expr_binding("client", rb, ctx, callee), ctx != "STORE", binding_lookup("client", rb, rb2), + import_binding("client", rb2, i), import_decl("client", _, path, _, i), + py_returns_arg(path, pos). +// the expression a value really is, through any number of such calls +py_passthrough_root(e, e) :- method_return_value_expr("client", _, e). +py_passthrough_root(e, x) :- + py_passthrough_root(e, c), call_of_expr(c, site), py_passthrough_arg(site, pos), + call_arg(site, pos, x). +method_returns_method("client", m, r) :- + method_return_value_expr("client", m, e), py_passthrough_root(e, x), x != e, + expr_names_method("client", x, r). + // ── decorator_hits_lib(SiteKey, LibMethodHash) ─────────────────────────────── // `@abstractmethod` names `abc.abstractmethod`, which HAS Python source and is in the // staged stdlib IR — it is a library boundary, not a blind spot. A BARE decorator has no diff --git a/graph/python/engine/resolution/builtins.dl b/graph/python/engine/resolution/builtins.dl index ca1e14d1..02da0163 100644 --- a/graph/python/engine/resolution/builtins.dl +++ b/graph/python/engine/resolution/builtins.dl @@ -182,6 +182,18 @@ py_copy_module("copy"). py_copy_function("copy"). py_copy_function("deepcopy"). +// ── py_returns_arg(DottedName, Position) — a library call that hands back an argument ── +// Each returns, unchanged, the argument at Position (documented behaviour, not inference): +// functools.update_wrapper(wrapper, wrapped) -> wrapper (copies __name__/__doc__ onto it) +// typing.cast(T, value) -> value (a no-op at run time) +// A decorator written `return update_wrapper(wrapper, f)`, or the typed spelling +// `return t.cast(F, update_wrapper(wrapper, f))`, is therefore the same shape as one +// returning `wrapper` under @functools.wraps. Without these every method it decorates was +// decorator_replaced_target, and no call through the attribute reached anything +// (call-edge-generation/call_chain.dl). Neither module is staged in a client-only run. +py_returns_arg("functools.update_wrapper", "0"). +py_returns_arg("typing.cast", "1"). + // ── py_builtin_dynamic(Name) — the escape hatches ──────────────────────────── // These do not merely lack Python source; they make the PROGRAM unanalysable at that // point. A call to getattr/eval/exec means the engine cannot know what runs, and the diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index d6b069c6..a1ab9506 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -313,6 +313,9 @@ .decl py_path_join_function(c0:symbol) .decl py_path_join_operator(c0:symbol) .decl py_copy_function(c0:symbol) +.decl py_returns_arg(c0:symbol,c1:symbol) +.decl py_passthrough_arg(c0:symbol,c1:symbol) +.decl py_passthrough_root(c0:symbol,c1:symbol) .decl dict_lookup_method(c0:symbol) .decl builtin_target(c0:symbol,c1:symbol) .decl builtin_object_init_target(c0:symbol) diff --git a/tests/cases/python/decorator-returning-update-wrapper/case.json b/tests/cases/python/decorator-returning-update-wrapper/case.json new file mode 100644 index 00000000..58328af4 --- /dev/null +++ b/tests/cases/python/decorator-returning-update-wrapper/case.json @@ -0,0 +1,13 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a decorator returning functools.update_wrapper(wrapper, f) hands back `wrapper`: a call to the decorated method resolves to the wrapper, and the caller reaches the method through it rather than by name", + "run": ["impact", "Scaffold.other"], + "want": ["2 hop(s) App.use"], + "avoid": ["[by name] App.use"]}, + {"why": "the typed spelling `return t.cast(F, update_wrapper(wrapper, f))`, typing imported as an alias, is the same shape", + "run": ["impact", "Scaffold.route"], + "want": ["2 hop(s) App.get"], + "avoid": ["[by name] App.get"]}, + {"why": "CONTROL: an undecorated method keeps its resolved caller", + "run": ["impact", "Scaffold.add"], + "want": ["[resolved] Scaffold.route"]}]} diff --git a/tests/cases/python/decorator-returning-update-wrapper/pkg/__init__.py b/tests/cases/python/decorator-returning-update-wrapper/pkg/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/decorator-returning-update-wrapper/pkg/scaffold.py b/tests/cases/python/decorator-returning-update-wrapper/pkg/scaffold.py new file mode 100644 index 00000000..08c14f15 --- /dev/null +++ b/tests/cases/python/decorator-returning-update-wrapper/pkg/scaffold.py @@ -0,0 +1,45 @@ +import typing as t +from functools import update_wrapper + +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) + + +def setupmethod(f: F) -> F: + f_name = f.__name__ + + def wrapper_func(self, *args, **kwargs): + self._check(f_name) + return f(self, *args, **kwargs) + + return t.cast(F, update_wrapper(wrapper_func, f)) + + +def plain(f): + def inner(self, *args): + return f(self, *args) + + return update_wrapper(inner, f) + + +class Scaffold: + def _check(self, n): + return n + + @setupmethod + def route(self, rule): + return self.add(rule) + + @plain + def other(self, x): + return x + + def add(self, rule): + return rule + + +class App(Scaffold): + def get(self, rule): + return self.route(rule) + + def use(self): + return self.other(1) From 6aae52715505dcbea41d951a4654e53ab00251a5 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:43:12 -0700 Subject: [PATCH 03/16] fix(impact): a call under `if __name__ == "__main__":` is not taken by the import walk impact's `at import` rung names every test file importing a module whose BODY reaches the change through calls that run, because a module that raises while being imported fails every file importing it. A module body's calls inside `if __name__ == "__main__":` are real edges, but they run only when the file is executed as a script, never on import. Walking them named every importer of a module that ends in a demo block. The fact export now records guard_only(module, callee) where every call site the module body has to that callee lies inside a top-level main guard, and up_running does not take those edges. Ordinary reachability is unchanged; only the import hop is. Measured with the mutation oracle on ten held-in repositories, the at-import rung was the second-noisiest (precision 0.16 over 720 selected test files), and 637 of those 720 were for targets that never run on import. On the repository carrying the shape its at-import selections drop 713 -> 305 and test-selection precision 0.335 -> 0.373 with recall unchanged; the control repository whose at-import selections were all correct is unchanged. Query cases 315/315 python, 264/264 typescript, 333/333 java; case main-guard-does-not-run-on-import fails on the base engine on the guarded check. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-impact | 31 ++++++++++++++++++- .../skills/axiomcode/scripts/dl/impact.dl | 4 ++- .../case.json | 9 ++++++ .../pkg/__init__.py | 0 .../pkg/demo.py | 13 ++++++++ .../tests/test_demo.py | 5 +++ .../tests/test_other.py | 5 +++ 7 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 tests/cases/python/main-guard-does-not-run-on-import/case.json create mode 100644 tests/cases/python/main-guard-does-not-run-on-import/pkg/__init__.py create mode 100644 tests/cases/python/main-guard-does-not-run-on-import/pkg/demo.py create mode 100644 tests/cases/python/main-guard-does-not-run-on-import/tests/test_demo.py create mode 100644 tests/cases/python/main-guard-does-not-run-on-import/tests/test_other.py diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 7a8dc195..df2fcce2 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1321,7 +1321,7 @@ class Impact: W('cs_fixture_type', sorted(x for x in fixt if not x[0].startswith('collection:'))) # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '63' # 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '64' # 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span @@ -1530,6 +1530,35 @@ class Impact: W('calls', [(r['caller_id'], r['callee_method_id'], ax_edges.STUB_TIER if r['call_site_id'] in stubs else r['tier'], g.site_file(r['file_path']) if r['file_path'] else '', link_line.get(r['call_site_id'], r['start_line'] or 0)) for r in g.q("SELECT e.call_site_id, e.caller_id, e.callee_method_id, e.tier, s.file_path, s.start_line FROM call_edges e LEFT JOIN call_sites s ON s.id = e.call_site_id WHERE e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL")] + self.protected_name_stubs()) + # A CALL UNDER `if __name__ == "__main__":` DOES NOT RUN ON IMPORT. The guard is true only when the file is run + # as a script, so a module body's calls inside it are real edges (the script makes them) but no hop for the + # import walk (dl/impact.dl up_running): otherwise a module ending in a demo block reads as breaking at import for + # every file that imports it. + guarded = {} + def guard_ranges(f): + if f not in guarded: + rs = [] + try: + import ast as _ast + for n in _ast.parse('\n'.join(self.lines(f))).body: + t = n.test if isinstance(n, _ast.If) else None + if isinstance(t, _ast.Compare) and len(t.ops) == 1 and isinstance(t.ops[0], _ast.Eq): + sides = [t.left, t.comparators[0]] + if any(isinstance(x, _ast.Name) and x.id == '__name__' for x in sides) and \ + any(isinstance(x, _ast.Constant) and x.value == '__main__' for x in sides): + rs.append((n.lineno, n.end_lineno)) + except (SyntaxError, ValueError): + pass + guarded[f] = rs + return guarded[f] + sites_of = collections.defaultdict(list) + for r in g.q("SELECT e.caller_id, e.callee_method_id, s.file_path, s.start_line FROM call_edges e JOIN call_sites s ON s.id = e.call_site_id " + "WHERE e.callee_method_id IS NOT NULL"): + sy = g.sym.get(r['caller_id']) or {} + if sy.get('kind') == 'module' and (r['file_path'] or '').endswith('.py'): + sites_of[(r['caller_id'], r['callee_method_id'])].append((g.site_file(r['file_path']), r['start_line'] or 0)) + W('guard_only', sorted((a, b) for (a, b), ss in sites_of.items() + if all(any(lo <= ln <= hi for lo, hi in guard_ranges(f)) for f, ln in ss))) # a hand-off on EVERY line its call site spans: a chained registration (`router\n .route('/')\n .post(auth(), ctrl.h)`) # records its edges on the statement's first line, while the handler is named on a later one W('handoff_at', sorted({(r['caller_id'], r['callee_method_id'], g.site_file(r['file_path']), l) for r in g.q( diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index f8d70e36..69752bec 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -937,7 +937,9 @@ parent_up(q, a, b, t) :- reach(q, a, d), d > 0, reach(q, b, d1), d1 = d - 1, fw_ // shape — which is the whole argument for keeping cases beside a corpus. .decl up_running(q:symbol, m:symbol, d:number) up_running(q, m, 0) :- seed(q, m). -up_running(q, a, d+1) :- up_running(q, b, d), edge(a, b, "known_edge"), !state_gate(a, b, _), d < 40. +// a module body's call made only under `if __name__ == "__main__":` runs when the file is a script, never on import +.decl guard_only(a:symbol, b:symbol) .input guard_only +up_running(q, a, d+1) :- up_running(q, b, d), edge(a, b, "known_edge"), !state_gate(a, b, _), !guard_only(a, b), d < 40. // the same gate as `up` (above): a caller that leaves the instance code keeps the route only on an instance given f .decl up_running_g(q:symbol, m:symbol, t:symbol, f:symbol, d:number) up_running_g(q, a, t, f, d+1) :- up_running(q, f, d), state_gate(a, f, t), edge(a, f, "known_edge"), d < 40. diff --git a/tests/cases/python/main-guard-does-not-run-on-import/case.json b/tests/cases/python/main-guard-does-not-run-on-import/case.json new file mode 100644 index 00000000..f50525ba --- /dev/null +++ b/tests/cases/python/main-guard-does-not-run-on-import/case.json @@ -0,0 +1,9 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a call made only under `if __name__ == \"__main__\":` runs when the file is a script, not on import: breaking `show` breaks no file that merely imports the module", + "run": ["impact", "show", "--tests"], + "want": ["0 of 2 test method(s) reach the change"], + "avoid": ["at import"]}, + {"why": "CONTROL: a call the module body makes OUTSIDE the guard does run on import, so an importer breaks with `render`", + "run": ["impact", "render", "--tests"], + "want": ["test_other.", "1 at import"]}]} diff --git a/tests/cases/python/main-guard-does-not-run-on-import/pkg/__init__.py b/tests/cases/python/main-guard-does-not-run-on-import/pkg/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/main-guard-does-not-run-on-import/pkg/demo.py b/tests/cases/python/main-guard-does-not-run-on-import/pkg/demo.py new file mode 100644 index 00000000..1bb817e7 --- /dev/null +++ b/tests/cases/python/main-guard-does-not-run-on-import/pkg/demo.py @@ -0,0 +1,13 @@ +def render(x): + return str(x) + + +def show(): + return render(1) + + +TABLE = render("header") + + +if __name__ == "__main__": + print(show()) diff --git a/tests/cases/python/main-guard-does-not-run-on-import/tests/test_demo.py b/tests/cases/python/main-guard-does-not-run-on-import/tests/test_demo.py new file mode 100644 index 00000000..1a974c9c --- /dev/null +++ b/tests/cases/python/main-guard-does-not-run-on-import/tests/test_demo.py @@ -0,0 +1,5 @@ +from pkg import demo + + +def test_render(): + assert demo.render(2) == "2" diff --git a/tests/cases/python/main-guard-does-not-run-on-import/tests/test_other.py b/tests/cases/python/main-guard-does-not-run-on-import/tests/test_other.py new file mode 100644 index 00000000..5e8f6973 --- /dev/null +++ b/tests/cases/python/main-guard-does-not-run-on-import/tests/test_other.py @@ -0,0 +1,5 @@ +import pkg.demo + + +def test_nothing(): + assert True From bfb70899ee44be952917b3f9230c8e9b8661c798 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:48:45 -0700 Subject: [PATCH 04/16] fix(impact): a conjunctive main guard (`cond and __name__ == "__main__"`) is a guard too `if sys.platform != "win32" and __name__ == "__main__":` is false on import whatever the other conjunct is, so the block cannot run then; an `or` could, and is not read as a guard. The first guard rule missed the conjunction, which on the repository carrying the shape was where almost all of the remaining false at-import routes came from. There: at-import selections 305 -> 55, of which 53 fail under the mutation oracle; test- selection precision 0.373 -> 0.446. Recall 0.810 -> 0.794: a few failing files had been selected only through an at-import route that cannot run, right by accident; their real route is one the graph does not see. Control repository unchanged. IMPACT_VERSION 65, so fact caches written under 64 are not reused. Case extended with the conjunctive spelling; query cases green. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-impact | 16 +++--- .../case.json | 53 ++++++++++++++++--- .../pkg/demo.py | 8 +++ 3 files changed, 63 insertions(+), 14 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index df2fcce2..d840e495 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1321,7 +1321,7 @@ class Impact: W('cs_fixture_type', sorted(x for x in fixt if not x[0].startswith('collection:'))) # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '64' # 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '65' # 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span @@ -1540,13 +1540,17 @@ class Impact: rs = [] try: import ast as _ast - for n in _ast.parse('\n'.join(self.lines(f))).body: - t = n.test if isinstance(n, _ast.If) else None + def is_main(t): if isinstance(t, _ast.Compare) and len(t.ops) == 1 and isinstance(t.ops[0], _ast.Eq): sides = [t.left, t.comparators[0]] - if any(isinstance(x, _ast.Name) and x.id == '__name__' for x in sides) and \ - any(isinstance(x, _ast.Constant) and x.value == '__main__' for x in sides): - rs.append((n.lineno, n.end_lineno)) + return any(isinstance(x, _ast.Name) and x.id == '__name__' for x in sides) and \ + any(isinstance(x, _ast.Constant) and x.value == '__main__' for x in sides) + # `cond and __name__ == "__main__"`: a conjunct false on import keeps the block from running; + # an `or` would not, so it is not read as a guard + return isinstance(t, _ast.BoolOp) and isinstance(t.op, _ast.And) and any(is_main(v) for v in t.values) + for n in _ast.parse('\n'.join(self.lines(f))).body: + if isinstance(n, _ast.If) and is_main(n.test): + rs.append((n.lineno, n.end_lineno)) except (SyntaxError, ValueError): pass guarded[f] = rs diff --git a/tests/cases/python/main-guard-does-not-run-on-import/case.json b/tests/cases/python/main-guard-does-not-run-on-import/case.json index f50525ba..4d1ef066 100644 --- a/tests/cases/python/main-guard-does-not-run-on-import/case.json +++ b/tests/cases/python/main-guard-does-not-run-on-import/case.json @@ -1,9 +1,46 @@ -{"lang": "python", "src": ".", +{ + "lang": "python", + "src": ".", "checks": [ - {"why": "a call made only under `if __name__ == \"__main__\":` runs when the file is a script, not on import: breaking `show` breaks no file that merely imports the module", - "run": ["impact", "show", "--tests"], - "want": ["0 of 2 test method(s) reach the change"], - "avoid": ["at import"]}, - {"why": "CONTROL: a call the module body makes OUTSIDE the guard does run on import, so an importer breaks with `render`", - "run": ["impact", "render", "--tests"], - "want": ["test_other.", "1 at import"]}]} + { + "why": "a call made only under `if __name__ == \"__main__\":` runs when the file is a script, not on import: breaking `show` breaks no file that merely imports the module", + "run": [ + "impact", + "show", + "--tests" + ], + "want": [ + "0 of 2 test method(s) reach the change" + ], + "avoid": [ + "at import" + ] + }, + { + "why": "a guard written as a conjunction (`cond and __name__ == \"__main__\"`) is still false on import", + "run": [ + "impact", + "banner", + "--tests" + ], + "want": [ + "0 of 2 test method(s) reach the change" + ], + "avoid": [ + "at import" + ] + }, + { + "why": "CONTROL: a call the module body makes OUTSIDE the guard does run on import, so an importer breaks with `render`", + "run": [ + "impact", + "render", + "--tests" + ], + "want": [ + "test_other.", + "1 at import" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/python/main-guard-does-not-run-on-import/pkg/demo.py b/tests/cases/python/main-guard-does-not-run-on-import/pkg/demo.py index 1bb817e7..96beb1b6 100644 --- a/tests/cases/python/main-guard-does-not-run-on-import/pkg/demo.py +++ b/tests/cases/python/main-guard-does-not-run-on-import/pkg/demo.py @@ -11,3 +11,11 @@ def show(): if __name__ == "__main__": print(show()) + + +def banner(): + return render("b") + + +if render and __name__ == "__main__": + banner() From cafd6d2931f194a7420eb1a6048c2f3dda61132b Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:00:15 -0700 Subject: [PATCH 05/16] fix(impact): an on-demand protocol member is reached from test code that builds the object, not from every constructor impact takes a hop from a protocol member (a dunder no call site names) to whoever constructs its type: you do not build a context manager without entering it. That holds for __enter__/__exit__, __bool__, __hash__, __call__ (precision 0.61-0.80 under the mutation oracle), and not for showing a value (__repr__, __str__, __format__), pickling or copying it (__getstate__, __setstate__, __reduce__, ...) or deleting from it (__delitem__, __delattr__, __del__): those run only when some code asks, and a constructor deep inside other code says nothing about that. Measured on ten held-in repositories, __repr__ alone was 124 selected test files at precision 0.05, across eight of them. For those members the hop is now taken only from test code that constructs the type: a test that builds the object is the one that asks for its repr. Dropping the hop outright removed 403 false files but left 13 targets with a failing test and an empty answer; this form keeps every answer non-empty. Ten held-in repositories (this commit with the two main-guard ones): test-selection precision 0.408 -> 0.454, recall 0.698 -> 0.694, empty answers unchanged at 84/535. Case on-demand-dunder-hop-from-tests fails on the base engine on the __repr__ check; its control (__eq__ keeps every constructor) passes on both. Query cases 318/318 python, 264/264 typescript, 333/333 java. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/dl/impact.dl | 14 +++++++++++++- .../on-demand-dunder-hop-from-tests/case.json | 9 +++++++++ .../pkg/__init__.py | 0 .../pkg/model.py | 17 +++++++++++++++++ .../tests/test_point.py | 5 +++++ .../tests/test_use.py | 5 +++++ 6 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 tests/cases/python/on-demand-dunder-hop-from-tests/case.json create mode 100644 tests/cases/python/on-demand-dunder-hop-from-tests/pkg/__init__.py create mode 100644 tests/cases/python/on-demand-dunder-hop-from-tests/pkg/model.py create mode 100644 tests/cases/python/on-demand-dunder-hop-from-tests/tests/test_point.py create mode 100644 tests/cases/python/on-demand-dunder-hop-from-tests/tests/test_use.py diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 69752bec..e831a6a7 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -1094,6 +1094,16 @@ direct(q, c, "uses", cat("framework-mediated, not a call: servlet_filter via ", // twice, which is the shape that made an override count as both contract and reached. .decl protocol_member(t:symbol, m:symbol) protocol_member(t, m) :- member(t, m, n, _), match("__.*__", n), n != "__init__". +// Showing a value (`__repr__`, `__str__`, `__format__`), pickling or copying it (`__getstate__`, `__setstate__`, +// `__reduce__`, ...) and deleting from it (`__delitem__`, `__delattr__`, `__del__`) happen only when some code asks, +// so a constructor deep inside other code says nothing about whether they run, and taking the hop from every one of +// them named every test above it. A TEST that builds the object is the one that asks: for these the hop is taken +// from test code only. +.decl protocol_on_demand(n:symbol) +protocol_on_demand("__repr__"). protocol_on_demand("__str__"). protocol_on_demand("__format__"). +protocol_on_demand("__getstate__"). protocol_on_demand("__setstate__"). protocol_on_demand("__getnewargs__"). +protocol_on_demand("__getnewargs_ex__"). protocol_on_demand("__reduce__"). protocol_on_demand("__reduce_ex__"). +protocol_on_demand("__delitem__"). protocol_on_demand("__delattr__"). protocol_on_demand("__del__"). .decl constructs(c:symbol, t:symbol) constructs(c, t) :- member(t, i, "__init__", _), edge(c, i, _), c != i. // The hop is taken only where the CHANGE ITSELF is a protocol member, not wherever one turns up in the closure. @@ -1102,7 +1112,9 @@ constructs(c, t) :- member(t, i, "__init__", _), edge(c, i, _), c != i. // that cost 115 false pairs for 46 true ones. Narrowed to the seed it keeps what it was written for — a // declaration with NO call site by design, whose callers are whoever built the object — and drops the rest. .decl protocol_hop(q:symbol, c:symbol, m:symbol) -protocol_hop(q, c, m) :- seed(q, m), protocol_member(t, m), constructs(c, t), c != m. +protocol_hop(q, c, m) :- seed(q, m), protocol_member(t, m), member(t, m, n, _), !protocol_on_demand(n), constructs(c, t), c != m. +protocol_hop(q, c, m) :- seed(q, m), protocol_member(t, m), member(t, m, n, _), protocol_on_demand(n), constructs(c, t), c != m, + decl_file(c, f), is_test_file(f). up(q, c, 1) :- protocol_hop(q, c, _). parent_up(q, a, b, "protocol") :- reach(q, a, d), d > 0, reach(q, b, d1), d1 = d - 1, protocol_hop(q, a, b). // WHAT RUNS AT IMPORT. `settings = Settings()` at the top level of a module is executed by the import statement, diff --git a/tests/cases/python/on-demand-dunder-hop-from-tests/case.json b/tests/cases/python/on-demand-dunder-hop-from-tests/case.json new file mode 100644 index 00000000..8ca71978 --- /dev/null +++ b/tests/cases/python/on-demand-dunder-hop-from-tests/case.json @@ -0,0 +1,9 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "showing a value is on demand: a test that builds the object is taken for `__repr__`, a constructor deep in the source is not", + "run": ["impact", "Point.__repr__", "--tests"], + "want": ["test_point.py"], + "avoid": ["test_use.py"]}, + {"why": "CONTROL: a protocol a constructed object does not avoid (`__eq__`) keeps the hop from every constructor", + "run": ["impact", "Point.__eq__", "--tests"], + "want": ["test_use.py", "test_point.py"]}]} diff --git a/tests/cases/python/on-demand-dunder-hop-from-tests/pkg/__init__.py b/tests/cases/python/on-demand-dunder-hop-from-tests/pkg/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/on-demand-dunder-hop-from-tests/pkg/model.py b/tests/cases/python/on-demand-dunder-hop-from-tests/pkg/model.py new file mode 100644 index 00000000..c37360f8 --- /dev/null +++ b/tests/cases/python/on-demand-dunder-hop-from-tests/pkg/model.py @@ -0,0 +1,17 @@ +class Point: + def __init__(self, x, y): + self.x, self.y = x, y + + def __repr__(self): + return f"Point({self.x}, {self.y})" + + def __eq__(self, other): + return (self.x, self.y) == (other.x, other.y) + + +def make(): + return Point(1, 2) + + +def use(): + return make() diff --git a/tests/cases/python/on-demand-dunder-hop-from-tests/tests/test_point.py b/tests/cases/python/on-demand-dunder-hop-from-tests/tests/test_point.py new file mode 100644 index 00000000..c0b95782 --- /dev/null +++ b/tests/cases/python/on-demand-dunder-hop-from-tests/tests/test_point.py @@ -0,0 +1,5 @@ +from pkg.model import Point + + +def test_repr(): + assert repr(Point(1, 2)) == "Point(1, 2)" diff --git a/tests/cases/python/on-demand-dunder-hop-from-tests/tests/test_use.py b/tests/cases/python/on-demand-dunder-hop-from-tests/tests/test_use.py new file mode 100644 index 00000000..17baa6db --- /dev/null +++ b/tests/cases/python/on-demand-dunder-hop-from-tests/tests/test_use.py @@ -0,0 +1,5 @@ +from pkg.model import use + + +def test_use(): + assert use() is not None From b0dca3c29691e2d6c5b9aa7bcba0331a88b492c3 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:23:09 -0700 Subject: [PATCH 06/16] fix(python): `obj.x = v` and `del obj.x` are calls to the property's setter and deleter The engine emitted a PROPERTY_READ edge for reading a @property and nothing for writing or deleting one, though `obj.x = v` runs `@x.setter` and `del obj.x` runs `@x.deleter` exactly as a read runs the getter. A setter therefore had no caller: an impact on it named no test, and everything the setter calls was cut off from whoever assigns. Setters occur in five of sixteen public repositories measured; the torture fixture documented the gap as a known miss. type_property_accessor finds the setter / deleter on the class that wins the name in the receiver's MRO (they share the getter's binding, so mro_lookup's single answer is not enough); property_write_edge keys on the attribute access's STORE / DEL context; the edge is exported as call kind PROPERTY_WRITE (schema vocabulary for Python, the kind TypeScript already uses for its accessors), mapped to the `property` rung. Torture: the CPython oracle confirms the new edge (agree 587 -> 588, missing 47 -> 46, extra unchanged); f43's known-miss marker is removed with its docstring rewritten at the same line count; goldens updated. Engine suite 43/43. Query cases 322/322 python, 264/264 typescript, 333/333 java; case property-setter-is-a-call fails on the base engine on all three write/delete checks. All six commits together, ten held-in repositories: test-selection recall 0.668 -> 0.700, precision 0.403 -> 0.452, impact source-caller recall 0.673 -> 0.686, path found 0.720 -> 0.752, context recall@5 0.789 -> 0.799; no repository regresses beyond 0.003 on any of them. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/bundle/SCHEMA.md | 3 +- graph/bundle/schema.ts | 3 +- .../engine/call-edge-generation/call_chain.dl | 26 ++++++++++++++++ .../engine/resolution/attribute-lookup.dl | 9 ++++++ graph/python/souffle/decls_all.dl | 2 ++ .../python/torture/client/f43_def_rebind.py | 8 ++--- .../test/python/torture/expected/coverage.txt | 10 +++--- .../python/torture/expected/torture.edges | 1 + .../python/torture/expected/torture.oracle | 2 +- .../skills/axiomcode/scripts/ax_edges.py | 4 +-- .../property-setter-is-a-call/case.json | 16 ++++++++++ .../property-setter-is-a-call/pkg/__init__.py | 0 .../python/property-setter-is-a-call/pkg/m.py | 31 +++++++++++++++++++ .../tests/test_delete.py | 7 +++++ .../tests/test_read.py | 5 +++ .../tests/test_write.py | 7 +++++ 16 files changed, 120 insertions(+), 14 deletions(-) create mode 100644 tests/cases/python/property-setter-is-a-call/case.json create mode 100644 tests/cases/python/property-setter-is-a-call/pkg/__init__.py create mode 100644 tests/cases/python/property-setter-is-a-call/pkg/m.py create mode 100644 tests/cases/python/property-setter-is-a-call/tests/test_delete.py create mode 100644 tests/cases/python/property-setter-is-a-call/tests/test_read.py create mode 100644 tests/cases/python/property-setter-is-a-call/tests/test_write.py diff --git a/graph/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md index 2c17b391..a78cdc69 100644 --- a/graph/bundle/SCHEMA.md +++ b/graph/bundle/SCHEMA.md @@ -442,7 +442,7 @@ One row per place a call is written (or, for a synthesised edge, the construct t - **typescript** — end_line / end_column come from the expression row; the call-site row itself records only the start. - **javascript** — caller_id is the parser's enclosing method, or the module initializer for top-level code. end_line / end_column come from the expression row. `require()` is a module edge, not a call site. - **typescript** — PROPERTY_READ and PROPERTY_WRITE rows are accessor invocations with no written call: the site is the property-access expression that runs the getter or setter, positioned from the expressions table, and callee_name is NULL because nothing was written; the accessor's name is on the callee's methods row. Filter them out with kind NOT IN (…) when counting calls. -- **python** — PROPERTY_READ, CONTEXT_MANAGER, ITERATION_PROTOCOL, METACLASS_CREATION and DYNAMIC_CALL rows are protocol or indirect edges with no written call: their site is the expression that triggers them, and callee_name is always NULL because nothing was written. SUBSCRIPT_CALL is NULL only when the subscript is not a written name (measured 206 of 337 rows on a Python subject). Filter them out with kind NOT IN (…) when counting calls. +- **python** — PROPERTY_READ, PROPERTY_WRITE, CONTEXT_MANAGER, ITERATION_PROTOCOL, METACLASS_CREATION and DYNAMIC_CALL rows are protocol or indirect edges with no written call: their site is the expression that triggers them, and callee_name is always NULL because nothing was written. SUBSCRIPT_CALL is NULL only when the subscript is not a written name (measured 206 of 337 rows on a Python subject). Filter them out with kind NOT IN (…) when counting calls. - **python** — The id is an EXPRESSION hash for a written call; a DECORATOR hash (PY_DECORATOR_…) for DECORATOR_APPLICATION and DECORATOR_* sites, positioned at the decorator line; and the class's TYPE hash for METACLASS_CREATION, positioned at the class declaration. ### `call_edges` @@ -531,6 +531,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h | `DECORATOR_*` | python | Applying an unparenthesised decorator; the suffix is the parser's decorator kind: BARE, ATTRIBUTE, SUBSCRIPT, EXPRESSION (and CALL/ATTRIBUTE_CALL when the factory expression is not itself a call site). The site is the decorator hash. | | `METACLASS_CREATION` | python | A class statement invokes its metaclass's `__new__` / `__init__` at import time, whether the metaclass is written on the statement (`class X(metaclass=M)`) or inherited from a base, and the nearest base's `__init_subclass__`. No written call; the site is the class's type hash. | | `PROPERTY_READ` | python | Reading `obj.attr` where `attr` is a `@property` runs the getter; reading `Cls.attr` where the METACLASS defines `attr` as a property runs that getter. No written call; the site is the attribute-access expression. | +| `PROPERTY_WRITE` | python | Assigning `obj.attr = v` where `attr` is a `@property` with a setter runs the setter; `del obj.attr` runs its deleter. No written call; the site is the attribute-access expression. | | `CONTEXT_MANAGER` | python | `with expr:` runs `__enter__` / `__exit__` (or the async pair). No written call; the site is the context-manager expression. | | `ITERATION_PROTOCOL` | python | `for x in expr:` (and comprehensions) runs `__iter__` / `__next__` (or the async pair). No written call; the site is the iterated expression. | | `SUBSCRIPT_PROTOCOL` | python | `x[k]` runs `__getitem__` (and `x[k] = v` / `del x[k]` the setter and deleter) of the receiver's class. No written call; the site is the subscript expression. Its own kind so it is never counted as a written call. | diff --git a/graph/bundle/schema.ts b/graph/bundle/schema.ts index 2e9af322..ac5e5a3c 100644 --- a/graph/bundle/schema.ts +++ b/graph/bundle/schema.ts @@ -667,6 +667,7 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'call_edges', column: 'kind', value: 'DECORATOR_*', languages: P, meaning: 'Applying an unparenthesised decorator; the suffix is the parser\'s decorator kind: BARE, ATTRIBUTE, SUBSCRIPT, EXPRESSION (and CALL/ATTRIBUTE_CALL when the factory expression is not itself a call site). The site is the decorator hash.' }, { table: 'call_edges', column: 'kind', value: 'METACLASS_CREATION', languages: P, meaning: 'A class statement invokes its metaclass\'s `__new__` / `__init__` at import time, whether the metaclass is written on the statement (`class X(metaclass=M)`) or inherited from a base, and the nearest base\'s `__init_subclass__`. No written call; the site is the class\'s type hash.' }, { table: 'call_edges', column: 'kind', value: 'PROPERTY_READ', languages: P, meaning: 'Reading `obj.attr` where `attr` is a `@property` runs the getter; reading `Cls.attr` where the METACLASS defines `attr` as a property runs that getter. No written call; the site is the attribute-access expression.' }, + { table: 'call_edges', column: 'kind', value: 'PROPERTY_WRITE', languages: P, meaning: 'Assigning `obj.attr = v` where `attr` is a `@property` with a setter runs the setter; `del obj.attr` runs its deleter. No written call; the site is the attribute-access expression.' }, { table: 'call_edges', column: 'kind', value: 'CONTEXT_MANAGER', languages: P, meaning: '`with expr:` runs `__enter__` / `__exit__` (or the async pair). No written call; the site is the context-manager expression.' }, { table: 'call_edges', column: 'kind', value: 'ITERATION_PROTOCOL', languages: P, meaning: '`for x in expr:` (and comprehensions) runs `__iter__` / `__next__` (or the async pair). No written call; the site is the iterated expression.' }, { table: 'call_edges', column: 'kind', value: 'SUBSCRIPT_PROTOCOL', languages: P, meaning: '`x[k]` runs `__getitem__` (and `x[k] = v` / `del x[k]` the setter and deleter) of the receiver\'s class. No written call; the site is the subscript expression. Its own kind so it is never counted as a written call.' }, @@ -748,7 +749,7 @@ export const NOTES: readonly NoteSpec[] = [ { language: 'typescript', table: 'overrides', note: 'EMPTY — this table is Java-shaped. The TypeScript dispatch envelope is in dispatch_candidates, with basis `nominal` or `structural`.' }, { language: 'typescript', table: 'type_instantiated', note: 'Every row has how = `new`. Not restricted to client provenance: a type the library constructs is still a type that exists at run time, and dropping it would narrow the envelope unsoundly.' }, { language: 'typescript', table: 'call_sites', note: 'PROPERTY_READ and PROPERTY_WRITE rows are accessor invocations with no written call: the site is the property-access expression that runs the getter or setter, positioned from the expressions table, and callee_name is NULL because nothing was written; the accessor\'s name is on the callee\'s methods row. Filter them out with kind NOT IN (…) when counting calls.' }, - { language: 'python', table: 'call_sites', note: 'PROPERTY_READ, CONTEXT_MANAGER, ITERATION_PROTOCOL, METACLASS_CREATION and DYNAMIC_CALL rows are protocol or indirect edges with no written call: their site is the expression that triggers them, and callee_name is always NULL because nothing was written. SUBSCRIPT_CALL is NULL only when the subscript is not a written name (measured 206 of 337 rows on a Python subject). Filter them out with kind NOT IN (…) when counting calls.' }, + { language: 'python', table: 'call_sites', note: 'PROPERTY_READ, PROPERTY_WRITE, CONTEXT_MANAGER, ITERATION_PROTOCOL, METACLASS_CREATION and DYNAMIC_CALL rows are protocol or indirect edges with no written call: their site is the expression that triggers them, and callee_name is always NULL because nothing was written. SUBSCRIPT_CALL is NULL only when the subscript is not a written name (measured 206 of 337 rows on a Python subject). Filter them out with kind NOT IN (…) when counting calls.' }, { language: 'python', table: 'call_sites', note: 'The id is an EXPRESSION hash for a written call; a DECORATOR hash (PY_DECORATOR_…) for DECORATOR_APPLICATION and DECORATOR_* sites, positioned at the decorator line; and the class\'s TYPE hash for METACLASS_CREATION, positioned at the class declaration.' }, { language: 'python', table: 'call_edges', note: 'A `boundary_lib` edge may point at a builtin (callee_provenance builtin, callee_label `builtin:NAME`) or at an unstaged import path (callee_provenance external) — neither has a methods row.' }, { language: 'java', table: 'call_edges', note: 'A `boundary_lib` edge with callee_provenance external names a method of an ancestor type no staged IR declares (callee_label `external:.`, no methods row). A site whose receiver is declared as such a type is multi_inferred even with one client override: the platform method itself, and the platform\'s own subclasses, are the other possible targets. Stage the library to replace the label with the real method.' }, diff --git a/graph/python/engine/call-edge-generation/call_chain.dl b/graph/python/engine/call-edge-generation/call_chain.dl index c6bf5e07..6c35b6cf 100644 --- a/graph/python/engine/call-edge-generation/call_chain.dl +++ b/graph/python/engine/call-edge-generation/call_chain.dl @@ -345,6 +345,25 @@ iter_protocol_edge(src, caller, m) :- expr_type("client", src, t), iter_protocol_target(t, m), expr_ultimate_method("client", src, caller). +// ── property_write_edge(WriteExprHash, CallerMethodHash, AccessorMethodHash) ── +// The store and delete halves of the property protocol: `obj.x = v` calls x's setter, +// `del obj.x` its deleter (type_property_accessor, resolution/attribute-lookup.dl). Same +// shape as a read, keyed on the access's own name context. +property_write_edge(e, caller, m) :- + expr_node("client", "ATTRIBUTE_ACCESS", _, n, e), + expr_name_context("client", "STORE", e), + expr_parent("client", e, "ATTRIBUTE_OBJECT", _, obj), + expr_type("client", obj, t), + type_property_accessor("client", t, n, "PROPERTY_SETTER", m), + expr_ultimate_method("client", e, caller). +property_write_edge(e, caller, m) :- + expr_node("client", "ATTRIBUTE_ACCESS", _, n, e), + expr_name_context("client", "DEL", e), + expr_parent("client", e, "ATTRIBUTE_OBJECT", _, obj), + expr_type("client", obj, t), + type_property_accessor("client", t, n, "PROPERTY_DELETER", m), + expr_ultimate_method("client", e, caller). + // ── property_read_edge(ReadExprHash, CallerMethodHash, GetterMethodHash) ───── property_read_edge(e, caller, getter) :- expr_node("client", "ATTRIBUTE_ACCESS", _, n, e), @@ -693,6 +712,7 @@ subscript_protocol_edge(sub, caller, m) :- // from the graph rather than being re-tiered. A dropped edge is worse than a mislabelled // one, and the golden caught it. protocol_edge(e, d, caller, m) :- property_read_edge(e, caller, m), method_decl(_, d, _, _, _, m). +protocol_edge(e, d, caller, m) :- property_write_edge(e, caller, m), method_decl(_, d, _, _, _, m). protocol_edge(e, d, caller, m) :- with_protocol_edge(e, caller, m), method_decl(_, d, _, _, _, m). protocol_edge(e, d, caller, m) :- iter_protocol_edge(e, caller, m), method_decl(_, d, _, _, _, m). protocol_edge(e, d, caller, m) :- subscript_protocol_edge(e, caller, m), method_decl(_, d, _, _, _, m). @@ -756,6 +776,12 @@ call_chain_edge(e, caller, "-", getter, "client", cls, "PROPERTY_READ") :- property_read_edge(e, caller, getter), method_decl(_, d, _, _, _, getter), protocol_edge_class(e, d, cls). +// PROPERTY WRITE — `obj.x = v` runs x's setter and `del obj.x` its deleter. Likewise no +// call site, and its own kind so it is never counted as a written call. +call_chain_edge(e, caller, "-", m, "client", cls, "PROPERTY_WRITE") :- + property_write_edge(e, caller, m), method_decl(_, d, _, _, _, m), + protocol_edge_class(e, d, cls). + // CONTEXT MANAGER — the same shape: an edge with no call site, its own kind so it can // never be mistaken for a written call. call_chain_edge(cm, caller, "-", m, "client", cls, "CONTEXT_MANAGER") :- diff --git a/graph/python/engine/resolution/attribute-lookup.dl b/graph/python/engine/resolution/attribute-lookup.dl index 3c032d13..7b44fe2d 100644 --- a/graph/python/engine/resolution/attribute-lookup.dl +++ b/graph/python/engine/resolution/attribute-lookup.dl @@ -317,6 +317,15 @@ type_call_target(p, t, m) :- mro_lookup(p, t, "__call__", m). type_property_getter(p, t, n, m) :- mro_lookup(p, t, n, m), method_kind(p, "PROPERTY_GETTER", _, m). +// ── type_property_accessor(Prov, TypeHash, Name, Kind, MethodHash) ─────────── +// `@x.setter` and `@x.deleter` are the other two halves: `obj.x = v` runs the setter and +// `del obj.x` the deleter, and like the getter neither has a call site. They share the +// getter's name and binding, so they are found on the class that WINS the name in the +// receiver's MRO, by their method kind, rather than through mro_lookup's one answer. +type_property_accessor(p, t, n, k, m) :- + mro_winner(p, t, n, c), method_owner(p, c, m), method_decl(p, n, _, _, _, m), + method_kind(p, k, _, m), (k = "PROPERTY_SETTER" ; k = "PROPERTY_DELETER"). + // ── A USER-WRITTEN DATA DESCRIPTOR IS THE SAME PROTOCOL (issue #326) ───────── // `@property` IS a data descriptor; the decorator is sugar over `__get__`/`__set__`. So // the clause above reads one spelling of the protocol and a class-level diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index a1ab9506..4818adea 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -597,6 +597,8 @@ .decl decorator_hits_lib(c0:symbol,c1:symbol) .decl decorator_hits_builtin(c0:symbol,c1:symbol) .decl property_read_edge(c0:symbol,c1:symbol,c2:symbol) +.decl property_write_edge(c0:symbol,c1:symbol,c2:symbol) +.decl type_property_accessor(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) .decl with_protocol_edge(c0:symbol,c1:symbol,c2:symbol) .decl iteration_protocol_sync(c0:symbol) .decl iteration_protocol_of(c0:symbol,c1:symbol) diff --git a/graph/test/python/torture/client/f43_def_rebind.py b/graph/test/python/torture/client/f43_def_rebind.py index 107989af..3a454e7e 100644 --- a/graph/test/python/torture/client/f43_def_rebind.py +++ b/graph/test/python/torture/client/f43_def_rebind.py @@ -89,10 +89,10 @@ def call_branched() -> str: def read_property() -> int: """The getter survives — the control that keeps this rule off property pairs. - EXPECT: miss — `h.value = 5` invokes the SETTER, and the engine emits a - PROPERTY_READ edge for a property read and nothing at all for a property WRITE. - That is a pre-existing gap this fixture happens to expose, not something #383 - changed; the getter edge on the next line is what this family is asserting. + `h.value = 5` invokes the SETTER, and the engine now emits a PROPERTY_WRITE + edge for it beside the PROPERTY_READ for the read; both halves of the pair + are live, which is exactly what this control exists to keep true. The getter + edge on the next line is what this family is asserting. """ h = Holder() h.value = 5 diff --git a/graph/test/python/torture/expected/coverage.txt b/graph/test/python/torture/expected/coverage.txt index 06b88e32..daf7e130 100644 --- a/graph/test/python/torture/expected/coverage.txt +++ b/graph/test/python/torture/expected/coverage.txt @@ -1,4 +1,4 @@ -=== per-family coverage (tier-4, 471 scored sites) === +=== per-family coverage (tier-4, 474 scored sites) === f01 inheritance & MRO links= 10 found= 10 (100.0%) missed= 0 wide= 0 WRONG= 0 f02 callables & closures links= 10 found= 9 (90.0%) missed= 1 wide= 0 WRONG= 0 f03 generics links= 14 found= 14 (100.0%) missed= 0 wide= 0 WRONG= 0 @@ -38,16 +38,16 @@ f40 data descriptor links= 14 found= 14 (100.0%) missed= 0 wide= 0 WRONG= 0 f41 class attribute absent links= 3 found= 3 (100.0%) missed= 0 wide= 0 WRONG= 0 f42 f42 links= 6 found= 6 (100.0%) missed= 0 wide= 0 WRONG= 0 - f43 f43 links= 7 found= 7 (100.0%) missed= 0 wide= 1 WRONG= 0 + f43 f43 links= 10 found= 10 (100.0%) missed= 0 wide= 1 WRONG= 0 nestmod.py nestmod.py links= 1 found= 1 (100.0%) missed= 0 wide= 0 WRONG= 0 pkgmod relative imports (subpackage) links= 3 found= 3 (100.0%) missed= 0 wide= 0 WRONG= 0 - TOTAL links=434 found=428 (98.6%) missed= 6 wide=37 WRONG= 0 - recall 428/434 = 98.6% of the links that actually ran + TOTAL links=437 found=431 (98.6%) missed= 6 wide=37 WRONG= 0 + recall 431/437 = 98.6% of the links that actually ran wide 37 a member of a SOUND SET that did not run on this pass WRONG 0 a single target asserted as certain that never ran - EXPECTED-MISS cases: {'FOUND': 20, 'MISSED': 24} (a CONCRETE here means a known blind spot closed) + EXPECTED-MISS cases: {'FOUND': 18, 'MISSED': 23} (a CONCRETE here means a known blind spot closed) --- non-concrete sites --- f02_callables.py:31 MISSED true=[('lib', 'callables.py', 22)] engine=[] diff --git a/graph/test/python/torture/expected/torture.edges b/graph/test/python/torture/expected/torture.edges index 4d85cc1a..557d3208 100644 --- a/graph/test/python/torture/expected/torture.edges +++ b/graph/test/python/torture/expected/torture.edges @@ -635,6 +635,7 @@ known_edge PROPERTY_READ f40_data_descriptor.via_data_descriptor -> f40_data_des known_edge PROPERTY_READ f40_data_descriptor.via_delete_descriptor -> f40_data_descriptor.Deletable.__get__ known_edge PROPERTY_READ f40_data_descriptor.via_property -> f40_data_descriptor.Prop.slot known_edge PROPERTY_READ f43_def_rebind.read_property -> f43_def_rebind.Holder.value +known_edge PROPERTY_WRITE f43_def_rebind.read_property -> f43_def_rebind.Holder.value known_edge SELF_CALL f02_callables.HoldsCallables.run -> f02_callables.LocalCallable.__call__ known_edge SELF_CALL f28_await.Registry.into_a_field -> f28_await.Registry.build known_edge SELF_CALL f28_await.Registry.via_self_receiver -> f28_await.Registry.build diff --git a/graph/test/python/torture/expected/torture.oracle b/graph/test/python/torture/expected/torture.oracle index 95fd8d90..4267008a 100644 --- a/graph/test/python/torture/expected/torture.oracle +++ b/graph/test/python/torture/expected/torture.oracle @@ -1 +1 @@ -oracle=634 engine=624 agree=587 missing=47 extra=37 +oracle=634 engine=625 agree=588 missing=46 extra=37 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index f5283b76..a1284328 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -12,7 +12,7 @@ + COMPUTED_CALL IIFE_CALL DYNAMIC_IMPORT_CALL DYNAMIC_CODE_CALL TAGGED_TEMPLATE_CALL FUNCTION_CALL_APPLY FUNCTION_CALL_CALL FUNCTION_CALL_BIND python SIMPLE_CALL METHOD_CALL SELF_CALL SUPER_CALL CHAINED_CALL SUBSCRIPT_CALL CONTEXT_MANAGER - PROPERTY_READ METACLASS_CREATION DYNAMIC_CALL UNKNOWN_CALLEE_CALL DECORATOR_{APPLICATION,ATTRIBUTE,BARE,CALL} + PROPERTY_READ PROPERTY_WRITE METACLASS_CREATION DYNAMIC_CALL UNKNOWN_CALLEE_CALL DECORATOR_{APPLICATION,ATTRIBUTE,BARE,CALL} csharp + boundary_generated known_implicit_ctor known_builtin_operator ambiguous_dynamic fan_capped event_dispatch runtime_observed (only with a runtime trace) · new property_read property_write @@ -109,7 +109,7 @@ 'DECORATOR_APPLICATION': 'decorator', 'DECORATOR_ATTRIBUTE': 'decorator', 'DECORATOR_BARE': 'decorator', 'DECORATOR_CALL': 'decorator', # an accessor: written as a field, run as a method - 'property_read': 'property', 'property_write': 'property', 'PROPERTY_READ': 'property', + 'property_read': 'property', 'property_write': 'property', 'PROPERTY_READ': 'property', 'PROPERTY_WRITE': 'property', # the language runs it at a block boundary 'CONTEXT_MANAGER': 'with', # run-time code loading diff --git a/tests/cases/python/property-setter-is-a-call/case.json b/tests/cases/python/property-setter-is-a-call/case.json new file mode 100644 index 00000000..ef9c8fbb --- /dev/null +++ b/tests/cases/python/property-setter-is-a-call/case.json @@ -0,0 +1,16 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "assigning `obj.x = v` runs x's @x.setter: a test that assigns reaches a change to the setter, one that only reads does not", + "run": ["impact", "pkg/m.py:10", "--tests"], + "want": ["test_write.py"], + "avoid": ["test_read.py"]}, + {"why": "`del obj.x` runs x's @x.deleter", + "run": ["impact", "pkg/m.py:14", "--tests"], + "want": ["test_delete.py"], + "avoid": ["test_read.py"]}, + {"why": "a setter's own calls are reached through the write too", + "run": ["impact", "check", "--tests"], + "want": ["test_write.py"]}, + {"why": "CONTROL: the getter keeps its readers", + "run": ["impact", "pkg/m.py:6", "--tests"], + "want": ["test_read.py"]}]} diff --git a/tests/cases/python/property-setter-is-a-call/pkg/__init__.py b/tests/cases/python/property-setter-is-a-call/pkg/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/property-setter-is-a-call/pkg/m.py b/tests/cases/python/property-setter-is-a-call/pkg/m.py new file mode 100644 index 00000000..49ecf51e --- /dev/null +++ b/tests/cases/python/property-setter-is-a-call/pkg/m.py @@ -0,0 +1,31 @@ +class Box: + def __init__(self): + self._v = 0 + + @property + def value(self): + return self._v + + @value.setter + def value(self, v): + self._v = check(v) + + @value.deleter + def value(self): + self._v = None + + +def check(v): + return v + + +def fill(b: Box): + b.value = 3 + + +def clear(b: Box): + del b.value + + +def read(b: Box): + return b.value diff --git a/tests/cases/python/property-setter-is-a-call/tests/test_delete.py b/tests/cases/python/property-setter-is-a-call/tests/test_delete.py new file mode 100644 index 00000000..fd8cf303 --- /dev/null +++ b/tests/cases/python/property-setter-is-a-call/tests/test_delete.py @@ -0,0 +1,7 @@ +from pkg.m import Box + + +def test_delete(): + b = Box() + del b.value + assert b.value is None diff --git a/tests/cases/python/property-setter-is-a-call/tests/test_read.py b/tests/cases/python/property-setter-is-a-call/tests/test_read.py new file mode 100644 index 00000000..843cb79f --- /dev/null +++ b/tests/cases/python/property-setter-is-a-call/tests/test_read.py @@ -0,0 +1,5 @@ +from pkg.m import Box + + +def test_read_only(): + assert Box().value == 0 diff --git a/tests/cases/python/property-setter-is-a-call/tests/test_write.py b/tests/cases/python/property-setter-is-a-call/tests/test_write.py new file mode 100644 index 00000000..b88149b7 --- /dev/null +++ b/tests/cases/python/property-setter-is-a-call/tests/test_write.py @@ -0,0 +1,7 @@ +from pkg.m import Box + + +def test_assign(): + b = Box() + b.value = 4 + assert b.value == 4 From ba6d9ba435bc424a758325d4a1b5256894b7e2e2 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:44:53 -0700 Subject: [PATCH 07/16] fix(python): a local's Optional/union annotation types it, and every operand of a 3+ way union counts Two gaps in "Optional[X] IS X", the rule that types a value annotated Optional[X] or X | None: - It existed for parameters, returns and fields but not for a LOCAL variable's annotation, so `ctx: Optional[Context] = ...` and `x: Foo | None = ...` left every call on the local unresolved while the same annotation on a parameter resolved. - `|` is left-associative: `A | B | None` is `(A | B) | None`, so A and B sit two levels below the annotation, under a nested union, and the depth-1 element rule saw only that nested union (which names no type) and None. Every union of three or more operands lost its members, on parameters, returns, fields and locals alike. binding_declared_nominal gains the Optional/union clause; union_operand walks nested PEP 604 unions and feeds their operands to type_ref_element, resolved and by name. Containers are untouched (union_operand is rooted at an Optional/union kind only). Optional/union locals occur in 15 of 16 public repositories measured (96 written with `|`, 50 with Optional[]/Union[]). Ten held-in repositories, against the previous commit: path found 0.752 -> 0.756, impact source-caller recall 0.686 -> 0.687 (one repository 0.795 -> 0.807); the new callers are a union's dispatch set and are labelled `one of a set`, resolved-caller precision unchanged at 0.979. Engine suite 43/43, torture unchanged; query cases 325/325; case optional-and-union-annotations fails on the base engine on both the local and the three-operand checks. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/expression-resolution/expr-type.dl | 9 ++++ graph/python/engine/resolution/annotations.dl | 19 ++++++++ graph/python/souffle/decls_all.dl | 1 + .../optional-and-union-annotations/case.json | 12 +++++ .../pkg/__init__.py | 0 .../optional-and-union-annotations/pkg/m.py | 46 +++++++++++++++++++ 6 files changed, 87 insertions(+) create mode 100644 tests/cases/python/optional-and-union-annotations/case.json create mode 100644 tests/cases/python/optional-and-union-annotations/pkg/__init__.py create mode 100644 tests/cases/python/optional-and-union-annotations/pkg/m.py diff --git a/graph/python/engine/expression-resolution/expr-type.dl b/graph/python/engine/expression-resolution/expr-type.dl index d46b6eca..ed530619 100644 --- a/graph/python/engine/expression-resolution/expr-type.dl +++ b/graph/python/engine/expression-resolution/expr-type.dl @@ -585,6 +585,15 @@ binding_declared_nominal(p, bind, d) :- binding_declared_ref(p, bind, r), type_ref_resolved(p, d, r), !type_is_structural(p, d). +// `x: Optional[Order] = ...` / `x: Order | None = ...` IS an Order (or None) — the rule the +// parameter, return and field annotations already have (resolution/annotations.dl, +// "Optional[X] IS X"), which the local annotation alone was missing: a local written +// with the commonest modern spelling stayed untyped while the same annotation on a +// parameter resolved. +binding_declared_nominal(p, bind, t) :- + type_ref_owner(p, bind, "BINDING", r), + type_ref(p, k, "VARIABLE_ANNOTATION", _, _, r), annotation_optional_kind(k), + type_ref_element(p, r, t), !type_is_structural(p, t). // The FK is resolved on only 14 of 479 variable annotations, so the name-based lookup // beside it is what carries this -- the same two clauses the parameter path uses, with // the same kind restriction. A SUBSCRIPT or a UNION names no single type and is served by diff --git a/graph/python/engine/resolution/annotations.dl b/graph/python/engine/resolution/annotations.dl index 0d671aa9..2b9087a4 100644 --- a/graph/python/engine/resolution/annotations.dl +++ b/graph/python/engine/resolution/annotations.dl @@ -199,6 +199,25 @@ type_ref_element(p, parentRef, t) :- annotation_owner_module(p, owner, ok, mod), type_name_in_module(p, mod, tn, t). +// ── union_operand(Prov, UnionRefHash, OperandRefHash) — every operand of a union ── +// `|` is LEFT-associative, so `A | B | None` is `(A | B) | None`: A and B are not +// children of the annotation but grandchildren, under a nested UNION_PEP604, and the +// depth-1 clauses above saw only that nested union (which names no type) and None. Every +// rule reading "Optional[X] IS X" therefore lost the members of any union with three or +// more operands, on a parameter, a return, a field or a local alike. +union_operand(p, r, c) :- + type_ref(p, k, _, _, _, r), annotation_optional_kind(k), type_ref_nesting(p, r, _, _, c). +union_operand(p, r, c) :- + union_operand(p, r, u), type_ref(p, "UNION_PEP604", _, _, _, u), type_ref_nesting(p, u, _, _, c). +// the operands BELOW depth 1 (depth 1 is the clauses above), resolved, and by name +type_ref_element(p, r, t) :- + union_operand(p, r, c), type_ref_nesting(p, _, _, d, c), d != "1", type_ref_resolved(p, t, c). +type_ref_element(p, r, t) :- + union_operand(p, r, c), type_ref_nesting(p, _, _, d, c), d != "1", + type_ref(p, _, "GENERIC_ARGUMENT", tn, _, c), tn != "", + type_ref_owner(p, owner, ok, r), annotation_owner_module(p, owner, ok, mod), + type_name_in_module(p, mod, tn, t). + // ── annotation_owner_module(Prov, OwnerHash, OwnerKind, ModuleHash) ────────── // The module an annotation was written in, whatever kind of declaration owns it. annotation_owner_module(p, ph, "METHOD_PARAM", mod) :- diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index 4818adea..6f1fd864 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -238,6 +238,7 @@ .decl element_lib_type_of(c0:symbol,c1:symbol) .decl binding_element_lib_type(c0:symbol,c1:symbol) .decl param_declared_type_by_parser(c0:symbol,c1:symbol,c2:symbol) +.decl union_operand(c0:symbol,c1:symbol,c2:symbol) .decl type_ref_element(c0:symbol,c1:symbol,c2:symbol) .decl annotation_owner_module(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl annotation_container_kind(c0:symbol) diff --git a/tests/cases/python/optional-and-union-annotations/case.json b/tests/cases/python/optional-and-union-annotations/case.json new file mode 100644 index 00000000..5747f72b --- /dev/null +++ b/tests/cases/python/optional-and-union-annotations/case.json @@ -0,0 +1,12 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a local annotated `Optional[Foo]` is a Foo, as the same annotation on a parameter already was", + "run": ["impact", "Foo.go"], + "want": ["[resolved] l_optional"]}, + {"why": "a three-operand PEP 604 union nests left-associatively; every operand counts, on a parameter and on a local", + "run": ["impact", "Bar.go"], + "want": ["p_pipe3", "l_pipe"], + "avoid": ["[by name] p_pipe3", "[by name] l_pipe"]}, + {"why": "CONTROL: a two-operand union and a plain annotation resolve as before", + "run": ["impact", "Foo.go"], + "want": ["[resolved] p_pipe", "[resolved] p_plain"]}]} diff --git a/tests/cases/python/optional-and-union-annotations/pkg/__init__.py b/tests/cases/python/optional-and-union-annotations/pkg/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/optional-and-union-annotations/pkg/m.py b/tests/cases/python/optional-and-union-annotations/pkg/m.py new file mode 100644 index 00000000..71acab0a --- /dev/null +++ b/tests/cases/python/optional-and-union-annotations/pkg/m.py @@ -0,0 +1,46 @@ +import typing as t +from typing import Optional, Union + + +class Foo: + def go(self): + return 1 + + +class Bar: + def go(self): + return 2 + + +def p_plain(x: Foo): + return x.go() + + +def p_optional(x: Optional[Foo]): + return x.go() + + +def p_t_optional(x: t.Optional[Foo]): + return x.go() + + +def p_pipe(x: Foo | None): + return x.go() + + +def p_union(x: Union[Foo, Bar]): + return x.go() + + +def l_optional(y): + z: Optional[Foo] = y + return z.go() + + +def l_pipe(y): + z: Foo | Bar | None = y + return z.go() + + +def p_pipe3(x: Foo | Bar | None): + return x.go() From 78eaedb0e1ba506f30d68e146a23aa8368eb93c5 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:14:07 -0700 Subject: [PATCH 08/16] fix(impact): a src-layout module is imported by its package name, and a test file imports the conftests above it The import facts named a Python module only by its path from the repository root, so in a src layout `src/app/core.py` was `src.app.core` and no `import app.core` matched it: a src-layout repository had almost no test-to-source imports, which the import walk (a function that runs while a module is imported breaks every file that imports it) and the loads-the-change filter both read. A module is now also named from its package root, the first directory above it that is not a package. pytest imports every conftest.py from the rootdir down to a test file's directory before the file, so what a conftest imports is imported for each test file beneath it; a test file now imports those conftests. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-impact | 26 ++++++++++++++++--- .../src-layout-conftest-import-time/case.json | 11 ++++++++ .../pyproject.toml | 6 +++++ .../src/app/__init__.py | 1 + .../src/app/registry.py | 25 ++++++++++++++++++ .../tests/conftest.py | 8 ++++++ .../tests/test_lookup.py | 5 ++++ .../tests/test_plain.py | 2 ++ 8 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 tests/cases/python/src-layout-conftest-import-time/case.json create mode 100644 tests/cases/python/src-layout-conftest-import-time/pyproject.toml create mode 100644 tests/cases/python/src-layout-conftest-import-time/src/app/__init__.py create mode 100644 tests/cases/python/src-layout-conftest-import-time/src/app/registry.py create mode 100644 tests/cases/python/src-layout-conftest-import-time/tests/conftest.py create mode 100644 tests/cases/python/src-layout-conftest-import-time/tests/test_lookup.py create mode 100644 tests/cases/python/src-layout-conftest-import-time/tests/test_plain.py diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index d840e495..75338e6d 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -806,13 +806,31 @@ class Impact: byfile = set(files) # a module path as each language spells it -> the file it names. Both spellings of a Python package are # kept (app/settings.py and app/settings/__init__.py), because the import statement cannot tell them apart. + # A module is also named from its package root, the first directory above it that is not itself a package: + # in a src layout `src/app/core.py` is imported as `app.core`, and the repository-relative spelling alone + # matches no import of it. mod = {} for f in files: stem = os.path.splitext(f)[0] - mod.setdefault(stem.replace('/', '.'), f) - if stem.endswith('/__init__'): - mod.setdefault(stem[:-len('/__init__')].replace('/', '.'), f) + names = [stem] + if f.endswith(('.py', '.pyi')): + parts = stem.split('/'); k = len(parts) - 1 + while k > 0 and '/'.join(parts[:k]) + '/__init__.py' in byfile: k -= 1 + if k > 0: names.append('/'.join(parts[k:])) + for n in names: + mod.setdefault(n.replace('/', '.'), f) + if n.endswith('/__init__'): + mod.setdefault(n[:-len('/__init__')].replace('/', '.'), f) out = set() + # pytest imports every conftest.py from the rootdir down to a test file's directory before the file itself, + # so whatever a conftest imports is imported for each test file beneath it + conftests = [f for f in files if f.rsplit('/', 1)[-1] == 'conftest.py'] + for f in files: + b = f.rsplit('/', 1)[-1] + if not (b.endswith('.py') and (b.startswith('test_') or b.endswith('_test.py'))): continue + for c in conftests: + cd = os.path.dirname(c) + if not cd or f.startswith(cd + '/'): out.add((f, c)) for f in files: ext = os.path.splitext(f)[1].lower() if not ext: # a python shebang script (#1376) @@ -1321,7 +1339,7 @@ class Impact: W('cs_fixture_type', sorted(x for x in fixt if not x[0].startswith('collection:'))) # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '65' # 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '66' # 66: imports_fact names a module from its package root (a src layout) and has each test file import the conftest.py files above it; 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span diff --git a/tests/cases/python/src-layout-conftest-import-time/case.json b/tests/cases/python/src-layout-conftest-import-time/case.json new file mode 100644 index 00000000..2efeb4d3 --- /dev/null +++ b/tests/cases/python/src-layout-conftest-import-time/case.json @@ -0,0 +1,11 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a src-layout package is imported by its package name: `from app import lookup` in a test is an import of src/app/__init__.py, so the test file reaches what that import runs", + "run": ["impact", "src/app/registry.py:4", "--tests"], + "want": ["test_lookup.py"]}, + {"why": "pytest imports tests/conftest.py before every test file under it, so a function that runs while the package is imported (normalise, through the module-level @register) breaks a test file that never imports the package itself", + "run": ["impact", "src/app/registry.py:4", "--tests"], + "want": ["test_plain.py"]}, + {"why": "CONTROL: describe runs at no import, so a test file that does not call it is not reached by the import walk", + "run": ["impact", "src/app/registry.py:19", "--tests"], + "avoid": ["test_plain.py"]}]} diff --git a/tests/cases/python/src-layout-conftest-import-time/pyproject.toml b/tests/cases/python/src-layout-conftest-import-time/pyproject.toml new file mode 100644 index 00000000..6d1bd6e7 --- /dev/null +++ b/tests/cases/python/src-layout-conftest-import-time/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "app" +version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = "tests" diff --git a/tests/cases/python/src-layout-conftest-import-time/src/app/__init__.py b/tests/cases/python/src-layout-conftest-import-time/src/app/__init__.py new file mode 100644 index 00000000..3df798e6 --- /dev/null +++ b/tests/cases/python/src-layout-conftest-import-time/src/app/__init__.py @@ -0,0 +1 @@ +from .registry import register, lookup diff --git a/tests/cases/python/src-layout-conftest-import-time/src/app/registry.py b/tests/cases/python/src-layout-conftest-import-time/src/app/registry.py new file mode 100644 index 00000000..43ce0e0a --- /dev/null +++ b/tests/cases/python/src-layout-conftest-import-time/src/app/registry.py @@ -0,0 +1,25 @@ +_TABLE = {} + + +def normalise(name): + return name.strip().lower() + + +def register(name): + def deco(fn): + _TABLE[normalise(name)] = fn + return fn + return deco + + +def lookup(name): + return _TABLE[normalise(name)] + + +def describe(name): + return f"handler {normalise(name)}" + + +@register("Default") +def default_handler(): + return "default" diff --git a/tests/cases/python/src-layout-conftest-import-time/tests/conftest.py b/tests/cases/python/src-layout-conftest-import-time/tests/conftest.py new file mode 100644 index 00000000..aa144041 --- /dev/null +++ b/tests/cases/python/src-layout-conftest-import-time/tests/conftest.py @@ -0,0 +1,8 @@ +import pytest + +import app + + +@pytest.fixture +def table(): + return app diff --git a/tests/cases/python/src-layout-conftest-import-time/tests/test_lookup.py b/tests/cases/python/src-layout-conftest-import-time/tests/test_lookup.py new file mode 100644 index 00000000..539bd9a1 --- /dev/null +++ b/tests/cases/python/src-layout-conftest-import-time/tests/test_lookup.py @@ -0,0 +1,5 @@ +from app import lookup + + +def test_lookup(): + assert lookup("default")() == "default" diff --git a/tests/cases/python/src-layout-conftest-import-time/tests/test_plain.py b/tests/cases/python/src-layout-conftest-import-time/tests/test_plain.py new file mode 100644 index 00000000..2733328f --- /dev/null +++ b/tests/cases/python/src-layout-conftest-import-time/tests/test_plain.py @@ -0,0 +1,2 @@ +def test_arithmetic(): + assert 1 + 1 == 2 From 5d153ad342a203228cbdfbef33bdaa999e4d65c1 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:00:35 -0700 Subject: [PATCH 09/16] fix(parser/python): an import of a module shipped with a .pyi beside it resolves to the .py The project linker keys modules by qualified name, and a package that ships `impl.pyi` beside `impl.py` declares that name twice. The later file won the map, and `.pyi` sorts after `.py`, so `from .impl import *` and `import pkg` resolved to the stub. The engine refuses a stub declaration as an edge target, so every call into such a package ended at the library boundary and its tests reached nothing in it. A stub no longer replaces a non-stub module of the same name. A stub with no `.py` beside it is still the import's target. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../parsers/python/extractors/python-resolution-linker.ts | 8 ++++++++ parser/src/workflows/python/python-project-analyzer.ts | 1 + .../stub-beside-source-resolves-to-source/case.json | 8 ++++++++ .../stub-beside-source-resolves-to-source/pkg/__init__.py | 1 + .../pkg/__init__.pyi | 1 + .../stub-beside-source-resolves-to-source/pkg/impl.py | 5 +++++ .../stub-beside-source-resolves-to-source/pkg/impl.pyi | 1 + .../stub-beside-source-resolves-to-source/pyproject.toml | 3 +++ .../tests/__init__.py | 0 .../tests/test_chunk.py | 5 +++++ 10 files changed, 33 insertions(+) create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/case.json create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.py create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.pyi create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.py create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.pyi create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/pyproject.toml create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/tests/__init__.py create mode 100644 tests/cases/python/stub-beside-source-resolves-to-source/tests/test_chunk.py diff --git a/parser/src/parsers/python/extractors/python-resolution-linker.ts b/parser/src/parsers/python/extractors/python-resolution-linker.ts index 0d5c5485..71d448e5 100644 --- a/parser/src/parsers/python/extractors/python-resolution-linker.ts +++ b/parser/src/parsers/python/extractors/python-resolution-linker.ts @@ -43,6 +43,12 @@ export interface ProjectModuleFacts extends ResolutionInput { * re-export fails to resolve. */ isPackage?: boolean; + /** + * Whether this module is a `.pyi` stub. A stub shipped beside its `.py` carries the + * same qualified name, and an import must reach the `.py`: that is the module the + * interpreter loads. A stub with no `.py` beside it stays the import's target. + */ + isStub?: boolean; } export interface ProjectResolutionStats { @@ -189,6 +195,8 @@ export class PythonResolutionLinker { const moduleByQualifiedName = new Map(); for (const module of modules) { + const seen = moduleByQualifiedName.get(module.qualifiedName); + if (seen !== undefined && module.isStub && !seen.isStub) continue; moduleByQualifiedName.set(module.qualifiedName, module); } diff --git a/parser/src/workflows/python/python-project-analyzer.ts b/parser/src/workflows/python/python-project-analyzer.ts index 0c07497a..0ec56d2b 100644 --- a/parser/src/workflows/python/python-project-analyzer.ts +++ b/parser/src/workflows/python/python-project-analyzer.ts @@ -320,6 +320,7 @@ export class PythonProjectAnalyzer { qualifiedName: facts.module.getQualifiedName(), moduleHash: facts.module.getHash(), isPackage: isPythonPackageInitFileName(path.basename(filePath)), + isStub: filePath.endsWith('.pyi'), scopes: facts.scopes, bindings: facts.bindings, types: facts.types, diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/case.json b/tests/cases/python/stub-beside-source-resolves-to-source/case.json new file mode 100644 index 00000000..97a74a7f --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/case.json @@ -0,0 +1,8 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a package shipping .pyi stubs beside its .py modules: a call that resolves to the stub's declaration reaches the .py implementation of the same qualified name, the one CPython runs", + "run": ["impact", "pkg/impl.py:4", "--tests"], + "want": ["test_chunk.py"]}, + {"why": "CONTROL: the stub itself is never an edge target", + "run": ["impact", "pkg/impl.pyi:1", "--tests"], + "avoid": ["[resolved] test_chunk"]}]} diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.py b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.py new file mode 100644 index 00000000..66c63213 --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.py @@ -0,0 +1 @@ +from .impl import * # noqa diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.pyi b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.pyi new file mode 100644 index 00000000..475cf4ea --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.pyi @@ -0,0 +1 @@ +from .impl import * diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.py b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.py new file mode 100644 index 00000000..79628415 --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.py @@ -0,0 +1,5 @@ +__all__ = ["chunk"] + + +def chunk(xs, n): + return [xs[i:i + n] for i in range(0, len(xs), n)] diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.pyi b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.pyi new file mode 100644 index 00000000..3f70ad89 --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.pyi @@ -0,0 +1 @@ +def chunk(xs: list, n: int) -> list: ... diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pyproject.toml b/tests/cases/python/stub-beside-source-resolves-to-source/pyproject.toml new file mode 100644 index 00000000..c311e64b --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "pkg" +version = "0" diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/tests/__init__.py b/tests/cases/python/stub-beside-source-resolves-to-source/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/tests/test_chunk.py b/tests/cases/python/stub-beside-source-resolves-to-source/tests/test_chunk.py new file mode 100644 index 00000000..c459b14b --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/tests/test_chunk.py @@ -0,0 +1,5 @@ +import pkg as p + + +def test_chunk(): + assert p.chunk([1, 2, 3], 2) == [[1, 2], [3]] From ab1e09be387cf24e7698281319eb08a82c5a25e3 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:03:03 -0700 Subject: [PATCH 10/16] fix(impact): a test that spawns `python -m pkg` runs pkg/__main__.py The spawn link (a test that starts a process whose arguments name an indexed file) only read PATHS. `subprocess.run([sys.executable, "-m", "pkg", ...])` names a MODULE: Python runs pkg/__main__.py (or pkg/mod.py for `-m pkg.mod`) as __main__, so everything the package's entry reaches was untested from that test's point of view, and a CLI suite that drives its tool this way selected nothing for an edit to the tool's internals. ax_spawn now resolves the name after each `-m` like an import: from the repository root or a source root (a directory that is no package and no test directory), package __main__ first, and only to a file this graph indexed. `-m coverage run -m pkg` names two modules; coverage is not indexed and links nothing. A string that only mentions `-m pkg` starts no process and stays unlinked. IMPACT_VERSION 66 -> 67 (spawns_fact changed). Measured with the mutation oracle on sixteen public repositories: the one whose CLI tests spawn `-m ` moves test-selection recall 0.422 -> 0.468 (+175 failing test files selected, none lost, 121 added files that do not fail); a second repository's `python -m ` spawn links but its targets sit behind a protocol call, so it does not move; no other repository spawns `-m`. Case test-runs-a-module-with-dash-m fails 3 of 4 checks on the base. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/ax_spawn.py | 34 +++++++++- .../skills/axiomcode/scripts/axiomcode-impact | 2 +- .../test-runs-a-module-with-dash-m/case.json | 68 +++++++++++++++++++ .../pkg/__init__.py | 0 .../pkg/__main__.py | 3 + .../test-runs-a-module-with-dash-m/pkg/cli.py | 5 ++ .../pkg/core.py | 6 ++ .../pkg/tool.py | 6 ++ .../tests/test_docs.py | 5 ++ .../tests/test_entry_points.py | 8 +++ .../tests/test_package_main.py | 7 ++ .../tests/test_tally.py | 5 ++ 12 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 tests/cases/python/test-runs-a-module-with-dash-m/case.json create mode 100644 tests/cases/python/test-runs-a-module-with-dash-m/pkg/__init__.py create mode 100644 tests/cases/python/test-runs-a-module-with-dash-m/pkg/__main__.py create mode 100644 tests/cases/python/test-runs-a-module-with-dash-m/pkg/cli.py create mode 100644 tests/cases/python/test-runs-a-module-with-dash-m/pkg/core.py create mode 100644 tests/cases/python/test-runs-a-module-with-dash-m/pkg/tool.py create mode 100644 tests/cases/python/test-runs-a-module-with-dash-m/tests/test_docs.py create mode 100644 tests/cases/python/test-runs-a-module-with-dash-m/tests/test_entry_points.py create mode 100644 tests/cases/python/test-runs-a-module-with-dash-m/tests/test_package_main.py create mode 100644 tests/cases/python/test-runs-a-module-with-dash-m/tests/test_tally.py diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_spawn.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_spawn.py index f26ed10c..f15e6c4a 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_spawn.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_spawn.py @@ -10,6 +10,10 @@ starts no process and is not linked; a path naming a file of another language is in no graph of this language and is not linked either (a spawn across languages is not built here). +A Python spawn can name a MODULE instead of a path: `[sys.executable, "-m", "pkg"]` runs pkg/__main__.py, and +`-m pkg.tool` runs pkg/tool.py, as __main__ either way. The name after `-m` is resolved like an import, from the +repository root or a source root, and only to a file this graph indexed (`-m pytest`, `-m coverage` name none). + Both backends read this module (dl/impact.dl through the `spawns_fact` input, graph_sql through its edge list), so they cannot drift apart on what counts as a spawn. """ @@ -119,6 +123,32 @@ def _resolve(lits, f, known): return out +MODULE = re.compile(r'[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*') +TEST_DIR = {'test', 'tests', 'testing'} + + +def _resolve_modules(lits, known): + """the indexed files `python -m pkg.mod` runs: pkg/mod/__main__.py for a package, else pkg/mod.py. + + The name is a MODULE, found on the import path, so it is matched from the repository root or from a source root + (`src/`): a directory that is no package itself and no test directory. `-m coverage run -m typer` names two + modules; the one this graph indexed is the one linked, and one it did not index (coverage, pytest) names none.""" + out = set() + for i in range(len(lits) - 1): + if lits[i] != '-m' or not MODULE.fullmatch(lits[i + 1]): continue + rel = lits[i + 1].replace('.', '/') + for cand in (rel + '/__main__.py', rel + '.py'): + hits = [] + for p in known: + if p != cand and not p.endswith('/' + cand): continue + root = p[:len(p) - len(cand)].rstrip('/') + if root and (root + '/__init__.py' in known or TEST_DIR & set(root.split('/'))): continue + hits.append((len(root), p)) + if hits: + out.add(min(hits)[1]); break + return out + + def links(test_files, mod_of, lines, at): """-> sorted [(caller, script_module, file, line)]: in a test file, a spawn whose arguments name an indexed script. @@ -140,7 +170,9 @@ def links(test_files, mod_of, lines, at): and text[max(0, m.start() - 40):m.start()].rstrip()[-1:] != '.'] for end in sorted(set(sites)): arg = _balanced(text, end) - targets = _resolve(_literals(text, arg), f, known) + lits = _literals(text, arg) + targets = _resolve(lits, f, known) + if fam == 'py': targets |= _resolve_modules(lits, known) if not targets: continue line = text.count('\n', 0, end) + 1 c = at(f, line) or mod_of.get(f) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 75338e6d..eee28309 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1339,7 +1339,7 @@ class Impact: W('cs_fixture_type', sorted(x for x in fixt if not x[0].startswith('collection:'))) # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '66' # 66: imports_fact names a module from its package root (a src layout) and has each test file import the conftest.py files above it; 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '67' # 67: spawns_fact also links the module a `python -m pkg.mod` spawn runs; 66: imports_fact names a module from its package root (a src layout) and has each test file import the conftest.py files above it; 65: guard_only also reads a conjunctive guard (`cond and __name__ == "__main__"`); 64: guard_only, a module body's call made only under `if __name__ == "__main__":`, which the import walk does not take; 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/case.json b/tests/cases/python/test-runs-a-module-with-dash-m/case.json new file mode 100644 index 00000000..01e3c4ec --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/case.json @@ -0,0 +1,68 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "`python -m pkg` runs pkg/__main__.py: a test that spawns it covers what the package's main reaches, though nothing imports pkg.cli", + "run": [ + "impact", + "render", + "--tests" + ], + "want": [ + "test_package_main.py", + "spawns" + ], + "avoid": [ + "test_docs.py", + "test_tally.py" + ] + }, + { + "why": "`-m coverage run -m pkg.tool` names two modules: coverage is not in this graph, pkg.tool is pkg/tool.py, run as __main__ so its main guard is taken", + "run": [ + "impact", + "go", + "--tests" + ], + "want": [ + "test_entry_points.py", + "spawns" + ], + "avoid": [ + "test_docs.py", + "test_package_main.py" + ] + }, + { + "why": "CONTROL: a string that only mentions `-m pkg` starts no process and links nothing; a function the module does not reach keeps exactly the test that calls it", + "run": [ + "impact", + "tally", + "--tests" + ], + "want": [ + "test_tally.py" + ], + "avoid": [ + "test_package_main.py", + "test_docs.py", + "spawns" + ] + }, + { + "why": "test-impact for an edit to a module the package main reaches selects the spawning test and not the one that only names the module", + "run": [ + "test-impact", + "{repo}", + "pkg/cli.py" + ], + "want": [ + "tests/test_package_main.py" + ], + "avoid": [ + "tests/test_docs.py" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/pkg/__init__.py b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/pkg/__main__.py b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/__main__.py new file mode 100644 index 00000000..4e28416e --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +main() diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/pkg/cli.py b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/cli.py new file mode 100644 index 00000000..9bcd4f9d --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/cli.py @@ -0,0 +1,5 @@ +from .core import render + + +def main(): + print(render("cli")) diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/pkg/core.py b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/core.py new file mode 100644 index 00000000..73d21203 --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/core.py @@ -0,0 +1,6 @@ +def render(name): + return f"<{name}>" + + +def tally(xs): + return len(xs) diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/pkg/tool.py b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/tool.py new file mode 100644 index 00000000..ac2490b2 --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/pkg/tool.py @@ -0,0 +1,6 @@ +def go(): + return "go" + + +if __name__ == "__main__": + print(go()) diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_docs.py b/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_docs.py new file mode 100644 index 00000000..ac86725e --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_docs.py @@ -0,0 +1,5 @@ +USAGE = "run it with: python -m pkg" + + +def test_usage_mentions_the_module(): + assert "-m pkg" in USAGE diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_entry_points.py b/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_entry_points.py new file mode 100644 index 00000000..dcd4991c --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_entry_points.py @@ -0,0 +1,8 @@ +import subprocess +import sys + +CMD = [sys.executable, "-m", "coverage", "run", "-m", "pkg.tool"] + + +def test_tool_runs(): + assert subprocess.run(CMD, capture_output=True).returncode == 0 diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_package_main.py b/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_package_main.py new file mode 100644 index 00000000..0654d644 --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_package_main.py @@ -0,0 +1,7 @@ +import subprocess +import sys + + +def test_module_runs(): + out = subprocess.run([sys.executable, "-m", "pkg", "--help"], capture_output=True, text=True).stdout + assert out diff --git a/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_tally.py b/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_tally.py new file mode 100644 index 00000000..4f0bf0c8 --- /dev/null +++ b/tests/cases/python/test-runs-a-module-with-dash-m/tests/test_tally.py @@ -0,0 +1,5 @@ +from pkg.core import tally + + +def test_tally(): + assert tally([1, 2]) == 2 From d202458e03ea4a5d6df116aebbd5ad2f7008ae1a Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:10:59 -0700 Subject: [PATCH 11/16] fix(python): getattr(self, f"visit_{...}") dispatches to the visit_* members of self's constructed subclasses Visitor and parser dispatch by a computed name -- `return getattr(self, f"visit_{type(node).__name__}", None)` in a base class, then `f = self.get_visitor(node); f(node)` -- had no edges at all, so every visit_* body was cut off from whoever drives the visitor. Three gaps stacked: - the parser gave an f-string an empty literal value. It now carries the f-string's constant lead (the text before its first `{`), which every string it can produce starts with; nothing else reads an FSTRING literal. - the existing prefix rule (`getattr(obj, "as_" + x)`) read only a `+` concatenation and only obj's own MRO. It now also reads an f-string lead, and on `self` it widens over the CONSTRUCTED subclasses, the same RTA bound self.m() has; a decorated member resolves to the wrapper its decorator returned. - a method returning such a lookup hands the set back (method_returns_method), as it would a bare name. The set is the program's own dispatch table, so the dispatch cap no longer refuses it (call_computed_name_family): a visitor with sixty members is sixty targets by construction, and the cap had dropped the whole site. Mutation oracle, sixteen public repositories (ten tuning, six held out): the visitor-heavy tuning repository moves test-selection recall 0.474 -> 0.878 (+169 failing test files selected, none lost; 203 added files do not fail, the same 0.45 precision as its base); one held-out repository +2; every other repository unchanged. Case getattr-fstring-prefix-dispatch fails 3 of 4 checks on the base (2 of 4 with the f-string rule but without the cap exemption). value-callee-is-unknown's computed-name check moves to a name with no written part (`getattr(self, event)`), which stays an unknown: the f-string form it used now resolves, as its `+` control already did. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/call-edge-generation/call_chain.dl | 9 ++ graph/python/engine/resolution/dispatch.dl | 26 ++++- .../engine/resolution/name-resolution.dl | 27 +++++- graph/python/souffle/decls_all.dl | 5 + .../extractors/python-expression-extractor.ts | 41 +++++++- .../app/__init__.py | 0 .../app/compiler.py | 97 +++++++++++++++++++ .../app/nodes.py | 6 ++ .../app/parser.py | 14 +++ .../app/visitor.py | 22 +++++ .../getattr-fstring-prefix-dispatch/case.json | 60 ++++++++++++ .../tests/test_generate.py | 6 ++ .../tests/test_leave.py | 6 ++ .../tests/test_parse.py | 5 + .../python/value-callee-is-unknown/case.json | 2 +- .../value-callee-is-unknown/src/handlers.py | 2 +- 16 files changed, 322 insertions(+), 6 deletions(-) create mode 100644 tests/cases/python/getattr-fstring-prefix-dispatch/app/__init__.py create mode 100644 tests/cases/python/getattr-fstring-prefix-dispatch/app/compiler.py create mode 100644 tests/cases/python/getattr-fstring-prefix-dispatch/app/nodes.py create mode 100644 tests/cases/python/getattr-fstring-prefix-dispatch/app/parser.py create mode 100644 tests/cases/python/getattr-fstring-prefix-dispatch/app/visitor.py create mode 100644 tests/cases/python/getattr-fstring-prefix-dispatch/case.json create mode 100644 tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_generate.py create mode 100644 tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_leave.py create mode 100644 tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_parse.py diff --git a/graph/python/engine/call-edge-generation/call_chain.dl b/graph/python/engine/call-edge-generation/call_chain.dl index 6c35b6cf..ac802b6a 100644 --- a/graph/python/engine/call-edge-generation/call_chain.dl +++ b/graph/python/engine/call-edge-generation/call_chain.dl @@ -156,6 +156,15 @@ method_returns_method("client", m, r) :- expr_call_candidate(site, g), method_returns_method("client", g, r). +// A function returning a member it LOOKED UP BY NAME: `def get_visitor(self, node): return +// getattr(self, f"visit_{type(node).__name__}", None)`, called as `f = self.get_visitor(node); +// f(node)`. The lookup is resolved where it is written (name-resolution.dl's getattr clauses), +// and the method hands that set back exactly as it would hand back a bare name. +method_returns_method("client", m, r) :- + method_return_value_expr("client", m, e), + call_of_expr(e, site), call_name(site, "getattr"), + expr_denotes_method("client", e, r). + // ── a call that hands back one of its arguments (py_returns_arg, resolution/builtins.dl) ── // Matched through the import that binds the callee, never by the bare name: // `functools.update_wrapper(...)` / `t.cast(...)` where the receiver is the name an `import` diff --git a/graph/python/engine/resolution/dispatch.dl b/graph/python/engine/resolution/dispatch.dl index f1f50d21..ed56e8cc 100644 --- a/graph/python/engine/resolution/dispatch.dl +++ b/graph/python/engine/resolution/dispatch.dl @@ -142,7 +142,31 @@ call_candidate_count(site, n) :- // ── call_dispatch_over_cap(SiteHash) ───────────────────────────────────────── call_dispatch_over_cap(site) :- - call_candidate_count(site, n), dispatch_capped_at(cap), n > cap. + call_candidate_count(site, n), dispatch_capped_at(cap), n > cap, + !call_computed_name_family(site). + +// ── call_computed_name_family(SiteHash) — a fan the PROGRAM wrote down ─────── +// The cap guards against a receiver type inferred too wide, where most of the fan is the +// engine's imprecision. A callee looked up by a name with a written prefix -- +// `getattr(self, f"visit_{...}")`, directly, through the local it was stored in, or +// through a method that returns it -- is the program's own dispatch table: every member +// named with the prefix is a target by construction, and a visitor with sixty visit_* +// methods is sixty targets, not sixty guesses. Refusing it dropped the visitor's whole +// body from every test that drives it. +method_returns_computed_name(m) :- + method_return_value_expr("client", m, e), getattr_computed_name(e, _, _). +computed_name_value(e) :- getattr_computed_name(e, _, _). +computed_name_value(e) :- + call_of_expr(e, s), expr_call_candidate(s, m), method_returns_computed_name(m). +call_computed_name_family(site) :- + call_callee_is_value(site), call_callee_expr(site, callee), computed_name_value(callee). +call_computed_name_family(site) :- + call_callee_is_value(site), call_callee_expr(site, callee), + expr_binding("client", b, ctx, callee), ctx != "STORE", + binding_lookup("client", b, b2), + expr_binding("client", b2, "STORE", tgt), + assign_pair("client", tgt, val), + computed_name_value(val). // ── expr_resolves_to_method(SiteHash, MethodHash) ──────────────────────────── // THE DISAMBIGUATED CALLEE SET the edge layer consumes. Same relation name as Java's, diff --git a/graph/python/engine/resolution/name-resolution.dl b/graph/python/engine/resolution/name-resolution.dl index 30888484..b9345eb1 100644 --- a/graph/python/engine/resolution/name-resolution.dl +++ b/graph/python/engine/resolution/name-resolution.dl @@ -1008,18 +1008,41 @@ computed_name_prefix(nameArg, pre) :- expr_operator("client", op, nameArg), py_str_concat_operator(op), expr_parent("client", nameArg, "OPERAND_LEFT", _, l), expr_node("client", "LITERAL", _, pre, l), pre != "". +// The same prefix written as an f-string, `getattr(self, f"visit_{type(node).__name__}")`: +// the parser hands over an f-string's constant lead (the text before its first `{`) as its +// literal value, and every string the f-string can produce starts with it. +computed_name_prefix(nameArg, pre) :- + expr_node("client", "FSTRING", _, pre, nameArg), pre != "". getattr_computed_name(e, obj, pre) :- call_of_expr(e, site), call_name(site, "getattr"), call_arg(site, "0", obj), call_arg(site, "1", nameArg), computed_name_prefix(nameArg, pre). -expr_denotes_method("client", e, m) :- +// The receiver's type, and where the receiver is `self`, every CONSTRUCTED subclass of it too: +// a visitor base names `visit_*` members it does not define itself, and self is whichever +// subclass was built -- the same self-dispatch bound (RTA) dispatch.dl puts on `self.m()`. +getattr_computed_type(e, pre, t) :- getattr_computed_name(e, obj, pre), - expr_type("client", obj, t), + expr_type("client", obj, t). +getattr_computed_type(e, pre, s) :- + getattr_computed_name(e, obj, pre), + expr_node("client", "SELF_REFERENCE", _, _, obj), + expr_type("client", obj, k), + type_subclass("client", k, s), + type_instantiated_reachable("client", s). +getattr_computed_member(e, m) :- + getattr_computed_type(e, pre, t), mro_lookup("client", t, n, m), strlen(n) > strlen(pre), substr(n, 0, strlen(pre)) = pre. +// a member a decorator replaced is the callable the decorator returned (a wrapper) +getattr_computed_member(e, m) :- + getattr_computed_type(e, pre, t), + mro_lookup_decorated("client", t, n, m), + strlen(n) > strlen(pre), + substr(n, 0, strlen(pre)) = pre. +expr_denotes_method("client", e, m) :- getattr_computed_member(e, m). // ── getattr(obj, name, default) IS AT LEAST the default ────────────────────── // h = getattr(record, "highlighter", self.highlighter) diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index 6f1fd864..44d353f5 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -534,6 +534,8 @@ .decl expr_operator(c0:symbol,c1:symbol,c2:symbol) .decl local_holds_untyped_return(c0:symbol,c1:symbol) .decl getattr_computed_name(c0:symbol,c1:symbol,c2:symbol) +.decl getattr_computed_type(c0:symbol,c1:symbol,c2:symbol) +.decl getattr_computed_member(c0:symbol,c1:symbol) .decl method_is_async(c0:symbol,c1:symbol) .decl await_operand(c0:symbol,c1:symbol,c2:symbol) .decl subscript_index_is_slice(c0:symbol,c1:symbol) @@ -668,3 +670,6 @@ .decl binding_declared_call_protocol(c0:symbol,c1:symbol,c2:symbol) .decl binding_assigned_lambda(c0:symbol,c1:symbol,c2:symbol) .decl instance_attr_method_value(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl method_returns_computed_name(c0:symbol) +.decl computed_name_value(c0:symbol) +.decl call_computed_name_family(c0:symbol) diff --git a/parser/src/parsers/python/extractors/python-expression-extractor.ts b/parser/src/parsers/python/extractors/python-expression-extractor.ts index d6a8763d..3db5d622 100644 --- a/parser/src/parsers/python/extractors/python-expression-extractor.ts +++ b/parser/src/parsers/python/extractors/python-expression-extractor.ts @@ -1580,7 +1580,11 @@ export class PythonExpressionExtractor { return; } - case PythonExpressionKind.FSTRING: + case PythonExpressionKind.FSTRING: { + builder.withLiteral(PythonLiteralType.FSTRING, this.fstringPrefixOf(node)); + return; + } + case PythonExpressionKind.FSTRING_INTERPOLATION: { builder.withLiteral(PythonLiteralType.FSTRING, ''); return; @@ -3077,6 +3081,41 @@ export class PythonExpressionExtractor { return EntityUtils.normalizeWhitespace(parts.join('')); } + /** + * An f-string's CONSTANT LEAD: the text written before its first `{...}`. + * `f"visit_{type(node).__name__}"` evaluates to some string starting with + * `visit_`, whatever the interpolation yields, so the lead is a fact about + * every value it can produce. That is what `getattr(self, f"visit_{...}")` + * dispatch needs: the candidates are exactly the members named with that + * prefix. Text after the first interpolation is not a prefix of anything and + * is left out. Empty when the f-string opens with an interpolation. + */ + private fstringPrefixOf(node: Parser.SyntaxNode): string { + const parts: string[] = []; + let done = false; + const collect = (current: Parser.SyntaxNode): void => { + for (let index = 0; index < current.namedChildCount && !done; index += 1) { + const part = current.namedChild(index); + if (!part) { + continue; + } + if (part.type === 'interpolation') { + done = true; + return; + } + if (part.type === 'string_content') { + parts.push(part.text); + continue; + } + if (part.type === 'string' || part.type === 'concatenated_string') { + collect(part); + } + } + }; + collect(node); + return EntityUtils.normalizeWhitespace(parts.join('')); + } + private binaryOperatorOf(node: Parser.SyntaxNode): string { const left = node.childForFieldName('left'); const right = node.childForFieldName('right'); diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/app/__init__.py b/tests/cases/python/getattr-fstring-prefix-dispatch/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/app/compiler.py b/tests/cases/python/getattr-fstring-prefix-dispatch/app/compiler.py new file mode 100644 index 00000000..e12ac85d --- /dev/null +++ b/tests/cases/python/getattr-fstring-prefix-dispatch/app/compiler.py @@ -0,0 +1,97 @@ +from .visitor import NodeVisitor, traced + + +def emit_name(): + return "name" + + +def emit_const(): + return "const" + + +def helper_not_visited(): + return "other" + + +class CodeGenerator(NodeVisitor): + def visit_Name(self, node): + return emit_name() + + @traced + def visit_Const(self, node): + return emit_const() + + def visit_Kind0(self, node): + return 0 + + def visit_Kind1(self, node): + return 1 + + def visit_Kind2(self, node): + return 2 + + def visit_Kind3(self, node): + return 3 + + def visit_Kind4(self, node): + return 4 + + def visit_Kind5(self, node): + return 5 + + def visit_Kind6(self, node): + return 6 + + def visit_Kind7(self, node): + return 7 + + def visit_Kind8(self, node): + return 8 + + def visit_Kind9(self, node): + return 9 + + def visit_Kind10(self, node): + return 10 + + def visit_Kind11(self, node): + return 11 + + def visit_Kind12(self, node): + return 12 + + def visit_Kind13(self, node): + return 13 + + def visit_Kind14(self, node): + return 14 + + def visit_Kind15(self, node): + return 15 + + def visit_Kind16(self, node): + return 16 + + def visit_Kind17(self, node): + return 17 + + def visit_Kind18(self, node): + return 18 + + def visit_Kind19(self, node): + return 19 + + def visit_Kind20(self, node): + return 20 + + def visit_Kind21(self, node): + return 21 + + def visit_Kind22(self, node): + return 22 + + def visit_Kind23(self, node): + return 23 + + def leave_Name(self, node): + return helper_not_visited() diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/app/nodes.py b/tests/cases/python/getattr-fstring-prefix-dispatch/app/nodes.py new file mode 100644 index 00000000..9800e398 --- /dev/null +++ b/tests/cases/python/getattr-fstring-prefix-dispatch/app/nodes.py @@ -0,0 +1,6 @@ +class Name: + pass + + +class Const: + pass diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/app/parser.py b/tests/cases/python/getattr-fstring-prefix-dispatch/app/parser.py new file mode 100644 index 00000000..4984ddfe --- /dev/null +++ b/tests/cases/python/getattr-fstring-prefix-dispatch/app/parser.py @@ -0,0 +1,14 @@ +def parse_if_body(): + return "if" + + +class Parser: + def __init__(self, keyword): + self.keyword = keyword + + def parse_statement(self): + f = getattr(self, f"parse_{self.keyword}") + return f() + + def parse_if(self): + return parse_if_body() diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/app/visitor.py b/tests/cases/python/getattr-fstring-prefix-dispatch/app/visitor.py new file mode 100644 index 00000000..4b718a1a --- /dev/null +++ b/tests/cases/python/getattr-fstring-prefix-dispatch/app/visitor.py @@ -0,0 +1,22 @@ +from functools import update_wrapper + + +class NodeVisitor: + def get_visitor(self, node): + return getattr(self, f"visit_{type(node).__name__}", None) + + def visit(self, node): + f = self.get_visitor(node) + if f is not None: + return f(node) + return self.generic_visit(node) + + def generic_visit(self, node): + return None + + +def traced(f): + def new_func(self, node): + return f(self, node) + + return update_wrapper(new_func, f) diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/case.json b/tests/cases/python/getattr-fstring-prefix-dispatch/case.json new file mode 100644 index 00000000..b9dadc6d --- /dev/null +++ b/tests/cases/python/getattr-fstring-prefix-dispatch/case.json @@ -0,0 +1,60 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "a visitor base's get_visitor returns getattr(self, f\"visit_{...}\"): visit() reaches every visit_* member of a constructed subclass -- 26 of them, past the dispatch cap of 20, because a prefix family is the program's own dispatch table -- so a test driving visit() covers what visit_Name calls", + "run": [ + "impact", + "emit_name", + "--tests" + ], + "want": [ + "test_generate.py" + ], + "avoid": [ + "test_parse.py" + ] + }, + { + "why": "a visit_* member a decorator replaced is reached through the wrapper the decorator returned", + "run": [ + "impact", + "emit_const", + "--tests" + ], + "want": [ + "test_generate.py" + ] + }, + { + "why": "the f-string prefix held in a local and called (`f = getattr(self, f\"parse_{kw}\"); f()`)", + "run": [ + "impact", + "parse_if_body", + "--tests" + ], + "want": [ + "test_parse.py" + ], + "avoid": [ + "test_generate.py" + ] + }, + { + "why": "CONTROL: a member whose name does not start with the prefix (leave_Name) is not a visit_ candidate: only the test calling it directly", + "run": [ + "impact", + "helper_not_visited", + "--tests" + ], + "want": [ + "test_leave.py" + ], + "avoid": [ + "test_generate.py", + "test_parse.py" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_generate.py b/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_generate.py new file mode 100644 index 00000000..a9d88382 --- /dev/null +++ b/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_generate.py @@ -0,0 +1,6 @@ +from app.compiler import CodeGenerator +from app.nodes import Name + + +def test_generate_name(): + assert CodeGenerator().visit(Name()) == "name" diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_leave.py b/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_leave.py new file mode 100644 index 00000000..f7c14497 --- /dev/null +++ b/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_leave.py @@ -0,0 +1,6 @@ +from app.compiler import CodeGenerator +from app.nodes import Name + + +def test_leave(): + assert CodeGenerator().leave_Name(Name()) == "other" diff --git a/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_parse.py b/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_parse.py new file mode 100644 index 00000000..7a4fa382 --- /dev/null +++ b/tests/cases/python/getattr-fstring-prefix-dispatch/tests/test_parse.py @@ -0,0 +1,5 @@ +from app.parser import Parser + + +def test_parse_if(): + assert Parser("if").parse_statement() == "if" diff --git a/tests/cases/python/value-callee-is-unknown/case.json b/tests/cases/python/value-callee-is-unknown/case.json index 7d63b1c5..1d36b13c 100644 --- a/tests/cases/python/value-callee-is-unknown/case.json +++ b/tests/cases/python/value-callee-is-unknown/case.json @@ -67,7 +67,7 @@ ] }, { - "why": "fire calls a local holding getattr(self, f'on_{event}'): the site is named, and the target is not called independent", + "why": "fire calls a local holding getattr(self, event): no part of the name is written, so the site is named and the target is not called independent (an f-string or `+` prefix resolves; see getattr-fstring-prefix-dispatch)", "run": [ "path", "Machine.fire", diff --git a/tests/cases/python/value-callee-is-unknown/src/handlers.py b/tests/cases/python/value-callee-is-unknown/src/handlers.py index 586521f5..703cac05 100644 --- a/tests/cases/python/value-callee-is-unknown/src/handlers.py +++ b/tests/cases/python/value-callee-is-unknown/src/handlers.py @@ -39,7 +39,7 @@ def via_local(name): class Machine: def fire(self, event): - hook = getattr(self, f"on_{event}", None) + hook = getattr(self, event, None) if hook is not None: hook() From ee7d906b38db69bc9e28f6096689a0f824d32387 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:22:47 -0700 Subject: [PATCH 12/16] fix(python): `with X() as y` types y as X where X.__enter__ returns self without an annotation with_target_declared typed a `with ... as` target from __enter__'s declared return or a literal `Self`. An unannotated `def __enter__(self): return self` -- the commonest way to write it -- typed nothing, so `with Session() as s: s.request(...)` was an untyped receiver and everything the session does was cut off from the code that opens one. Five such __enter__ definitions in three of sixteen public repositories. The written return is now read: no annotation and a return value that is `self` binds the target to the context manager's own type. An __enter__ that returns something else is untouched (control in the case). Mutation oracle, sixteen repositories: one tuning repository +22 failing test files selected (14 added files do not fail), none lost; all others unchanged. Case with-enter-returns-self fails its first check on the base. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/python/engine/resolution/iteration.dl | 12 ++++++ .../with-enter-returns-self/app/__init__.py | 0 .../with-enter-returns-self/app/session.py | 38 +++++++++++++++++++ .../python/with-enter-returns-self/case.json | 30 +++++++++++++++ .../tests/test_fetch.py | 5 +++ .../tests/test_pooled.py | 5 +++ 6 files changed, 90 insertions(+) create mode 100644 tests/cases/python/with-enter-returns-self/app/__init__.py create mode 100644 tests/cases/python/with-enter-returns-self/app/session.py create mode 100644 tests/cases/python/with-enter-returns-self/case.json create mode 100644 tests/cases/python/with-enter-returns-self/tests/test_fetch.py create mode 100644 tests/cases/python/with-enter-returns-self/tests/test_pooled.py diff --git a/graph/python/engine/resolution/iteration.dl b/graph/python/engine/resolution/iteration.dl index 7a3e3279..374cc9f6 100644 --- a/graph/python/engine/resolution/iteration.dl +++ b/graph/python/engine/resolution/iteration.dl @@ -98,6 +98,18 @@ with_target_declared(p, tgt, ct) :- method_decl(p, _, _, _, ret, m), self_return_name(ret). +// The same, WRITTEN rather than declared: an __enter__ with no return annotation whose +// return value is `self` (`def __enter__(self): return self`). The protocol hands the +// target what __enter__ returned, and that is the context manager itself. +with_target_declared(p, tgt, ct) :- + with_pair(p, tgt, cm), + expr_root_context(p, crc, cm), context_manager_enter_of(crc, en), + expr_type(p, cm, ct), + mro_lookup(p, ct, en, m), + !method_declared_return_type(p, m, _), + method_return_value_expr(p, m, e), + expr_node(p, "SELF_REFERENCE", _, _, e). + // AN __enter__ RETURN IS A DECLARED TYPE, not an exact one, so it goes out through // declared_dispatch and is registered with call_recv_declared in dispatch.dl. A declared // type escaping that gate has produced a confident-wrong answer four times in this engine. diff --git a/tests/cases/python/with-enter-returns-self/app/__init__.py b/tests/cases/python/with-enter-returns-self/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/with-enter-returns-self/app/session.py b/tests/cases/python/with-enter-returns-self/app/session.py new file mode 100644 index 00000000..8e01cd89 --- /dev/null +++ b/tests/cases/python/with-enter-returns-self/app/session.py @@ -0,0 +1,38 @@ +def send_bytes(): + return b"sent" + + +def open_raw(): + return "raw" + + +class Session: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def request(self, url): + return send_bytes() + + +class Pool: + def __enter__(self): + return open_raw() + + def __exit__(self, *exc): + return False + + def request(self, url): + return send_bytes() + + +def fetch(url): + with Session() as session: + return session.request(url) + + +def fetch_pooled(url): + with Pool() as conn: + return conn.request(url) diff --git a/tests/cases/python/with-enter-returns-self/case.json b/tests/cases/python/with-enter-returns-self/case.json new file mode 100644 index 00000000..e94fc855 --- /dev/null +++ b/tests/cases/python/with-enter-returns-self/case.json @@ -0,0 +1,30 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "`with Session() as session` where Session.__enter__ is unannotated and returns self: session IS the Session, so session.request() resolves", + "run": [ + "impact", + "send_bytes", + "--tests" + ], + "want": [ + "test_fetch.py" + ] + }, + { + "why": "CONTROL: an unannotated __enter__ that returns something else does not type its target as the context manager: conn.request() stays a by-name match, never a resolved call", + "run": [ + "impact", + "Pool.request" + ], + "want": [ + "[by name] fetch_pooled" + ], + "avoid": [ + "[resolved] fetch_pooled" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/python/with-enter-returns-self/tests/test_fetch.py b/tests/cases/python/with-enter-returns-self/tests/test_fetch.py new file mode 100644 index 00000000..4ac13099 --- /dev/null +++ b/tests/cases/python/with-enter-returns-self/tests/test_fetch.py @@ -0,0 +1,5 @@ +from app.session import fetch + + +def test_fetch(): + assert fetch("u") == b"sent" diff --git a/tests/cases/python/with-enter-returns-self/tests/test_pooled.py b/tests/cases/python/with-enter-returns-self/tests/test_pooled.py new file mode 100644 index 00000000..a43b88e1 --- /dev/null +++ b/tests/cases/python/with-enter-returns-self/tests/test_pooled.py @@ -0,0 +1,5 @@ +from app.session import fetch_pooled + + +def test_pooled(): + fetch_pooled("u") From b836d07518aedea9ca42d1e25384f63bb418dfd6 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:22:47 -0700 Subject: [PATCH 13/16] fix(python): a loop over a mapping's .items()/.values()/.keys() types its targets from the annotation `for name, field in self.fields.items()` with `fields: dict[str, Field]` bound nothing: no element rule looked through a view call, and an items() loop has a TUPLE target that no rule bound. About 80 loops over an annotated mapping's view in eleven of sixteen public repositories. mapping_annotation_of finds the container annotation an expression carries (a field through self or a typed object, a parameter, a callee's declared return, a single-write local holding one); the slots are read POSITIONALLY (key at generic position 0, value at 1) rather than through type_ref_element, which merges both. values()/keys() yield one slot; items() binds each name of a tuple target to its own slot. The view names are catalogued in builtins.dl. Mutation oracle, sixteen repositories: one tuning repository +11 failing test files selected (7 added do not fail), none lost; path found on tuning 0.756 -> 0.764. Case dict-view-loop-types-the-value fails 3 of 4 checks on the base; its control (a key slot is never the value type) passes on both. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/python/engine/resolution/builtins.dl | 10 +++ graph/python/engine/resolution/iteration.dl | 73 +++++++++++++++++++ graph/python/souffle/decls_all.dl | 7 ++ .../app/__init__.py | 0 .../app/schema.py | 55 ++++++++++++++ .../dict-view-loop-types-the-value/case.json | 17 +++++ .../tests/test_dump.py | 5 ++ .../tests/test_keys.py | 9 +++ .../tests/test_load.py | 5 ++ 9 files changed, 181 insertions(+) create mode 100644 tests/cases/python/dict-view-loop-types-the-value/app/__init__.py create mode 100644 tests/cases/python/dict-view-loop-types-the-value/app/schema.py create mode 100644 tests/cases/python/dict-view-loop-types-the-value/case.json create mode 100644 tests/cases/python/dict-view-loop-types-the-value/tests/test_dump.py create mode 100644 tests/cases/python/dict-view-loop-types-the-value/tests/test_keys.py create mode 100644 tests/cases/python/dict-view-loop-types-the-value/tests/test_load.py diff --git a/graph/python/engine/resolution/builtins.dl b/graph/python/engine/resolution/builtins.dl index 02da0163..755133bb 100644 --- a/graph/python/engine/resolution/builtins.dl +++ b/graph/python/engine/resolution/builtins.dl @@ -370,3 +370,13 @@ class_definition_keyword("metaclass"). // (expression-resolution/expr-type.dl, clause a'), where the convention is the only // signal there is: a lambda has no annotation and no `def` to classify. instance_receiver_name("self"). + +// ── dict_view_name / dict_view_slot / dict_items_view — a mapping's views ──── +// `keys()` yields the key (generic position 0), `values()` the value (position 1), and +// `items()` a (key, value) pair. Read by resolution/iteration.dl. +dict_view_name("keys"). +dict_view_name("values"). +dict_view_name("items"). +dict_view_slot("keys", "0"). +dict_view_slot("values", "1"). +dict_items_view("items"). diff --git a/graph/python/engine/resolution/iteration.dl b/graph/python/engine/resolution/iteration.dl index 374cc9f6..76d50692 100644 --- a/graph/python/engine/resolution/iteration.dl +++ b/graph/python/engine/resolution/iteration.dl @@ -293,6 +293,79 @@ binding_element_type(p, b, t) :- expr_binding(p, b, _, tgt), element_type_of(p, src, t). +// ── A MAPPING'S VIEWS: `.values()`, `.keys()`, `.items()` ───────────────────── +// self.dump_fields: dict[str, Field] = {} +// for name, field_obj in self.dump_fields.items(): field_obj.serialize(...) +// Every clause above types the container itself; a view is a CALL on it, and none of them +// looked through one, so the commonest way to walk a mapping typed nothing -- and an +// `items()` loop binds a TUPLE target, which no clause bound at all. A mapping annotation +// names its key at generic position 0 and its value at position 1, so the slots are +// read positionally rather than through type_ref_element (which merges both, the safe +// direction for iterating the mapping itself, and wrong for a view that yields one side). +// view_slot names which side each view yields; `items()` yields (key, value) pairs. +// dict_view_slot / dict_items_view are CPython names, catalogued in resolution/builtins.dl. + +// the annotation a mapping-valued expression carries: a field (through self or through +// any typed object), a parameter, or a callee's declared return +mapping_annotation_of(src, r) :- + expr_attr_of_self("client", src, n, owner), + type_attr_field("client", owner, n, f), + type_ref_owner("client", f, "FIELD", r), + type_ref("client", k, "FIELD_TYPE", _, _, r), annotation_container_kind(k). +mapping_annotation_of(src, r) :- + attr_access("client", src, n, obj), + expr_type("client", obj, ot), + type_attr_field("client", ot, n, f), + type_ref_owner("client", f, "FIELD", r), + type_ref("client", k, "FIELD_TYPE", _, _, r), annotation_container_kind(k). +mapping_annotation_of(src, r) :- + expr_names_param("client", src, ph), + type_ref_owner("client", ph, "METHOD_PARAM", r), + type_ref("client", k, "METHOD_PARAM", _, _, r), annotation_container_kind(k). +mapping_annotation_of(src, r) :- + call_of_expr(src, site), expr_call_candidate(site, m), + type_ref_owner("client", m, "METHOD", r), + type_ref("client", k, "METHOD_RETURN", _, _, r), annotation_container_kind(k). +// a single-write local holding one of those +mapping_annotation_of(src, r) :- + expr_binding("client", b, ctx, src), ctx != "STORE", + binding_lookup("client", b, b2), + binding_rebinding("client", "1", _, _, b2), + expr_binding("client", b2, "STORE", tgt), + assign_pair("client", tgt, val), + mapping_annotation_of(val, r). + +// the type at one generic position of an annotation (resolved, or by name in its module) +type_ref_slot(r, pos, t) :- + type_ref_nesting("client", r, pos, "1", child), + type_ref("client", _, "GENERIC_ARGUMENT", _, _, child), + type_ref_resolved("client", t, child). +type_ref_slot(r, pos, t) :- + type_ref_nesting("client", r, pos, "1", child), + type_ref("client", _, "GENERIC_ARGUMENT", tn, _, child), tn != "", + type_ref_owner("client", owner, ok, r), + annotation_owner_module("client", owner, ok, mod), + type_name_in_module("client", mod, tn, t). + +// `m.keys()` / `m.values()` / `m.items()` written with no argument +dict_view_call(src, recv, view) :- + call_of_expr(src, site), call_name(site, view), dict_view_name(view), + call_receiver_object(site, recv), !call_arg(site, _, _). +// the element a `keys()` / `values()` view yields +element_type_of("client", src, t) :- + dict_view_call(src, recv, view), dict_view_slot(view, pos), + mapping_annotation_of(recv, r), r != "", type_ref_slot(r, pos, t). +// an `items()` pair's two slots, and the names a TUPLE loop target binds to them +dict_item_slot_type(src, pos, t) :- + dict_items_view(view), dict_view_call(src, recv, view), dict_view_slot(_, pos), + mapping_annotation_of(recv, r), type_ref_slot(r, pos, t). +binding_element_type("client", b, t) :- + iter_pair("client", tgt, src), + expr_node("client", "TUPLE", _, _, tgt), + expr_parent("client", tgt, "ELEMENT", pos, el), + expr_binding("client", b, _, el), + dict_item_slot_type(src, pos, t). + // ── iteration_untyped(Prov, TargetExprHash, IterableExprHash) ──────────────── // A loop whose container the engine could not type. Emitted so the cost is countable // rather than absorbed into a general "receiver untyped". diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index 44d353f5..b91eccdb 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -530,6 +530,8 @@ .decl field_holds_method(c0:symbol,c1:symbol,c2:symbol) .decl type_attr_method_value(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl type_attr_class_value(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl dict_view_name(c0:symbol) +.decl dict_items_view(c0:symbol) .decl computed_name_prefix(c0:symbol,c1:symbol) .decl expr_operator(c0:symbol,c1:symbol,c2:symbol) .decl local_holds_untyped_return(c0:symbol,c1:symbol) @@ -670,6 +672,11 @@ .decl binding_declared_call_protocol(c0:symbol,c1:symbol,c2:symbol) .decl binding_assigned_lambda(c0:symbol,c1:symbol,c2:symbol) .decl instance_attr_method_value(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl dict_view_slot(c0:symbol,c1:symbol) +.decl mapping_annotation_of(c0:symbol,c1:symbol) +.decl type_ref_slot(c0:symbol,c1:symbol,c2:symbol) +.decl dict_view_call(c0:symbol,c1:symbol,c2:symbol) +.decl dict_item_slot_type(c0:symbol,c1:symbol,c2:symbol) .decl method_returns_computed_name(c0:symbol) .decl computed_name_value(c0:symbol) .decl call_computed_name_family(c0:symbol) diff --git a/tests/cases/python/dict-view-loop-types-the-value/app/__init__.py b/tests/cases/python/dict-view-loop-types-the-value/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/dict-view-loop-types-the-value/app/schema.py b/tests/cases/python/dict-view-loop-types-the-value/app/schema.py new file mode 100644 index 00000000..6098b261 --- /dev/null +++ b/tests/cases/python/dict-view-loop-types-the-value/app/schema.py @@ -0,0 +1,55 @@ +def serialize_field(): + return "s" + + +def deserialize_field(): + return "d" + + +def describe_field(): + return "x" + + +def key_upper(): + return "K" + + +class Field: + def serialize(self, value): + return serialize_field() + + def deserialize(self, value): + return deserialize_field() + + def describe(self): + return describe_field() + + +class Name: + def upper(self): + return key_upper() + + +class Schema: + def __init__(self): + self.dump_fields: dict[str, Field] = {} + self.load_fields: dict[str, Field] = {} + self.names: dict[Name, int] = {} + + def dump(self, obj): + out = {} + for attr_name, field_obj in self.dump_fields.items(): + out[attr_name] = field_obj.serialize(obj) + return out + + def load(self, data): + return [f.deserialize(data) for f in self.load_fields.values()] + + def keys_only(self): + return [k.upper() for k in self.names.keys()] + + def counts(self): + total = 0 + for name, count in self.names.items(): + total += count + return total diff --git a/tests/cases/python/dict-view-loop-types-the-value/case.json b/tests/cases/python/dict-view-loop-types-the-value/case.json new file mode 100644 index 00000000..16c58f1e --- /dev/null +++ b/tests/cases/python/dict-view-loop-types-the-value/case.json @@ -0,0 +1,17 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "`for k, v in self.dump_fields.items()` with `dump_fields: dict[str, Field]`: v is a Field (the VALUE of each item), so v.serialize() resolves", + "run": ["impact", "serialize_field", "--tests"], + "want": ["test_dump.py"], + "avoid": ["test_load.py", "test_keys.py"]}, + {"why": "`for f in self.load_fields.values()`: f is a Field", + "run": ["impact", "deserialize_field", "--tests"], + "want": ["test_load.py"], + "avoid": ["test_dump.py"]}, + {"why": "`.keys()` yields the KEY type: k.upper() on a dict[Name, int] is Name.upper", + "run": ["impact", "key_upper", "--tests"], + "want": ["test_keys.py"]}, + {"why": "CONTROL: an item's first slot is the key, never the value: in `for name, count in names.items()` nothing calls Field", + "run": ["impact", "describe_field", "--tests"], + "want": ["tests: 0 of"], + "avoid": ["test_keys.py", "test_dump.py"]}]} diff --git a/tests/cases/python/dict-view-loop-types-the-value/tests/test_dump.py b/tests/cases/python/dict-view-loop-types-the-value/tests/test_dump.py new file mode 100644 index 00000000..5a02e011 --- /dev/null +++ b/tests/cases/python/dict-view-loop-types-the-value/tests/test_dump.py @@ -0,0 +1,5 @@ +from app.schema import Schema + + +def test_dump(): + assert Schema().dump(1) == {} diff --git a/tests/cases/python/dict-view-loop-types-the-value/tests/test_keys.py b/tests/cases/python/dict-view-loop-types-the-value/tests/test_keys.py new file mode 100644 index 00000000..58c35f10 --- /dev/null +++ b/tests/cases/python/dict-view-loop-types-the-value/tests/test_keys.py @@ -0,0 +1,9 @@ +from app.schema import Schema + + +def test_keys(): + assert Schema().keys_only() == [] + + +def test_counts(): + assert Schema().counts() == 0 diff --git a/tests/cases/python/dict-view-loop-types-the-value/tests/test_load.py b/tests/cases/python/dict-view-loop-types-the-value/tests/test_load.py new file mode 100644 index 00000000..a6a32648 --- /dev/null +++ b/tests/cases/python/dict-view-loop-types-the-value/tests/test_load.py @@ -0,0 +1,5 @@ +from app.schema import Schema + + +def test_load(): + assert Schema().load(1) == [] From e4eeb91807e74cc868f03df5207dcddf4a8eaee1 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:36:07 -0700 Subject: [PATCH 14/16] fix(python): a parametrize row or a fixture's params= value types the argument the runner fills `@pytest.mark.parametrize("cls", [String, Integer])` calls the test once per value, and `@pytest.fixture(params=(A, B))` hands `request.param` each one in turn; neither is a written call, so the parameter stayed untyped. Parametrize values that are classes, instances or functions occur in fourteen of sixteen public repositories. framework-behavior/dispatch.dl reads the rows: the decorator's values written in place, or a display held by a single-write module name (also through `from m import NAME`); one argument name takes the whole row, several take the row's k-th element (the existing argnames position, now asked for every direct argument); `pytest.param(...)` rows count. value-flow.dl types the parameter from each value (instance, class object, or function), and `request.param` inside a params fixture likewise; a fixture that returns a class makes the requesting parameter that class. The pytest names are catalogued in knobs.dl. Mutation oracle, sixteen repositories: one held-out repository +17 failing test files selected, another +1; 154 added files do not fail (the converter fixture there types every test that takes it; 52 of the 169 added ran the target). Nothing lost. Case parametrize-values-type-the-argument fails 3 of 4 on the base; control: string values naming a class type nothing. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../python/engine/config-resolution/knobs.dl | 8 ++ .../engine/framework-behavior/dispatch.dl | 73 ++++++++++++++++++- graph/python/engine/resolution/value-flow.dl | 21 ++++++ graph/python/souffle/decls_all.dl | 6 ++ .../app/__init__.py | 0 .../app/fields.py | 46 ++++++++++++ .../case.json | 18 +++++ .../tests/__init__.py | 0 .../tests/base.py | 3 + .../tests/conftest.py | 13 ++++ .../tests/test_boxes.py | 8 ++ .../tests/test_convert.py | 2 + .../tests/test_fields.py | 9 +++ .../tests/test_strings.py | 6 ++ 14 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 tests/cases/python/parametrize-values-type-the-argument/app/__init__.py create mode 100644 tests/cases/python/parametrize-values-type-the-argument/app/fields.py create mode 100644 tests/cases/python/parametrize-values-type-the-argument/case.json create mode 100644 tests/cases/python/parametrize-values-type-the-argument/tests/__init__.py create mode 100644 tests/cases/python/parametrize-values-type-the-argument/tests/base.py create mode 100644 tests/cases/python/parametrize-values-type-the-argument/tests/conftest.py create mode 100644 tests/cases/python/parametrize-values-type-the-argument/tests/test_boxes.py create mode 100644 tests/cases/python/parametrize-values-type-the-argument/tests/test_convert.py create mode 100644 tests/cases/python/parametrize-values-type-the-argument/tests/test_fields.py create mode 100644 tests/cases/python/parametrize-values-type-the-argument/tests/test_strings.py diff --git a/graph/python/engine/config-resolution/knobs.dl b/graph/python/engine/config-resolution/knobs.dl index 19231f11..27c24871 100644 --- a/graph/python/engine/config-resolution/knobs.dl +++ b/graph/python/engine/config-resolution/knobs.dl @@ -165,6 +165,14 @@ py_parametrize_deco("parametrize"). py_parametrize_argnames_kw("argnames"). py_parametrize_indirect_kw("indirect"). py_parametrize_indirect_all("True"). +// The VALUES the runner hands in: the decorator's second argument (or `argvalues=`), a +// fixture's `params=`, a row wrapped as `pytest.param(v, ..., id=...)`, and, inside a fixture +// declared with params, the attribute `request.param` it reads the current one from. +py_parametrize_argvalues_kw("argvalues"). +py_fixture_params_kw("params"). +py_param_row_wrapper("param"). +py_fixture_request_param("request"). +py_request_param_attr("param"). // ── py_argnames_sep / py_argnames_lead: how "a, b" separates argument names ── // The runner splits the string on "," and strips each piece. Souffle cannot split, so a diff --git a/graph/python/engine/framework-behavior/dispatch.dl b/graph/python/engine/framework-behavior/dispatch.dl index c2c8dc38..f7277835 100644 --- a/graph/python/engine/framework-behavior/dispatch.dl +++ b/graph/python/engine/framework-behavior/dispatch.dl @@ -326,7 +326,7 @@ py_argnames_sep_at(h, s, i) :- py_parametrize_argnames(h, s), py_argnames_sep(se // A whole token: led by the string's start, a separator or a space, and followed by its // end, a separator or a space, so `server` is not found inside `server_url`. .decl py_argnames_found(h:symbol, name:symbol, i:number) -py_argnames_found(h, p, i) :- py_fixture_value_param(h, p), py_parametrize_argnames(h, s), +py_argnames_found(h, p, i) :- py_argnames_demand(h, p), py_parametrize_argnames(h, s), strlen(s) > strlen(p), i = range(0, strlen(s) - strlen(p) + 1), substr(s, i, strlen(p)) = p. .decl py_argnames_led(h:symbol, name:symbol, i:number) py_argnames_led(h, p, 0) :- py_argnames_found(h, p, 0). @@ -346,10 +346,15 @@ py_value_row_kind("LIST"). py_argnames_pos(h, p, k) :- py_argnames_name_at(h, p, i), py_parametrize_argnames(h, s), k = count : { py_argnames_sep_at(h, s, j), j < i }. // ... or, for argnames written as a list or tuple of strings, its element position there. -py_argnames_pos(h, p, k) :- py_fixture_value_param(h, p), +py_argnames_pos(h, p, k) :- py_argnames_demand(h, p), decorator_expr("client", de, h), call_arg(de, "0", a), expr_parent("client", a, "ELEMENT", ks, el), expr_node("client", "LITERAL", _, p, el), k = to_number(ks). +// the argument names whose position is asked for: one a getfixturevalue call reads, and +// every argument the decorator supplies directly (its values type the parameter, below) +.decl py_argnames_demand(h:symbol, p:symbol) +py_argnames_demand(h, p) :- py_fixture_value_param(h, p). +py_argnames_demand(h, p) :- py_parametrize_direct(req, p), py_parametrize_on(req, h). .decl py_fixture_value_param(h:symbol, p:symbol) py_fixture_value_param(h, p) :- py_fixture_value_call(req, e), call_arg(e, "0", a), expr_node("client", "NAME_REFERENCE", _, p, a), @@ -362,6 +367,70 @@ py_fixture_request(req, name) :- py_fixture_value_call(req, e), expr_parent("client", tup, "ELEMENT", to_string(k), el), expr_node("client", "LITERAL", _, name, el), name != "". +// ── PARAMETRIZE AND FIXTURE PARAMS: the VALUES the runner hands an argument ── +// @pytest.mark.parametrize("field_cls", [String, Integer]) -> field_cls is one of the classes +// @pytest.mark.parametrize("obj, n", [(Box(), 1), pytest.param(Bag(), 2)]) +// @pytest.fixture(params=(Converter, BaseConverter)) +// def converter_cls(request): return request.param -> one of the classes +// The runner calls the test once per row with the row's values in place of the argument, +// so each value is an argument reaching the parameter exactly as one written at a call +// site is (resolution/value-flow.dl types it). Only values WRITTEN in the decorator, or in +// a display a single-write name in this module (or one it imports the name from) holds; +// a value list built by a call is not read. +.decl py_pz_rows(h:symbol, rows:symbol) +py_pz_rows(h, a) :- py_parametrize_on(_, h), decorator_expr("client", de, h), call_arg(de, "1", a). +py_pz_rows(h, a) :- py_parametrize_on(_, h), decorator_expr("client", de, h), call_kwarg(de, k, a), py_parametrize_argvalues_kw(k). +py_pz_rows(h, a) :- py_fixture_deco_on(_, h), decorator_expr("client", de, h), call_kwarg(de, k, a), py_fixture_params_kw(k). +// the display a rows expression is: written in place, or held by a single-write name +.decl py_pz_display(rows:symbol, d:symbol) +py_pz_display(a, a) :- py_pz_rows(_, a), py_value_row_kind(k), expr_node("client", k, _, _, a). +py_pz_display(a, d) :- py_pz_rows(_, a), + expr_binding("client", b, ctx, a), ctx != "STORE", binding_lookup("client", b, b2), + py_pz_name_display(b2, d). +.decl py_pz_name_display(b:symbol, d:symbol) +py_pz_name_display(b, d) :- binding_rebinding("client", "1", _, _, b), + expr_binding("client", b, "STORE", tgt), assign_pair("client", tgt, d), + py_value_row_kind(k), expr_node("client", k, _, _, d). +// `from tests.base import ALL_FIELDS`: the module-level binding of that name in the module +py_pz_name_display(b, d) :- import_binding("client", b, i), + import_alias("client", orig, _, i), orig != "", import_relative("client", _, pkg, i), pkg != "", + module_decl("client", _, pkg, _, mod), + binding_decl("client", orig, _, "MODULE_LEVEL", _, mb), binding_owner("client", mod, _, mb), + binding_rebinding("client", "1", _, _, mb), + expr_binding("client", mb, "STORE", tgt), assign_pair("client", tgt, d), + py_value_row_kind(k), expr_node("client", k, _, _, d). +// a row, with `pytest.param(v, ..., id=...)` read as its positional values +.decl py_pz_row(h:symbol, row:symbol) +py_pz_row(h, r) :- py_pz_rows(h, a), py_pz_display(a, d), expr_parent("client", d, "ELEMENT", _, r). +.decl py_pz_param_call(row:symbol) +py_pz_param_call(r) :- py_pz_row(_, r), call_of_expr(r, site), call_name(site, w), py_param_row_wrapper(w). +// the value at one argument's position in a row: the whole row (one argument name), else +// the row's k-th element +.decl py_pz_single(h:symbol) +py_pz_single(h) :- py_parametrize_argnames(h, s), !py_argnames_sep_at(h, s, _). +py_pz_single(h) :- py_fixture_deco_on(_, h). +.decl py_pz_value(h:symbol, k:number, v:symbol) +py_pz_value(h, 0, r) :- py_pz_single(h), py_pz_row(h, r), !py_pz_param_call(r). +py_pz_value(h, 0, v) :- py_pz_single(h), py_pz_row(h, r), py_pz_param_call(r), call_arg(r, "0", v). +py_pz_value(h, k, v) :- !py_pz_single(h), py_pz_row(h, r), !py_pz_param_call(r), + py_value_row_kind(rk), expr_node("client", rk, _, _, r), + expr_parent("client", r, "ELEMENT", ks, v), k = to_number(ks). +py_pz_value(h, k, v) :- !py_pz_single(h), py_pz_row(h, r), py_pz_param_call(r), + call_arg(r, ks, v), k = to_number(ks). +// the parameter each value reaches: a test's directly parametrized argument ... +.decl py_pz_param_value(ph:symbol, v:symbol) +py_pz_param_value(ph, v) :- py_parametrize_direct(req, name), py_parametrize_on(req, h), + !py_pz_single(h), py_argnames_pos(h, name, k), py_pz_value(h, k, v), + param_decl("client", name, _, _, req, ph). +py_pz_param_value(ph, v) :- py_parametrize_direct(req, name), py_parametrize_on(req, h), + py_parametrize_argname(req, h, name), py_pz_single(h), py_pz_value(h, 0, v), + param_decl("client", name, _, _, req, ph). +// ... and, in a fixture declared with params=, what `request.param` evaluates to +.decl py_pz_request_param(e:symbol, v:symbol) +py_pz_request_param(e, v) :- py_fixture_deco_on(f, h), py_pz_value(h, 0, v), + py_fixture_request_param(rq), param_decl("client", rq, _, _, f, ph), + py_request_param_attr(pa), attr_access("client", e, pa, obj), expr_names_param("client", obj, ph). + // (0) a fixture a CLASS declares serves the tests of that class and of its subclasses, // and nothing else; for them it wins over the module's fixture of the name. Read as a // plain same-module fixture it was also handed to a sibling class's tests, and the diff --git a/graph/python/engine/resolution/value-flow.dl b/graph/python/engine/resolution/value-flow.dl index 69fb4ae4..83a3747d 100644 --- a/graph/python/engine/resolution/value-flow.dl +++ b/graph/python/engine/resolution/value-flow.dl @@ -112,6 +112,27 @@ param_arg_type(ph, t) :- param_decl("client", pn, _, _, req, ph), py_fixture_value_type(fix, t). +// ── a parameter the runner fills from a PARAMETRIZE row, or from FIXTURE PARAMS ── +// `@pytest.mark.parametrize("cls", [String, Integer])` calls the test once per value, so +// each value reaches `cls` as an argument would: an instance types it, a class object +// makes it that class, a function makes it callable as that function. Inside a fixture +// declared `params=[...]`, `request.param` is each of those values in turn. +// framework-behavior/dispatch.dl reads the rows (py_pz_param_value, py_pz_request_param). +param_arg_type(ph, t) :- py_pz_param_value(ph, v), expr_type("client", v, t). +param_arg_class(ph, t) :- py_pz_param_value(ph, v), expr_type_class_object("client", v, t). +param_arg_method(ph, m) :- py_pz_param_value(ph, v), expr_denotes_method("client", v, m). +expr_type("client", e, t) :- py_pz_request_param(e, v), expr_type("client", v, t). +expr_type_class_object("client", e, t) :- py_pz_request_param(e, v), expr_type_class_object("client", v, t). +expr_denotes_method("client", e, m) :- py_pz_request_param(e, v), expr_denotes_method("client", v, m). +// a fixture that hands back a CLASS (`return request.param` over classes) makes the +// requesting parameter that class, as a returned instance makes it an instance +py_fixture_value_class(fix, t) :- py_fixture_decl(fix, _), + method_return_value_expr("client", fix, e), expr_type_class_object("client", e, t). +param_arg_class(ph, t) :- + py_fixs_injection(req, fix, pn), + param_decl("client", pn, _, _, req, ph), + py_fixture_value_class(fix, t). + // ── param_arg_method(ParamHash, MethodHash) — a CALLABLE reaching a parameter ── // `Delegator(target)` puts the FUNCTION `target` into the parameter `fn`. Java would // need a functional interface for this; in Python it is an ordinary assignment. diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index b91eccdb..fdc9b80b 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -52,6 +52,11 @@ .decl py_fixture_value_method(v:symbol) .decl py_parametrize_deco(v:symbol) .decl py_parametrize_argnames_kw(v:symbol) +.decl py_parametrize_argvalues_kw(v:symbol) +.decl py_fixture_params_kw(v:symbol) +.decl py_param_row_wrapper(v:symbol) +.decl py_fixture_request_param(v:symbol) +.decl py_request_param_attr(v:symbol) .decl py_parametrize_indirect_kw(v:symbol) .decl py_parametrize_indirect_all(v:symbol) .decl py_argnames_sep(v:symbol) @@ -680,3 +685,4 @@ .decl method_returns_computed_name(c0:symbol) .decl computed_name_value(c0:symbol) .decl call_computed_name_family(c0:symbol) +.decl py_fixture_value_class(c0:symbol,c1:symbol) diff --git a/tests/cases/python/parametrize-values-type-the-argument/app/__init__.py b/tests/cases/python/parametrize-values-type-the-argument/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/parametrize-values-type-the-argument/app/fields.py b/tests/cases/python/parametrize-values-type-the-argument/app/fields.py new file mode 100644 index 00000000..016d46d0 --- /dev/null +++ b/tests/cases/python/parametrize-values-type-the-argument/app/fields.py @@ -0,0 +1,46 @@ +def string_done(): + return "s" + + +def integer_done(): + return "i" + + +def box_done(): + return "b" + + +def plain_done(): + return "p" + + +def never_done(): + return "n" + + +class String: + def deserialize(self, value): + return string_done() + + +class Integer: + def deserialize(self, value): + return integer_done() + + +class Box: + def open(self): + return box_done() + + +class Converter: + def __init__(self, strict=False): + self.strict = strict + + def structure(self, value): + return plain_done() + + +class Unused: + def structure(self, value): + return never_done() diff --git a/tests/cases/python/parametrize-values-type-the-argument/case.json b/tests/cases/python/parametrize-values-type-the-argument/case.json new file mode 100644 index 00000000..cd7c606e --- /dev/null +++ b/tests/cases/python/parametrize-values-type-the-argument/case.json @@ -0,0 +1,18 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "@parametrize(\"FieldClass\", ALL_FIELDS) where ALL_FIELDS is a list of classes another test module holds: FieldClass is each class, FieldClass() constructs it, and field.deserialize() resolves", + "run": ["impact", "string_done", "--tests"], + "want": ["test_fields.py"], + "avoid": ["test_boxes.py", "test_convert.py"]}, + {"why": "several argument names: `box` is the SECOND element of each row, and pytest.param(...) rows count too", + "run": ["impact", "box_done", "--tests"], + "want": ["test_boxes.py"], + "avoid": ["test_fields.py"]}, + {"why": "a fixture declared params=(Converter,) returns request.param, a CLASS; the fixture that calls it builds a Converter, and the test's converter.structure() resolves", + "run": ["impact", "plain_done", "--tests"], + "want": ["test_convert.py"], + "avoid": ["test_fields.py"]}, + {"why": "CONTROL: string values naming a class are strings, not the class: nothing reaches Unused.structure", + "run": ["impact", "never_done", "--tests"], + "want": ["tests: 0 of"], + "avoid": ["test_strings.py", "test_convert.py"]}]} diff --git a/tests/cases/python/parametrize-values-type-the-argument/tests/__init__.py b/tests/cases/python/parametrize-values-type-the-argument/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/parametrize-values-type-the-argument/tests/base.py b/tests/cases/python/parametrize-values-type-the-argument/tests/base.py new file mode 100644 index 00000000..5827e88d --- /dev/null +++ b/tests/cases/python/parametrize-values-type-the-argument/tests/base.py @@ -0,0 +1,3 @@ +from app.fields import Integer, String + +ALL_FIELDS = [String, Integer] diff --git a/tests/cases/python/parametrize-values-type-the-argument/tests/conftest.py b/tests/cases/python/parametrize-values-type-the-argument/tests/conftest.py new file mode 100644 index 00000000..92e04e2e --- /dev/null +++ b/tests/cases/python/parametrize-values-type-the-argument/tests/conftest.py @@ -0,0 +1,13 @@ +import pytest + +from app.fields import Converter + + +@pytest.fixture(params=(Converter,)) +def converter_cls(request): + return request.param + + +@pytest.fixture +def converter(converter_cls): + return converter_cls(strict=True) diff --git a/tests/cases/python/parametrize-values-type-the-argument/tests/test_boxes.py b/tests/cases/python/parametrize-values-type-the-argument/tests/test_boxes.py new file mode 100644 index 00000000..2808f6e1 --- /dev/null +++ b/tests/cases/python/parametrize-values-type-the-argument/tests/test_boxes.py @@ -0,0 +1,8 @@ +import pytest + +from app.fields import Box + + +@pytest.mark.parametrize("label, box", [("a", Box()), pytest.param("b", Box(), id="b")]) +def test_open(label, box): + assert box.open() diff --git a/tests/cases/python/parametrize-values-type-the-argument/tests/test_convert.py b/tests/cases/python/parametrize-values-type-the-argument/tests/test_convert.py new file mode 100644 index 00000000..90693707 --- /dev/null +++ b/tests/cases/python/parametrize-values-type-the-argument/tests/test_convert.py @@ -0,0 +1,2 @@ +def test_structure(converter): + assert converter.structure(1) diff --git a/tests/cases/python/parametrize-values-type-the-argument/tests/test_fields.py b/tests/cases/python/parametrize-values-type-the-argument/tests/test_fields.py new file mode 100644 index 00000000..3e5715cf --- /dev/null +++ b/tests/cases/python/parametrize-values-type-the-argument/tests/test_fields.py @@ -0,0 +1,9 @@ +import pytest + +from tests.base import ALL_FIELDS + + +@pytest.mark.parametrize("FieldClass", ALL_FIELDS) +def test_none(FieldClass): + field = FieldClass() + assert field.deserialize(None) diff --git a/tests/cases/python/parametrize-values-type-the-argument/tests/test_strings.py b/tests/cases/python/parametrize-values-type-the-argument/tests/test_strings.py new file mode 100644 index 00000000..08d096da --- /dev/null +++ b/tests/cases/python/parametrize-values-type-the-argument/tests/test_strings.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.mark.parametrize("name", ["Unused", "String"]) +def test_names(name): + assert name.upper() From 8b6c3ece52bb15e9eb6bedc242fd2fdd36cabe6f Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:41:47 -0700 Subject: [PATCH 15/16] fix(python): repr(x), len(x), str(x) and the other one-dunder builtins run the argument's dunder `assert repr(v) == "<...>"` reached nothing of v's: the call resolves to the builtin (C code) and the dunder it runs had no caller. BUILTIN_PROTOCOL edges now go from the argument expression to the dunder repr, str, len, hash, bool, iter, next, abs, format and reversed run on the argument's type -- only for the bare builtin (a client `repr` is not it), with exactly one argument (`str(b, "utf-8")` decodes), and with CPython's fallbacks (str -> __repr__, bool -> __len__ when the class has no slot of its own). New call kind BUILTIN_PROTOCOL in the schema vocabulary, tiered by target count like the other protocol edges; the names are catalogued in builtins.dl. Mutation oracle, sixteen repositories: +13 failing test files selected across five repositories and NO added file that does not fail; path found 0.764 -> 0.776 on tuning and 0.600 -> 0.607 held out. Case builtin-runs-the-dunder fails 3 of 4 on the base; control: str() on a class with its own __str__ does not run its __repr__. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/bundle/SCHEMA.md | 3 +- graph/bundle/schema.ts | 3 +- .../engine/call-edge-generation/call_chain.dl | 34 ++++++++++++++++ graph/python/engine/resolution/builtins.dl | 17 ++++++++ graph/python/souffle/decls_all.dl | 5 +++ .../builtin-runs-the-dunder/app/__init__.py | 0 .../builtin-runs-the-dunder/app/validators.py | 40 +++++++++++++++++++ .../python/builtin-runs-the-dunder/case.json | 17 ++++++++ .../builtin-runs-the-dunder/tests/test_len.py | 6 +++ .../tests/test_other.py | 5 +++ .../tests/test_repr.py | 6 +++ .../builtin-runs-the-dunder/tests/test_str.py | 9 +++++ 12 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 tests/cases/python/builtin-runs-the-dunder/app/__init__.py create mode 100644 tests/cases/python/builtin-runs-the-dunder/app/validators.py create mode 100644 tests/cases/python/builtin-runs-the-dunder/case.json create mode 100644 tests/cases/python/builtin-runs-the-dunder/tests/test_len.py create mode 100644 tests/cases/python/builtin-runs-the-dunder/tests/test_other.py create mode 100644 tests/cases/python/builtin-runs-the-dunder/tests/test_repr.py create mode 100644 tests/cases/python/builtin-runs-the-dunder/tests/test_str.py diff --git a/graph/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md index a78cdc69..a39a76d7 100644 --- a/graph/bundle/SCHEMA.md +++ b/graph/bundle/SCHEMA.md @@ -442,7 +442,7 @@ One row per place a call is written (or, for a synthesised edge, the construct t - **typescript** — end_line / end_column come from the expression row; the call-site row itself records only the start. - **javascript** — caller_id is the parser's enclosing method, or the module initializer for top-level code. end_line / end_column come from the expression row. `require()` is a module edge, not a call site. - **typescript** — PROPERTY_READ and PROPERTY_WRITE rows are accessor invocations with no written call: the site is the property-access expression that runs the getter or setter, positioned from the expressions table, and callee_name is NULL because nothing was written; the accessor's name is on the callee's methods row. Filter them out with kind NOT IN (…) when counting calls. -- **python** — PROPERTY_READ, PROPERTY_WRITE, CONTEXT_MANAGER, ITERATION_PROTOCOL, METACLASS_CREATION and DYNAMIC_CALL rows are protocol or indirect edges with no written call: their site is the expression that triggers them, and callee_name is always NULL because nothing was written. SUBSCRIPT_CALL is NULL only when the subscript is not a written name (measured 206 of 337 rows on a Python subject). Filter them out with kind NOT IN (…) when counting calls. +- **python** — PROPERTY_READ, PROPERTY_WRITE, CONTEXT_MANAGER, ITERATION_PROTOCOL, BUILTIN_PROTOCOL, METACLASS_CREATION and DYNAMIC_CALL rows are protocol or indirect edges with no written call: their site is the expression that triggers them, and callee_name is always NULL because nothing was written. SUBSCRIPT_CALL is NULL only when the subscript is not a written name (measured 206 of 337 rows on a Python subject). Filter them out with kind NOT IN (…) when counting calls. - **python** — The id is an EXPRESSION hash for a written call; a DECORATOR hash (PY_DECORATOR_…) for DECORATOR_APPLICATION and DECORATOR_* sites, positioned at the decorator line; and the class's TYPE hash for METACLASS_CREATION, positioned at the class declaration. ### `call_edges` @@ -535,6 +535,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h | `CONTEXT_MANAGER` | python | `with expr:` runs `__enter__` / `__exit__` (or the async pair). No written call; the site is the context-manager expression. | | `ITERATION_PROTOCOL` | python | `for x in expr:` (and comprehensions) runs `__iter__` / `__next__` (or the async pair). No written call; the site is the iterated expression. | | `SUBSCRIPT_PROTOCOL` | python | `x[k]` runs `__getitem__` (and `x[k] = v` / `del x[k]` the setter and deleter) of the receiver's class. No written call; the site is the subscript expression. Its own kind so it is never counted as a written call. | +| `BUILTIN_PROTOCOL` | python | `repr(x)`, `str(x)`, `len(x)`, `hash(x)`, `bool(x)`, `iter(x)`, `next(x)`, `abs(x)`, `format(x)` and `reversed(x)` run the matching dunder of the argument's class (str falls back to `__repr__`, bool to `__len__`). The written call is to the builtin; this edge is the dunder it runs. The site is the argument expression. Its own kind so it is never counted as a written call. | **`call_edges.tier` values** diff --git a/graph/bundle/schema.ts b/graph/bundle/schema.ts index ac5e5a3c..c70bf076 100644 --- a/graph/bundle/schema.ts +++ b/graph/bundle/schema.ts @@ -671,6 +671,7 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'call_edges', column: 'kind', value: 'CONTEXT_MANAGER', languages: P, meaning: '`with expr:` runs `__enter__` / `__exit__` (or the async pair). No written call; the site is the context-manager expression.' }, { table: 'call_edges', column: 'kind', value: 'ITERATION_PROTOCOL', languages: P, meaning: '`for x in expr:` (and comprehensions) runs `__iter__` / `__next__` (or the async pair). No written call; the site is the iterated expression.' }, { table: 'call_edges', column: 'kind', value: 'SUBSCRIPT_PROTOCOL', languages: P, meaning: '`x[k]` runs `__getitem__` (and `x[k] = v` / `del x[k]` the setter and deleter) of the receiver\'s class. No written call; the site is the subscript expression. Its own kind so it is never counted as a written call.' }, + { table: 'call_edges', column: 'kind', value: 'BUILTIN_PROTOCOL', languages: P, meaning: '`repr(x)`, `str(x)`, `len(x)`, `hash(x)`, `bool(x)`, `iter(x)`, `next(x)`, `abs(x)`, `format(x)` and `reversed(x)` run the matching dunder of the argument\'s class (str falls back to `__repr__`, bool to `__len__`). The written call is to the builtin; this edge is the dunder it runs. The site is the argument expression. Its own kind so it is never counted as a written call.' }, // entry_points.reason { table: 'entry_points', column: 'reason', value: 'main', languages: ['java', 'csharp'], meaning: 'A static `main`. C#: a static `Main`, or the method top-level statements compile to.' }, @@ -749,7 +750,7 @@ export const NOTES: readonly NoteSpec[] = [ { language: 'typescript', table: 'overrides', note: 'EMPTY — this table is Java-shaped. The TypeScript dispatch envelope is in dispatch_candidates, with basis `nominal` or `structural`.' }, { language: 'typescript', table: 'type_instantiated', note: 'Every row has how = `new`. Not restricted to client provenance: a type the library constructs is still a type that exists at run time, and dropping it would narrow the envelope unsoundly.' }, { language: 'typescript', table: 'call_sites', note: 'PROPERTY_READ and PROPERTY_WRITE rows are accessor invocations with no written call: the site is the property-access expression that runs the getter or setter, positioned from the expressions table, and callee_name is NULL because nothing was written; the accessor\'s name is on the callee\'s methods row. Filter them out with kind NOT IN (…) when counting calls.' }, - { language: 'python', table: 'call_sites', note: 'PROPERTY_READ, PROPERTY_WRITE, CONTEXT_MANAGER, ITERATION_PROTOCOL, METACLASS_CREATION and DYNAMIC_CALL rows are protocol or indirect edges with no written call: their site is the expression that triggers them, and callee_name is always NULL because nothing was written. SUBSCRIPT_CALL is NULL only when the subscript is not a written name (measured 206 of 337 rows on a Python subject). Filter them out with kind NOT IN (…) when counting calls.' }, + { language: 'python', table: 'call_sites', note: 'PROPERTY_READ, PROPERTY_WRITE, CONTEXT_MANAGER, ITERATION_PROTOCOL, BUILTIN_PROTOCOL, METACLASS_CREATION and DYNAMIC_CALL rows are protocol or indirect edges with no written call: their site is the expression that triggers them, and callee_name is always NULL because nothing was written. SUBSCRIPT_CALL is NULL only when the subscript is not a written name (measured 206 of 337 rows on a Python subject). Filter them out with kind NOT IN (…) when counting calls.' }, { language: 'python', table: 'call_sites', note: 'The id is an EXPRESSION hash for a written call; a DECORATOR hash (PY_DECORATOR_…) for DECORATOR_APPLICATION and DECORATOR_* sites, positioned at the decorator line; and the class\'s TYPE hash for METACLASS_CREATION, positioned at the class declaration.' }, { language: 'python', table: 'call_edges', note: 'A `boundary_lib` edge may point at a builtin (callee_provenance builtin, callee_label `builtin:NAME`) or at an unstaged import path (callee_provenance external) — neither has a methods row.' }, { language: 'java', table: 'call_edges', note: 'A `boundary_lib` edge with callee_provenance external names a method of an ancestor type no staged IR declares (callee_label `external:.`, no methods row). A site whose receiver is declared as such a type is multi_inferred even with one client override: the platform method itself, and the platform\'s own subclasses, are the other possible targets. Stage the library to replace the label with the real method.' }, diff --git a/graph/python/engine/call-edge-generation/call_chain.dl b/graph/python/engine/call-edge-generation/call_chain.dl index ac802b6a..c527a151 100644 --- a/graph/python/engine/call-edge-generation/call_chain.dl +++ b/graph/python/engine/call-edge-generation/call_chain.dl @@ -682,6 +682,33 @@ subscript_protocol_edge(sub, caller, m) :- mro_lookup("client", t, "__class_getitem__", m), expr_ultimate_method("client", sub, caller). +// ── BUILTIN PROTOCOL — `repr(x)` runs type(x).__repr__ ───────────────────────── +// A builtin function that exists to call one dunder of its argument: repr, str, len, +// hash, bool, iter, next, abs, format, reversed. The written call resolves to the builtin +// (C code, boundary_lib), and the dunder it runs -- a client method, often the one a test +// exists to check -- had no caller: `assert repr(v) == "<...>"` reached nothing of v's. +// Only the BARE builtin name (bound to nothing in the client, so it is the builtin), with +// exactly one argument (`str(b, "utf-8")` decodes and runs no __str__), on an argument the +// engine can type. str() falls back to __repr__ and bool() to __len__ when the class +// defines no __str__ / __bool__ of its own, as CPython's type slots do. The builtin names +// and their dunders are catalogued in resolution/builtins.dl (builtin_protocol_slot). +builtin_protocol_arg(e, fn, x) :- + call_of_expr(e, site), call_callee_is_value(site), call_callee_expr(site, callee), + expr_binding("client", b, _, callee), binding_lookup_unresolved("client", b, fn, _), + builtin_protocol_slot(fn, _), + call_arg(site, "0", x), !call_arg(site, "1", _), !call_kwarg(site, _, _). +// The edge's site is the ARGUMENT expression, as an iteration's is the iterated one: the +// call expression is already the written call to the builtin. +builtin_protocol_edge(x, caller, m) :- + builtin_protocol_arg(_, fn, x), builtin_protocol_slot(fn, d), + expr_type("client", x, t), mro_lookup("client", t, d, m), + expr_ultimate_method("client", x, caller). +builtin_protocol_edge(x, caller, m) :- + builtin_protocol_arg(_, fn, x), builtin_protocol_fallback(fn, d, d2), + expr_type("client", x, t), !type_has_member(t, d), mro_lookup("client", t, d2, m), + expr_ultimate_method("client", x, caller). +type_has_member(t, d) :- builtin_protocol_fallback(_, d, _), mro_lookup("client", t, d, _). + // ── THE THREE PROTOCOL EDGES ARE TIERED BY TARGET COUNT, like every other edge ── // // A property read, a context-manager entry and an iteration all reach their target @@ -725,6 +752,7 @@ protocol_edge(e, d, caller, m) :- property_write_edge(e, caller, m), method_decl protocol_edge(e, d, caller, m) :- with_protocol_edge(e, caller, m), method_decl(_, d, _, _, _, m). protocol_edge(e, d, caller, m) :- iter_protocol_edge(e, caller, m), method_decl(_, d, _, _, _, m). protocol_edge(e, d, caller, m) :- subscript_protocol_edge(e, caller, m), method_decl(_, d, _, _, _, m). +protocol_edge(e, d, caller, m) :- builtin_protocol_edge(e, caller, m), method_decl(_, d, _, _, _, m). // Aggregate in its own stratum, mirroring site_client_target_count above. protocol_edge_target_count(e, d, n) :- @@ -809,6 +837,12 @@ call_chain_edge(sub, caller, "-", m, "client", cls, "SUBSCRIPT_PROTOCOL") :- subscript_protocol_edge(sub, caller, m), method_decl(_, d, _, _, _, m), protocol_edge_class(sub, d, cls). +// BUILTIN PROTOCOL — likewise. Its own kind: the written call is to the builtin, and +// this edge is the dunder that builtin runs, never a second written call. +call_chain_edge(x, caller, "-", m, "client", cls, "BUILTIN_PROTOCOL") :- + builtin_protocol_edge(x, caller, m), method_decl(_, d, _, _, _, m), + protocol_edge_class(x, d, cls). + // A site whose caller could not be determined AT ALL would vanish from every rule // above. That must be impossible (the parser guarantees a non-empty owner), so it is // asserted rather than assumed: any such site is emitted with caller "-" so the diff --git a/graph/python/engine/resolution/builtins.dl b/graph/python/engine/resolution/builtins.dl index 755133bb..f530f72d 100644 --- a/graph/python/engine/resolution/builtins.dl +++ b/graph/python/engine/resolution/builtins.dl @@ -371,6 +371,23 @@ class_definition_keyword("metaclass"). // signal there is: a lambda has no annotation and no `def` to classify. instance_receiver_name("self"). +// ── builtin_protocol_slot(Builtin, Dunder) / builtin_protocol_fallback ─────── +// The one-dunder builtins: `repr(x)` runs type(x).__repr__, and so on. str() falls back to +// __repr__ and bool() to __len__ when the class has no slot of its own. Read by +// call-edge-generation/call_chain.dl's BUILTIN PROTOCOL edges. +builtin_protocol_slot("repr", "__repr__"). +builtin_protocol_slot("str", "__str__"). +builtin_protocol_slot("len", "__len__"). +builtin_protocol_slot("hash", "__hash__"). +builtin_protocol_slot("bool", "__bool__"). +builtin_protocol_slot("iter", "__iter__"). +builtin_protocol_slot("next", "__next__"). +builtin_protocol_slot("abs", "__abs__"). +builtin_protocol_slot("format", "__format__"). +builtin_protocol_slot("reversed", "__reversed__"). +builtin_protocol_fallback("str", "__str__", "__repr__"). +builtin_protocol_fallback("bool", "__bool__", "__len__"). + // ── dict_view_name / dict_view_slot / dict_items_view — a mapping's views ──── // `keys()` yields the key (generic position 0), `values()` the value (position 1), and // `items()` a (key, value) pair. Read by resolution/iteration.dl. diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index fdc9b80b..3ab8b4a1 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -686,3 +686,8 @@ .decl computed_name_value(c0:symbol) .decl call_computed_name_family(c0:symbol) .decl py_fixture_value_class(c0:symbol,c1:symbol) +.decl builtin_protocol_slot(c0:symbol,c1:symbol) +.decl builtin_protocol_fallback(c0:symbol,c1:symbol,c2:symbol) +.decl builtin_protocol_arg(c0:symbol,c1:symbol,c2:symbol) +.decl builtin_protocol_edge(c0:symbol,c1:symbol,c2:symbol) +.decl type_has_member(c0:symbol,c1:symbol) diff --git a/tests/cases/python/builtin-runs-the-dunder/app/__init__.py b/tests/cases/python/builtin-runs-the-dunder/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/builtin-runs-the-dunder/app/validators.py b/tests/cases/python/builtin-runs-the-dunder/app/validators.py new file mode 100644 index 00000000..346245b8 --- /dev/null +++ b/tests/cases/python/builtin-runs-the-dunder/app/validators.py @@ -0,0 +1,40 @@ +def describe_type(t): + return t.__name__ + + +def count_items(): + return 3 + + +def plain_text(): + return "plain" + + +class InstanceOf: + def __init__(self, type): + self.type = type + + def __repr__(self): + return f"" + + +class Bag: + def __len__(self): + return count_items() + + +class Plain: + def __repr__(self): + return plain_text() + + +class Shown: + def __str__(self): + return "shown" + + def __repr__(self): + return plain_text() + + +def instance_of(type): + return InstanceOf(type) diff --git a/tests/cases/python/builtin-runs-the-dunder/case.json b/tests/cases/python/builtin-runs-the-dunder/case.json new file mode 100644 index 00000000..0f6dc7a4 --- /dev/null +++ b/tests/cases/python/builtin-runs-the-dunder/case.json @@ -0,0 +1,17 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "repr(v) runs type(v).__repr__: a test asserting a validator's repr covers what that __repr__ calls", + "run": ["impact", "describe_type", "--tests"], + "want": ["test_repr.py"], + "avoid": ["test_len.py", "test_other.py"]}, + {"why": "len(x) runs __len__, and bool(x) on a class with no __bool__ runs __len__ too", + "run": ["impact", "count_items", "--tests"], + "want": ["test_len.py"], + "avoid": ["test_repr.py"]}, + {"why": "str(x) on a class with no __str__ runs its __repr__", + "run": ["impact", "Plain.__repr__", "--tests"], + "want": ["test_str.py"]}, + {"why": "CONTROL: str(x) on a class that defines __str__ does not run its __repr__, and constructing it runs neither", + "run": ["impact", "Shown.__repr__", "--tests"], + "want": ["tests: 0 of"], + "avoid": ["test_str.py", "test_other.py"]}]} diff --git a/tests/cases/python/builtin-runs-the-dunder/tests/test_len.py b/tests/cases/python/builtin-runs-the-dunder/tests/test_len.py new file mode 100644 index 00000000..d74f9b85 --- /dev/null +++ b/tests/cases/python/builtin-runs-the-dunder/tests/test_len.py @@ -0,0 +1,6 @@ +from app.validators import Bag + + +def test_len(): + assert len(Bag()) == 3 + assert bool(Bag()) diff --git a/tests/cases/python/builtin-runs-the-dunder/tests/test_other.py b/tests/cases/python/builtin-runs-the-dunder/tests/test_other.py new file mode 100644 index 00000000..115c6281 --- /dev/null +++ b/tests/cases/python/builtin-runs-the-dunder/tests/test_other.py @@ -0,0 +1,5 @@ +from app.validators import Shown + + +def test_other(): + assert Shown() is not None diff --git a/tests/cases/python/builtin-runs-the-dunder/tests/test_repr.py b/tests/cases/python/builtin-runs-the-dunder/tests/test_repr.py new file mode 100644 index 00000000..705b30de --- /dev/null +++ b/tests/cases/python/builtin-runs-the-dunder/tests/test_repr.py @@ -0,0 +1,6 @@ +from app.validators import instance_of + + +def test_repr(): + v = instance_of(int) + assert repr(v) == "" diff --git a/tests/cases/python/builtin-runs-the-dunder/tests/test_str.py b/tests/cases/python/builtin-runs-the-dunder/tests/test_str.py new file mode 100644 index 00000000..bd948d90 --- /dev/null +++ b/tests/cases/python/builtin-runs-the-dunder/tests/test_str.py @@ -0,0 +1,9 @@ +from app.validators import Plain, Shown + + +def test_str_falls_back(): + assert str(Plain()) == "plain" + + +def test_str_own(): + assert str(Shown()) == "shown" From 0646c84eea658281c11e69657cd8557cc3dc6bf4 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:53:38 -0700 Subject: [PATCH 16/16] fix(python): a parameter's default value reaches it, as a call-site argument does `def getter(val, field_obj=field_obj): return field_obj.deserialize(val)` -- the closure idiom that pins a loop variable -- is called with one argument, so field_obj only ever holds its default, and no call site passes it: the parameter was untyped and the call on it unresolved. The default is a value the def statement writes once (the parser links it, param_default_expr); value-flow.dl now lets it reach the parameter exactly as a call-site argument does -- an instance types it, a class object makes it that class, a function makes it callable as that function -- alongside whatever callers pass. A parameter defaulted to a name from an enclosing scope appears in fifteen of sixteen public repositories (sentinels and constants included, which type nothing callable). Mutation oracle, sixteen repositories: one tuning repository +13 failing test files selected (16 added files do not fail), none lost, every other repository unchanged. Case parameter-default-types-it fails 2 of 3 on the base; control: an argument passed at a call site still reaches the parameter. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/python/engine/resolution/value-flow.dl | 10 ++++ .../app/__init__.py | 0 .../parameter-default-types-it/app/schema.py | 48 +++++++++++++++++++ .../parameter-default-types-it/case.json | 13 +++++ .../tests/test_emit.py | 5 ++ .../tests/test_load.py | 5 ++ .../tests/test_other.py | 5 ++ 7 files changed, 86 insertions(+) create mode 100644 tests/cases/python/parameter-default-types-it/app/__init__.py create mode 100644 tests/cases/python/parameter-default-types-it/app/schema.py create mode 100644 tests/cases/python/parameter-default-types-it/case.json create mode 100644 tests/cases/python/parameter-default-types-it/tests/test_emit.py create mode 100644 tests/cases/python/parameter-default-types-it/tests/test_load.py create mode 100644 tests/cases/python/parameter-default-types-it/tests/test_other.py diff --git a/graph/python/engine/resolution/value-flow.dl b/graph/python/engine/resolution/value-flow.dl index 83a3747d..6fd224af 100644 --- a/graph/python/engine/resolution/value-flow.dl +++ b/graph/python/engine/resolution/value-flow.dl @@ -112,6 +112,16 @@ param_arg_type(ph, t) :- param_decl("client", pn, _, _, req, ph), py_fixture_value_type(fix, t). +// ── a parameter's DEFAULT VALUE reaches it ──────────────────────────────────── +// `def getter(val, field_obj=field_obj): return field_obj.deserialize(val)` -- the closure +// idiom that pins a loop variable -- is called with one argument, so the only value +// field_obj ever holds is its default, and no call site passes one. The default is an +// argument the def statement writes once, evaluated where the def runs: one more value +// reaching the parameter, alongside whatever the call sites pass. +param_arg_type(ph, t) :- param_default_expr("client", e, ph), expr_type("client", e, t). +param_arg_class(ph, t) :- param_default_expr("client", e, ph), expr_type_class_object("client", e, t). +param_arg_method(ph, m) :- param_default_expr("client", e, ph), expr_denotes_method("client", e, m). + // ── a parameter the runner fills from a PARAMETRIZE row, or from FIXTURE PARAMS ── // `@pytest.mark.parametrize("cls", [String, Integer])` calls the test once per value, so // each value reaches `cls` as an argument would: an instance types it, a class object diff --git a/tests/cases/python/parameter-default-types-it/app/__init__.py b/tests/cases/python/parameter-default-types-it/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/parameter-default-types-it/app/schema.py b/tests/cases/python/parameter-default-types-it/app/schema.py new file mode 100644 index 00000000..9e9827a5 --- /dev/null +++ b/tests/cases/python/parameter-default-types-it/app/schema.py @@ -0,0 +1,48 @@ +def deserialized(): + return "d" + + +def handled(): + return "h" + + +def other_handled(): + return "o" + + +class Field: + def deserialize(self, value): + return deserialized() + + +def default_handler(): + return handled() + + +def other_handler(): + return other_handled() + + +class Schema: + def __init__(self): + self.fields: list[Field] = [Field()] + + def getters(self): + out = [] + for field_obj in self.fields: + def getter(val, field_obj=field_obj): + return field_obj.deserialize(val) + out.append(getter) + return out + + +def load(data): + return [g(data) for g in Schema().getters()] + + +def notify(event, handler=default_handler): + return handler() + + +def emit(): + return notify("e") diff --git a/tests/cases/python/parameter-default-types-it/case.json b/tests/cases/python/parameter-default-types-it/case.json new file mode 100644 index 00000000..76acc212 --- /dev/null +++ b/tests/cases/python/parameter-default-types-it/case.json @@ -0,0 +1,13 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a closure's `field_obj=field_obj` default pins a loop variable: field_obj is a Field, so field_obj.deserialize() resolves and a test of load() covers it", + "run": ["impact", "deserialized", "--tests"], + "want": ["test_load.py"], + "avoid": ["test_emit.py"]}, + {"why": "a callable default (`handler=default_handler`) is what handler() runs when the caller passes none", + "run": ["impact", "handled", "--tests"], + "want": ["test_emit.py"]}, + {"why": "CONTROL: an argument passed at a call site still reaches the parameter alongside the default", + "run": ["impact", "other_handled", "--tests"], + "want": ["test_other.py"], + "avoid": ["test_load.py"]}]} diff --git a/tests/cases/python/parameter-default-types-it/tests/test_emit.py b/tests/cases/python/parameter-default-types-it/tests/test_emit.py new file mode 100644 index 00000000..4eb608e8 --- /dev/null +++ b/tests/cases/python/parameter-default-types-it/tests/test_emit.py @@ -0,0 +1,5 @@ +from app.schema import emit + + +def test_emit(): + assert emit() == "h" diff --git a/tests/cases/python/parameter-default-types-it/tests/test_load.py b/tests/cases/python/parameter-default-types-it/tests/test_load.py new file mode 100644 index 00000000..b4b85e5d --- /dev/null +++ b/tests/cases/python/parameter-default-types-it/tests/test_load.py @@ -0,0 +1,5 @@ +from app.schema import load + + +def test_load(): + assert load(1) == ["d"] diff --git a/tests/cases/python/parameter-default-types-it/tests/test_other.py b/tests/cases/python/parameter-default-types-it/tests/test_other.py new file mode 100644 index 00000000..2084637a --- /dev/null +++ b/tests/cases/python/parameter-default-types-it/tests/test_other.py @@ -0,0 +1,5 @@ +from app.schema import notify, other_handler + + +def test_other(): + assert notify("e", other_handler) == "o"