From 799be100cf33f051f506ddb141f5b5af017aa67a Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:57:42 -0700 Subject: [PATCH 01/31] release 0.1.5: every package and plugin manifest at 0.1.5 (the parser keeps its own version) Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- gemini-extension.json | 2 +- package.json | 12 ++++++------ plugins/axiomcode/.claude-plugin/plugin.json | 2 +- plugins/axiomcode/.codex-plugin/plugin.json | 2 +- plugins/axiomcode/.cursor-plugin/plugin.json | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/gemini-extension.json b/gemini-extension.json index 200b15780..e8f293b1a 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.4", + "version": "0.1.5", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.", "contextFileName": "plugins/axiomcode/AGENTS.md", "mcpServers": { diff --git a/package.json b/package.json index 33e5e5bfb..6d6335c85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@axiomcode/code-graph", - "version": "0.1.4", + "version": "0.1.5", "description": "AxiomCode Graph: a resolved call graph of your codebase grounded in formal methods, so you and your coding agents can see who calls what, what a change breaks, and which tests it reaches.", "repository": { "type": "git", @@ -30,11 +30,11 @@ ], "license": "FSL-1.1-Apache-2.0", "optionalDependencies": { - "@axiomcode/engine-darwin-arm64": "0.1.4", - "@axiomcode/engine-darwin-x64": "0.1.4", - "@axiomcode/engine-linux-x64": "0.1.4", - "@axiomcode/engine-linux-arm64": "0.1.4", - "@axiomcode/engine-win32-x64": "0.1.4" + "@axiomcode/engine-darwin-arm64": "0.1.5", + "@axiomcode/engine-darwin-x64": "0.1.5", + "@axiomcode/engine-linux-x64": "0.1.5", + "@axiomcode/engine-linux-arm64": "0.1.5", + "@axiomcode/engine-win32-x64": "0.1.5" }, "bin": { "axiomcode": "bin/axiomcode.js" diff --git a/plugins/axiomcode/.claude-plugin/plugin.json b/plugins/axiomcode/.claude-plugin/plugin.json index b419564ab..d2d495463 100644 --- a/plugins/axiomcode/.claude-plugin/plugin.json +++ b/plugins/axiomcode/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "axiomcode", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.", - "version": "0.1.4", + "version": "0.1.5", "author": { "name": "AxiomCode" } diff --git a/plugins/axiomcode/.codex-plugin/plugin.json b/plugins/axiomcode/.codex-plugin/plugin.json index 672ebecb5..35878e94b 100644 --- a/plugins/axiomcode/.codex-plugin/plugin.json +++ b/plugins/axiomcode/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.4", + "version": "0.1.5", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.", "author": { "name": "AxiomCode", "url": "https://github.com/AxiomCodeAI/axiomcodegraph" }, "homepage": "https://github.com/AxiomCodeAI/axiomcodegraph", diff --git a/plugins/axiomcode/.cursor-plugin/plugin.json b/plugins/axiomcode/.cursor-plugin/plugin.json index 0edce18ab..538a6f939 100644 --- a/plugins/axiomcode/.cursor-plugin/plugin.json +++ b/plugins/axiomcode/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.4", + "version": "0.1.5", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed. Java, TypeScript, Python, JavaScript.", "author": { "name": "AxiomCode" }, "homepage": "https://github.com/AxiomCodeAI/axiomcodegraph", From a7227526b48e5a153912e460033952f89d18651f Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:10:56 -0700 Subject: [PATCH 02/31] python: a pytest fixture reaches exactly the tests that receive it (#1537, #1539, #1527, #1528, #1529, #1531, #1512) Fixture visibility now follows the runner's own rules instead of a name match. Engine (framework-behavior/dispatch.dl, knobs.dl): - a fixture belongs to every module it is an attribute of, so a conftest's star import makes it that conftest's fixture (#1537) - a module named in pytest_plugins registers its fixtures for the session (#1537) - the registered name is the decorator's name= when it gives one (#1539) - an argument the test's own parametrize supplies directly requests no fixture; an indirect one still does (#1529) - a fixture a class declares serves that class and its subclasses and shadows the module's fixture of the name (#1512) Impact (axiomcode-impact, dl/impact.dl, graph_sql.py): - a parameter is joined to a fixture through the engine's fixture_injection edge where the engine ran, not by name - a fixture injected by name is no longer credited to every test of its file or class (#1527) - a usefixtures marker on a class, a module's pytestmark and the ini usefixtures option reach the tests they apply to (#1528), including a fixture from a plugin module or a star-imported fixtures module - a declaration decorated @fixture is never a test, and conftest.py is never collected (#1531) Cases: graph/test/python/cases/30-fixture-visibility and tests/cases/python/fixture-reaches-its-requesters, each with near-miss controls (a same-named fixture in a sibling directory, a def name that is not the registered name, an unloaded fixtures module, an indirect argument, a sibling class). --- .../python/engine/config-resolution/knobs.dl | 29 +++ .../engine/framework-behavior/dispatch.dl | 121 +++++++++- graph/python/souffle/decls_all.dl | 8 + .../30-fixture-visibility/src/app/__init__.py | 0 .../30-fixture-visibility/src/app/svc.py | 46 ++++ .../30-fixture-visibility/src/conftest.py | 2 + .../src/other/__init__.py | 0 .../src/other/conftest.py | 3 + .../src/other/more_fixtures.py | 8 + .../src/other/test_other.py | 8 + .../src/tests/__init__.py | 0 .../src/tests/conftest.py | 28 +++ .../src/tests/fixtures.py | 19 ++ .../src/tests/helpers.py | 10 + .../src/tests/test_accounts.py | 36 +++ .../src/tests/test_explicit.py | 12 + .../src/tests/test_params.py | 32 +++ .../src/tests/test_svc.py | 41 ++++ .../src/tests/unused_fixtures.py | 14 ++ .../src/testsupport/__init__.py | 0 .../src/testsupport/plugin.py | 13 ++ .../expected/30-fixture-visibility.edges | 23 ++ .../expected/30-fixture-visibility.entries | 10 + .../expected/30-fixture-visibility.framework | 20 ++ .../expected/30-fixture-visibility.tiers | 28 +++ .../skills/axiomcode/scripts/axiomcode-impact | 125 ++++++++++- .../skills/axiomcode/scripts/dl/impact.dl | 33 ++- .../skills/axiomcode/scripts/graph_sql.py | 8 +- .../fixture-reaches-its-requesters/case.json | 208 ++++++++++++++++++ .../conftest.py | 1 + .../other/__init__.py | 0 .../other/conftest.py | 1 + .../other/more.py | 8 + .../other/test_other.py | 9 + .../fixture-reaches-its-requesters/pytest.ini | 2 + .../shop/__init__.py | 0 .../shop/svc.py | 46 ++++ .../support/__init__.py | 0 .../support/plugin.py | 8 + .../tests/__init__.py | 0 .../tests/conftest.py | 35 +++ .../tests/fixtures.py | 8 + .../tests/test_accounts.py | 26 +++ .../tests/test_forms.py | 16 ++ .../tests/test_images.py | 2 + .../tests/test_marker.py | 12 + .../tests/test_plugin_marker.py | 7 + .../tests/test_pytestmark.py | 7 + .../tests/test_svc.py | 35 +++ 49 files changed, 1085 insertions(+), 23 deletions(-) create mode 100644 graph/test/python/cases/30-fixture-visibility/src/app/__init__.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/app/svc.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/conftest.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/other/__init__.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/other/conftest.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/other/more_fixtures.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/other/test_other.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/tests/__init__.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/tests/conftest.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/tests/fixtures.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/tests/helpers.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/tests/test_accounts.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/tests/test_explicit.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/tests/test_params.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/tests/test_svc.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/tests/unused_fixtures.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/testsupport/__init__.py create mode 100644 graph/test/python/cases/30-fixture-visibility/src/testsupport/plugin.py create mode 100644 graph/test/python/expected/30-fixture-visibility.edges create mode 100644 graph/test/python/expected/30-fixture-visibility.entries create mode 100644 graph/test/python/expected/30-fixture-visibility.framework create mode 100644 graph/test/python/expected/30-fixture-visibility.tiers create mode 100644 tests/cases/python/fixture-reaches-its-requesters/case.json create mode 100644 tests/cases/python/fixture-reaches-its-requesters/conftest.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/other/__init__.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/other/conftest.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/other/more.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/other/test_other.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/pytest.ini create mode 100644 tests/cases/python/fixture-reaches-its-requesters/shop/__init__.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/shop/svc.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/support/__init__.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/support/plugin.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/tests/__init__.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/tests/conftest.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/tests/fixtures.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/tests/test_accounts.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/tests/test_forms.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/tests/test_images.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/tests/test_marker.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/tests/test_plugin_marker.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/tests/test_pytestmark.py create mode 100644 tests/cases/python/fixture-reaches-its-requesters/tests/test_svc.py diff --git a/graph/python/engine/config-resolution/knobs.dl b/graph/python/engine/config-resolution/knobs.dl index 74c252ab5..0cf83db79 100644 --- a/graph/python/engine/config-resolution/knobs.dl +++ b/graph/python/engine/config-resolution/knobs.dl @@ -124,6 +124,35 @@ py_task_dispatch_method("apply_async"). // the fixture to produce the argument. The parameter name is the only join. py_fixture_deco("fixture"). +// ── py_fixture_name_kw(Keyword): the decorator argument that names the fixture ─ +// `@pytest.fixture(name="client") def client_fixture()` registers `client`, and a test +// requesting `client_fixture` gets nothing. The documented way to keep a fixture from +// shadowing a module-level name. +py_fixture_name_kw("name"). + +// ── py_fixture_plugins_var(Name): the module global that lists plugin modules ─ +// `pytest_plugins = ["pkg.fixtures"]` (a string, a list or a tuple) makes the runner +// import those modules as plugins, and a plugin's fixtures are visible to every test in +// the session, not only to the directory of the module that listed them. +py_fixture_plugins_var("pytest_plugins"). + +// ── py_parametrize_deco(Tail) and its keywords: a value supplied DIRECTLY ──── +// `@pytest.mark.parametrize("user", [...])` passes the value in, and the fixture called +// `user` is NOT run for that test. With `indirect=True`, or with the name in an +// `indirect=[...]` list, the value goes to the fixture instead and the fixture runs. +py_parametrize_deco("parametrize"). +py_parametrize_argnames_kw("argnames"). +py_parametrize_indirect_kw("indirect"). +py_parametrize_indirect_all("True"). + +// ── 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 +// name is tested as a whole token: led by a separator (with or without the usual single +// space) and followed by one, inside the string wrapped in separators. +py_argnames_sep(","). +py_argnames_lead(","). +py_argnames_lead(", "). + // ── py_test_name_prefix(Prefix) — how a runner recognises a test ───────────── // Injection happens only into a COLLECTED test. Without this, every function whose // parameter happens to share a fixture's name would be wired to it. diff --git a/graph/python/engine/framework-behavior/dispatch.dl b/graph/python/engine/framework-behavior/dispatch.dl index a3f943c01..a2c155e78 100644 --- a/graph/python/engine/framework-behavior/dispatch.dl +++ b/graph/python/engine/framework-behavior/dispatch.dl @@ -69,6 +69,7 @@ .decl py_fw_deco_tail(v:symbol) py_fw_deco_tail(v) :- py_task_register_deco(v). py_fw_deco_tail(v) :- py_fixture_deco(v). +py_fw_deco_tail(v) :- py_parametrize_deco(v). py_fw_deco_tail(v) :- py_signal_receiver_deco(v). py_fw_deco_tail(v) :- py_orm_hook_deco(v). @@ -146,9 +147,20 @@ framework_edge(from, to, "task_dispatch", meth, "registered") :- // The runner matches a test's PARAMETER NAME against the declared fixtures and calls // the fixture to build the argument. The name is the entire join, so the confidence is // `by_name`: it is correct exactly where the runner's scoping agrees with lexical scope. +// +// THE NAME IS THE ONE THE DECORATOR REGISTERS. `@fixture(name="client")` on +// `def client_fixture()` registers `client`, and only a parameter called `client` gets +// it. Keying on the def name wires the wrong parameter and misses the right one. .decl py_fixture_decl(m:symbol, name:symbol) -py_fixture_decl(m, name) :- decorator_target("client", _, m, h), m != "", - py_deco_named(h, v), py_fixture_deco(v), +.decl py_fixture_deco_on(m:symbol, h:symbol) +py_fixture_deco_on(m, h) :- decorator_target("client", _, m, h), m != "", + py_deco_named(h, v), py_fixture_deco(v). +.decl py_fixture_renamed(h:symbol, name:symbol) +py_fixture_renamed(h, name) :- py_fixture_deco_on(_, h), + annotation_arg("client", k, name, "STRING_LITERAL", _, "true", h, _), + py_fixture_name_kw(k), name != "". +py_fixture_decl(m, name) :- py_fixture_deco_on(m, h), py_fixture_renamed(h, name). +py_fixture_decl(m, name) :- py_fixture_deco_on(m, h), !py_fixture_renamed(h, _), method_decl("client", name, _, _, _, m). // The runner calls a requested fixture; nothing in the client does. @@ -191,32 +203,116 @@ py_fixture_requester(m) :- py_fixture_decl(m, _). // strict > excludes it and the commonest place a project keeps shared fixtures // contributes nothing at all. At the root d is "" and the prefix test admits every // requester, which is exactly right. +// +// A FIXTURE BELONGS TO EVERY MODULE IT IS AN ATTRIBUTE OF, not only to the file that +// declares it. The runner registers the fixtures it finds among a module's attributes, +// so `from .fixtures import *` in a conftest, or `from .helpers import helper_fx` in a +// test module, makes the fixture that module's own. Keyed on the declaring file alone, +// a fixture module a conftest star-imports reached no test at all (#1537). +// module_member_method already applies the language's export rule to a star import +// (`__all__`, else no leading underscore), so a name the star does not bind is not +// registered here either. +.decl py_fixture_in_file(fix:symbol, file:symbol) +py_fixture_in_file(fix, p) :- py_fixture_decl(fix, _), method_file("client", p, fix). +py_fixture_in_file(fix, p) :- py_fixture_decl(fix, _), + module_member_method("client", mod, _, fix), module_file("client", p, mod). + .decl py_fixture_conftest(fix:symbol, dir:symbol) -py_fixture_conftest(fix, d) :- py_fixture_decl(fix, _), - method_file("client", p, fix), py_fixture_scope_file(c), +py_fixture_conftest(fix, d) :- py_fixture_in_file(fix, p), py_fixture_scope_file(c), strlen(p) >= strlen(c), substr(p, strlen(p) - strlen(c), strlen(c)) = c, d = substr(p, 0, strlen(p) - strlen(c)). +// py_fixture_plugin(Fixture) -- a fixture of a module some module names in its +// `pytest_plugins` global. A plugin is registered for the whole session, so its fixtures +// are visible to every test, below any conftest or same-module fixture of the name. +// The value is a string, or a list or tuple of strings, each a dotted module name, +// joined to the module by that exact qualified name. +.decl py_fixture_plugin_name(q:symbol) +py_fixture_plugin_name(q) :- py_fixture_plugins_var(n), py_module_level_binding(_, n, b), + expr_binding("client", b, "STORE", tgt), assign_pair("client", tgt, val), + expr_node("client", "LITERAL", _, q, val), q != "". +py_fixture_plugin_name(q) :- py_fixture_plugins_var(n), py_module_level_binding(_, n, b), + expr_binding("client", b, "STORE", tgt), assign_pair("client", tgt, val), + expr_parent("client", val, "ELEMENT", _, el), + expr_node("client", "LITERAL", _, q, el), q != "". +.decl py_fixture_plugin(fix:symbol) +py_fixture_plugin(fix) :- py_fixture_plugin_name(q), module_decl("client", _, q, _, mod), + module_file("client", p, mod), py_fixture_in_file(fix, p). + // A requested parameter: no default, not a receiver, on a test or another fixture. -.decl py_fixture_request(req:symbol, name:symbol) -py_fixture_request(req, name) :- py_fixture_requester(req), +// And NOT an argument the test's own parametrize decorator supplies DIRECTLY: the runner +// passes that value in and never runs the fixture of the name (#1529). An `indirect` +// one is handed to the fixture, which does run, so it stays a request. +.decl py_fixture_param(req:symbol, name:symbol) +py_fixture_param(req, name) :- py_fixture_requester(req), param_decl("client", name, _, _, req, ph), name != "", param_default("client", "false", _, ph), param_kind("client", _, _, "false", ph). -// (a) the test's own module wins outright. +.decl py_parametrize_on(req:symbol, h:symbol) +py_parametrize_on(req, h) :- decorator_target("client", _, req, h), req != "", + py_deco_named(h, v), py_parametrize_deco(v). +// the argnames, as the string "a, b" (first positional, or the keyword) ... +.decl py_parametrize_argnames(h:symbol, s:symbol) +py_parametrize_argnames(h, s) :- py_parametrize_on(_, h), + annotation_arg("client", _, s, "STRING_LITERAL", "0", "false", h, _). +py_parametrize_argnames(h, s) :- py_parametrize_on(_, h), + annotation_arg("client", k, s, "STRING_LITERAL", _, "true", h, _), + py_parametrize_argnames_kw(k). +.decl py_parametrize_argname(req:symbol, h:symbol, name:symbol) +py_parametrize_argname(req, h, name) :- py_fixture_param(req, name), + py_parametrize_on(req, h), py_parametrize_argnames(h, s), + py_argnames_sep(sep), py_argnames_lead(lead), + contains(cat(lead, cat(name, sep)), cat(sep, cat(s, sep))). +// ... or as a list or tuple of strings. +py_parametrize_argname(req, h, name) :- py_fixture_param(req, name), + py_parametrize_on(req, h), decorator_expr("client", de, h), call_arg(de, "0", a), + expr_parent("client", a, "ELEMENT", _, el), + expr_node("client", "LITERAL", _, name, el). +.decl py_parametrize_indirect(h:symbol, name:symbol) +py_parametrize_indirect(h, name) :- py_parametrize_argname(_, h, name), + annotation_arg("client", k, v, _, _, "true", h, _), + py_parametrize_indirect_kw(k), py_parametrize_indirect_all(v). +py_parametrize_indirect(h, name) :- py_parametrize_argname(_, h, name), + decorator_expr("client", de, h), call_kwarg(de, k, a), py_parametrize_indirect_kw(k), + expr_parent("client", a, "ELEMENT", _, el), + expr_node("client", "LITERAL", _, name, el). +.decl py_parametrize_direct(req:symbol, name:symbol) +py_parametrize_direct(req, name) :- py_parametrize_argname(req, h, name), + !py_parametrize_indirect(h, name). + +.decl py_fixture_request(req:symbol, name:symbol) +py_fixture_request(req, name) :- py_fixture_param(req, name), + !py_parametrize_direct(req, name). + +// (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 +// module fixture it overrides was handed to its own (#1512). +.decl py_fixture_class(fix:symbol, t:symbol) +py_fixture_class(fix, t) :- py_fixture_decl(fix, _), method_owner("client", t, fix), t != "". +.decl py_fixture_in_class(req:symbol, name:symbol, fix:symbol) +py_fixture_in_class(req, name, fix) :- py_fixture_request(req, name), + py_fixture_decl(fix, name), fix != req, py_fixture_class(fix, t), + method_owner("client", u, req), type_self_or_ancestor("client", u, t). +.decl py_fixture_class_shadowed(req:symbol, name:symbol) +py_fixture_class_shadowed(req, name) :- py_fixture_in_class(req, name, _). + +// (a) the test's own module wins next: declared at its top level, or imported into it. .decl py_fixture_same_module(req:symbol, name:symbol, fix:symbol) py_fixture_same_module(req, name, fix) :- py_fixture_request(req, name), - py_fixture_decl(fix, name), fix != req, - method_file("client", p, req), method_file("client", p, fix). + py_fixture_decl(fix, name), fix != req, !py_fixture_class(fix, _), + !py_fixture_class_shadowed(req, name), + method_file("client", p, req), py_fixture_in_file(fix, p). .decl py_fixture_shadowed(req:symbol, name:symbol) py_fixture_shadowed(req, name) :- py_fixture_same_module(req, name, _). +py_fixture_shadowed(req, name) :- py_fixture_class_shadowed(req, name). // (b) otherwise a conftest at or above the requester, NEAREST first. Depth is the // length of the governed directory, so the longest prefix is the nearest ancestor. .decl py_fixture_conftest_cand(req:symbol, name:symbol, fix:symbol, depth:number) py_fixture_conftest_cand(req, name, fix, dl) :- py_fixture_request(req, name), - py_fixture_decl(fix, name), fix != req, + py_fixture_decl(fix, name), fix != req, !py_fixture_class(fix, _), py_fixture_conftest(fix, d), method_file("client", p, req), strlen(p) > strlen(d), substr(p, 0, strlen(d)) = d, dl = strlen(d). .decl py_fixture_nearest(req:symbol, name:symbol, depth:number) @@ -224,9 +320,14 @@ py_fixture_nearest(req, name, m) :- py_fixture_conftest_cand(req, name, _, _), m = max dl : { py_fixture_conftest_cand(req, name, _, dl) }. .decl py_fixture_injection(from:symbol, to:symbol, name:symbol) +py_fixture_injection(req, fix, name) :- py_fixture_in_class(req, name, fix). py_fixture_injection(req, fix, name) :- py_fixture_same_module(req, name, fix). py_fixture_injection(req, fix, name) :- py_fixture_conftest_cand(req, name, fix, dl), !py_fixture_shadowed(req, name), py_fixture_nearest(req, name, dl). +// (c) failing both, a plugin's fixture, which every test sees. +py_fixture_injection(req, fix, name) :- py_fixture_request(req, name), + py_fixture_decl(fix, name), fix != req, py_fixture_plugin(fix), !py_fixture_class(fix, _), + !py_fixture_shadowed(req, name), !py_fixture_nearest(req, name, _). .decl py_fixture_requested(m:symbol) py_fixture_requested(m) :- py_fixture_injection(_, m, _). diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index 4a52c2810..6a1396f3e 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -46,6 +46,14 @@ .decl py_fixture_deco(v:symbol) .decl py_fixture_scope_file(v:symbol) .decl py_test_name_prefix(v:symbol) +.decl py_fixture_name_kw(v:symbol) +.decl py_fixture_plugins_var(v:symbol) +.decl py_parametrize_deco(v:symbol) +.decl py_parametrize_argnames_kw(v:symbol) +.decl py_parametrize_indirect_kw(v:symbol) +.decl py_parametrize_indirect_all(v:symbol) +.decl py_argnames_sep(v:symbol) +.decl py_argnames_lead(v:symbol) .decl py_route_table(v:symbol) .decl py_route_entry_fn(v:symbol) .decl py_signal_receiver_deco(v:symbol) diff --git a/graph/test/python/cases/30-fixture-visibility/src/app/__init__.py b/graph/test/python/cases/30-fixture-visibility/src/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/python/cases/30-fixture-visibility/src/app/svc.py b/graph/test/python/cases/30-fixture-visibility/src/app/svc.py new file mode 100644 index 000000000..6bf27ae5d --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/app/svc.py @@ -0,0 +1,46 @@ +"""Production code. Each function is reached from a test ONLY through one fixture body, +so which fixture a test is wired to decides which of these it reaches.""" + + +def make_repo(): + return "repo" + + +def make_service(): + return "service" + + +def make_cache(): + return "cache" + + +def make_client(): + return "client" + + +def make_user(name): + return {"name": name} + + +def make_widget(): + return "widget" + + +def make_orphan(): + return "orphan" + + +def make_helper(): + return "helper" + + +def make_hidden(): + return "hidden" + + +def module_account(): + return "module" + + +def class_account(): + return "class" diff --git a/graph/test/python/cases/30-fixture-visibility/src/conftest.py b/graph/test/python/cases/30-fixture-visibility/src/conftest.py new file mode 100644 index 000000000..59956a286 --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/conftest.py @@ -0,0 +1,2 @@ +"""The root conftest, and the one place a `pytest_plugins` list is honoured.""" +pytest_plugins = ["testsupport.plugin"] diff --git a/graph/test/python/cases/30-fixture-visibility/src/other/__init__.py b/graph/test/python/cases/30-fixture-visibility/src/other/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/python/cases/30-fixture-visibility/src/other/conftest.py b/graph/test/python/cases/30-fixture-visibility/src/other/conftest.py new file mode 100644 index 000000000..aab7d61cf --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/other/conftest.py @@ -0,0 +1,3 @@ +"""A sibling directory's conftest, star-importing its own fixture module by its +ABSOLUTE name (tests/conftest.py uses the relative spelling).""" +from other.more_fixtures import * diff --git a/graph/test/python/cases/30-fixture-visibility/src/other/more_fixtures.py b/graph/test/python/cases/30-fixture-visibility/src/other/more_fixtures.py new file mode 100644 index 000000000..07b5de802 --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/other/more_fixtures.py @@ -0,0 +1,8 @@ +import pytest + +from app.svc import make_widget + + +@pytest.fixture +def widget(): + return make_widget() diff --git a/graph/test/python/cases/30-fixture-visibility/src/other/test_other.py b/graph/test/python/cases/30-fixture-visibility/src/other/test_other.py new file mode 100644 index 000000000..fc796a332 --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/other/test_other.py @@ -0,0 +1,8 @@ +def test_widget(widget): + """other/more_fixtures.py::widget, through this directory's conftest.""" + assert widget + + +def test_service_here(service): + """testsupport/plugin.py::service: a plugin fixture is visible outside tests/ too.""" + assert service diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/__init__.py b/graph/test/python/cases/30-fixture-visibility/src/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/conftest.py b/graph/test/python/cases/30-fixture-visibility/src/tests/conftest.py new file mode 100644 index 000000000..887e35943 --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/tests/conftest.py @@ -0,0 +1,28 @@ +"""Four ways a fixture reaches this directory's tests, and one way it does not. + + cache declared here + repo star-imported from .fixtures + client registered under the name given to the decorator; the def is + `client_fixture`, and a test asking for `client_fixture` gets nothing + user requested three ways by tests/test_params.py, one of which shadows it +""" +import pytest + +from app.svc import make_cache, make_client, make_user + +from .fixtures import * + + +@pytest.fixture +def cache(): + return make_cache() + + +@pytest.fixture(name="client") +def client_fixture(): + return make_client() + + +@pytest.fixture +def user(): + return make_user("default") diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/fixtures.py b/graph/test/python/cases/30-fixture-visibility/src/tests/fixtures.py new file mode 100644 index 000000000..487b895ef --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/tests/fixtures.py @@ -0,0 +1,19 @@ +"""Fixtures that reach tests only because tests/conftest.py star-imports this module. + +The runner registers every public attribute of a conftest, and `from .fixtures import *` +makes `repo` one. `_hidden` is not re-exported by a star import (a leading underscore), +so it is not a conftest attribute and no test may reach it through the star. +""" +import pytest + +from app.svc import make_hidden, make_repo + + +@pytest.fixture +def repo(): + return make_repo() + + +@pytest.fixture +def _hidden(): + return make_hidden() diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/helpers.py b/graph/test/python/cases/30-fixture-visibility/src/tests/helpers.py new file mode 100644 index 000000000..25a1fb28e --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/tests/helpers.py @@ -0,0 +1,10 @@ +"""Imported by NAME into one test module, which makes `helper_fx` an attribute of that +module and so a fixture the runner finds there, and only there.""" +import pytest + +from app.svc import make_helper + + +@pytest.fixture +def helper_fx(): + return make_helper() diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/test_accounts.py b/graph/test/python/cases/30-fixture-visibility/src/tests/test_accounts.py new file mode 100644 index 000000000..74ee5a01c --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/tests/test_accounts.py @@ -0,0 +1,36 @@ +"""A fixture a class declares serves that class and its subclasses, and overrides the module's.""" +import pytest + +from app.svc import class_account, module_account + + +@pytest.fixture +def account(): + return module_account() + + +class TestOverride: + @pytest.fixture + def account(self): + return class_account() + + def test_in_class(self, account): + """TestOverride.account only: the class fixture shadows the module one.""" + assert account == "class" + + +class TestOverrideChild(TestOverride): + def test_in_subclass(self, account): + """TestOverride.account, inherited.""" + assert account == "class" + + +class TestPlain: + def test_no_override(self, account): + """the module fixture: a SIBLING class's fixture is not visible here.""" + assert account == "module" + + +def test_module_level(account): + """the module fixture.""" + assert account == "module" diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/test_explicit.py b/graph/test/python/cases/30-fixture-visibility/src/tests/test_explicit.py new file mode 100644 index 000000000..4e8ecf7b4 --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/tests/test_explicit.py @@ -0,0 +1,12 @@ +"""A fixture imported by name into the test module is a fixture of this module.""" +from .helpers import helper_fx + + +def test_helper(helper_fx): + """tests/helpers.py::helper_fx, through the import above.""" + assert helper_fx + + +def test_repo_here(repo): + """tests/fixtures.py::repo, through the conftest star import, as in test_svc.py.""" + assert repo diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/test_params.py b/graph/test/python/cases/30-fixture-visibility/src/tests/test_params.py new file mode 100644 index 000000000..1b7dafc3d --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/tests/test_params.py @@ -0,0 +1,32 @@ +"""A DIRECT parametrize argname is a value the runner passes in, not a fixture request. +An indirect one goes through the fixture.""" +import pytest + + +@pytest.mark.parametrize("user", [{"name": "direct"}]) +def test_direct(user): + """NOTHING: `user` is supplied by the parametrize decorator.""" + assert user["name"] == "direct" + + +@pytest.mark.parametrize("user, cache", [({"name": "a"}, "c")]) +def test_direct_pair(user, cache): + """NOTHING for either: both argnames are parametrized directly.""" + assert user and cache + + +@pytest.mark.parametrize("user", ["other"], indirect=True) +def test_indirect(user): + """tests/conftest.py::user, which receives "other" as request.param.""" + assert user + + +@pytest.mark.parametrize("n", [1, 2]) +def test_other_argname(n, user): + """tests/conftest.py::user: only `n` is parametrized.""" + assert user and n + + +def test_plain(user): + """tests/conftest.py::user.""" + assert user diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/test_svc.py b/graph/test/python/cases/30-fixture-visibility/src/tests/test_svc.py new file mode 100644 index 000000000..7009ddbb3 --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/tests/test_svc.py @@ -0,0 +1,41 @@ +"""The requesters. Each test names one fixture; the comment says which def runs.""" + + +def test_repo(repo): + """tests/fixtures.py::repo, through the conftest's star import.""" + assert repo + + +def test_service(service): + """testsupport/plugin.py::service, through the root conftest's pytest_plugins.""" + assert service + + +def test_cache(cache): + """tests/conftest.py::cache, declared in the conftest. The control that always worked.""" + assert cache + + +def test_client(client): + """tests/conftest.py::client_fixture, registered as `client`.""" + assert client + + +def test_client_by_def_name(client_fixture): + """NOTHING: the fixture is called `client`, and `client_fixture` names no fixture.""" + assert client_fixture + + +def test_orphan(orphan): + """NOTHING: tests/unused_fixtures.py is never loaded.""" + assert orphan + + +def test_hidden(_hidden): + """NOTHING: a star import does not re-export a leading-underscore name.""" + assert _hidden + + +def test_widget(widget): + """NOTHING: `widget` is star-imported into other/conftest.py, a SIBLING directory.""" + assert widget diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/unused_fixtures.py b/graph/test/python/cases/30-fixture-visibility/src/tests/unused_fixtures.py new file mode 100644 index 000000000..58b1166f3 --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/tests/unused_fixtures.py @@ -0,0 +1,14 @@ +"""A fixture module that NOTHING imports and no conftest names. + +`orphan` is declared with the fixture decorator and a test requests a parameter called +`orphan`, but the runner never loads this module, so the request fails. The name alone +is not visibility. +""" +import pytest + +from app.svc import make_orphan + + +@pytest.fixture +def orphan(): + return make_orphan() diff --git a/graph/test/python/cases/30-fixture-visibility/src/testsupport/__init__.py b/graph/test/python/cases/30-fixture-visibility/src/testsupport/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/python/cases/30-fixture-visibility/src/testsupport/plugin.py b/graph/test/python/cases/30-fixture-visibility/src/testsupport/plugin.py new file mode 100644 index 000000000..ce3972c56 --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/testsupport/plugin.py @@ -0,0 +1,13 @@ +"""A plugin module, loaded because the root conftest lists it in `pytest_plugins`. + +A plugin's fixtures are registered for the whole session, so every test sees `service`, +including the one under other/, which is outside the conftest's own directory. +""" +import pytest + +from app.svc import make_service + + +@pytest.fixture +def service(): + return make_service() diff --git a/graph/test/python/expected/30-fixture-visibility.edges b/graph/test/python/expected/30-fixture-visibility.edges new file mode 100644 index 000000000..cafafc473 --- /dev/null +++ b/graph/test/python/expected/30-fixture-visibility.edges @@ -0,0 +1,23 @@ +ambiguous_unknown DECORATOR_APPLICATION tests.conftest. -> - +ambiguous_unknown DECORATOR_APPLICATION tests.test_params. -> - +ambiguous_unknown DECORATOR_ATTRIBUTE other.more_fixtures. -> - +ambiguous_unknown DECORATOR_ATTRIBUTE tests.conftest. -> - +ambiguous_unknown DECORATOR_ATTRIBUTE tests.fixtures. -> - +ambiguous_unknown DECORATOR_ATTRIBUTE tests.helpers. -> - +ambiguous_unknown DECORATOR_ATTRIBUTE tests.test_accounts. -> - +ambiguous_unknown DECORATOR_ATTRIBUTE tests.test_accounts.TestOverride. -> - +ambiguous_unknown DECORATOR_ATTRIBUTE tests.unused_fixtures. -> - +ambiguous_unknown DECORATOR_ATTRIBUTE testsupport.plugin. -> - +boundary_lib DECORATOR_CALL tests.conftest. -> external:pytest.fixture +boundary_lib DECORATOR_CALL tests.test_params. -> external:pytest.mark.parametrize +known_edge SIMPLE_CALL other.more_fixtures.widget -> app.svc.make_widget +known_edge SIMPLE_CALL tests.conftest.cache -> app.svc.make_cache +known_edge SIMPLE_CALL tests.conftest.client_fixture -> app.svc.make_client +known_edge SIMPLE_CALL tests.conftest.user -> app.svc.make_user +known_edge SIMPLE_CALL tests.fixtures._hidden -> app.svc.make_hidden +known_edge SIMPLE_CALL tests.fixtures.repo -> app.svc.make_repo +known_edge SIMPLE_CALL tests.helpers.helper_fx -> app.svc.make_helper +known_edge SIMPLE_CALL tests.test_accounts.TestOverride.account -> app.svc.class_account +known_edge SIMPLE_CALL tests.test_accounts.account -> app.svc.module_account +known_edge SIMPLE_CALL tests.unused_fixtures.orphan -> app.svc.make_orphan +known_edge SIMPLE_CALL testsupport.plugin.service -> app.svc.make_service diff --git a/graph/test/python/expected/30-fixture-visibility.entries b/graph/test/python/expected/30-fixture-visibility.entries new file mode 100644 index 000000000..5d5c2f148 --- /dev/null +++ b/graph/test/python/expected/30-fixture-visibility.entries @@ -0,0 +1,10 @@ +── entry_point (9) ── + fixture other.more_fixtures.widget other/more_fixtures.py:7 + fixture tests.conftest.cache tests/conftest.py:17 + fixture tests.conftest.client_fixture tests/conftest.py:22 + fixture tests.conftest.user tests/conftest.py:27 + fixture tests.fixtures.repo tests/fixtures.py:13 + fixture tests.helpers.helper_fx tests/helpers.py:9 + fixture tests.test_accounts.TestOverride.account tests/test_accounts.py:14 + fixture tests.test_accounts.account tests/test_accounts.py:8 + fixture testsupport.plugin.service testsupport/plugin.py:12 diff --git a/graph/test/python/expected/30-fixture-visibility.framework b/graph/test/python/expected/30-fixture-visibility.framework new file mode 100644 index 000000000..94e97b891 --- /dev/null +++ b/graph/test/python/expected/30-fixture-visibility.framework @@ -0,0 +1,20 @@ +── framework_edge (15) ── + fixture_injection by_name other.test_other.test_service_here (other/test_other.py) -> testsupport.plugin.service (testsupport/plugin.py) [service] + fixture_injection by_name other.test_other.test_widget (other/test_other.py) -> other.more_fixtures.widget (other/more_fixtures.py) [widget] + fixture_injection by_name tests.test_accounts.TestOverride.test_in_class (tests/test_accounts.py) -> tests.test_accounts.TestOverride.account (tests/test_accounts.py) [account] + fixture_injection by_name tests.test_accounts.TestOverrideChild.test_in_subclass (tests/test_accounts.py) -> tests.test_accounts.TestOverride.account (tests/test_accounts.py) [account] + fixture_injection by_name tests.test_accounts.TestPlain.test_no_override (tests/test_accounts.py) -> tests.test_accounts.account (tests/test_accounts.py) [account] + fixture_injection by_name tests.test_accounts.test_module_level (tests/test_accounts.py) -> tests.test_accounts.account (tests/test_accounts.py) [account] + fixture_injection by_name tests.test_explicit.test_helper (tests/test_explicit.py) -> tests.helpers.helper_fx (tests/helpers.py) [helper_fx] + fixture_injection by_name tests.test_explicit.test_repo_here (tests/test_explicit.py) -> tests.fixtures.repo (tests/fixtures.py) [repo] + fixture_injection by_name tests.test_params.test_indirect (tests/test_params.py) -> tests.conftest.user (tests/conftest.py) [user] + fixture_injection by_name tests.test_params.test_other_argname (tests/test_params.py) -> tests.conftest.user (tests/conftest.py) [user] + fixture_injection by_name tests.test_params.test_plain (tests/test_params.py) -> tests.conftest.user (tests/conftest.py) [user] + fixture_injection by_name tests.test_svc.test_cache (tests/test_svc.py) -> tests.conftest.cache (tests/conftest.py) [cache] + fixture_injection by_name tests.test_svc.test_client (tests/test_svc.py) -> tests.conftest.client_fixture (tests/conftest.py) [client] + fixture_injection by_name tests.test_svc.test_repo (tests/test_svc.py) -> tests.fixtures.repo (tests/fixtures.py) [repo] + fixture_injection by_name tests.test_svc.test_service (tests/test_svc.py) -> testsupport.plugin.service (testsupport/plugin.py) [service] +── framework_unjoined (0) ── +── remote_edge (0) ── +── remote_unserved (0) ── +── remote_unsent (0) ── diff --git a/graph/test/python/expected/30-fixture-visibility.tiers b/graph/test/python/expected/30-fixture-visibility.tiers new file mode 100644 index 000000000..ea812c21a --- /dev/null +++ b/graph/test/python/expected/30-fixture-visibility.tiers @@ -0,0 +1,28 @@ +distinct call sites emitted: 31 + +--- by tier: edge ROWS, and the distinct SITES they cover --- + 15 rows 15 sites ambiguous_unknown + 5 rows 5 sites boundary_lib + 11 rows 11 sites known_edge + +--- edge rows by call kind --- + 5 DECORATOR_APPLICATION + 10 DECORATOR_ATTRIBUTE + 5 DECORATOR_CALL + 11 SIMPLE_CALL + +--- unresolved reasons --- + 5 decorator_factory_result_untyped + 10 unmodelled_decorator + +--- the engine's own conservation ledger --- + 31 _total_sites + 15 ambiguous_unknown + 5 boundary_lib + 11 known_edge + +--- reconciling rows against the conserved site count --- + edge rows 31 + minus extra rows from multi-target sites 0 + = tier/site pairs 31 + engine's conserved site total 31 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 467ec300e..965b9e33e 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -129,8 +129,11 @@ TEST_DECOR = re.compile(r'(^|\.)(\w*Test\w*|Fact|Theory|it|test)$') # `RFC2616PolicyTestMixin`, `StorageTestMixin`, `TestBase` -- declares real tests, collected # through a subclass named Test*. Anchoring the name dropped every one of them. TEST_OWNER = re.compile(r'(Test|Spec|ITCase)') -# a decoration that says the method is a dependency the framework builds, not a test -NON_TEST_DECOR = re.compile(r'^(Bean|Configuration|Component|Provides|Produces|TestConfiguration)$') +# a decoration that says the method is a dependency the framework builds, not a test. A pytest `@fixture` is one +# whatever its name: `def test_image()` under it is built for the tests that request it, never collected (#1531). +NON_TEST_DECOR = re.compile(r'^(Bean|Configuration|Component|Provides|Produces|TestConfiguration|fixture)$') +# a file the runner imports for its fixtures and hooks and never collects tests from +NON_TEST_FILE = re.compile(r'(^|/)conftest\.py$') RET_TYPE = re.compile(r'\)\s*:\s*(.+)$') # a URL path as a router accepts it: path-absolute (RFC 3986 §3.3) with no whitespace and no quote in it. A path # parameter — `{id}`, ``, `:id`, `*` — is left exactly as written: its spelling is per framework, and the @@ -983,6 +986,7 @@ class Impact: # pytest function, a module-level `function testX()`), or a type that is a test class. if not s['name'].startswith(('test', 'it')): return False if any(NON_TEST_DECOR.match(d) for d in decs.get(i, ())): return False + if NON_TEST_FILE.search(s.get('file') or ''): return False own = (s.get('owner') or '').split('.')[-1] if own and not TEST_OWNER.search(own): return False # JUnit 3 reads the convention on `public void testX()`. A method that DECLARES a return @@ -1024,24 +1028,133 @@ class Impact: # Three facts, so the JOIN stays a rule: the parameter names a callable takes, the name and path a fixture # serves under, and the prefixes a file lies under. A fixture in conftest.py serves its directory subtree # (pytest's own rule); any other fixture serves its own file. + # + # WHERE THE ENGINE RESOLVED THE INJECTION, ITS ANSWER IS THE ONE USED. The Python engine's fixture_injection + # edge applies the runner's own resolution: the fixture name the decorator registers (`name=`), the fixtures + # a conftest star-imports or a `pytest_plugins` list loads, the nearest conftest winning, and an argument the + # test's own parametrize supplies directly requesting nothing. The name join below knows none of that, so + # for a file the engine covered it is not written at all: on a Python graph it credited `make_repo`, reached + # through a star-imported fixture, to 0 tests, and a directly parametrized `user` to its fixture (#1537, #1529). fdir = lambda f: f.rsplit('/', 1)[0] + '/' if '/' in f else '' + dtext = collections.defaultdict(list) + for r in (g.q("SELECT owner_id, name, text FROM decorations") if g.has('decorations') else []): dtext[r[0]].append((r[1], r[2] or '')) + engine_fixtures = g.has('ext_framework_edge') + is_py = lambda i: re.search(r'\.pyi?$', (g.sym.get(i) or {}).get('file') or '') is not None + inj = [] + if engine_fixtures: + for c0, c1 in g.q("SELECT c0, c1 FROM ext_framework_edge WHERE c2 = 'fixture_injection'"): + if c0 in g.sym and c1 in g.sym: inj.append((c0, c1)) + W('injects', sorted(set(inj))) params = [] for i, sy in g.sym.items(): if not sy.get('method_id') or sy['kind'] == 'module': continue + if engine_fixtures and is_py(i): continue sig = sy.get('signature') or '' for n in re.findall(r'[A-Za-z_]\w*', sig[sig.find('(') + 1:sig.rfind(')')] if '(' in sig else ''): if n != 'self' and n != 'cls': params.append((i, n)) W('takes_param', sorted(set(params))) + # A fixture pytest INJECTS BY NAME runs for the tests that request it, and for no other test beside it. + # The rules that carry a fixture to every test of its file or its class are right for a setUp, a module + # setup or an autouse fixture, and wrong for this one: a module-level `@pytest.fixture def cart()` was + # credited to every test in its file, requested or not (#1527). Such a fixture reaches tests through + # uses_fixture only. An autouse fixture is not in this set: it does run for every test in its scope. + FIX_NAME = re.compile(r'\bname\s*=\s*[\'"]([^\'"]+)[\'"]') + named = [] + for i, ds in dtext.items(): + if i not in g.sym or not is_py(i): continue + fd = [t for n, t in ds if (n or '').split('.')[-1] == 'fixture'] + if fd and not any(re.search(r'autouse\s*=\s*True', t) for t in fd): named.append((i,)) + W('injected_fixture', sorted(set(named))) + # A fixture is ALSO visible where pytest registers the module holding it: a conftest's `from X import *` makes + # X's fixtures that conftest's (its directory), and a module named in `pytest_plugins` is loaded for the whole + # session (every path). The parameter join reads the engine's resolution; a usefixtures marker names the fixture + # as a string and joins here, so without these rows a marker naming a plugin's fixture reached nothing (#1537). + import ast + pyfiles = sorted({sy.get('file') for sy in g.sym.values() if (sy.get('file') or '').endswith('.py')}) + def module_file(dotted, base=''): + tails = (dotted.replace('.', '/') + '.py', dotted.replace('.', '/') + '/__init__.py') + return [f for f in pyfiles if any(f == base + t or (not base and f.endswith('/' + t)) for t in tails)] + extra_scope = collections.defaultdict(set) + for f in pyfiles: + conf = f.rsplit('/', 1)[-1] == 'conftest.py' + src = '\n'.join(self.lines(f)) + if not conf and 'pytest_plugins' not in src: continue + if conf and 'import' not in src and 'pytest_plugins' not in src: continue + try: tree = ast.parse(src) + except (SyntaxError, ValueError): continue + for st in tree.body: + if conf and isinstance(st, ast.ImportFrom) and any(a.name == '*' for a in st.names): + base = fdir(f) + if st.level: + for _ in range(st.level - 1): base = fdir(base.rstrip('/')) + tgt = module_file(st.module or '', base) if st.module else [] + else: + tgt = module_file(st.module or '') + for t in tgt: extra_scope[t].add(fdir(f)) + tg = st.targets if isinstance(st, ast.Assign) else [] + if any(isinstance(t, ast.Name) and t.id == 'pytest_plugins' for t in tg): + for c in ast.walk(st.value): + if isinstance(c, ast.Constant) and isinstance(c.value, str): + for t in module_file(c.value): extra_scope[t].add('') scope = []; auto = [] - dtext = collections.defaultdict(list) - for r in (g.q("SELECT owner_id, name, text FROM decorations") if g.has('decorations') else []): dtext[r[0]].append((r[1], r[2] or '')) - for i in fx: + # a pytest fixture outside a test file (a plugin module, a fixtures module a conftest star-imports) is still + # one a marker can name: `fx` holds only test-file declarations + for i in sorted(set(fx) | {i for (i,) in named} | {i for i, ds in dtext.items() if i in g.sym and is_py(i) and any((n or '').split('.')[-1] == 'fixture' for n, _ in ds)}): sy = g.sym.get(i) or {} f = sy.get('file') or '' if not sy.get('name'): continue - scope.append((i, sy['name'], fdir(f) if f.rsplit('/', 1)[-1] == 'conftest.py' else f)) + # the name a marker or a parameter asks for is the one the decorator registers, when it gives one + reg = next((m.group(1) for n, t in dtext.get(i, ()) if (n or '').split('.')[-1] == 'fixture' for m in [FIX_NAME.search(t)] if m), sy['name']) + scope.append((i, reg, fdir(f) if f.rsplit('/', 1)[-1] == 'conftest.py' else f)) + scope.extend((i, reg, p) for p in extra_scope.get(f, ())) if any(re.search(r'autouse\s*=\s*True', t) for _, t in dtext.get(i, ())): auto.append((i,)) W('fixture_scope', sorted(set(scope))); W('autouse_fixture', sorted(set(auto))) + # A usefixtures marker that decorates NO declaration: a module's `pytestmark = pytest.mark.usefixtures("x")` + # (or a list of marks) applies to every test in that module, and the ini option `usefixtures = x y` (pytest.ini, + # tox.ini, setup.cfg, pyproject.toml) to every test under the directory holding that file. Neither is a + # decoration, so the marker rule never saw them and the fixture's callees reached no test (#1528). Written as + # (path prefix, fixture name): a file for pytestmark, a directory for the ini. + mark_scope = set() + py_test_files = sorted({(g.sym.get(m) or {}).get('file') or '' for m in tm} - {''}) + py_test_files = [f for f in py_test_files if f.endswith('.py')] + def usefixture_names(node): + for c in ast.walk(node): + if isinstance(c, ast.Call) and (getattr(c.func, 'attr', None) or getattr(c.func, 'id', None)) == 'usefixtures': + for a in c.args: + if isinstance(a, ast.Constant) and isinstance(a.value, str): yield a.value + for f in py_test_files: + src = '\n'.join(self.lines(f)) + if 'pytestmark' not in src: continue + try: tree = ast.parse(src) + except (SyntaxError, ValueError): continue + for st in tree.body: + tg = st.targets if isinstance(st, ast.Assign) else [st.target] if isinstance(st, ast.AnnAssign) and st.value else [] + if any(isinstance(t, ast.Name) and t.id == 'pytestmark' for t in tg): + mark_scope |= {(f, n) for n in usefixture_names(st.value)} + dirs = set() + for f in py_test_files: + parts = f.split('/')[:-1] + for k in range(len(parts) + 1): dirs.add('/'.join(parts[:k])) + import configparser + for d in sorted(dirs): + pre = d + '/' if d else '' + for name, sect in (('pytest.ini', 'pytest'), ('tox.ini', 'pytest'), ('setup.cfg', 'tool:pytest'), ('pyproject.toml', None)): + fp = os.path.join(g.repo, pre + name) + if not os.path.isfile(fp): continue + val = None + try: + if sect: + cp = configparser.RawConfigParser(); cp.read(fp) + if cp.has_option(sect, 'usefixtures'): val = cp.get(sect, 'usefixtures') + else: + t = open(fp, errors='replace').read() + blk = re.search(r'^\[tool\.pytest\.ini_options\]\s*$(.*?)(?=^\[|\Z)', t, re.M | re.S) + mv = blk and re.search(r'^usefixtures\s*=\s*(\[[^\]]*\]|"[^"]*"|\'[^\']*\')', blk.group(1), re.M) + if mv: val = ' '.join(re.findall(r'[\'"]([^\'"]*)[\'"]', mv.group(1))) + except (OSError, configparser.Error, UnicodeDecodeError): + val = None + if val: mark_scope |= {(pre, n) for n in re.split(r'[\s,]+', val) if re.fullmatch(r'[A-Za-z_]\w*', n)} + W('usefixtures_scope', sorted(mark_scope)) fsc = set() for i, sy in g.sym.items(): f = sy.get('file') or '' diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index d7f708168..ba27e97a2 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -89,6 +89,14 @@ .decl fixture_scope(fx:symbol, n:symbol, p:symbol) .input fixture_scope .decl file_scope(f:symbol, p:symbol) .input file_scope .decl autouse_fixture(fx:symbol) .input autouse_fixture +// injects(m, fx) the language engine resolved m's request to fixture fx (Python: the runner's own +// visibility, the decorator's `name=`, star imports and `pytest_plugins`) +// injected_fixture(fx) fx is injected by name and runs only for what requests it: not every test of its file +.decl injects(m:symbol, fx:symbol) .input injects +.decl injected_fixture(fx:symbol) .input injected_fixture +// usefixtures_scope(p, n) a usefixtures marker no declaration carries: a module's `pytestmark` (p is that +// file) or the ini option (p is the directory holding the ini file) +.decl usefixtures_scope(p:symbol, n:symbol) .input usefixtures_scope .decl kind(s:symbol, k:symbol) .input kind .decl decl_file(s:symbol, f:symbol) .input decl_file .decl lex_parent(inner:symbol, outer:symbol) .input lex_parent @@ -774,14 +782,16 @@ imports_file(f, h) :- imports_file(f, g), imports_file(g, h), f != h. // ── tests: a test's own body reaches the change, or a fixture its framework runs before it does ─────────────── .decl test_hit(q:symbol, m:symbol, d:number, via:symbol) test_hit(q, m, d, "") :- reach(q, m, d), test_method(m). -test_hit(q, m, d, fx) :- reach(q, fx, d), fixture(fx), owner(fx, t), scope(t, s), member(s, m, _, _), test_method(m). +test_hit(q, m, d, fx) :- reach(q, fx, d), fixture(fx), !injected_fixture(fx), owner(fx, t), scope(t, s), member(s, m, _, _), test_method(m). test_hit(q, m, d, fx) :- reach(q, fx, d), fixture(fx), typ(fx, _, _), member(fx, m, _, _), test_method(m). // a test file is imported as a whole: a class body or a fixture at its top level runs before every test in it — pytest // injects a fixture by parameter name and a the Python validation library model class is built at import, and neither is a call edge // An OWNED fixture is already carried, scoped to its type, by the rule above. Only a fixture that // belongs to no type -- a conftest.py fixture, a module-level setup -- has the file as its scope; a // constructor is a fixture too, and file-keyed it carried a sibling @Nested class's tests. -test_hit(q, m, d, c) :- reach(q, c, d), decl_file(c, f), decl_file(m, f), test_method(m), !test_method(c), (kind(c, "module") ; (fixture(c), !owner(c, _))). +// A fixture injected BY NAME is not one of these: it runs for the tests that request it (uses_fixture below), and a +// module-level `@pytest.fixture` credited to every test in its file counted tests that never ran it (#1527). +test_hit(q, m, d, c) :- reach(q, c, d), decl_file(c, f), decl_file(m, f), test_method(m), !test_method(c), (kind(c, "module") ; (fixture(c), !owner(c, _), !injected_fixture(c))). // a CLASS body runs before the tests that class declares, and before those of a type nested in it or deriving from // it -- which is what scope/2 already ranges over. Keyed on the file instead, one Java source file is one unit, so a // reached @Nested class carried every test of its SIBLING classes too. @@ -792,7 +802,7 @@ test_hit(q, m, d, c) :- reach(q, c, d), kind(c, "class"), scope(c, s), member(s, // JUnit file holds several @Nested classes, and a helper private to one of them is not written for a sibling's // tests. A helper that belongs to no type -- a module-level function, an arrow beside the describe block -- has no // scope narrower than the file, and keeps it. -test_hit(q, m, d, c) :- reach(q, c, d), owner(c, t), scope(t, s), member(s, m, _, _), test_method(m), !test_method(c), decl_file(c, f), is_test_file(f). +test_hit(q, m, d, c) :- reach(q, c, d), owner(c, t), scope(t, s), member(s, m, _, _), test_method(m), !test_method(c), !injected_fixture(c), decl_file(c, f), is_test_file(f). // A helper no type owns is scoped by the innermost declaration that lexically encloses it: a // `describe` callback holds its own helpers and its own tests, not a sibling block's; a module holds // its file, which is the file rule kept where it was right. TypeScript has no other scope to use -- @@ -821,10 +831,10 @@ helper_block_test(c, m) :- calls(x, c, _, _, _), encloses_test(x), lex_in(x, m), helper_block_test(c, m) :- calls(x, c, _, _, _), !encloses_test(x), !test_method(x), tblock(b, x), !tighter(b, x), lex_in(b, m), test_method(m). .decl helper_has_block(c:symbol) helper_has_block(c) :- helper_block_test(c, _). -test_hit(q, m, d, c) :- reach(q, c, d), !owner(c, _), !encloses_test(c), helper_has_block(c), helper_block_test(c, m), !test_method(c), decl_file(c, f), is_test_file(f). -test_hit(q, m, d, c) :- reach(q, c, d), !owner(c, _), !encloses_test(c), !helper_has_block(c), lex_parent(c, p), lex_in(p, m), test_method(m), !test_method(c), decl_file(c, f), is_test_file(f). +test_hit(q, m, d, c) :- reach(q, c, d), !owner(c, _), !encloses_test(c), helper_has_block(c), helper_block_test(c, m), !test_method(c), !injected_fixture(c), decl_file(c, f), is_test_file(f). +test_hit(q, m, d, c) :- reach(q, c, d), !owner(c, _), !encloses_test(c), !helper_has_block(c), lex_parent(c, p), lex_in(p, m), test_method(m), !test_method(c), !injected_fixture(c), decl_file(c, f), is_test_file(f). // nothing recorded a span for it, so the file is still the only scope known -test_hit(q, m, d, c) :- reach(q, c, d), !owner(c, _), !lex_parent(c, _), !encloses_test(c), decl_file(c, f), decl_file(m, f), test_method(m), !test_method(c), is_test_file(f). +test_hit(q, m, d, c) :- reach(q, c, d), !owner(c, _), !lex_parent(c, _), !encloses_test(c), decl_file(c, f), decl_file(m, f), test_method(m), !test_method(c), !injected_fixture(c), is_test_file(f). .decl encloses_test(c:symbol) encloses_test(c) :- lex_in(c, m), test_method(m). .decl lex_in(outer:symbol, inner:symbol) @@ -847,7 +857,18 @@ uses_fixture(m, fx) :- takes_param(m, n), fixture_scope(fx, n, p), decl_file(m, (test_method(m) ; fixture(m)), owner(fx, t), owner(m, u), scope(t, u). uses_fixture(m, fx) :- takes_param(m, n), fixture_scope(fx, n, p), decl_file(m, f), file_scope(f, p), m != fx, (test_method(m) ; fixture(m)), !owner(fx, _). +// Where the language engine resolved the request, its edge is the answer (the name join above is then not fed for +// that language). A fixture a class declares stays scoped to that class's tests, as above. +uses_fixture(m, fx) :- injects(m, fx), !owner(fx, _). +uses_fixture(m, fx) :- injects(m, fx), owner(fx, t), owner(m, u), scope(t, u). uses_fixture(m, fx) :- dec_literal(m, "usefixtures", n, _, _), fixture_scope(fx, n, p), decl_file(m, f), file_scope(f, p), m != fx. +// The marker on a CLASS is recorded on the type, and applies to every test the class holds, and to those of a class +// nested in it or deriving from it (scope/2). Joined to the declaration it decorates only, it reached no test (#1528). +uses_fixture(m, fx) :- dec_literal(t, "usefixtures", n, _, _), typ(t, _, _), scope(t, s), member(s, m, _, _), test_method(m), + fixture_scope(fx, n, p), decl_file(m, f), file_scope(f, p), m != fx. +// A module's `pytestmark` and the ini `usefixtures` option apply the marker to every test under their path (#1528). +uses_fixture(m, fx) :- usefixtures_scope(p, n), decl_file(m, f), file_scope(f, p), test_method(m), + fixture_scope(fx, n, q), file_scope(f, q), m != fx. uses_fixture(m, fx) :- autouse_fixture(fx), fixture_scope(fx, _, p), decl_file(m, f), file_scope(f, p), test_method(m), m != fx. // a fixture may request another fixture, and then both run before the test uses_fixture(m, g) :- uses_fixture(m, fx), uses_fixture(fx, g), m != g. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 89ead0e11..b2814e723 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -562,7 +562,9 @@ def _test_sets(q, lines=None, rel=None): tm, fx = set(), set() for sid, name, kind, mid, tid in q("SELECT id, name, kind, method_id, type_id FROM symbols WHERE is_test=1"): d = dec.get(sid, ()) - if mid and kind in ('method', 'function') and (any(TEST_DECOR.search(x) for x in d) or (name or '').startswith(('test', 'it'))): + # a pytest fixture named test_* is built for the tests that request it and never collected (#1531) + if mid and kind in ('method', 'function') and (any(TEST_DECOR.search(x) for x in d) or (name or '').startswith(('test', 'it'))) \ + and not any((x or '').split('.')[-1] == 'fixture' for x in d): tm.add(sid) if (tid and not mid) or kind in ('constructor', 'module') or name in FIXTURE_NAMES or any(FIXTURE_DECOR.match((x or '').split('.')[-1]) for x in d): fx.add(sid) @@ -2431,6 +2433,10 @@ def _has_framework_hops(q, at=None, site_file=None): return True if _has(q, 'symbols') and q("SELECT 1 FROM symbols WHERE file LIKE '%conftest.py' AND method_id IS NOT NULL LIMIT 1"): return True + # a fixture injected by name anywhere, conftest or not: the rules credit it only to the tests that request + # it (`injects` / `injected_fixture`), where this port's file-wide rule credits every test in its file (#1527) + if _has(q, 'decorations') and q("SELECT 1 FROM decorations WHERE name = 'fixture' AND (file LIKE '%.py' OR file LIKE '%.pyi') LIMIT 1"): + return True except Exception: return True return False diff --git a/tests/cases/python/fixture-reaches-its-requesters/case.json b/tests/cases/python/fixture-reaches-its-requesters/case.json new file mode 100644 index 000000000..fe970a661 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/case.json @@ -0,0 +1,208 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "a fixture star-imported into conftest.py is that conftest's fixture, and reaches the tests that request it (#1537)", + "run": [ + "impact", + "make_repo", + "--tests" + ], + "want": [ + "test_svc.py::test_repo" + ] + }, + { + "why": "a fixture of a module named in pytest_plugins reaches every test that requests it, in any directory (#1537)", + "run": [ + "impact", + "make_service", + "--tests" + ], + "want": [ + "test_svc.py::test_service" + ] + }, + { + "why": "@pytest.fixture(name=...) is requested by the declared name, and the def name requests nothing (#1539)", + "run": [ + "impact", + "make_client", + "--tests" + ], + "want": [ + "test_svc.py::test_client" + ], + "avoid": [ + "test_client_by_def_name" + ] + }, + { + "why": "a module-level fixture reaches the tests in its file that request it, not every test in the file (#1527)", + "run": [ + "impact", + "make_cart", + "--tests" + ], + "want": [ + "test_forms.py::test_cart" + ], + "avoid": [ + "test_unrelated" + ] + }, + { + "why": "an argument the test parametrizes directly is not a request for the fixture of that name (#1529)", + "run": [ + "impact", + "make_user", + "--tests" + ], + "want": [ + "test_plain_user" + ], + "avoid": [ + "test_direct" + ] + }, + { + "why": "a usefixtures marker on a class reaches the tests the class holds, and not a sibling class's (#1528)", + "run": [ + "impact", + "start_server", + "--tests" + ], + "want": [ + "test_marker.py (1) via server: TestWithServer::test_uses_server" + ], + "avoid": [ + "test_no_server" + ] + }, + { + "why": "a sibling directory's conftest, star import and all, is not visible here", + "run": [ + "impact", + "make_widget", + "--tests" + ], + "want": [ + "test_other.py::test_widget" + ], + "avoid": [ + "test_widget_here" + ] + }, + { + "why": "an argument handed to the fixture with indirect=True still requests it (control for #1529)", + "run": [ + "impact", + "make_user", + "--tests" + ], + "want": [ + "test_indirect" + ] + }, + { + "why": "a fixture a class declares wins over the module fixture it overrides for that class's tests (#1512)", + "run": [ + "impact", + "module_account", + "--tests" + ], + "want": [ + "TestPlain::test_no_override", + "test_accounts.py::test_module_level" + ], + "avoid": [ + "test_in_class" + ] + }, + { + "why": "the class fixture serves its own class's tests only (control for #1512)", + "run": [ + "impact", + "class_account", + "--tests" + ], + "want": [ + "TestOverride::test_in_class" + ], + "avoid": [ + "test_no_override", + "test_module_level" + ] + }, + { + "why": "a fixture named test_* is not a test, and conftest.py is never collected (#1531)", + "run": [ + "impact", + "load_image", + "--tests" + ], + "want": [ + "test_images.py::test_size" + ], + "avoid": [ + "conftest.py::test_image" + ] + }, + { + "why": "a module's pytestmark usefixtures reaches the tests of that module only (#1528)", + "run": [ + "impact", + "make_clean_dir", + "--tests" + ], + "want": [ + "test_pytestmark.py::test_module_marker" + ], + "avoid": [ + "test_uses_server", + "test_repo" + ] + }, + { + "why": "the ini usefixtures option reaches every test that can see the fixture, and not a sibling directory's (#1528)", + "run": [ + "impact", + "load_config", + "--tests" + ], + "want": [ + "test_pytestmark.py::test_module_marker", + "test_svc.py::test_repo" + ], + "avoid": [ + "test_other.py::test_widget" + ] + }, + { + "why": "a pytestmark marker naming a pytest_plugins module's fixture reaches the module's tests (#1528, #1537)", + "run": [ + "impact", + "make_service", + "--tests" + ], + "want": [ + "test_plugin_marker.py::test_marks_plugin_fixture" + ] + }, + { + "why": "a marker naming a star-imported fixture reaches the tests below that conftest, not a sibling directory's (#1537)", + "run": [ + "impact", + "make_widget", + "--tests" + ], + "want": [ + "test_other.py::test_marks_widget" + ], + "avoid": [ + "test_pytestmark.py" + ] + } + ] +} diff --git a/tests/cases/python/fixture-reaches-its-requesters/conftest.py b/tests/cases/python/fixture-reaches-its-requesters/conftest.py new file mode 100644 index 000000000..979a9c965 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/conftest.py @@ -0,0 +1 @@ +pytest_plugins = ["support.plugin"] diff --git a/tests/cases/python/fixture-reaches-its-requesters/other/__init__.py b/tests/cases/python/fixture-reaches-its-requesters/other/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/fixture-reaches-its-requesters/other/conftest.py b/tests/cases/python/fixture-reaches-its-requesters/other/conftest.py new file mode 100644 index 000000000..34f198344 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/other/conftest.py @@ -0,0 +1 @@ +from other.more import * diff --git a/tests/cases/python/fixture-reaches-its-requesters/other/more.py b/tests/cases/python/fixture-reaches-its-requesters/other/more.py new file mode 100644 index 000000000..3feed055f --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/other/more.py @@ -0,0 +1,8 @@ +import pytest + +from shop.svc import make_widget + + +@pytest.fixture +def widget(): + return make_widget() diff --git a/tests/cases/python/fixture-reaches-its-requesters/other/test_other.py b/tests/cases/python/fixture-reaches-its-requesters/other/test_other.py new file mode 100644 index 000000000..7a3ce1171 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/other/test_other.py @@ -0,0 +1,9 @@ +import pytest + +def test_widget(widget): + assert widget + + +@pytest.mark.usefixtures("widget") +def test_marks_widget(): + assert True diff --git a/tests/cases/python/fixture-reaches-its-requesters/pytest.ini b/tests/cases/python/fixture-reaches-its-requesters/pytest.ini new file mode 100644 index 000000000..1549fbcc7 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +usefixtures = config_loaded diff --git a/tests/cases/python/fixture-reaches-its-requesters/shop/__init__.py b/tests/cases/python/fixture-reaches-its-requesters/shop/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/fixture-reaches-its-requesters/shop/svc.py b/tests/cases/python/fixture-reaches-its-requesters/shop/svc.py new file mode 100644 index 000000000..05557ccce --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/shop/svc.py @@ -0,0 +1,46 @@ +def make_repo(): + return "repo" + + +def make_service(): + return "service" + + +def make_client(): + return "client" + + +def make_user(name): + return {"name": name} + + +def make_cart(items): + return list(items) + + +def make_widget(): + return "widget" + + +def start_server(): + return "server" + + +def module_account(): + return "module" + + +def class_account(): + return "class" + + +def load_image(): + return "image" + + +def make_clean_dir(): + return "dir" + + +def load_config(): + return "config" diff --git a/tests/cases/python/fixture-reaches-its-requesters/support/__init__.py b/tests/cases/python/fixture-reaches-its-requesters/support/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/fixture-reaches-its-requesters/support/plugin.py b/tests/cases/python/fixture-reaches-its-requesters/support/plugin.py new file mode 100644 index 000000000..6d0eec7de --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/support/plugin.py @@ -0,0 +1,8 @@ +import pytest + +from shop.svc import make_service + + +@pytest.fixture +def service(): + return make_service() diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/__init__.py b/tests/cases/python/fixture-reaches-its-requesters/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/conftest.py b/tests/cases/python/fixture-reaches-its-requesters/tests/conftest.py new file mode 100644 index 000000000..9dde62b78 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/conftest.py @@ -0,0 +1,35 @@ +import pytest + +from shop.svc import load_config, load_image, make_clean_dir, make_client, make_user, start_server + +from .fixtures import * + + +@pytest.fixture(name="client") +def client_fixture(): + return make_client() + + +@pytest.fixture +def user(): + return make_user("default") + + +@pytest.fixture +def server(): + return start_server() + + +@pytest.fixture +def test_image(): + return load_image() + + +@pytest.fixture +def cleandir(): + return make_clean_dir() + + +@pytest.fixture +def config_loaded(): + return load_config() diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/fixtures.py b/tests/cases/python/fixture-reaches-its-requesters/tests/fixtures.py new file mode 100644 index 000000000..910df7999 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/fixtures.py @@ -0,0 +1,8 @@ +import pytest + +from shop.svc import make_repo + + +@pytest.fixture +def repo(): + return make_repo() diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/test_accounts.py b/tests/cases/python/fixture-reaches-its-requesters/tests/test_accounts.py new file mode 100644 index 000000000..d5be4e0ce --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/test_accounts.py @@ -0,0 +1,26 @@ +import pytest + +from shop.svc import class_account, module_account + + +@pytest.fixture +def account(): + return module_account() + + +class TestOverride: + @pytest.fixture + def account(self): + return class_account() + + def test_in_class(self, account): + assert account == "class" + + +class TestPlain: + def test_no_override(self, account): + assert account == "module" + + +def test_module_level(account): + assert account == "module" diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/test_forms.py b/tests/cases/python/fixture-reaches-its-requesters/tests/test_forms.py new file mode 100644 index 000000000..4d7cc7d9e --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/test_forms.py @@ -0,0 +1,16 @@ +import pytest + +from shop.svc import make_cart + + +@pytest.fixture +def cart(): + return make_cart([1]) + + +def test_cart(cart): + assert cart == [1] + + +def test_unrelated(): + assert True diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/test_images.py b/tests/cases/python/fixture-reaches-its-requesters/tests/test_images.py new file mode 100644 index 000000000..519bdac21 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/test_images.py @@ -0,0 +1,2 @@ +def test_size(test_image): + assert test_image diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/test_marker.py b/tests/cases/python/fixture-reaches-its-requesters/tests/test_marker.py new file mode 100644 index 000000000..5c3c8cfda --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/test_marker.py @@ -0,0 +1,12 @@ +import pytest + + +@pytest.mark.usefixtures("server") +class TestWithServer: + def test_uses_server(self): + assert True + + +class TestWithoutServer: + def test_no_server(self): + assert True diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/test_plugin_marker.py b/tests/cases/python/fixture-reaches-its-requesters/tests/test_plugin_marker.py new file mode 100644 index 000000000..010810341 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/test_plugin_marker.py @@ -0,0 +1,7 @@ +import pytest + +pytestmark = pytest.mark.usefixtures("service") + + +def test_marks_plugin_fixture(): + assert True diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/test_pytestmark.py b/tests/cases/python/fixture-reaches-its-requesters/tests/test_pytestmark.py new file mode 100644 index 000000000..b3af9c759 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/test_pytestmark.py @@ -0,0 +1,7 @@ +import pytest + +pytestmark = pytest.mark.usefixtures("cleandir") + + +def test_module_marker(): + assert True diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/test_svc.py b/tests/cases/python/fixture-reaches-its-requesters/tests/test_svc.py new file mode 100644 index 000000000..2971a8c58 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/test_svc.py @@ -0,0 +1,35 @@ +import pytest + + +def test_repo(repo): + assert repo + + +def test_service(service): + assert service + + +def test_client(client): + assert client + + +def test_client_by_def_name(client_fixture): + assert client_fixture + + +@pytest.mark.parametrize("user", [{"name": "direct"}]) +def test_direct(user): + assert user["name"] == "direct" + + +@pytest.mark.parametrize("user", ["other"], indirect=True) +def test_indirect(user): + assert user + + +def test_plain_user(user): + assert user["name"] == "default" + + +def test_widget_here(widget): + assert widget From 66ebc2f30600dbfa73eecefff21cfbe8aae679a5 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:02:44 -0700 Subject: [PATCH 03/31] java: Spring Boot registrations make beans, and impact names who registers or is injected with them (#1392, #1396, #1414) A class Spring Boot registers without a stereotype was not a bean, so every injection of it read unsatisfied and a change to it or to the class that registers it looked local. Engine (config-resolution): - The parser reads META-INF/spring/.imports and META-INF/spring.factories (one descriptor per key, Properties syntax). A class named in an imports file or under a configuration key of spring.factories is an auto_configuration bean with its constructor reached; any other spring.factories key names a registered type whose documented callbacks are entry points, not a bean. - @EnableConfigurationProperties({T.class}) and @ConfigurationPropertiesScan register properties beans; @MapperScan (value, basePackages, basePackageClasses, Ant patterns, annotationClass, markerInterface) and MyBatis @Mapper register mapper proxy beans. MapStruct's @Mapper is excluded by its import. Query layer (impact): - registers(t, by, kind): a bean whose definition site is an annotation on another type. impact on the type lists that type as "registers it as a bean". - A type target lists who is injected with a bean one of its own @Bean methods defines, so a change to a configuration class no longer reads as local. - Both rules are in impact.dl and in the SQL port; IMPACT_VERSION 30. Tests: java case 65-boot-bean-registration (near misses: a comment-only entry, an unrelated properties value, an unregistered properties class, interfaces outside every scan, MapStruct's @Mapper, a plain interface), plugin case bean-registered-or-defined-by-a-class (near misses: an unregistered properties class of the same shape, a non-@Bean method returning an injected type). --- graph/bundle/SCHEMA.md | 4 + graph/bundle/schema.ts | 4 + graph/java/engine/config-resolution/di.dl | 105 +++++++++++++ .../engine/config-resolution/entry-points.dl | 12 ++ graph/java/engine/config-resolution/knobs.dl | 38 +++++ .../config-resolution/lib-bean-scope.dl | 4 + .../java/engine/config-resolution/services.dl | 32 +++- .../engine/projections/config-services.dl | 7 + graph/java/souffle/decls_all.dl | 31 ++++ .../src/META-INF/services/probe.boot.Plugin | 1 + .../src/META-INF/spring.factories | 4 + ...ot.autoconfigure.AutoConfiguration.imports | 2 + .../src/application.properties | 1 + .../boot/CommentedAutoConfiguration.java | 8 + .../src/probe/boot/CommentedInitializer.java | 8 + .../probe/boot/LegacyAutoConfiguration.java | 13 ++ .../src/probe/boot/Plugin.java | 5 + .../src/probe/boot/UnrelatedHandler.java | 8 + .../probe/boot/WidgetAutoConfiguration.java | 11 ++ .../src/probe/boot/WidgetInitializer.java | 12 ++ .../src/probe/boot/WidgetPlugin.java | 11 ++ .../src/probe/boot/WidgetWire.java | 9 ++ .../src/probe/config/CacheProperties.java | 15 ++ .../src/probe/config/MailProperties.java | 13 ++ .../src/probe/config/MapperConfig.java | 8 + .../src/probe/config/PropsConfig.java | 10 ++ .../src/probe/config/QueueProperties.java | 13 ++ .../src/probe/config/ShopMapperConfig.java | 8 + .../src/probe/convert/ItemConverter.java | 9 ++ .../src/probe/dao/StockMapper.java | 9 ++ .../src/probe/daoextra/ExtraDao.java | 6 + .../src/probe/mapper/ItemMapper.java | 6 + .../src/probe/other/TagMapper.java | 6 + .../src/probe/scanned/ScanProperties.java | 13 ++ .../src/probe/service/ItemService.java | 35 +++++ .../src/probe/service/Mailer.java | 26 ++++ .../src/probe/service/PriceSource.java | 6 + .../src/probe/shop/dao/OrderDao.java | 6 + .../src/probe/web/ItemController.java | 14 ++ .../expected/65-boot-bean-registration.config | 90 ++++++++++++ .../expected/65-boot-bean-registration.edges | 16 ++ .../65-boot-bean-registration.envelope | 1 + .../expected/65-boot-bean-registration.fields | 30 ++++ .../65-boot-bean-registration.type-use | 59 ++++++++ .../services/ServiceDescriptor.ts | 15 +- parser/src/constants/consts.ts | 5 + .../src/parsers/services/services-parser.ts | 139 +++++++++++++++++- .../services/services-project-analyzer.ts | 42 ++++-- .../skills/axiomcode/reference/impact.md | 3 + .../skills/axiomcode/scripts/axiomcode-impact | 6 +- .../skills/axiomcode/scripts/dl/impact.dl | 11 ++ .../skills/axiomcode/scripts/graph_sql.py | 58 ++++++++ skills/axiomcode/reference/impact.md | 3 + .../case.json | 16 ++ .../src/shop/config/Gadget.java | 5 + .../src/shop/config/GadgetFactory.java | 6 + .../src/shop/config/MailProperties.java | 10 ++ .../src/shop/config/PropsConfig.java | 8 + .../src/shop/config/QueueProperties.java | 11 ++ .../src/shop/config/Widget.java | 5 + .../src/shop/config/WidgetConfig.java | 10 ++ .../src/shop/service/Assembler.java | 18 +++ .../src/shop/service/Mailer.java | 18 +++ 63 files changed, 1094 insertions(+), 24 deletions(-) create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/META-INF/services/probe.boot.Plugin create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/META-INF/spring.factories create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/application.properties create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/boot/CommentedAutoConfiguration.java create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/boot/CommentedInitializer.java create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/boot/LegacyAutoConfiguration.java create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/boot/Plugin.java create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/boot/UnrelatedHandler.java create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetAutoConfiguration.java create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetInitializer.java create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetPlugin.java create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetWire.java create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/config/CacheProperties.java create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/config/MailProperties.java create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/config/MapperConfig.java create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/config/PropsConfig.java create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/config/QueueProperties.java create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/config/ShopMapperConfig.java create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/convert/ItemConverter.java create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/dao/StockMapper.java create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/daoextra/ExtraDao.java create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/mapper/ItemMapper.java create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/other/TagMapper.java create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/scanned/ScanProperties.java create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/service/ItemService.java create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/service/Mailer.java create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/service/PriceSource.java create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/shop/dao/OrderDao.java create mode 100644 graph/test/java/cases/65-boot-bean-registration/src/probe/web/ItemController.java create mode 100644 graph/test/java/expected/65-boot-bean-registration.config create mode 100644 graph/test/java/expected/65-boot-bean-registration.edges create mode 100644 graph/test/java/expected/65-boot-bean-registration.envelope create mode 100644 graph/test/java/expected/65-boot-bean-registration.fields create mode 100644 graph/test/java/expected/65-boot-bean-registration.type-use create mode 100644 tests/cases/java/bean-registered-or-defined-by-a-class/case.json create mode 100644 tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/Gadget.java create mode 100644 tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/GadgetFactory.java create mode 100644 tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/MailProperties.java create mode 100644 tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/PropsConfig.java create mode 100644 tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/QueueProperties.java create mode 100644 tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/Widget.java create mode 100644 tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/WidgetConfig.java create mode 100644 tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/service/Assembler.java create mode 100644 tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/service/Mailer.java diff --git a/graph/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md index e5e44c854..7c96bcb6a 100644 --- a/graph/bundle/SCHEMA.md +++ b/graph/bundle/SCHEMA.md @@ -653,6 +653,8 @@ Methods the runtime invokes without a client call site — process roots, test m | `lifecycle_factory` | java | The method an XML bean definition names as `factory-method`; the container calls it to build the bean. | | `config_handler` | java | A callback of a class a configuration file names under a key that expects a class (not an annotation); the container instantiates it and calls it. | | `service_loader` | java | A callback of a provider listed in `META-INF/services`; `ServiceLoader` instantiates it and the caller reaches it through the service interface. | +| `auto_configuration` | java | The constructor or a container callback of a configuration class named in `META-INF/spring/*.imports` or under a configuration key of `META-INF/spring.factories` (`EnableAutoConfiguration`); Spring Boot registers it as a bean. | +| `spring_factories` | java | The constructor or callback of a class named under any other key of `META-INF/spring.factories` (`ApplicationContextInitializer`, `EnvironmentPostProcessor`, …); `SpringFactoriesLoader` instantiates it and calls it. Not a bean. | | `unimported_module` | typescript, javascript | The initializer of a module nothing imports — a script or a bundle root. | | `exported_from_entry_module` | typescript | A named function exported from a module nothing in the project imports. Its caller is the package's consumer, which is not in the repository. Placeholder names (``) are not roots. | | `package_export` | typescript | A named function exported from a module the project's `package.json` publishes (`main`, `module`, `exports`, `types`, `source`), mapped from build output back to its source. Its caller is the package's consumer. Covers the entry a project's own tests import, which `exported_from_entry_module` cannot see. | @@ -940,6 +942,8 @@ Types this run creates an instance of — the rapid-type-analysis set that bound | `anonymous` | java | An anonymous class exists only by being instantiated. | | `enum_constant` | java | An enum's constants are its instances. | | `service_loader` | java | A provider listed in `META-INF/services`: `ServiceLoader` constructs it reflectively, with no `new` in the source. | +| `auto_configuration` | java | A configuration class named in `META-INF/spring/*.imports` or `spring.factories` (`EnableAutoConfiguration`): Spring Boot constructs it reflectively. | +| `spring_factories` | java | A class named under another key of `META-INF/spring.factories`: `SpringFactoriesLoader` constructs it reflectively. | **Notes** diff --git a/graph/bundle/schema.ts b/graph/bundle/schema.ts index 5e6c5d4d8..041ccc50f 100644 --- a/graph/bundle/schema.ts +++ b/graph/bundle/schema.ts @@ -682,6 +682,8 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'entry_points', column: 'reason', value: 'lifecycle_factory', languages: J, meaning: 'The method an XML bean definition names as `factory-method`; the container calls it to build the bean.' }, { table: 'entry_points', column: 'reason', value: 'config_handler', languages: J, meaning: 'A callback of a class a configuration file names under a key that expects a class (not an annotation); the container instantiates it and calls it.' }, { table: 'entry_points', column: 'reason', value: 'service_loader', languages: J, meaning: 'A callback of a provider listed in `META-INF/services`; `ServiceLoader` instantiates it and the caller reaches it through the service interface.' }, + { table: 'entry_points', column: 'reason', value: 'auto_configuration', languages: J, meaning: 'The constructor or a container callback of a configuration class named in `META-INF/spring/*.imports` or under a configuration key of `META-INF/spring.factories` (`EnableAutoConfiguration`); Spring Boot registers it as a bean.' }, + { table: 'entry_points', column: 'reason', value: 'spring_factories', languages: J, meaning: 'The constructor or callback of a class named under any other key of `META-INF/spring.factories` (`ApplicationContextInitializer`, `EnvironmentPostProcessor`, …); `SpringFactoriesLoader` instantiates it and calls it. Not a bean.' }, { table: 'entry_points', column: 'reason', value: 'unimported_module', languages: ['typescript', 'javascript'], meaning: 'The initializer of a module nothing imports — a script or a bundle root.' }, { table: 'entry_points', column: 'reason', value: 'exported_from_entry_module', languages: T, meaning: 'A named function exported from a module nothing in the project imports. Its caller is the package\'s consumer, which is not in the repository. Placeholder names (``) are not roots.' }, { table: 'entry_points', column: 'reason', value: 'package_export', languages: T, meaning: 'A named function exported from a module the project\'s `package.json` publishes (`main`, `module`, `exports`, `types`, `source`), mapped from build output back to its source. Its caller is the package\'s consumer. Covers the entry a project\'s own tests import, which `exported_from_entry_module` cannot see.' }, @@ -705,6 +707,8 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'type_instantiated', column: 'how', value: 'anonymous', languages: J, meaning: 'An anonymous class exists only by being instantiated.' }, { table: 'type_instantiated', column: 'how', value: 'enum_constant', languages: J, meaning: 'An enum\'s constants are its instances.' }, { table: 'type_instantiated', column: 'how', value: 'service_loader', languages: J, meaning: 'A provider listed in `META-INF/services`: `ServiceLoader` constructs it reflectively, with no `new` in the source.' }, + { table: 'type_instantiated', column: 'how', value: 'auto_configuration', languages: J, meaning: 'A configuration class named in `META-INF/spring/*.imports` or `spring.factories` (`EnableAutoConfiguration`): Spring Boot constructs it reflectively.' }, + { table: 'type_instantiated', column: 'how', value: 'spring_factories', languages: J, meaning: 'A class named under another key of `META-INF/spring.factories`: `SpringFactoriesLoader` constructs it reflectively.' }, // skipped.reason — parser/src/enums/SkippedFileReason.ts. The sets differ per front end // because they decline for different things; a value listed for no language is one no diff --git a/graph/java/engine/config-resolution/di.dl b/graph/java/engine/config-resolution/di.dl index d2f1b7ffd..45ec9be7b 100644 --- a/graph/java/engine/config-resolution/di.dl +++ b/graph/java/engine/config-resolution/di.dl @@ -200,6 +200,110 @@ cfg_decap_demand(sn) :- cfg_repo_iface(t), type_decl(_, sn, _, _, _, _, t). bean_def(name, t, "repository_proxy", t) :- cfg_repo_iface(t), !cfg_repo_excluded(t), type_decl(_, sn, _, _, _, _, t), cfg_decap(sn, name). +// (b'') REGISTRY-NAMED CONFIGURATION: a class META-INF/spring/*.imports names, or a +// configuration key of META-INF/spring.factories (services.dl). Boot registers each one +// as a bean named by its fully-qualified name. Not a stereotype on @AutoConfiguration: +// component scanning EXCLUDES an auto-configuration, so the file is what makes it a +// bean, and a stereotype rule would give it the decapitalized name Boot never uses. +cfg_registry_config(p, t) :- svc_provider_resolves(p, d, t), svc_registration(d, "auto_configuration"). +cfg_registry_config(p, t) :- svc_provider_resolves_lib(p, d, t), svc_registration(d, "auto_configuration"). +bean_def(qname, t, "auto_configuration", p) :- cfg_registry_config(p, t), + type_decl(_, _, qname, _, _, _, t). + +// ── class arguments of an annotation, resolved in the annotation's own file ── +// config_class_ref resolves a simple name through ANY file that names it; a bean is a +// claim about one class, so this resolves it through the imports, package and file +// of the annotation that wrote it, or as a fully-qualified name. +cfg_ann_class_arg(prov, a, an, t) :- ann_arg(prov, a, an, v, "CLASS_REFERENCE", _), + ann_file(prov, a, f), type_resolves_in_file(_, v, f, t). +cfg_ann_class_arg(prov, a, an, t) :- ann_arg(prov, a, an, v, "CLASS_REFERENCE", _), + type_decl(_, _, v, _, _, _, t). + +// (d) PROPERTIES CLASSES registered by @EnableConfigurationProperties({A.class}) or by a +// @ConfigurationPropertiesScan over their package. The bean is named +// "-", or the fully-qualified name when the class names no +// prefix. Not container-CONSTRUCTED in the injection sense: a constructor of a +// properties class is BOUND from configuration keys (impact.dl), so its parameters are +// not bean injection points. +cfg_props_bean_type(a, t) :- ann_on_type(prov, a, n, _), cfg_enable_props_ann(n), + cfg_ann_class_arg(prov, a, "value", t). +cfg_props_bean_type(a, t) :- cfg_scan_hit(a, "properties", t). +cfg_props_type_prefix(t, pre) :- cfg_props_bean_type(_, t), cfg_props_prefix(t, pre), pre != "". +cfg_props_type_has_prefix(t) :- cfg_props_type_prefix(t, _). +bean_def(cat(pre, cat("-", qname)), t, "enabled_properties", a) :- cfg_props_bean_type(a, t), + cfg_props_type_prefix(t, pre), type_decl(_, _, qname, _, _, _, t). +bean_def(qname, t, "enabled_properties", a) :- cfg_props_bean_type(a, t), + !cfg_props_type_has_prefix(t), type_decl(_, _, qname, _, _, _, t). + +// (e) MYBATIS MAPPER PROXIES: an interface carrying @Mapper, or any interface in a +// package a @MapperScan covers. mybatis-spring registers a MapperFactoryBean per +// interface, which injects a JDK proxy of it; the interface has no implementation in any +// source tree, so without this every injection of a mapper reads `unsatisfied`. +cfg_foreign_ann_file(n, fp) :- cfg_foreign_ann(n, pkg), import_single(_, n, cat(pkg, cat(".", n)), fp, _). +cfg_foreign_ann_file(n, fp) :- cfg_foreign_ann(n, pkg), import_wildcard(_, pkg, fp, _). +cfg_mapper_site(a, t) :- ann_on_type(prov, a, n, t), cfg_mapper_ann(n), + type_decl(prov, _, _, fp, "INTERFACE_TYPE", _, t), !cfg_foreign_ann_file(n, fp). +cfg_mapper_site(a, t) :- cfg_scan_hit(a, "mapper", t). +cfg_decap_demand(sn) :- cfg_mapper_site(_, t), type_decl(_, sn, _, _, _, _, t). +bean_def(name, t, "mapper_proxy", a) :- cfg_mapper_site(a, t), + type_decl(_, sn, _, _, _, _, t), cfg_decap(sn, name). + +// ── PACKAGE SCANS (knobs.dl §19) ───────────────────────────────────────────── +// cfg_scan_pattern(Ann, Pattern): a base package as written, or the package of a +// basePackageClasses class, or with none of the three arguments the annotated class's own. +// A placeholder ("${…}") is left out: its value is not known here, and guessing a package +// would register beans the container never sees. +cfg_scan(kind, prov, a, owner) :- ann_on_type(prov, a, n, owner), cfg_pkg_scan_ann(n, kind). +cfg_scan_names_base(a) :- cfg_scan(_, prov, a, _), ann_arg(prov, a, an, _, _, _), cfg_pkg_scan_arg(an). +cfg_scan_names_base(a) :- cfg_scan(_, prov, a, _), ann_arg(prov, a, "basePackageClasses", _, _, _). +cfg_scan_pattern(a, pat) :- cfg_scan(_, prov, a, _), ann_arg(prov, a, an, pat, "STRING_LITERAL", _), + cfg_pkg_scan_arg(an), pat != "", !contains("$", pat), !contains("{", pat). +cfg_scan_pattern(a, pkg) :- cfg_scan(_, prov, a, _), cfg_ann_class_arg(prov, a, "basePackageClasses", c), + cfg_scan_type_pkg(c, pkg), pkg != "". +cfg_scan_pattern(a, pkg) :- cfg_scan(_, _, a, owner), !cfg_scan_names_base(a), + cfg_scan_type_pkg(owner, pkg), pkg != "". +// type_pkg / lib_type_pkg keep the trailing dot ("a.b."), which is how the import +// resolution joins on them; a scan pattern is written without it. +cfg_scan_type_pkg(t, pkg) :- type_pkg(t, d), strlen(d) > 1, pkg = substr(d, 0, strlen(d) - 1). +cfg_scan_type_pkg(t, pkg) :- lib_type_pkg(t, d), strlen(d) > 1, pkg = substr(d, 0, strlen(d) - 1). + +// Pattern -> regex, one character at a time: `.` is literal, `*` is any run within one +// package segment, `.**` any number of further segments. The package itself and every +// subpackage below it are covered, as a classpath scan of `pkg/**/*.class` covers them. +cfg_scan_rx_step(p, 0, "") :- cfg_scan_pattern(_, p). +cfg_scan_rx_step(p, i + 3, cat(r, "([.][^.]+)*")) :- cfg_scan_rx_step(p, i, r), i < strlen(p), + substr(p, i, min(3, strlen(p) - i)) = ".**". +cfg_scan_rx_step(p, i + 1, cat(r, "[.]")) :- cfg_scan_rx_step(p, i, r), i < strlen(p), + substr(p, i, 1) = ".", substr(p, i, min(3, strlen(p) - i)) != ".**". +cfg_scan_rx_step(p, i + 1, cat(r, "[^.]*")) :- cfg_scan_rx_step(p, i, r), i < strlen(p), + substr(p, i, 1) = "*". +cfg_scan_rx_step(p, i + 1, cat(r, c)) :- cfg_scan_rx_step(p, i, r), i < strlen(p), + c = substr(p, i, 1), c != ".", c != "*". +cfg_scan_rx(p, cat(r, "([.].+)?")) :- cfg_scan_rx_step(p, strlen(p), r). + +// What each kind of scan picks up: an interface (mapper), or a @ConfigurationProperties +// class that is not already a @Component (properties). +cfg_scan_candidate("mapper", t, pkg) :- type_decl(_, _, _, _, "INTERFACE_TYPE", _, t), cfg_scan_type_pkg(t, pkg). +cfg_scan_candidate("properties", t, pkg) :- ann_on_type(_, _, n, t), cfg_props_ann(n), + !cfg_stereotype(_, _, t), cfg_scan_type_pkg(t, pkg). +cfg_scan_match(a, kind, t) :- cfg_scan(kind, _, a, _), cfg_scan_pattern(a, pat), cfg_scan_rx(pat, rx), + cfg_scan_candidate(kind, t, pkg), match(rx, pkg). + +// @MapperScan(annotationClass = X.class) / (markerInterface = Y.class) narrow the scan to +// interfaces carrying X or extending Y (either one, when both are given); the marker +// itself is not a mapper. +cfg_scan_filter_ann(a, v) :- cfg_scan("mapper", prov, a, _), ann_arg(prov, a, "annotationClass", v, _, _), v != "". +cfg_scan_filter_marker(a, m) :- cfg_scan("mapper", prov, a, _), cfg_ann_class_arg(prov, a, "markerInterface", m). +cfg_scan_filtered(a) :- cfg_scan("mapper", prov, a, _), ann_arg(prov, a, "annotationClass", _, _, _). +cfg_scan_filtered(a) :- cfg_scan("mapper", prov, a, _), ann_arg(prov, a, "markerInterface", _, _, _). +cfg_scan_hit(a, kind, t) :- cfg_scan_match(a, kind, t), !cfg_scan_filtered(a). +cfg_scan_hit(a, "mapper", t) :- cfg_scan_match(a, "mapper", t), cfg_scan_filter_ann(a, v), + ann_on_type(_, _, n, t), n = v. +cfg_scan_hit(a, "mapper", t) :- cfg_scan_match(a, "mapper", t), cfg_scan_filter_ann(a, v), + ann_on_type(_, _, n, t), match(cat(".*[.]", n), v). +cfg_scan_hit(a, "mapper", t) :- cfg_scan_match(a, "mapper", t), cfg_scan_filter_marker(a, m), + type_ancestor(t, m). + // (c) XML (xml-wiring.dl) bean_def(id, t, "xml", elem) :- xml_bean(elem, id, t, _). @@ -251,6 +355,7 @@ cfg_inject_ctor(m, owner) :- ann_on_method(prov, _, n, m, owner), cfg_inject_ann // constructor-injected and Spring disagreed — ctor_inject precision 0.500 -> 1.000. cfg_container_constructed(t) :- bean_def(_, t, "stereotype", _). cfg_container_constructed(t) :- bean_def(_, t, "xml", _). +cfg_container_constructed(t) :- bean_def(_, t, "auto_configuration", _). cfg_type_ctor(t, m) :- method_kind(prov, "CONSTRUCTOR", _, m), method_owner(prov, t, m). cfg_type_ctor_count(t, n) :- cfg_container_constructed(t), n = count : { cfg_type_ctor(t, _) }. cfg_inject_ctor(m, t) :- cfg_type_ctor_count(t, 1), cfg_type_ctor(t, m), diff --git a/graph/java/engine/config-resolution/entry-points.dl b/graph/java/engine/config-resolution/entry-points.dl index 4b426d765..36eebe369 100644 --- a/graph/java/engine/config-resolution/entry-points.dl +++ b/graph/java/engine/config-resolution/entry-points.dl @@ -35,6 +35,11 @@ cfg_container_callback_name("sessionCreated"). cfg_container_callback_name("s cfg_container_callback_name("onMessage"). cfg_container_callback_name("onEvent"). cfg_container_callback_name("run"). cfg_container_callback_name("call"). cfg_container_callback_name("handle"). cfg_container_callback_name("execute"). +// The methods SpringFactoriesLoader's documented extension points are called through, +// for a class a spring.factories key names (services.dl). Kept apart from the list above: +// `initialize` and `analyze` are ordinary names on a servlet or a ServiceLoader provider. +cfg_factories_callback_name("initialize"). cfg_factories_callback_name("onApplicationEvent"). +cfg_factories_callback_name("postProcessEnvironment"). cfg_factories_callback_name("analyze"). // cfg_registered_type(TypeHash, Kind): a type the container instantiates because // config named it. Everything below fans out from here. @@ -46,6 +51,9 @@ cfg_registered_type(t, "config_handler") :- config_class_ref(fmt, k, _, t, "clie config_entry_point(m, kind, t) :- cfg_registered_type(t, kind), java_method(name, _, _, _, _, _, _, t, _, _, _, _, _, _, _, _, "INSTANCE_METHOD", _, _, _, _, m), cfg_container_callback_name(name). +config_entry_point(m, "spring_factories", t) :- cfg_registered_type(t, "spring_factories"), + java_method(name, _, _, _, _, _, _, t, _, _, _, _, _, _, _, _, "INSTANCE_METHOD", _, _, _, _, m), + cfg_factories_callback_name(name). // … and by "overrides something declared in a library supertype", which is what a // servlet/filter/listener actually is. Independent of the name list, so a container // API that IS in the library set is covered exactly rather than approximately. @@ -80,6 +88,10 @@ config_entry_point(m, "bean_ctor", t) :- bean_def(_, t, "stereotype", _), method_kind("client", "CONSTRUCTOR", _, m), method_owner("client", t, m). config_entry_point(m, "bean_ctor", t) :- bean_def(_, t, "xml", _), method_kind("client", "CONSTRUCTOR", _, m), method_owner("client", t, m). +// A properties class registered by @EnableConfigurationProperties / a properties scan is +// constructed by the binder (di.dl (d)). +config_entry_point(m, "bean_ctor", t) :- bean_def(_, t, "enabled_properties", _), + method_kind("client", "CONSTRUCTOR", _, m), method_owner("client", t, m). // (6) GENERATED-STUB OVERRIDES — the gRPC shape. A method that OVERRIDES one declared // in a supertype whose simple name matches a stub-base suffix (GreeterImplBase) is diff --git a/graph/java/engine/config-resolution/knobs.dl b/graph/java/engine/config-resolution/knobs.dl index 4782efe5d..7cff9af42 100644 --- a/graph/java/engine/config-resolution/knobs.dl +++ b/graph/java/engine/config-resolution/knobs.dl @@ -301,3 +301,41 @@ cfg_repository_base_suffix("Dao"). // An annotation that marks a repository base as NOT itself a bean. A house base over // a framework base carries it, and the framework does not register a proxy for it. cfg_no_repository_ann("NoRepositoryBean"). + +// ── (17) REGISTRIES THAT NAME CONFIGURATION CLASSES ───────────────────────── +// Spring Boot registers a starter's classes from a FILE, not from a scan: +// META-INF/spring/.imports (every entry is a configuration class) and, in the +// older form, META-INF/spring.factories. In spring.factories only these keys name +// configuration classes, which the container registers as beans named by their +// fully-qualified name; every other key (ApplicationContextInitializer, +// EnvironmentPostProcessor, …) names a class SpringFactoriesLoader instantiates and +// calls, which is a registered type and not a bean. +cfg_config_registry_key("org.springframework.boot.autoconfigure.EnableAutoConfiguration"). +cfg_config_registry_key("org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration"). +cfg_config_registry_key("org.springframework.cloud.bootstrap.BootstrapConfiguration"). + +// ── (18) CLASSES AN ANNOTATION ARGUMENT REGISTERS AS BEANS ────────────────── +// @EnableConfigurationProperties({A.class, B.class}) registers each listed class as a +// bean named "-" (the fully-qualified name alone when the +// class names no prefix), the name Boot's registrar gives it. +cfg_enable_props_ann("EnableConfigurationProperties"). + +// ── (19) PACKAGE SCANS THAT REGISTER BEANS WITHOUT A STEREOTYPE ───────────── +// cfg_pkg_scan_ann(Annotation, Kind). The annotation names base packages (value / +// basePackages, Ant-style `*` and `**` allowed) or classes whose packages are scanned +// (basePackageClasses); with none of the three, the annotated class's own package. +// mapper every INTERFACE in a scanned package becomes a MyBatis mapper proxy bean +// (mybatis-spring's ClassPathMapperScanner) +// properties every @ConfigurationProperties class that is not also a @Component +cfg_pkg_scan_ann("MapperScan", "mapper"). +cfg_pkg_scan_ann("ConfigurationPropertiesScan", "properties"). +cfg_pkg_scan_arg("value"). cfg_pkg_scan_arg("basePackages"). + +// An annotation that makes the INTERFACE it sits on a proxy bean, as a Spring Data +// repository base does for its children: MyBatis @Mapper (registered by the Boot +// starter's scan of the application package). +cfg_mapper_ann("Mapper"). +// The same simple name imported from this package is a different annotation. MapStruct's +// @Mapper marks an interface it GENERATES an implementation of, and whether that is a +// bean depends on its componentModel, so it is not a proxy bean. +cfg_foreign_ann("Mapper", "org.mapstruct"). diff --git a/graph/java/engine/config-resolution/lib-bean-scope.dl b/graph/java/engine/config-resolution/lib-bean-scope.dl index 3537a0b01..0e42c5c58 100644 --- a/graph/java/engine/config-resolution/lib-bean-scope.dl +++ b/graph/java/engine/config-resolution/lib-bean-scope.dl @@ -73,6 +73,10 @@ cfg_factory_type(m, t) :- cfg_factory("lib", _, m, _), lib_method_return_type(m, // makes client the default, which is where XML elements come from. cfg_lib_bean_site(a) :- cfg_stereotype("lib", a, _). cfg_lib_bean_site(m) :- cfg_factory("lib", _, m, _). +// A bean a library's own annotation registers (@EnableConfigurationProperties, @Mapper, +// a package scan), or a library class a registration file names. +cfg_lib_bean_site(a) :- ann_on_type("lib", a, _, _). +cfg_lib_bean_site(p) :- svc_provider_resolves_lib(p, _, _). bean_origin(name, t, "lib") :- bean_def(name, t, _, site), cfg_lib_bean_site(site). bean_origin(name, t, "client") :- bean_def(name, t, _, site), !cfg_lib_bean_site(site). diff --git a/graph/java/engine/config-resolution/services.dl b/graph/java/engine/config-resolution/services.dl index c5add2f8f..61e92b59f 100644 --- a/graph/java/engine/config-resolution/services.dl +++ b/graph/java/engine/config-resolution/services.dl @@ -40,16 +40,42 @@ svc_provider_resolves_lib(p, d, t) :- svc_provider_lib(p, d, pkg, simple), svc_provider_resolves(p, d, t) :- svc_provider_class_lib(p, d, qname), java_type(_, qname, _, _, _, _, _, _, _, _, _, _, _, t). +// ── WHICH REGISTRY the descriptor is ─────────────────────────────────────────────── +// Spring's two registration files have the provider-configuration shape and arrive in the +// same relations, told apart by path: +// META-INF/spring/.imports one class per line; every entry is a configuration +// class Boot registers (auto_configuration) +// META-INF/spring.factories one descriptor per key; a key cfg_config_registry_key +// lists names configuration classes (auto_configuration), +// any other key a class SpringFactoriesLoader +// instantiates and calls (spring_factories) +// anything else META-INF/services (service_loader) +svc_spring_file(d, "imports") :- svc_descriptor_path(d, rel), + match("(.*/)?META-INF/spring/[^/]+[.]imports", rel). +svc_spring_file(d, "factories") :- svc_descriptor_path(d, rel), + match("(.*/)?META-INF/spring[.]factories", rel). +svc_is_spring_file(d) :- svc_spring_file(d, _). +svc_descriptor_any(d, svc) :- svc_descriptor(d, svc, _). +svc_descriptor_any(d, svc) :- svc_descriptor_lib(d, svc, _). + +svc_registration(d, "service_loader") :- svc_descriptor_path(d, _), !svc_is_spring_file(d). +svc_registration(d, "auto_configuration") :- svc_spring_file(d, "imports"). +svc_registration(d, "auto_configuration") :- svc_spring_file(d, "factories"), + svc_descriptor_any(d, svc), cfg_config_registry_key(svc). +svc_registration(d, "spring_factories") :- svc_spring_file(d, "factories"), + svc_descriptor_any(d, svc), !cfg_config_registry_key(svc). + // ── The type is REGISTERED: the container instantiates it and calls back into it ───── // cfg_registered_type already drives all three entry-point shapes in entry-points.dl — // callbacks by name, "overrides a library supertype" (which is what implementing a service // interface IS, so no name list is needed), and the constructor. -cfg_registered_type(t, "service_loader") :- svc_provider_resolves(_, _, t). +cfg_registered_type(t, kind) :- svc_provider_resolves(_, d, t), svc_registration(d, kind). // ── The type is INSTANTIATED, with provenance ─────────────────────────────────────── // Reported, never used to NARROW dispatch: the instantiation set can only ever be a lower // bound (containers, factories and reflection instantiate types this cannot see), so // filtering virtual dispatch on it would delete must-have edges. A consumer that wants // precision may subtract; the graph itself stays sound. -type_instantiated(t, "service_loader") :- svc_provider_resolves(_, _, t). -type_instantiated(t, "service_loader_lib") :- svc_provider_resolves_lib(_, _, t). +type_instantiated(t, kind) :- svc_provider_resolves(_, d, t), svc_registration(d, kind). +type_instantiated(t, cat(kind, "_lib")) :- svc_provider_resolves_lib(_, d, t), + svc_registration(d, kind). diff --git a/graph/java/engine/projections/config-services.dl b/graph/java/engine/projections/config-services.dl index 3f1c5da37..e96298078 100644 --- a/graph/java/engine/projections/config-services.dl +++ b/graph/java/engine/projections/config-services.dl @@ -34,3 +34,10 @@ svc_provider_class(p, d, qname) :- java_service_provider(qname, _, _, _, _, _, " svc_descriptor_lib(d, svc, wf) :- lib_service_descriptor(svc, _, _, _, _, wf, _, _, _, _, _, _, _, d). svc_provider_lib(p, d, pkg, simple) :- lib_service_provider(_, _, simple, pkg, _, _, "true", _, _, _, _, _, _, _, d, _, _, _, p). svc_provider_class_lib(p, d, qname) :- lib_service_provider(qname, _, _, _, _, _, "true", _, _, _, _, _, _, _, d, _, _, _, p). + +// svc_descriptor_path(Descriptor, RelativePath): the file that configured it. Spring's two +// registries, META-INF/spring/.imports and META-INF/spring.factories (one descriptor +// per key), arrive in these same relations; the path is what tells them apart +// (config-resolution/services.dl). +svc_descriptor_path(d, rel) :- java_service_descriptor(_, _, _, _, _, _, _, _, _, rel, _, _, _, d). +svc_descriptor_path(d, rel) :- lib_service_descriptor(_, _, _, _, _, _, _, _, _, rel, _, _, _, d). diff --git a/graph/java/souffle/decls_all.dl b/graph/java/souffle/decls_all.dl index 9f89483b8..f74129342 100644 --- a/graph/java/souffle/decls_all.dl +++ b/graph/java/souffle/decls_all.dl @@ -371,6 +371,11 @@ .decl svc_provider_resolves_lib(c0:symbol,c1:symbol,c2:symbol) .decl svc_descriptor_lib(c0:symbol,c1:symbol,c2:symbol) .decl svc_provider_lib(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl svc_descriptor_path(c0:symbol,c1:symbol) +.decl svc_descriptor_any(c0:symbol,c1:symbol) +.decl svc_spring_file(c0:symbol,c1:symbol) +.decl svc_is_spring_file(c0:symbol) +.decl svc_registration(c0:symbol,c1:symbol) .decl type_instantiated(c0:symbol,c1:symbol) .decl type_param(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) .decl type_parent(c0:symbol,c1:symbol) @@ -571,6 +576,32 @@ .decl cfg_repo_name_matches(c0:symbol) .decl cfg_repo_super_name(c0:symbol,c1:symbol) .decl cfg_repository_base_suffix(c0:symbol) +.decl cfg_config_registry_key(c0:symbol) +.decl cfg_factories_callback_name(c0:symbol) +.decl cfg_enable_props_ann(c0:symbol) +.decl cfg_pkg_scan_ann(c0:symbol,c1:symbol) +.decl cfg_pkg_scan_arg(c0:symbol) +.decl cfg_mapper_ann(c0:symbol) +.decl cfg_foreign_ann(c0:symbol,c1:symbol) +.decl cfg_registry_config(c0:symbol,c1:symbol) +.decl cfg_ann_class_arg(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl cfg_props_bean_type(c0:symbol,c1:symbol) +.decl cfg_props_type_prefix(c0:symbol,c1:symbol) +.decl cfg_props_type_has_prefix(c0:symbol) +.decl cfg_foreign_ann_file(c0:symbol,c1:symbol) +.decl cfg_mapper_site(c0:symbol,c1:symbol) +.decl cfg_scan(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl cfg_scan_names_base(c0:symbol) +.decl cfg_scan_pattern(c0:symbol,c1:symbol) +.decl cfg_scan_type_pkg(c0:symbol,c1:symbol) +.decl cfg_scan_rx_step(c0:symbol,c1:number,c2:symbol) +.decl cfg_scan_rx(c0:symbol,c1:symbol) +.decl cfg_scan_candidate(c0:symbol,c1:symbol,c2:symbol) +.decl cfg_scan_match(c0:symbol,c1:symbol,c2:symbol) +.decl cfg_scan_filter_ann(c0:symbol,c1:symbol) +.decl cfg_scan_filter_marker(c0:symbol,c1:symbol) +.decl cfg_scan_filtered(c0:symbol) +.decl cfg_scan_hit(c0:symbol,c1:symbol,c2:symbol) .decl cfg_satisfying_type(c0:symbol,c1:symbol) .decl cfg_scheduled_ann(c0:symbol) .decl cfg_setter_demand(c0:symbol) diff --git a/graph/test/java/cases/65-boot-bean-registration/src/META-INF/services/probe.boot.Plugin b/graph/test/java/cases/65-boot-bean-registration/src/META-INF/services/probe.boot.Plugin new file mode 100644 index 000000000..cde12edf4 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/META-INF/services/probe.boot.Plugin @@ -0,0 +1 @@ +probe.boot.WidgetPlugin diff --git a/graph/test/java/cases/65-boot-bean-registration/src/META-INF/spring.factories b/graph/test/java/cases/65-boot-bean-registration/src/META-INF/spring.factories new file mode 100644 index 000000000..865f25a25 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/META-INF/spring.factories @@ -0,0 +1,4 @@ +# probe.boot.CommentedInitializer is named in a comment only +org.springframework.context.ApplicationContextInitializer=probe.boot.WidgetInitializer +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ + probe.boot.LegacyAutoConfiguration diff --git a/graph/test/java/cases/65-boot-bean-registration/src/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/graph/test/java/cases/65-boot-bean-registration/src/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..70f0bdb84 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +# probe.boot.CommentedAutoConfiguration +probe.boot.WidgetAutoConfiguration diff --git a/graph/test/java/cases/65-boot-bean-registration/src/application.properties b/graph/test/java/cases/65-boot-bean-registration/src/application.properties new file mode 100644 index 000000000..a26a94137 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/application.properties @@ -0,0 +1 @@ +widget.fallback-name=probe.boot.UnrelatedHandler diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/CommentedAutoConfiguration.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/CommentedAutoConfiguration.java new file mode 100644 index 000000000..f17ca19fb --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/CommentedAutoConfiguration.java @@ -0,0 +1,8 @@ +package probe.boot; + +/** CONTROL: named only in a comment line of the imports file. */ +public class CommentedAutoConfiguration { + public CommentedAutoConfiguration() { + WidgetWire.loose(); + } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/CommentedInitializer.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/CommentedInitializer.java new file mode 100644 index 000000000..427d910f1 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/CommentedInitializer.java @@ -0,0 +1,8 @@ +package probe.boot; + +/** CONTROL: named only in a comment line of spring.factories. */ +public class CommentedInitializer { + public void initialize() { + WidgetWire.loose(); + } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/LegacyAutoConfiguration.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/LegacyAutoConfiguration.java new file mode 100644 index 000000000..77fc21d44 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/LegacyAutoConfiguration.java @@ -0,0 +1,13 @@ +package probe.boot; + +import probe.service.Mailer; + +/** SUBJECT: a spring.factories EnableAutoConfiguration value on a continued line; its constructor is injected. */ +public class LegacyAutoConfiguration { + private final Mailer mailer; + + public LegacyAutoConfiguration(Mailer mailer) { + this.mailer = mailer; + WidgetWire.legacy(); + } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/Plugin.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/Plugin.java new file mode 100644 index 000000000..c9452c3bc --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/Plugin.java @@ -0,0 +1,5 @@ +package probe.boot; + +public interface Plugin { + void start(); +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/UnrelatedHandler.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/UnrelatedHandler.java new file mode 100644 index 000000000..dc0c27891 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/UnrelatedHandler.java @@ -0,0 +1,8 @@ +package probe.boot; + +/** CONTROL: named as a value in an unrelated properties file. */ +public class UnrelatedHandler { + public UnrelatedHandler() { + WidgetWire.loose(); + } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetAutoConfiguration.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetAutoConfiguration.java new file mode 100644 index 000000000..d6d95610c --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetAutoConfiguration.java @@ -0,0 +1,11 @@ +package probe.boot; + +import org.springframework.boot.autoconfigure.AutoConfiguration; + +/** SUBJECT: named in AutoConfiguration.imports, so a bean with its constructor reached. */ +@AutoConfiguration +public class WidgetAutoConfiguration { + public WidgetAutoConfiguration() { + WidgetWire.open(); + } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetInitializer.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetInitializer.java new file mode 100644 index 000000000..b8109119f --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetInitializer.java @@ -0,0 +1,12 @@ +package probe.boot; + +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; + +/** SUBJECT: a spring.factories initializer: registered and called, not a bean. */ +public class WidgetInitializer implements ApplicationContextInitializer { + @Override + public void initialize(ConfigurableApplicationContext ctx) { + WidgetWire.init(); + } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetPlugin.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetPlugin.java new file mode 100644 index 000000000..0f7294461 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetPlugin.java @@ -0,0 +1,11 @@ +package probe.boot; + +/** CONTROL: a META-INF/services provider, registered before this change. */ +public class WidgetPlugin implements Plugin { + public WidgetPlugin() { + WidgetWire.plug(); + } + + @Override + public void start() { } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetWire.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetWire.java new file mode 100644 index 000000000..caaef1782 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetWire.java @@ -0,0 +1,9 @@ +package probe.boot; + +public class WidgetWire { + public static void open() { } + public static void legacy() { } + public static void init() { } + public static void plug() { } + public static void loose() { } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/config/CacheProperties.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/config/CacheProperties.java new file mode 100644 index 000000000..f153d1641 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/config/CacheProperties.java @@ -0,0 +1,15 @@ +package probe.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** CONTROL: a bean by stereotype before this change. */ +@Component +@ConfigurationProperties(prefix = "app.cache") +public class CacheProperties { + private int size; + + public int getSize() { return size; } + + public void setSize(int size) { this.size = size; } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/config/MailProperties.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/config/MailProperties.java new file mode 100644 index 000000000..e1f84822b --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/config/MailProperties.java @@ -0,0 +1,13 @@ +package probe.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** SUBJECT: registered by @EnableConfigurationProperties on PropsConfig. */ +@ConfigurationProperties(prefix = "app.mail") +public class MailProperties { + private String host; + + public String getHost() { return host; } + + public void setHost(String host) { this.host = host; } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/config/MapperConfig.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/config/MapperConfig.java new file mode 100644 index 000000000..50cace3e7 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/config/MapperConfig.java @@ -0,0 +1,8 @@ +package probe.config; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("probe.mapper") +public class MapperConfig { } diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/config/PropsConfig.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/config/PropsConfig.java new file mode 100644 index 000000000..2b4f1bd20 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/config/PropsConfig.java @@ -0,0 +1,10 @@ +package probe.config; + +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties({MailProperties.class}) +@ConfigurationPropertiesScan("probe.scanned") +public class PropsConfig { } diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/config/QueueProperties.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/config/QueueProperties.java new file mode 100644 index 000000000..970570e3a --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/config/QueueProperties.java @@ -0,0 +1,13 @@ +package probe.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** CONTROL: registered by nothing, and outside the properties scan. Stays unsatisfied. */ +@ConfigurationProperties("app.queue") +public class QueueProperties { + private int depth; + + public int getDepth() { return depth; } + + public void setDepth(int depth) { this.depth = depth; } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/config/ShopMapperConfig.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/config/ShopMapperConfig.java new file mode 100644 index 000000000..dc6728747 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/config/ShopMapperConfig.java @@ -0,0 +1,8 @@ +package probe.config; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan(basePackages = {"probe.**.dao"}) +public class ShopMapperConfig { } diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/convert/ItemConverter.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/convert/ItemConverter.java new file mode 100644 index 000000000..f9b4d4315 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/convert/ItemConverter.java @@ -0,0 +1,9 @@ +package probe.convert; + +import org.mapstruct.Mapper; + +/** CONTROL: MapStruct's @Mapper, not MyBatis's. Not a proxy bean. */ +@Mapper +public interface ItemConverter { + String convert(Long id); +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/dao/StockMapper.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/dao/StockMapper.java new file mode 100644 index 000000000..7ba6d3753 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/dao/StockMapper.java @@ -0,0 +1,9 @@ +package probe.dao; + +import org.apache.ibatis.annotations.Mapper; + +/** SUBJECT: @Mapper; also under the Ant pattern probe.**.dao. */ +@Mapper +public interface StockMapper { + int count(Long id); +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/daoextra/ExtraDao.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/daoextra/ExtraDao.java new file mode 100644 index 000000000..41d1d4966 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/daoextra/ExtraDao.java @@ -0,0 +1,6 @@ +package probe.daoextra; + +/** CONTROL: "probe.**.dao" does not cover the package probe.daoextra. */ +public interface ExtraDao { + int extra(Long id); +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/mapper/ItemMapper.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/mapper/ItemMapper.java new file mode 100644 index 000000000..2ddbef79d --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/mapper/ItemMapper.java @@ -0,0 +1,6 @@ +package probe.mapper; + +/** SUBJECT: no annotation, registered by @MapperScan("probe.mapper"). */ +public interface ItemMapper { + int touch(Long id); +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/other/TagMapper.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/other/TagMapper.java new file mode 100644 index 000000000..5623ddd5e --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/other/TagMapper.java @@ -0,0 +1,6 @@ +package probe.other; + +/** CONTROL: outside every @MapperScan package and not annotated. Stays unsatisfied. */ +public interface TagMapper { + int tag(Long id); +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/scanned/ScanProperties.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/scanned/ScanProperties.java new file mode 100644 index 000000000..e4e131293 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/scanned/ScanProperties.java @@ -0,0 +1,13 @@ +package probe.scanned; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** SUBJECT: picked up by @ConfigurationPropertiesScan("probe.scanned"). */ +@ConfigurationProperties("app.scan") +public class ScanProperties { + private int limit; + + public int getLimit() { return limit; } + + public void setLimit(int limit) { this.limit = limit; } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/service/ItemService.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/service/ItemService.java new file mode 100644 index 000000000..21ba81baa --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/service/ItemService.java @@ -0,0 +1,35 @@ +package probe.service; + +import org.springframework.stereotype.Service; +import probe.convert.ItemConverter; +import probe.dao.StockMapper; +import probe.daoextra.ExtraDao; +import probe.mapper.ItemMapper; +import probe.other.TagMapper; +import probe.shop.dao.OrderDao; + +@Service +public class ItemService { + private final ItemMapper items; + private final StockMapper stock; + private final OrderDao orders; + private final ExtraDao extra; + private final TagMapper tags; + private final ItemConverter converter; + private final PriceSource prices; + + public ItemService(ItemMapper items, StockMapper stock, OrderDao orders, ExtraDao extra, + TagMapper tags, ItemConverter converter, PriceSource prices) { + this.items = items; + this.stock = stock; + this.orders = orders; + this.extra = extra; + this.tags = tags; + this.converter = converter; + this.prices = prices; + } + + public int touch(Long id) { + return items.touch(id) + stock.count(id) + orders.open(id) + extra.extra(id) + tags.tag(id); + } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/service/Mailer.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/service/Mailer.java new file mode 100644 index 000000000..afb2b4f7c --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/service/Mailer.java @@ -0,0 +1,26 @@ +package probe.service; + +import org.springframework.stereotype.Service; +import probe.config.CacheProperties; +import probe.config.MailProperties; +import probe.config.QueueProperties; +import probe.scanned.ScanProperties; + +@Service +public class Mailer { + private final MailProperties mail; + private final CacheProperties cache; + private final QueueProperties queue; + private final ScanProperties scan; + + public Mailer(MailProperties mail, CacheProperties cache, QueueProperties queue, ScanProperties scan) { + this.mail = mail; + this.cache = cache; + this.queue = queue; + this.scan = scan; + } + + public String host() { return mail.getHost(); } + + public int size() { return cache.getSize() + queue.getDepth() + scan.getLimit(); } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/service/PriceSource.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/service/PriceSource.java new file mode 100644 index 000000000..073ea0ab7 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/service/PriceSource.java @@ -0,0 +1,6 @@ +package probe.service; + +/** CONTROL: a plain interface with no implementation. Stays unsatisfied. */ +public interface PriceSource { + long price(Long id); +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/shop/dao/OrderDao.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/shop/dao/OrderDao.java new file mode 100644 index 000000000..28e289802 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/shop/dao/OrderDao.java @@ -0,0 +1,6 @@ +package probe.shop.dao; + +/** SUBJECT: matched by @MapperScan(basePackages = "probe.**.dao") two segments down. */ +public interface OrderDao { + int open(Long id); +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/web/ItemController.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/web/ItemController.java new file mode 100644 index 000000000..0445c7e8f --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/web/ItemController.java @@ -0,0 +1,14 @@ +package probe.web; + +import org.springframework.web.bind.annotation.RestController; +import probe.service.ItemService; + +/** CONTROL: a stereotype bean injected by type, known_bean before this change. */ +@RestController +public class ItemController { + private final ItemService service; + + public ItemController(ItemService service) { + this.service = service; + } +} diff --git a/graph/test/java/expected/65-boot-bean-registration.config b/graph/test/java/expected/65-boot-bean-registration.config new file mode 100644 index 000000000..adf982dbf --- /dev/null +++ b/graph/test/java/expected/65-boot-bean-registration.config @@ -0,0 +1,90 @@ +── bean_def (14) ── + auto_configuration probe.boot.LegacyAutoConfiguration <- probe.boot.LegacyAutoConfiguration + auto_configuration probe.boot.WidgetAutoConfiguration <- probe.boot.WidgetAutoConfiguration + enabled_properties app.mail-probe.config.MailProperties <- probe.config.MailProperties + enabled_properties app.scan-probe.scanned.ScanProperties <- probe.scanned.ScanProperties + mapper_proxy itemMapper <- probe.mapper.ItemMapper + mapper_proxy orderDao <- probe.shop.dao.OrderDao + mapper_proxy stockMapper <- probe.dao.StockMapper + stereotype cacheProperties <- probe.config.CacheProperties + stereotype itemController <- probe.web.ItemController + stereotype itemService <- probe.service.ItemService + stereotype mailer <- probe.service.Mailer + stereotype mapperConfig <- probe.config.MapperConfig + stereotype propsConfig <- probe.config.PropsConfig + stereotype shopMapperConfig <- probe.config.ShopMapperConfig +── bean_origin (14) ── + client app.mail-probe.config.MailProperties <- probe.config.MailProperties + client app.scan-probe.scanned.ScanProperties <- probe.scanned.ScanProperties + client cacheProperties <- probe.config.CacheProperties + client itemController <- probe.web.ItemController + client itemMapper <- probe.mapper.ItemMapper + client itemService <- probe.service.ItemService + client mailer <- probe.service.Mailer + client mapperConfig <- probe.config.MapperConfig + client orderDao <- probe.shop.dao.OrderDao + client probe.boot.LegacyAutoConfiguration <- probe.boot.LegacyAutoConfiguration + client probe.boot.WidgetAutoConfiguration <- probe.boot.WidgetAutoConfiguration + client propsConfig <- probe.config.PropsConfig + client shopMapperConfig <- probe.config.ShopMapperConfig + client stockMapper <- probe.dao.StockMapper +── inject_point (13) ── + ctor_param probe.boot.LegacyAutoConfiguration#(Mailer):mailer : probe.service.Mailer + ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):converter : probe.convert.ItemConverter + ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):extra : probe.daoextra.ExtraDao + ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):items : probe.mapper.ItemMapper + ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):orders : probe.shop.dao.OrderDao + ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):prices : probe.service.PriceSource + ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):stock : probe.dao.StockMapper + ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):tags : probe.other.TagMapper + ctor_param probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties):cache : probe.config.CacheProperties + ctor_param probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties):mail : probe.config.MailProperties + ctor_param probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties):queue : probe.config.QueueProperties + ctor_param probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties):scan : probe.scanned.ScanProperties + ctor_param probe.web.ItemController#(ItemService):service : probe.service.ItemService +── di_edge (13) ── + known_bean ctor_param probe.boot.LegacyAutoConfiguration#(Mailer):mailer : probe.service.Mailer <- mailer (probe.service.Mailer) + known_bean ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):items : probe.mapper.ItemMapper <- itemMapper (probe.mapper.ItemMapper) + known_bean ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):orders : probe.shop.dao.OrderDao <- orderDao (probe.shop.dao.OrderDao) + known_bean ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):stock : probe.dao.StockMapper <- stockMapper (probe.dao.StockMapper) + known_bean ctor_param probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties):cache : probe.config.CacheProperties <- cacheProperties (probe.config.CacheProperties) + known_bean ctor_param probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties):mail : probe.config.MailProperties <- app.mail-probe.config.MailProperties (probe.config.MailProperties) + known_bean ctor_param probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties):scan : probe.scanned.ScanProperties <- app.scan-probe.scanned.ScanProperties (probe.scanned.ScanProperties) + known_bean ctor_param probe.web.ItemController#(ItemService):service : probe.service.ItemService <- itemService (probe.service.ItemService) + unsatisfied ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):converter : probe.convert.ItemConverter <- - (-) + unsatisfied ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):extra : probe.daoextra.ExtraDao <- - (-) + unsatisfied ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):prices : probe.service.PriceSource <- - (-) + unsatisfied ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):tags : probe.other.TagMapper <- - (-) + unsatisfied ctor_param probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties):queue : probe.config.QueueProperties <- - (-) +── config_class_ref (2) ── + annotation @EnableConfigurationProperties "MailProperties" -> probe.config.MailProperties [client] + properties widget.fallback-name "probe.boot.UnrelatedHandler" -> probe.boot.UnrelatedHandler [client] +── config_key_ref (0) ── +── config_binding (1) ── + widget.fallback-name class_binding type probe.boot.UnrelatedHandler +── config_affects_method (1) ── + widget.fallback-name class_binding probe.boot.UnrelatedHandler#() +── config_entry_point (8) ── + auto_configuration probe.boot.LegacyAutoConfiguration#(Mailer) + auto_configuration probe.boot.WidgetAutoConfiguration#() + bean_ctor probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) + bean_ctor probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties) + bean_ctor probe.web.ItemController#(ItemService) + service_loader probe.boot.WidgetPlugin#() + service_loader probe.boot.WidgetPlugin#start() + spring_factories probe.boot.WidgetInitializer#initialize(ConfigurableApplicationContext) +── bean_condition (0) ── +── config_unresolved [DECLARED UNKNOWNS] (5) ── + unsatisfied_injection_point di probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):converter "probe.convert.ItemConverter" + unsatisfied_injection_point di probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):extra "probe.daoextra.ExtraDao" + unsatisfied_injection_point di probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):prices "probe.service.PriceSource" + unsatisfied_injection_point di probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):tags "probe.other.TagMapper" + unsatisfied_injection_point di probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties):queue "probe.config.QueueProperties" +── remote_edge (0) ── +── remote_unserved [SENT, NO CONSUMER HERE] (0) ── +── remote_unsent [SERVED, NO PRODUCER HERE] (0) ── +── remote_undetermined [DECLARED UNKNOWNS] (0) ── +── persistence_query (0) ── +── persistence_entity (0) ── +── persistence_field (0) ── +── persistence_unresolved [DECLARED UNKNOWNS] (0) ── diff --git a/graph/test/java/expected/65-boot-bean-registration.edges b/graph/test/java/expected/65-boot-bean-registration.edges new file mode 100644 index 000000000..7a2855d07 --- /dev/null +++ b/graph/test/java/expected/65-boot-bean-registration.edges @@ -0,0 +1,16 @@ +known_edge method probe.boot.CommentedAutoConfiguration#() -> probe.boot.WidgetWire#loose() +known_edge method probe.boot.CommentedInitializer#initialize() -> probe.boot.WidgetWire#loose() +known_edge method probe.boot.LegacyAutoConfiguration#(Mailer) -> probe.boot.WidgetWire#legacy() +known_edge method probe.boot.UnrelatedHandler#() -> probe.boot.WidgetWire#loose() +known_edge method probe.boot.WidgetAutoConfiguration#() -> probe.boot.WidgetWire#open() +known_edge method probe.boot.WidgetInitializer#initialize(ConfigurableApplicationContext) -> probe.boot.WidgetWire#init() +known_edge method probe.boot.WidgetPlugin#() -> probe.boot.WidgetWire#plug() +known_edge method probe.service.ItemService#touch(Long) -> probe.dao.StockMapper#count(Long) +known_edge method probe.service.ItemService#touch(Long) -> probe.daoextra.ExtraDao#extra(Long) +known_edge method probe.service.ItemService#touch(Long) -> probe.mapper.ItemMapper#touch(Long) +known_edge method probe.service.ItemService#touch(Long) -> probe.other.TagMapper#tag(Long) +known_edge method probe.service.ItemService#touch(Long) -> probe.shop.dao.OrderDao#open(Long) +known_edge method probe.service.Mailer#host() -> probe.config.MailProperties#getHost() +known_edge method probe.service.Mailer#size() -> probe.config.CacheProperties#getSize() +known_edge method probe.service.Mailer#size() -> probe.config.QueueProperties#getDepth() +known_edge method probe.service.Mailer#size() -> probe.scanned.ScanProperties#getLimit() diff --git a/graph/test/java/expected/65-boot-bean-registration.envelope b/graph/test/java/expected/65-boot-bean-registration.envelope new file mode 100644 index 000000000..0c4a28e69 --- /dev/null +++ b/graph/test/java/expected/65-boot-bean-registration.envelope @@ -0,0 +1 @@ +nominal probe.boot.Plugin.start -> probe.boot.WidgetPlugin.start diff --git a/graph/test/java/expected/65-boot-bean-registration.fields b/graph/test/java/expected/65-boot-bean-registration.fields new file mode 100644 index 000000000..1b88854f7 --- /dev/null +++ b/graph/test/java/expected/65-boot-bean-registration.fields @@ -0,0 +1,30 @@ +known_edge read probe.config.CacheProperties#getSize() -> probe.config.CacheProperties#size +known_edge read probe.config.MailProperties#getHost() -> probe.config.MailProperties#host +known_edge read probe.config.QueueProperties#getDepth() -> probe.config.QueueProperties#depth +known_edge read probe.scanned.ScanProperties#getLimit() -> probe.scanned.ScanProperties#limit +known_edge read probe.service.ItemService#touch(Long) -> probe.service.ItemService#extra +known_edge read probe.service.ItemService#touch(Long) -> probe.service.ItemService#items +known_edge read probe.service.ItemService#touch(Long) -> probe.service.ItemService#orders +known_edge read probe.service.ItemService#touch(Long) -> probe.service.ItemService#stock +known_edge read probe.service.ItemService#touch(Long) -> probe.service.ItemService#tags +known_edge read probe.service.Mailer#host() -> probe.service.Mailer#mail +known_edge read probe.service.Mailer#size() -> probe.service.Mailer#cache +known_edge read probe.service.Mailer#size() -> probe.service.Mailer#queue +known_edge read probe.service.Mailer#size() -> probe.service.Mailer#scan +known_edge write probe.boot.LegacyAutoConfiguration#(Mailer) -> probe.boot.LegacyAutoConfiguration#mailer +known_edge write probe.config.CacheProperties#setSize(int) -> probe.config.CacheProperties#size +known_edge write probe.config.MailProperties#setHost(String) -> probe.config.MailProperties#host +known_edge write probe.config.QueueProperties#setDepth(int) -> probe.config.QueueProperties#depth +known_edge write probe.scanned.ScanProperties#setLimit(int) -> probe.scanned.ScanProperties#limit +known_edge write probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) -> probe.service.ItemService#converter +known_edge write probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) -> probe.service.ItemService#extra +known_edge write probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) -> probe.service.ItemService#items +known_edge write probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) -> probe.service.ItemService#orders +known_edge write probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) -> probe.service.ItemService#prices +known_edge write probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) -> probe.service.ItemService#stock +known_edge write probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) -> probe.service.ItemService#tags +known_edge write probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties) -> probe.service.Mailer#cache +known_edge write probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties) -> probe.service.Mailer#mail +known_edge write probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties) -> probe.service.Mailer#queue +known_edge write probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties) -> probe.service.Mailer#scan +known_edge write probe.web.ItemController#(ItemService) -> probe.web.ItemController#service diff --git a/graph/test/java/expected/65-boot-bean-registration.type-use b/graph/test/java/expected/65-boot-bean-registration.type-use new file mode 100644 index 000000000..f6f6159e1 --- /dev/null +++ b/graph/test/java/expected/65-boot-bean-registration.type-use @@ -0,0 +1,59 @@ +ambiguous_unknown ANNOTATION_TYPE 0 probe.boot.WidgetAutoConfiguration [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.boot.WidgetInitializer [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.boot.WidgetPlugin [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.config.CacheProperties [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.config.MailProperties [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.config.MapperConfig [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.config.PropsConfig [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.config.QueueProperties [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.config.ShopMapperConfig [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.convert.ItemConverter [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.dao.StockMapper [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.scanned.ScanProperties [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.service.ItemService [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.service.Mailer [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.web.ItemController [ANNOTATION] -> - +ambiguous_unknown FIELD_TYPE 0 probe.config.MailProperties [FIELD] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 0 probe.boot.WidgetInitializer [TYPE] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 1 probe.boot.WidgetInitializer [TYPE] -> - +ambiguous_unknown METHOD_PARAM 0 probe.boot.WidgetInitializer#initialize(ConfigurableApplicationContext) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.config.MailProperties#setHost(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.convert.ItemConverter#convert(Long) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.dao.StockMapper#count(Long) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.daoextra.ExtraDao#extra(Long) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.mapper.ItemMapper#touch(Long) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.other.TagMapper#tag(Long) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.service.ItemService#touch(Long) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.service.PriceSource#price(Long) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.shop.dao.OrderDao#open(Long) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_RETURN 0 probe.config.MailProperties#getHost() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 probe.convert.ItemConverter#convert(Long) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 probe.service.Mailer#host() [METHOD] -> - +known_edge ANNOTATION_PARAM 0 probe.config.PropsConfig [ANNOTATION_ARGUMENT] -> probe.config.MailProperties +known_edge FIELD_TYPE 0 probe.boot.LegacyAutoConfiguration [FIELD] -> probe.service.Mailer +known_edge FIELD_TYPE 0 probe.service.ItemService [FIELD] -> probe.convert.ItemConverter +known_edge FIELD_TYPE 0 probe.service.ItemService [FIELD] -> probe.dao.StockMapper +known_edge FIELD_TYPE 0 probe.service.ItemService [FIELD] -> probe.daoextra.ExtraDao +known_edge FIELD_TYPE 0 probe.service.ItemService [FIELD] -> probe.mapper.ItemMapper +known_edge FIELD_TYPE 0 probe.service.ItemService [FIELD] -> probe.other.TagMapper +known_edge FIELD_TYPE 0 probe.service.ItemService [FIELD] -> probe.service.PriceSource +known_edge FIELD_TYPE 0 probe.service.ItemService [FIELD] -> probe.shop.dao.OrderDao +known_edge FIELD_TYPE 0 probe.service.Mailer [FIELD] -> probe.config.CacheProperties +known_edge FIELD_TYPE 0 probe.service.Mailer [FIELD] -> probe.config.MailProperties +known_edge FIELD_TYPE 0 probe.service.Mailer [FIELD] -> probe.config.QueueProperties +known_edge FIELD_TYPE 0 probe.service.Mailer [FIELD] -> probe.scanned.ScanProperties +known_edge FIELD_TYPE 0 probe.web.ItemController [FIELD] -> probe.service.ItemService +known_edge IMPLEMENTS_INTERFACE 0 probe.boot.WidgetPlugin [TYPE] -> probe.boot.Plugin +known_edge METHOD_PARAM 0 probe.boot.LegacyAutoConfiguration#(Mailer) [METHOD_PARAM] -> probe.service.Mailer +known_edge METHOD_PARAM 0 probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) [METHOD_PARAM] -> probe.convert.ItemConverter +known_edge METHOD_PARAM 0 probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) [METHOD_PARAM] -> probe.dao.StockMapper +known_edge METHOD_PARAM 0 probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) [METHOD_PARAM] -> probe.daoextra.ExtraDao +known_edge METHOD_PARAM 0 probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) [METHOD_PARAM] -> probe.mapper.ItemMapper +known_edge METHOD_PARAM 0 probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) [METHOD_PARAM] -> probe.other.TagMapper +known_edge METHOD_PARAM 0 probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) [METHOD_PARAM] -> probe.service.PriceSource +known_edge METHOD_PARAM 0 probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) [METHOD_PARAM] -> probe.shop.dao.OrderDao +known_edge METHOD_PARAM 0 probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties) [METHOD_PARAM] -> probe.config.CacheProperties +known_edge METHOD_PARAM 0 probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties) [METHOD_PARAM] -> probe.config.MailProperties +known_edge METHOD_PARAM 0 probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties) [METHOD_PARAM] -> probe.config.QueueProperties +known_edge METHOD_PARAM 0 probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties) [METHOD_PARAM] -> probe.scanned.ScanProperties +known_edge METHOD_PARAM 0 probe.web.ItemController#(ItemService) [METHOD_PARAM] -> probe.service.ItemService diff --git a/parser/src/analysis-types/services/ServiceDescriptor.ts b/parser/src/analysis-types/services/ServiceDescriptor.ts index b4e28f65a..5a992646a 100644 --- a/parser/src/analysis-types/services/ServiceDescriptor.ts +++ b/parser/src/analysis-types/services/ServiceDescriptor.ts @@ -46,6 +46,7 @@ export class ServiceDescriptor implements EntityIdentifiable { private filePath: string; private baseMservPath: string; private serviceVersionLinkHash: string; + private entryKey: string; private serviceDescriptorUniqueHash: string = ''; private constructor(builder: ServiceDescriptorBuilder) { @@ -62,6 +63,7 @@ export class ServiceDescriptor implements EntityIdentifiable { this.filePath = builder.filePath; this.baseMservPath = builder.baseMservPath; this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + this.entryKey = builder.entryKey; this.generateHash(); } @@ -110,7 +112,11 @@ export class ServiceDescriptor implements EntityIdentifiable { const content = this.filePath + '||' + this.baseMservPath + - '||' + this.serviceVersionLinkHash; + '||' + this.serviceVersionLinkHash + + // One file, several descriptors: `spring.factories` configures one service + // per KEY. Appended only when set, so a one-descriptor file keeps the key + // it always had. + (this.entryKey ? '||' + this.entryKey : ''); this.serviceDescriptorUniqueHash = EntityUtils.generateEntityHash( ENTITY_IDENTIFIERS.SERVICE_DESCRIPTOR, @@ -192,6 +198,7 @@ class ServiceDescriptorBuilder { filePath: string; baseMservPath: string; serviceVersionLinkHash: string; + entryKey: string = ''; constructor( serviceInterfaceBinaryName: string, @@ -251,6 +258,12 @@ class ServiceDescriptorBuilder { return this; } + /** The key inside a file that configures several services (`spring.factories`). */ + withEntryKey(entryKey: string): ServiceDescriptorBuilder { + this.entryKey = entryKey; + return this; + } + build(): ServiceDescriptor { return new (ServiceDescriptor as any)(this); } diff --git a/parser/src/constants/consts.ts b/parser/src/constants/consts.ts index 000999d09..77bbeb883 100644 --- a/parser/src/constants/consts.ts +++ b/parser/src/constants/consts.ts @@ -185,3 +185,8 @@ export const GRADLE_DEFAULT_VERSION_CATALOG = 'gradle/libs.versions.toml'; */ export const META_INF_DIR = 'META-INF'; export const SERVICES_DIR = 'services'; +/** `META-INF/spring/.imports`: Spring Boot's registration files (auto-configurations). */ +export const SPRING_DIR = 'spring'; +export const SPRING_IMPORTS_SUFFIX = '.imports'; +/** `META-INF/spring.factories`: Spring's `SpringFactoriesLoader` registry, a properties file. */ +export const SPRING_FACTORIES_FILE = 'spring.factories'; diff --git a/parser/src/parsers/services/services-parser.ts b/parser/src/parsers/services/services-parser.ts index 89794016d..6fdd2186d 100644 --- a/parser/src/parsers/services/services-parser.ts +++ b/parser/src/parsers/services/services-parser.ts @@ -84,15 +84,19 @@ export class ServicesParser { * @param filePath Absolute path to the file * @param baseMservPath Project root path * @param serviceVersionLinkHash Service version hash + * @param serviceName The service being configured, when the file name is not + * it verbatim: Spring's `META-INF/spring/.imports` has the same + * one-class-per-line, `#`-comment format and names its service `` * @returns Tuple of [ServiceDescriptor, ServiceProvider[]] */ parse( content: string, filePath: string, baseMservPath: string, - serviceVersionLinkHash: string + serviceVersionLinkHash: string, + serviceName?: string ): [ServiceDescriptor, ServiceProvider[]] { - const fileName = path.basename(filePath); + const fileName = serviceName ?? path.basename(filePath); const service = this.resolveBinaryName(fileName); const lines = this.split(content); const parsedLines = this.parseProviderLines(lines); @@ -113,6 +117,130 @@ export class ServicesParser { .withRelativePath(this.toPosix(path.relative(baseMservPath, filePath)) || fileName) .build(); + const providers = this.buildProviders(parsedLines, descriptor, filePath, baseMservPath, serviceVersionLinkHash); + descriptor.setProviderCounts( + providers.length, + providers.filter((p) => p.getIsWellFormedName()).length + ); + + return [descriptor, providers]; + } + + /** + * Parses `META-INF/spring.factories`: one descriptor per KEY, one provider per + * comma-separated class in its value. + * + * The file is a `java.util.Properties` file, which `SpringFactoriesLoader` + * reads as such, so its clauses are the Properties ones and not the + * ServiceLoader ones: a comment is a LINE whose first non-blank character is + * `#` or `!` (a `#` inside a value is text); a line ending in an odd number of + * backslashes continues on the next, whose leading blanks are dropped; the key + * ends at the first unescaped `=`, `:` or blank. Each provider keeps the + * physical line and columns it was written on, so a continued value still + * points at the line that names the class. + */ + parseSpringFactories( + content: string, + filePath: string, + baseMservPath: string, + serviceVersionLinkHash: string + ): Array<[ServiceDescriptor, ServiceProvider[]]> { + const lines = this.split(content); + const relativePath = this.toPosix(path.relative(baseMservPath, filePath)) || path.basename(filePath); + const byKey = new Map(); + + for (const logical of this.logicalLines(lines)) { + const { text, at } = logical; + let i = 0; + while (i < text.length && (text[i] === ' ' || text[i] === '\t' || text[i] === '\f')) i++; + if (i >= text.length || text[i] === '#' || text[i] === '!') continue; + const keyStart = i; + while (i < text.length && !'=: \t\f'.includes(text[i]!)) { + i += text[i] === '\\' ? 2 : 1; + } + const key = text.slice(keyStart, i); + while (i < text.length && (text[i] === ' ' || text[i] === '\t' || text[i] === '\f')) i++; + if (i < text.length && (text[i] === '=' || text[i] === ':')) i++; + + const parsed = byKey.get(key) ?? []; + let start = i; + for (let j = i; j <= text.length; j++) { + if (j < text.length && text[j] !== ',') continue; + const raw = text.slice(start, j); + const lead = raw.length - raw.trimStart().length; + const name = raw.trim(); + if (name.length > 0) { + const [line, col] = at[start + lead]!; + parsed.push({ binaryName: name, line, startCol: col, endCol: col + name.length, hasInlineComment: false }); + } + start = j + 1; + } + byKey.set(key, parsed); + } + + const out: Array<[ServiceDescriptor, ServiceProvider[]]> = []; + for (const [key, parsedLines] of byKey) { + const service = this.resolveBinaryName(key); + const descriptor = ServiceDescriptor.builder(key, filePath, baseMservPath, serviceVersionLinkHash) + .withServiceInterface(service.qualifiedName) + .withSimpleName(service.simpleName) + .withPackageName(service.packageName) + .withIsNestedServiceName(service.isNested) + .withIsWellFormedServiceName(service.isWellFormed) + .withLineCount(lines.length) + .withRelativePath(relativePath) + .withEntryKey(key) + .build(); + const providers = this.buildProviders(parsedLines, descriptor, filePath, baseMservPath, serviceVersionLinkHash); + descriptor.setProviderCounts( + providers.length, + providers.filter((p) => p.getIsWellFormedName()).length + ); + out.push([descriptor, providers]); + } + return out; + } + + /** + * Joins backslash-continued physical lines into logical ones, keeping for each + * character the 1-based line and 0-based column it came from. + */ + private logicalLines(lines: string[]): Array<{ text: string; at: Array<[number, number]> }> { + const out: Array<{ text: string; at: Array<[number, number]> }> = []; + let text = ''; + let at: Array<[number, number]> = []; + let continuing = false; + lines.forEach((raw, index) => { + let from = 0; + if (continuing) { + while (from < raw.length && (raw[from] === ' ' || raw[from] === '\t' || raw[from] === '\f')) from++; + } + let trailing = 0; + for (let k = raw.length - 1; k >= from && raw[k] === '\\'; k--) trailing++; + const continues = trailing % 2 === 1; + const to = continues ? raw.length - 1 : raw.length; + for (let k = from; k < to; k++) { + text += raw[k]; + at.push([index + 1, k]); + } + continuing = continues; + if (!continues) { + out.push({ text, at }); + text = ''; + at = []; + } + }); + if (continuing) out.push({ text, at }); + return out; + } + + private buildProviders( + parsedLines: ParsedProviderLine[], + descriptor: ServiceDescriptor, + filePath: string, + baseMservPath: string, + serviceVersionLinkHash: string + ): ServiceProvider[] { const seen = new Set(); const providers: ServiceProvider[] = []; @@ -145,12 +273,7 @@ export class ServicesParser { ); }); - descriptor.setProviderCounts( - providers.length, - providers.filter((p) => p.getIsWellFormedName()).length - ); - - return [descriptor, providers]; + return providers; } /** diff --git a/parser/src/workflows/services/services-project-analyzer.ts b/parser/src/workflows/services/services-project-analyzer.ts index 04f107ae2..36c187562 100644 --- a/parser/src/workflows/services/services-project-analyzer.ts +++ b/parser/src/workflows/services/services-project-analyzer.ts @@ -12,6 +12,9 @@ import { OUTPUT_SERVICE_PROVIDER_CSV_FILENAME, OUTPUT_SKIPPED_SERVICES_FILES_CSV_FILENAME, SERVICES_DIR, + SPRING_DIR, + SPRING_FACTORIES_FILE, + SPRING_IMPORTS_SUFFIX, } from '@/constants/consts'; import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; import { SkippedFileReason } from '@/enums/SkippedFileReason'; @@ -29,7 +32,9 @@ interface SkippedServicesFile { } /** - * Extracts `META-INF/services` provider-configuration files across a codebase. + * Extracts `META-INF/services` provider-configuration files across a codebase, + * and Spring's two registries in the same shape: `META-INF/spring/.imports` + * and `META-INF/spring.factories` (one descriptor per key). * * ## Why one file is attributed to exactly one project * @@ -143,14 +148,19 @@ export class ServicesProjectAnalyzer { // skipped. It is a well-formed descriptor that declares zero providers — // a real and meaningful statement — and recording it as a skipped file // would make "no providers declared" indistinguishable from "not read". - const [descriptor, providers] = this.parser.parse( - content, - filePath, - baseMservPath, - serviceVersionHash - ); - this.allDescriptors.push(descriptor); - this.allProviders.push(...providers); + const fileName = path.basename(filePath); + const parentDir = path.basename(path.dirname(filePath)); + const parsed: Array<[ServiceDescriptor, ServiceProvider[]]> = + parentDir === META_INF_DIR && fileName === SPRING_FACTORIES_FILE + ? this.parser.parseSpringFactories(content, filePath, baseMservPath, serviceVersionHash) + : parentDir === SPRING_DIR && fileName.endsWith(SPRING_IMPORTS_SUFFIX) + ? [this.parser.parse(content, filePath, baseMservPath, serviceVersionHash, + fileName.slice(0, -SPRING_IMPORTS_SUFFIX.length))] + : [this.parser.parse(content, filePath, baseMservPath, serviceVersionHash)]; + for (const [descriptor, providers] of parsed) { + this.allDescriptors.push(descriptor); + this.allProviders.push(...providers); + } } catch (error) { console.error(` ❌ Error parsing ${filePath}:`, error); this.recordSkip( @@ -211,6 +221,16 @@ export class ServicesProjectAnalyzer { if (entry.name === META_INF_DIR) { await this.collectProviderFiles(path.join(subPath, SERVICES_DIR), files); + // Spring's two registries beside it: `META-INF/spring/*.imports` (one class + // per line, the ServiceLoader format) and `META-INF/spring.factories`. + await this.collectProviderFiles(path.join(subPath, SPRING_DIR), files, SPRING_IMPORTS_SUFFIX); + try { + if ((await fs.stat(path.join(subPath, SPRING_FACTORIES_FILE))).isFile()) { + files.push(path.join(subPath, SPRING_FACTORIES_FILE)); + } + } catch { + // No spring.factories here, which is the common case. + } // A `META-INF` directory holds resources, never a nested project, so // there is nothing else beneath it to look for. continue; @@ -224,7 +244,7 @@ export class ServicesProjectAnalyzer { } } - private async collectProviderFiles(servicesDir: string, files: string[]): Promise { + private async collectProviderFiles(servicesDir: string, files: string[], suffix = ''): Promise { let entries; try { entries = await fs.readdir(servicesDir, { withFileTypes: true }); @@ -234,7 +254,7 @@ export class ServicesProjectAnalyzer { } for (const entry of entries) { - if (entry.isFile()) { + if (entry.isFile() && entry.name.endsWith(suffix)) { files.push(path.join(servicesDir, entry.name)); } } diff --git a/plugins/axiomcode/skills/axiomcode/reference/impact.md b/plugins/axiomcode/skills/axiomcode/reference/impact.md index 967f21f51..5d07da8ae 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/impact.md +++ b/plugins/axiomcode/skills/axiomcode/reference/impact.md @@ -29,6 +29,9 @@ every printed chain hop and every `[resolved]` entry is looked up again in `grap - **what the container injects** — a type registered as a bean, or a method that defines one, lists the callables the container hands it to (`ctor_param`, a field injection): `receives it by dependency injection — the container hands it over, no call site`. Swapping a `@Bean` implementation reaches its consumers this way. + A class that registers the type from another class (`@EnableConfigurationProperties({T.class})`, a `@MapperScan` + or properties package scan) is listed as `registers it as a bean`, and a configuration class lists who is injected + with the beans its own `@Bean` methods define (`is injected with a bean this class defines`). - **what a framework hands over (Python)**: the engine's `framework_edge` joins a task body to its `.delay()` / `.apply_async()` producer, a `@receiver` to the `send` of the same signal object, a view to its route table, a `Depends()` provider to the handler declaring it, and a fixture to the test naming it. The end that hands over is listed diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 467ec300e..b85ecfa77 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -680,7 +680,7 @@ class Impact: return [ln for ln in range(s['line'], min(s['end_line'], len(L)) + 1) if pat.search(L[ln - 1])] # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '29' # 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 = '30' # 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 @@ -1108,6 +1108,10 @@ class Impact: if hit: sw.append((i, tid, str(len(hit)))) W('throws_', sorted(set(thr))); W('catches', sorted(set(cat_))); W('switch_over', sorted(set(sw))) W('config', cfg); W('config_key_known', [(k,) for k in sorted(keys)]); W('bean', beans); W('injected', sorted(set(inj))) + # a bean another class registers by naming it in an annotation: the query is graph_sql's, so the rules and the + # hook's fast path read the same rows + import graph_sql + W('registers', sorted({(t, by, kind) for t, rows in graph_sql._registers(g.q).items() for by, kind in rows})) W('bean_factory', sorted(set(factories))) # A CROSS-PROCESS HOP IS A DEPENDENT, AND IT WAS IN THE DATABASE UNREAD (#1108). # framework-behavior emits remote_edge for a hop no call site expresses -- a queue, a route, a diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index d7f708168..46c1ce769 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -104,6 +104,8 @@ // bean(name, t, kind) a type registered as a bean (a stereotype, an @Bean factory) // bean_factory(m, t) the METHOD an @Bean factory is declared on, and the type it produces // injected(t, into, kind) a value of type t is handed to `into` by the container, not by a call the graph can see +// registers(t, by, kind) the bean of type t exists because an annotation on the type `by` names it +// (@EnableConfigurationProperties({T.class}), a mapper or properties package scan) .decl config_site(k:symbol, c:symbol, d:symbol, f:symbol, l:number) .input config_site // nonsource(n, f, l) the name n written in a file the index does NOT read as source // qual_name(s, n) a qualified spelling of symbol s, for a binding written in full @@ -114,6 +116,7 @@ .decl bean(n:symbol, t:symbol, kind:symbol) .input bean .decl bean_factory(m:symbol, t:symbol) .input bean_factory .decl injected(t:symbol, into:symbol, kind:symbol) .input injected +.decl registers(t:symbol, by:symbol, kind:symbol) .input registers // ── vocabulary ───────────────────────────────────────────────────────────────────────────────────────────────── .decl local_kind(k:symbol) @@ -291,6 +294,14 @@ direct(q, c, "uses", cat("receives it by dependency injection (", kind, ") — t // which names the METHOD. Keying on `bean(_, owner(m), _)` instead only says the method's OWNER is a bean, so // it fired for every method of every injected @Service and never for a factory whose @Configuration is not injected. direct(q, c, "uses", cat("is injected with the bean this defines (", kind, ")"), "resolved", "", 0) :- target(q, "method", m, _), bean_factory(m, bt), injected(bt, c, kind), c != m. +// a TYPE that another class registers as a bean: that class, which a rename, a move or a delete of the type breaks. +// Without it the registering annotation was one [by name] row among the text matches, and a type registered only +// that way read as a class nothing depends on. +direct(q, c, "uses", cat("registers it as a bean (", kind, "): the container creates it, no call site"), "resolved", "", 0) :- target(q, "type", t, _), registers(t, c, kind), !inside_target(q, c). +// a TYPE that DEFINES beans (a @Configuration class, an auto-configuration): whoever is injected with a bean one of its +// @Bean methods defines. The factory methods are inside the target, so the method rule above never runs for them +// and a change to the class said nothing resolved depended on it. +direct(q, c, "uses", cat("is injected with a bean this class defines (", kind, ")"), "resolved", "", 0) :- target(q, "type", _, _), inside_target(q, m), bean_factory(m, bt), injected(bt, c, kind), !inside_target(q, c). // a METHOD: its callers, and the unresolved sites written with its name // A RESOLVED call at a route registration says what the code does and not what it means: on JavaScript the engine diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 89ead0e11..8ee423922 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -979,6 +979,43 @@ def _injected(q): return _INJECTED[key] + +_REGISTERS = {} +def _registers(q): + """`registers(t,by,kind)`: a bean of type t whose definition site is an annotation declared on ANOTHER type `by`. + + The engine records where each bean comes from (`ext_bean_def.c3`): for @EnableConfigurationProperties({T.class}) + and for a package scan (@MapperScan, @ConfigurationPropertiesScan) that is the annotation on the configuration class, + and `type_use` names the type that annotation sits on. A stereotype's site is on the bean's own type, so it is not a + registration by someone else and is left out. One query, read by the exporter (the rules) and by the fast path. + """ + key = id(q) + if key not in _REGISTERS: + idx = {} + if _has(q, 'ext_bean_def') and _has(q, 'type_use'): + for t, by, kind in q("""SELECT DISTINCT b.c1, s.id, b.c2 FROM ext_bean_def b + JOIN type_use u ON u.owner_id = b.c3 AND u.owner_kind = 'ANNOTATION' + AND u.context = 'ANNOTATION_TYPE' + JOIN symbols s ON s.id = u.owner_type_id AND s.method_id IS NULL + WHERE b.c1 IS NOT NULL AND u.owner_type_id <> b.c1"""): + idx.setdefault(t, set()).add((by, kind or 'bean')) + _REGISTERS[key] = idx + return _REGISTERS[key] + + +_FACTORIES = {} +def _factories(q): + """`bean_factory(m,t)`: the method an @Bean factory is declared on, and the type it produces.""" + key = id(q) + if key not in _FACTORIES: + idx = {} + if _has(q, 'ext_bean_def'): + for mid, t in q("SELECT c3, c1 FROM ext_bean_def WHERE c2 = 'factory_method' AND c3 IS NOT NULL AND c3 <> ''"): + idx.setdefault(mid, set()).add(t) + _FACTORIES[key] = idx + return _FACTORIES[key] + + def _bean_definition_consumers(q, ids): """A method that DEFINES a bean (@Bean): whoever the container injects that type into. @@ -1591,6 +1628,27 @@ def direct_for_type(q, tids, at, inside, textuse, importuse, rel, code=None): rows.append((c, 'uses', f'receives it by dependency injection ({kind}) — the container hands it ' f'over, no call site', 'resolved', '', 0)) + # ── a type another class REGISTERS as a bean ─────────────────────────────────────────────────────────── + # direct(q,c,"uses",cat("registers it as a bean (",kind,"): …"),"resolved","",0) + # :- target(q,"type",t,_), registers(t,c,kind), !inside_target(q,c) + # ── a type that DEFINES beans: who is injected with one of them ──────────────────────────────────────── + # direct(q,c,"uses",cat("is injected with a bean this class defines (",kind,")"),"resolved","",0) + # :- target(q,"type",_,_), inside_target(q,m), bean_factory(m,bt), injected(bt,c,kind), !inside_target(q,c) + bean_rows = set() + regs = _registers(q) + for t in tids: + for c, kind in regs.get(t, ()): + if c not in inside: + bean_rows.add((c, 'uses', f'registers it as a bean ({kind}): the container creates it, no call site', + 'resolved', '', 0)) + fac = _factories(q) + for m in inside: + for bt in fac.get(m, ()): + for c, kind in inj.get(bt, ()): + if c not in inside: + bean_rows.add((c, 'uses', f'is injected with a bean this class defines ({kind})', 'resolved', '', 0)) + rows += sorted(bean_rows) + # ── the GENERATED accessors of the type's own fields: 276-277 ───────────────────────────────────────── # gen(t,"get"|"set"), field(fl,t,…), accessor(fl,an,…), unresolved(c,an,k,f,l), !ctor_kind(k), # !inside_target(q,c). `unresolved` here, not `named_site` — the type form is narrower than the field one. diff --git a/skills/axiomcode/reference/impact.md b/skills/axiomcode/reference/impact.md index 967f21f51..5d07da8ae 100644 --- a/skills/axiomcode/reference/impact.md +++ b/skills/axiomcode/reference/impact.md @@ -29,6 +29,9 @@ every printed chain hop and every `[resolved]` entry is looked up again in `grap - **what the container injects** — a type registered as a bean, or a method that defines one, lists the callables the container hands it to (`ctor_param`, a field injection): `receives it by dependency injection — the container hands it over, no call site`. Swapping a `@Bean` implementation reaches its consumers this way. + A class that registers the type from another class (`@EnableConfigurationProperties({T.class})`, a `@MapperScan` + or properties package scan) is listed as `registers it as a bean`, and a configuration class lists who is injected + with the beans its own `@Bean` methods define (`is injected with a bean this class defines`). - **what a framework hands over (Python)**: the engine's `framework_edge` joins a task body to its `.delay()` / `.apply_async()` producer, a `@receiver` to the `send` of the same signal object, a view to its route table, a `Depends()` provider to the handler declaring it, and a fixture to the test naming it. The end that hands over is listed diff --git a/tests/cases/java/bean-registered-or-defined-by-a-class/case.json b/tests/cases/java/bean-registered-or-defined-by-a-class/case.json new file mode 100644 index 000000000..6102f0c17 --- /dev/null +++ b/tests/cases/java/bean-registered-or-defined-by-a-class/case.json @@ -0,0 +1,16 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a class registered as a bean by another class's annotation (@EnableConfigurationProperties) names that class as a dependent, not only as a text match", + "run": ["impact", "MailProperties"], + "want": ["registers it as a bean (enabled_properties)", "PropsConfig", "dependency injection (ctor_param)", "Mailer.Mailer"]}, + {"why": "near miss: the same shape registered by nothing has no registration row", + "run": ["impact", "QueueProperties"], + "want": ["Mailer.Mailer"], + "avoid": ["registers it as a bean"]}, + {"why": "a @Configuration class's answer names who is injected with the beans its @Bean methods define; before, it said nothing resolved depended on it", + "run": ["impact", "WidgetConfig"], + "want": ["is injected with a bean this class defines (ctor_param)", "Assembler.Assembler"], + "avoid": ["nothing resolved in the source depends on it"]}, + {"why": "near miss: a plain class whose method returns an injected type defines no bean", + "run": ["impact", "GadgetFactory"], + "avoid": ["is injected with a bean this class defines", "Assembler.Assembler"]}]} diff --git a/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/Gadget.java b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/Gadget.java new file mode 100644 index 000000000..36f9a2b3e --- /dev/null +++ b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/Gadget.java @@ -0,0 +1,5 @@ +package shop.config; + +public class Gadget { + public String name() { return "gadget"; } +} diff --git a/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/GadgetFactory.java b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/GadgetFactory.java new file mode 100644 index 000000000..f33649b0b --- /dev/null +++ b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/GadgetFactory.java @@ -0,0 +1,6 @@ +package shop.config; + +/** Near miss: a method that returns a Gadget, with no @Bean and no @Configuration. */ +public class GadgetFactory { + public Gadget gadget() { return new Gadget(); } +} diff --git a/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/MailProperties.java b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/MailProperties.java new file mode 100644 index 000000000..5bbfe4775 --- /dev/null +++ b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/MailProperties.java @@ -0,0 +1,10 @@ +package shop.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.mail") +public class MailProperties { + private String host; + public String getHost() { return host; } + public void setHost(String host) { this.host = host; } +} diff --git a/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/PropsConfig.java b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/PropsConfig.java new file mode 100644 index 000000000..f367a2d29 --- /dev/null +++ b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/PropsConfig.java @@ -0,0 +1,8 @@ +package shop.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties({MailProperties.class}) +public class PropsConfig { } diff --git a/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/QueueProperties.java b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/QueueProperties.java new file mode 100644 index 000000000..5a4fdf27e --- /dev/null +++ b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/QueueProperties.java @@ -0,0 +1,11 @@ +package shop.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Near miss: the same shape as MailProperties, registered by nothing. */ +@ConfigurationProperties(prefix = "app.queue") +public class QueueProperties { + private int depth; + public int getDepth() { return depth; } + public void setDepth(int depth) { this.depth = depth; } +} diff --git a/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/Widget.java b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/Widget.java new file mode 100644 index 000000000..21da6fc1b --- /dev/null +++ b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/Widget.java @@ -0,0 +1,5 @@ +package shop.config; + +public class Widget { + public String name() { return "widget"; } +} diff --git a/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/WidgetConfig.java b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/WidgetConfig.java new file mode 100644 index 000000000..07486e597 --- /dev/null +++ b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/WidgetConfig.java @@ -0,0 +1,10 @@ +package shop.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class WidgetConfig { + @Bean + public Widget widget() { return new Widget(); } +} diff --git a/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/service/Assembler.java b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/service/Assembler.java new file mode 100644 index 000000000..157f0a700 --- /dev/null +++ b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/service/Assembler.java @@ -0,0 +1,18 @@ +package shop.service; + +import org.springframework.stereotype.Service; +import shop.config.Gadget; +import shop.config.Widget; + +@Service +public class Assembler { + private final Widget widget; + private final Gadget gadget; + + public Assembler(Widget widget, Gadget gadget) { + this.widget = widget; + this.gadget = gadget; + } + + public String build() { return widget.name() + gadget.name(); } +} diff --git a/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/service/Mailer.java b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/service/Mailer.java new file mode 100644 index 000000000..ee33ba748 --- /dev/null +++ b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/service/Mailer.java @@ -0,0 +1,18 @@ +package shop.service; + +import org.springframework.stereotype.Service; +import shop.config.MailProperties; +import shop.config.QueueProperties; + +@Service +public class Mailer { + private final MailProperties mail; + private final QueueProperties queue; + + public Mailer(MailProperties mail, QueueProperties queue) { + this.mail = mail; + this.queue = queue; + } + + public String host() { return mail.getHost() + queue.getDepth(); } +} From 503275b6b3debe55dd39bea185586763dd4f772e Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:56:06 -0700 Subject: [PATCH 04/31] java: a published Spring application event reaches the listeners its type fits (#1391) publishEvent(e) ended at the library boundary, so path from a publisher to its @EventListener said the two were independent and impact on a listener said the change was local. call-edge-generation/event_dispatch.dl adds an event_dispatch call edge from the publish site to every listener whose declared event type the published type is assignable to: the exact type, any ancestor of it, and the catch-all ApplicationEvent and Object listeners. A listener is an @EventListener or @TransactionalEventListener method (the classes named on the annotation win, else its one parameter) or the onApplicationEvent override of an ApplicationListener implementation, which also becomes a queue entry point. Publishers are a knob table (knobs.dl section 17) matched against the receiver's declared type, so a project class with its own publishEvent stays an ordinary call. A listener for a subtype does not receive the supertype's event, and a listener for an unrelated type receives nothing. Query layer: an event_dispatch caller reads "publishes an event this handles" and is not reported as a call that bypasses the container proxy. Case 65-spring-application-events and plugin case event-reaches-its-listeners, with controls. next-step-reads-the-whole-answer keeps its unmodelled library-call check on an Object-typed event. --- graph/bundle/SCHEMA.md | 4 +- graph/bundle/schema.ts | 4 +- .../call-edge-generation/event_dispatch.dl | 114 ++++++++++++++++++ .../engine/config-resolution/entry-points.dl | 3 + graph/java/engine/config-resolution/knobs.dl | 36 ++++++ .../engine/framework-behavior/destinations.dl | 8 +- graph/java/souffle/decls_all.dl | 19 +++ .../src/probe/AllEvents.java | 14 +++ .../src/probe/Audit.java | 34 ++++++ .../src/probe/EverythingLog.java | 11 ++ .../src/probe/ExpressOrderPlaced.java | 6 + .../src/probe/LocalBus.java | 9 ++ .../src/probe/OrderCancelled.java | 3 + .../src/probe/OrderPlaced.java | 6 + .../src/probe/OrderService.java | 42 +++++++ .../src/probe/OrderShipped.java | 7 ++ .../src/probe/Refunds.java | 16 +++ .../src/probe/ShipListener.java | 12 ++ .../src/probe/WidgetBuilt.java | 8 ++ .../src/probe/WidgetListener.java | 11 ++ .../65-spring-application-events.config | 52 ++++++++ .../65-spring-application-events.edges | 54 +++++++++ .../65-spring-application-events.fields | 16 +++ .../65-spring-application-events.type-use | 56 +++++++++ .../skills/axiomcode/scripts/ax_edges.py | 10 ++ .../skills/axiomcode/scripts/axiomcode-impact | 2 +- .../skills/axiomcode/scripts/dl/impact.dl | 4 +- .../skills/axiomcode/scripts/graph_sql.py | 6 +- .../event-reaches-its-listeners/case.json | 20 +++ .../src/probe/AllEvents.java | 14 +++ .../src/probe/Audit.java | 34 ++++++ .../src/probe/ExpressOrderPlaced.java | 6 + .../src/probe/LocalBus.java | 9 ++ .../src/probe/OrderCancelled.java | 3 + .../src/probe/OrderPlaced.java | 6 + .../src/probe/OrderService.java | 38 ++++++ .../src/probe/OrderShipped.java | 7 ++ .../src/probe/ShipListener.java | 12 ++ .../src/probe/WidgetBuilt.java | 8 ++ .../src/probe/WidgetListener.java | 11 ++ .../case.json | 6 +- .../src/com/example/OrderService.java | 2 + 42 files changed, 731 insertions(+), 12 deletions(-) create mode 100644 graph/java/engine/call-edge-generation/event_dispatch.dl create mode 100644 graph/test/java/cases/65-spring-application-events/src/probe/AllEvents.java create mode 100644 graph/test/java/cases/65-spring-application-events/src/probe/Audit.java create mode 100644 graph/test/java/cases/65-spring-application-events/src/probe/EverythingLog.java create mode 100644 graph/test/java/cases/65-spring-application-events/src/probe/ExpressOrderPlaced.java create mode 100644 graph/test/java/cases/65-spring-application-events/src/probe/LocalBus.java create mode 100644 graph/test/java/cases/65-spring-application-events/src/probe/OrderCancelled.java create mode 100644 graph/test/java/cases/65-spring-application-events/src/probe/OrderPlaced.java create mode 100644 graph/test/java/cases/65-spring-application-events/src/probe/OrderService.java create mode 100644 graph/test/java/cases/65-spring-application-events/src/probe/OrderShipped.java create mode 100644 graph/test/java/cases/65-spring-application-events/src/probe/Refunds.java create mode 100644 graph/test/java/cases/65-spring-application-events/src/probe/ShipListener.java create mode 100644 graph/test/java/cases/65-spring-application-events/src/probe/WidgetBuilt.java create mode 100644 graph/test/java/cases/65-spring-application-events/src/probe/WidgetListener.java create mode 100644 graph/test/java/expected/65-spring-application-events.config create mode 100644 graph/test/java/expected/65-spring-application-events.edges create mode 100644 graph/test/java/expected/65-spring-application-events.fields create mode 100644 graph/test/java/expected/65-spring-application-events.type-use create mode 100644 tests/cases/java/event-reaches-its-listeners/case.json create mode 100644 tests/cases/java/event-reaches-its-listeners/src/probe/AllEvents.java create mode 100644 tests/cases/java/event-reaches-its-listeners/src/probe/Audit.java create mode 100644 tests/cases/java/event-reaches-its-listeners/src/probe/ExpressOrderPlaced.java create mode 100644 tests/cases/java/event-reaches-its-listeners/src/probe/LocalBus.java create mode 100644 tests/cases/java/event-reaches-its-listeners/src/probe/OrderCancelled.java create mode 100644 tests/cases/java/event-reaches-its-listeners/src/probe/OrderPlaced.java create mode 100644 tests/cases/java/event-reaches-its-listeners/src/probe/OrderService.java create mode 100644 tests/cases/java/event-reaches-its-listeners/src/probe/OrderShipped.java create mode 100644 tests/cases/java/event-reaches-its-listeners/src/probe/ShipListener.java create mode 100644 tests/cases/java/event-reaches-its-listeners/src/probe/WidgetBuilt.java create mode 100644 tests/cases/java/event-reaches-its-listeners/src/probe/WidgetListener.java diff --git a/graph/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md index 7c96bcb6a..a55aef7cf 100644 --- a/graph/bundle/SCHEMA.md +++ b/graph/bundle/SCHEMA.md @@ -481,6 +481,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h | `lambda_body` | java | An edge from the method that INVOKES a lambda to something the lambda body calls. The body is not a method of its own, so its calls are attributed to the invoker rather than lost (call-edge-generation/lambda_dispatch.dl). | | `ctor_delegate` | java | `this(…)` / `super(…)` inside a constructor. | | `anon_new` | java | `new X() { … }` — an anonymous class creation. | +| `event` | java | Synthesised: the publishEvent call site to a listener it runs (tier event_dispatch). Not a written call to that method. | | `record_accessor` | java | Synthesised: a record pattern `case Pair(var l, var r)` calls each accessor. Not a written call; the site is the pattern expression. | | `FUNCTION_CALL` | typescript | `f(…)` — a bare callee. | | `METHOD_CALL` | typescript | `obj.m(…)`. | @@ -550,6 +551,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h | `fan_capped` | javascript, java, csharp | More targets than --dispatch-cap: the set was refused rather than emitted. JavaScript: callee is NULL. Java and C#: callee is the declared base method the fan would have started from; dispatch-capped-sites.csv carries the refused count. | | `callback_registered` | javascript | The site HANDS the callee this function (`xs.forEach(f)`, `p.then(f)`, `emitter.on('x', h)`, `setTimeout(f)`), which may invoke it. Not the site's own callee; a reachability edge, labelled so it is never read as a resolved call. | | `event_dispatch` | javascript | `x.emit('name')` reaching a handler registered by `x.on('name', h)` on a value x may hold — name-sensitive for literal names, every handler on that value for a computed one. | +| `event_dispatch` | java | A Spring application event: `publishEvent(e)` reaching each listener (`@EventListener`, `@TransactionalEventListener`, `ApplicationListener.onApplicationEvent`) whose declared event type e's static type is, or is a subtype of. Added beside the publishEvent boundary row, never in place of it (call-edge-generation/event_dispatch.dl). | | `intrinsic_terminal` | typescript | The site is a JSX intrinsic element or a dynamic `import()` — a runtime intrinsic, not a function the graph can name. | **`call_edges.callee_provenance` values** @@ -642,7 +644,7 @@ Methods the runtime invokes without a client call site — process roots, test m | `bean_ctor` | java, typescript | Constructor of a container-managed class. TypeScript: the class carries a framework decorator (`@Injectable`, `@Component`, `@Module`), so the container constructs it and nothing in the repository does. | | `factory` | java | A `@Bean` factory method. | | `lifecycle` | java, typescript, csharp | Java: `@PostConstruct` / `@PreDestroy` and similar hooks. TypeScript: a hook the container calls by name on a decorated class (`ngOnInit`, `onModuleInit`), which has no call site anywhere. C#: a method the host calls on a hosted service (`ExecuteAsync`, `StartAsync`, `StopAsync`, and the `IHostedLifecycleService` hooks), including one that derives from the host's base through the project's own base class. | -| `queue` | java, csharp | A message-listener method. C#: a broker consumer or a bus message handler. | +| `queue` | java, csharp | A message-listener method. Java: also a Spring application event listener (`@EventListener`, `@TransactionalEventListener`, an `ApplicationListener` implementation). C#: a broker consumer or a bus message handler. | | `scheduled` | java | A `@Scheduled` method. | | `grpc_service` | java, python, csharp | A gRPC service implementation the server invokes on a request, with no call site reaching it: a generated `ImplBase` override (Java); a class deriving from a generated `*Servicer` base in a `_pb2_grpc` module, overriding a method that base declares (Python). | | `web_servlet` | java | A servlet class named in `web.xml` (``): its container callbacks (`doGet`, `service`, …) and the library methods it overrides are invoked by the container. | diff --git a/graph/bundle/schema.ts b/graph/bundle/schema.ts index 041ccc50f..193966434 100644 --- a/graph/bundle/schema.ts +++ b/graph/bundle/schema.ts @@ -512,6 +512,7 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'call_edges', column: 'tier', value: 'fan_capped', languages: ['javascript', 'java', 'csharp'], meaning: 'More targets than --dispatch-cap: the set was refused rather than emitted. JavaScript: callee is NULL. Java and C#: callee is the declared base method the fan would have started from; dispatch-capped-sites.csv carries the refused count.' }, { table: 'call_edges', column: 'tier', value: 'callback_registered', languages: S, meaning: 'The site HANDS the callee this function (`xs.forEach(f)`, `p.then(f)`, `emitter.on(\'x\', h)`, `setTimeout(f)`), which may invoke it. Not the site\'s own callee; a reachability edge, labelled so it is never read as a resolved call.' }, { table: 'call_edges', column: 'tier', value: 'event_dispatch', languages: S, meaning: '`x.emit(\'name\')` reaching a handler registered by `x.on(\'name\', h)` on a value x may hold — name-sensitive for literal names, every handler on that value for a computed one.' }, + { table: 'call_edges', column: 'tier', value: 'event_dispatch', languages: J, meaning: 'A Spring application event: `publishEvent(e)` reaching each listener (`@EventListener`, `@TransactionalEventListener`, `ApplicationListener.onApplicationEvent`) whose declared event type e\'s static type is, or is a subtype of. Added beside the publishEvent boundary row, never in place of it (call-edge-generation/event_dispatch.dl).' }, { table: 'call_edges', column: 'tier', value: 'intrinsic_terminal', languages: T, meaning: 'The site is a JSX intrinsic element or a dynamic `import()` — a runtime intrinsic, not a function the graph can name.' }, // fields.kind / field_access.access / field_access.tier (#663) @@ -612,6 +613,7 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'call_edges', column: 'kind', value: 'lambda_body', languages: J, meaning: 'An edge from the method that INVOKES a lambda to something the lambda body calls. The body is not a method of its own, so its calls are attributed to the invoker rather than lost (call-edge-generation/lambda_dispatch.dl).' }, { table: 'call_edges', column: 'kind', value: 'ctor_delegate', languages: J, meaning: '`this(…)` / `super(…)` inside a constructor.' }, { table: 'call_edges', column: 'kind', value: 'anon_new', languages: J, meaning: '`new X() { … }` — an anonymous class creation.' }, + { table: 'call_edges', column: 'kind', value: 'event', languages: J, meaning: 'Synthesised: the publishEvent call site to a listener it runs (tier event_dispatch). Not a written call to that method.' }, { table: 'call_edges', column: 'kind', value: 'record_accessor', languages: J, meaning: 'Synthesised: a record pattern `case Pair(var l, var r)` calls each accessor. Not a written call; the site is the pattern expression.' }, // — TypeScript (the parser's callKind) { table: 'call_edges', column: 'kind', value: 'FUNCTION_CALL', languages: T, meaning: '`f(…)` — a bare callee.' }, @@ -671,7 +673,7 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'entry_points', column: 'reason', value: 'bean_ctor', languages: ['java', 'typescript'], meaning: 'Constructor of a container-managed class. TypeScript: the class carries a framework decorator (`@Injectable`, `@Component`, `@Module`), so the container constructs it and nothing in the repository does.' }, { table: 'entry_points', column: 'reason', value: 'factory', languages: J, meaning: 'A `@Bean` factory method.' }, { table: 'entry_points', column: 'reason', value: 'lifecycle', languages: ['java', 'typescript', 'csharp'], meaning: 'Java: `@PostConstruct` / `@PreDestroy` and similar hooks. TypeScript: a hook the container calls by name on a decorated class (`ngOnInit`, `onModuleInit`), which has no call site anywhere. C#: a method the host calls on a hosted service (`ExecuteAsync`, `StartAsync`, `StopAsync`, and the `IHostedLifecycleService` hooks), including one that derives from the host\'s base through the project\'s own base class.' }, - { table: 'entry_points', column: 'reason', value: 'queue', languages: ['java', 'csharp'], meaning: 'A message-listener method. C#: a broker consumer or a bus message handler.' }, + { table: 'entry_points', column: 'reason', value: 'queue', languages: ['java', 'csharp'], meaning: 'A message-listener method. Java: also a Spring application event listener (`@EventListener`, `@TransactionalEventListener`, an `ApplicationListener` implementation). C#: a broker consumer or a bus message handler.' }, { table: 'entry_points', column: 'reason', value: 'scheduled', languages: J, meaning: 'A `@Scheduled` method.' }, { table: 'entry_points', column: 'reason', value: 'grpc_service', languages: ['java', 'python', 'csharp'], meaning: 'A gRPC service implementation the server invokes on a request, with no call site reaching it: a generated `ImplBase` override (Java); a class deriving from a generated `*Servicer` base in a `_pb2_grpc` module, overriding a method that base declares (Python).' }, { table: 'entry_points', column: 'reason', value: 'web_servlet', languages: J, meaning: 'A servlet class named in `web.xml` (``): its container callbacks (`doGet`, `service`, …) and the library methods it overrides are invoked by the container.' }, diff --git a/graph/java/engine/call-edge-generation/event_dispatch.dl b/graph/java/engine/call-edge-generation/event_dispatch.dl new file mode 100644 index 000000000..2888a322c --- /dev/null +++ b/graph/java/engine/call-edge-generation/event_dispatch.dl @@ -0,0 +1,114 @@ +// ============================================================================ +// Call-edge generation · APPLICATION EVENTS (publishEvent -> the listeners it runs) +// +// publisher.publishEvent(new OrderPlaced(id)); // written here +// @EventListener void on(OrderPlaced e) { … } // run from there +// class Ship implements ApplicationListener { void onApplicationEvent(OrderShipped e) } +// +// Spring's multicaster hands the event to every listener whose declared event type the +// event is assignable to, synchronously and in the publishing thread unless @Async is +// on the listener. No call site names the listener, so without this file the publisher +// ended at `external:…ApplicationEventPublisher.publishEvent`, `path` from publisher to +// listener said the two were independent, and `impact` on a listener said the change +// was local (#1391). +// +// WHY A CALL EDGE AND NOT A remote_edge. framework-behavior/destinations.dl joins two +// ends that live in different processes on a written STRING, and emits remote_edge so a +// reader can tell that from an invocation. An application event crosses no process and +// has no string: it is a dispatch inside one JVM, keyed on a TYPE, and the listener runs +// before publishEvent returns. That is the JavaScript `emit` -> `on` shape, so it uses +// the tier the JavaScript front end already emits for it, `event_dispatch`, and every +// tool that walks call_chain_edge (path, impact, entry_reachable) sees through it with +// no change. The boundary row for the publishEvent call itself is kept: the edge is +// ADDED beside it, never in place of it. +// +// THE KEY IS THE EVENT'S TYPE, with a subtype rule, exactly as Spring matches it: +// an event of static type E reaches a listener for L when E is L or a subtype of L. +// So a listener for OrderPlaced receives an ExpressOrderPlaced, a listener for +// ExpressOrderPlaced does NOT receive a plain OrderPlaced, and a listener for an +// unrelated type receives neither. A listener for the event's SUPERTYPE (ApplicationEvent, +// or a project base event) receives it too, which is what Spring does. +// +// WHAT IS NOT MODELLED, and stays unlinked rather than guessed: +// - a publisher wrapped in a project helper (`Events.publish(e)` whose body calls +// publishEvent on an Object parameter): the static type there is Object, so only an +// Object listener would match, which is the honest static answer. +// - generic events (EntityCreated) are matched on the erased type, and a +// PayloadApplicationEvent listener is not matched to a published T. +// - @EventListener(condition = …) is not evaluated; the listener is linked. +// - a listener's returned event is not re-published. +// ============================================================================ + +// ── the publish sites ─────────────────────────────────────────────────────── +// A type NAMED as a publisher or a listener interface: an external node (the library is +// not staged, which is the usual case) or a staged library type. The external node's id +// is `external:`, or `external:` when the name came from an +// on-demand import, so both spellings are matched. Demand-driven from the knob lists, so +// this never walks the type table. +event_knob_type(n) :- cfg_event_publisher(n, _). +event_knob_type(n) :- cfg_event_listener_type(n, _). +event_knob_type(n) :- cfg_event_catch_all(n). +event_named_type(x, n) :- event_knob_type(n), external_type(x), x = cat("external:", n). +event_named_type(x, n) :- event_knob_type(n), external_type(x), + strlen(x) > strlen(n) + 1, + substr(x, max(0, strlen(x) - strlen(n) - 1), strlen(n) + 1) = cat(".", n). +event_named_type(t, n) :- event_knob_type(n), lib_type(n, _, _, _, _, _, _, _, _, _, _, _, _, t). + +// a receiver declared as a publisher type, or as a staged subtype of one +event_publisher_recv_type(t, name) :- event_named_type(t, n), cfg_event_publisher(n, name). +event_publisher_recv_type(t, name) :- event_named_type(a, n), cfg_event_publisher(n, name), + type_ancestor(t, a). + +event_publish_site(call) :- call_site(call, name, recv), cfg_event_publisher(_, name), + expr_type(_, recv, t), event_publisher_recv_type(t, name). + +// what is published: the static type of the first argument +event_published(call, caller, et) :- event_publish_site(call), + expr_child("client", call, "ARGUMENT", arg), expr_position("client", arg, "0"), + expr_type(_, arg, et), call_from(call, caller). + +// ── the listeners, and the event type each one declares ───────────────────── +event_param_type(m, t) :- java_method_parameter(_, _, m, _, _, _, _, _, _, _, _, _, p), + method_param_type_resolves(_, p, _, t). +event_param_type(m, x) :- java_method_parameter(_, _, m, _, _, _, _, _, _, _, _, _, p), + method_param_type_external(p, x). + +// (a) @EventListener / @TransactionalEventListener. The types named on the annotation +// win when present (Spring then ignores the parameter for matching); otherwise the +// one parameter's type. +event_listener_ann_method(m, a) :- ann_on_method(_, a, n, m, _), cfg_event_listener_ann(n), + method_owner("client", _, m). +event_listener_names_classes(m) :- event_listener_ann_method(m, a), + ann_arg(_, a, arg, _, "CLASS_REFERENCE", _), cfg_event_listener_arg(arg). +event_listens(m, t) :- event_listener_ann_method(m, a), + ann_arg(_, a, arg, v, "CLASS_REFERENCE", _), cfg_event_listener_arg(arg), + config_class_ref("annotation", a, v, t, _). +event_listens(m, t) :- event_listener_ann_method(m, _), !event_listener_names_classes(m), + method_paramc(m, 1), event_param_type(m, t). + +// (b) an ApplicationListener implementation: its onApplicationEvent(E) override. The +// override's parameter IS the type argument, so it is read from there rather than +// from the implements clause. +event_listener_class(t, mname) :- event_named_type(x, n), cfg_event_listener_type(n, mname), + type_ancestor(t, x), java_type(_, _, _, _, _, _, _, _, _, _, _, _, _, t). +event_listener_method(m) :- event_listener_class(t, mname), + java_method(mname, _, _, _, _, _, _, t, _, _, _, _, _, _, _, _, "INSTANCE_METHOD", "1", _, _, _, m). +event_listens(m, t) :- event_listener_method(m), event_param_type(m, t). + +// ── the edge ──────────────────────────────────────────────────────────────── +// Two clauses rather than one disjunction, so each joins on the type column: the exact +// type, then every ancestor of it. +event_dispatch_edge(call, caller, m) :- event_published(call, caller, et), event_listens(m, et). +event_dispatch_edge(call, caller, m) :- event_published(call, caller, et), type_ancestor(et, lt), + event_listens(m, lt). + +// A listener declared for a type EVERY event is: Spring wraps an event that is not an +// ApplicationEvent in a PayloadApplicationEvent before multicasting it, so a listener for +// ApplicationEvent receives a published OrderPlaced as well as every ApplicationEvent +// subtype, and a listener for Object receives everything. Neither is reached by the +// subtype rule for a plain object event. +event_catch_all_listener(m) :- event_named_type(x, n), cfg_event_catch_all(n), event_listens(m, x). +event_dispatch_edge(call, caller, m) :- event_published(call, caller, _), event_catch_all_listener(m). + +call_chain_edge(call, caller, "-", m, "client", "event_dispatch", "event") :- + event_dispatch_edge(call, caller, m). diff --git a/graph/java/engine/config-resolution/entry-points.dl b/graph/java/engine/config-resolution/entry-points.dl index 36eebe369..98c0535e8 100644 --- a/graph/java/engine/config-resolution/entry-points.dl +++ b/graph/java/engine/config-resolution/entry-points.dl @@ -75,6 +75,9 @@ config_entry_point(m, kind, t) :- xml_lifecycle_method(t, mname, kind), config_entry_point(m, "lifecycle", m) :- ann_on_method(_, _, n, m, _), cfg_lifecycle_ann(n). config_entry_point(m, "scheduled", m) :- ann_on_method(_, _, n, m, _), cfg_scheduled_ann(n). config_entry_point(m, "queue", m) :- ann_on_method(_, _, n, m, _), cfg_listener_ann(n). +// … and an ApplicationListener implementation's onApplicationEvent, which Spring calls +// for every matching published event without any annotation on the method (#1391). +config_entry_point(m, "queue", m) :- event_listener_method(m). // A listener/scheduler annotation on the TYPE (@KafkaListener on a class, with // @KafkaHandler methods) registers the type, not one method. config_entry_point(m, "queue", t) :- ann_on_type(_, _, n, t), cfg_listener_ann(n), diff --git a/graph/java/engine/config-resolution/knobs.dl b/graph/java/engine/config-resolution/knobs.dl index 7cff9af42..82f96b0b9 100644 --- a/graph/java/engine/config-resolution/knobs.dl +++ b/graph/java/engine/config-resolution/knobs.dl @@ -54,6 +54,7 @@ cfg_props_ann("ConfigurationProperties"). cfg_lifecycle_ann("PostConstruct"). cfg_lifecycle_ann("PreDestroy"). cfg_scheduled_ann("Scheduled"). cfg_listener_ann("EventListener"). cfg_listener_ann("KafkaListener"). +cfg_listener_ann("TransactionalEventListener"). cfg_listener_ann("RabbitListener"). cfg_listener_ann("JmsListener"). cfg_listener_ann("SqsListener"). cfg_listener_ann("StreamListener"). cfg_listener_ann("RabbitHandler"). cfg_listener_ann("SqsHandler"). @@ -339,3 +340,38 @@ cfg_mapper_ann("Mapper"). // @Mapper marks an interface it GENERATES an implementation of, and whether that is a // bean depends on its componentModel, so it is not a proxy bean. cfg_foreign_ann("Mapper", "org.mapstruct"). + +// ── (20) APPLICATION EVENTS: publishEvent(e) runs every listener e's type fits ── +// Spring hands a published event to each listener whose declared event type the +// event's type is assignable to. No call site names the listener, so without a model +// the publisher and the listener are independent in the graph +// (call-edge-generation/event_dispatch.dl). +// +// cfg_event_publisher(TypeSimpleName, Method): a call to Method on a receiver declared +// as this type publishes its first argument. Simple names, matched against an +// external node (`external:`) or a staged library type. A project +// class that happens to declare its own publishEvent is neither, so a call on it stays +// the ordinary call it is. +cfg_event_publisher("ApplicationEventPublisher", "publishEvent"). +cfg_event_publisher("ApplicationContext", "publishEvent"). +cfg_event_publisher("ConfigurableApplicationContext", "publishEvent"). +cfg_event_publisher("AbstractApplicationContext", "publishEvent"). +cfg_event_publisher("GenericApplicationContext", "publishEvent"). +cfg_event_publisher("AnnotationConfigApplicationContext", "publishEvent"). +cfg_event_publisher("WebApplicationContext", "publishEvent"). +cfg_event_publisher("ConfigurableWebApplicationContext", "publishEvent"). +cfg_event_publisher("ApplicationEventMulticaster", "multicastEvent"). +cfg_event_publisher("SimpleApplicationEventMulticaster", "multicastEvent"). +// A method annotation that makes the method a listener. Its event type is the one +// parameter's type, or the types named in cfg_event_listener_arg when given. +cfg_event_listener_ann("EventListener"). +cfg_event_listener_ann("TransactionalEventListener"). +cfg_event_listener_arg("classes"). cfg_event_listener_arg("value"). +// cfg_event_listener_type(TypeSimpleName, Method): implementing this type makes +// Method a listener, for the type of its one parameter (ApplicationListener is +// implemented by onApplicationEvent(E)). +cfg_event_listener_type("ApplicationListener", "onApplicationEvent"). +// A listener declared for one of these receives every published event: a plain object +// is wrapped in a PayloadApplicationEvent, which is an ApplicationEvent. +cfg_event_catch_all("ApplicationEvent"). +cfg_event_catch_all("Object"). diff --git a/graph/java/engine/framework-behavior/destinations.dl b/graph/java/engine/framework-behavior/destinations.dl index eb7f4946f..3c21a9408 100644 --- a/graph/java/engine/framework-behavior/destinations.dl +++ b/graph/java/engine/framework-behavior/destinations.dl @@ -17,9 +17,11 @@ // restate every existing consumer in terms of it; // - it is included AFTER call-edge-generation because it CONSUMES the resolved graph. // -// Other framework-mediated hops belong here as they are built: an event published to -// an @EventListener, an @Async or @Scheduled handoff, a gRPC stub to its service -// implementation. Each is a hop no call site expresses. +// Other framework-mediated hops belong here as they are built: an @Async or @Scheduled +// handoff, a gRPC stub to its service implementation. Each is a hop no call site +// expresses. An application event published to an @EventListener is NOT one of them: +// it stays in one JVM and is keyed on a type rather than a string, so it is a call edge +// with tier event_dispatch (call-edge-generation/event_dispatch.dl). // // A producer and the consumer that serves it live in different deployables. Neither // calls the other — there is no call site to resolve — so every rule in diff --git a/graph/java/souffle/decls_all.dl b/graph/java/souffle/decls_all.dl index f74129342..858323e87 100644 --- a/graph/java/souffle/decls_all.dl +++ b/graph/java/souffle/decls_all.dl @@ -913,3 +913,22 @@ .decl cfg_staged_lib_type(c0:symbol) .decl cfg_injectable_scope_type(c0:symbol) .decl gen_accessor_field(c0:symbol,c1:symbol) +// call-edge-generation/event_dispatch.dl + knobs.dl (17): Spring application events (#1391) +.decl cfg_event_publisher(c0:symbol,c1:symbol) +.decl cfg_event_listener_ann(c0:symbol) +.decl cfg_event_listener_arg(c0:symbol) +.decl cfg_event_listener_type(c0:symbol,c1:symbol) +.decl cfg_event_catch_all(c0:symbol) +.decl event_knob_type(c0:symbol) +.decl event_catch_all_listener(c0:symbol) +.decl event_named_type(c0:symbol,c1:symbol) +.decl event_publisher_recv_type(c0:symbol,c1:symbol) +.decl event_publish_site(c0:symbol) +.decl event_published(c0:symbol,c1:symbol,c2:symbol) +.decl event_param_type(c0:symbol,c1:symbol) +.decl event_listener_ann_method(c0:symbol,c1:symbol) +.decl event_listener_names_classes(c0:symbol) +.decl event_listens(c0:symbol,c1:symbol) +.decl event_listener_class(c0:symbol,c1:symbol) +.decl event_listener_method(c0:symbol) +.decl event_dispatch_edge(c0:symbol,c1:symbol,c2:symbol) diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/AllEvents.java b/graph/test/java/cases/65-spring-application-events/src/probe/AllEvents.java new file mode 100644 index 000000000..f02e9bcd8 --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/AllEvents.java @@ -0,0 +1,14 @@ +package probe; + +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +// a listener for the framework base type: every published event reaches it, an +// ApplicationEvent subtype (OrderShipped) as itself and a plain object (OrderPlaced) +// wrapped in a PayloadApplicationEvent +@Component +public class AllEvents implements ApplicationListener { + @Override + public void onApplicationEvent(ApplicationEvent event) { } +} diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/Audit.java b/graph/test/java/cases/65-spring-application-events/src/probe/Audit.java new file mode 100644 index 000000000..da8938d4b --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/Audit.java @@ -0,0 +1,34 @@ +package probe; + +import org.springframework.context.event.EventListener; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.stereotype.Component; + +@Component +public class Audit { + @EventListener + public void on(OrderPlaced e) { record(e.id); } + + @EventListener + public void onExpress(ExpressOrderPlaced e) { record(e.id); } + + @TransactionalEventListener + public void afterCommit(OrderPlaced e) { record("t"); } + + // the event type named on the annotation, no parameter + @EventListener(classes = OrderCancelled.class) + public void onCancelled() { record("c"); } + + // several types named on the annotation, in the array form + @EventListener({OrderPlaced.class, OrderCancelled.class}) + public void onEither() { record("either"); } + + // control: listens for a type nothing publishes + @EventListener + public void onWidget(WidgetBuilt w) { record("w"); } + + // control: the right parameter type, but not a listener + public void direct(OrderPlaced e) { record(e.id); } + + void record(String s) { } +} diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/EverythingLog.java b/graph/test/java/cases/65-spring-application-events/src/probe/EverythingLog.java new file mode 100644 index 000000000..ef05cecd8 --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/EverythingLog.java @@ -0,0 +1,11 @@ +package probe; + +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +// a listener for Object receives every published event, whatever its type +@Component +public class EverythingLog { + @EventListener + public void any(Object event) { } +} diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/ExpressOrderPlaced.java b/graph/test/java/cases/65-spring-application-events/src/probe/ExpressOrderPlaced.java new file mode 100644 index 000000000..e8829c60a --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/ExpressOrderPlaced.java @@ -0,0 +1,6 @@ +package probe; + +// a subtype: a listener for OrderPlaced receives it, a listener for this type does not receive a plain OrderPlaced +public class ExpressOrderPlaced extends OrderPlaced { + public ExpressOrderPlaced(String id) { super(id); } +} diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/LocalBus.java b/graph/test/java/cases/65-spring-application-events/src/probe/LocalBus.java new file mode 100644 index 000000000..eafdbb69e --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/LocalBus.java @@ -0,0 +1,9 @@ +package probe; + +import org.springframework.stereotype.Component; + +// control: a method called publishEvent on a project type; a call to it is an ordinary call +@Component +public class LocalBus { + public void publishEvent(Object event) { } +} diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/OrderCancelled.java b/graph/test/java/cases/65-spring-application-events/src/probe/OrderCancelled.java new file mode 100644 index 000000000..468e17681 --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/OrderCancelled.java @@ -0,0 +1,3 @@ +package probe; + +public class OrderCancelled { } diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/OrderPlaced.java b/graph/test/java/cases/65-spring-application-events/src/probe/OrderPlaced.java new file mode 100644 index 000000000..fefee7106 --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/OrderPlaced.java @@ -0,0 +1,6 @@ +package probe; + +public class OrderPlaced { + public final String id; + public OrderPlaced(String id) { this.id = id; } +} diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/OrderService.java b/graph/test/java/cases/65-spring-application-events/src/probe/OrderService.java new file mode 100644 index 000000000..e04d09cf1 --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/OrderService.java @@ -0,0 +1,42 @@ +package probe; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import java.util.List; + +@Service +public class OrderService { + private final ApplicationEventPublisher publisher; + private final ApplicationContext ctx; + private final Audit audit; + private final LocalBus bus; + + public OrderService(ApplicationEventPublisher publisher, ApplicationContext ctx, Audit audit, LocalBus bus) { + this.publisher = publisher; this.ctx = ctx; this.audit = audit; this.bus = bus; + } + + // a field receiver and a `new` argument + public void place() { publisher.publishEvent(new OrderPlaced("1")); } + + // a subtype of OrderPlaced: reaches the OrderPlaced listeners AND the ExpressOrderPlaced one + public void placeExpress() { publisher.publishEvent(new ExpressOrderPlaced("2")); } + + // the context as publisher, and a local carrying the event + public void ship() { + OrderShipped shipped = new OrderShipped(this); + ctx.publishEvent(shipped); + } + + // a parameter as receiver and as event + public void relay(ApplicationEventPublisher p, OrderCancelled e) { p.publishEvent(e); } + + // published from inside a lambda: attributed to the method the lambda is written in + public void placeAll(List ids) { ids.forEach(id -> publisher.publishEvent(new OrderPlaced(id))); } + + // control: a direct call, which resolved before this change + public void placeDirect() { audit.direct(new OrderPlaced("3")); } + + // control: a project class with its own publishEvent is not a Spring publisher + public void placeLocal() { bus.publishEvent(new OrderPlaced("4")); } +} diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/OrderShipped.java b/graph/test/java/cases/65-spring-application-events/src/probe/OrderShipped.java new file mode 100644 index 000000000..3a2df8ee7 --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/OrderShipped.java @@ -0,0 +1,7 @@ +package probe; + +import org.springframework.context.ApplicationEvent; + +public class OrderShipped extends ApplicationEvent { + public OrderShipped(Object source) { super(source); } +} diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/Refunds.java b/graph/test/java/cases/65-spring-application-events/src/probe/Refunds.java new file mode 100644 index 000000000..59680a993 --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/Refunds.java @@ -0,0 +1,16 @@ +package probe; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationEventPublisherAware; +import org.springframework.stereotype.Component; + +// the publisher handed over through ApplicationEventPublisherAware and kept in a field +@Component +public class Refunds implements ApplicationEventPublisherAware { + private ApplicationEventPublisher events; + + @Override + public void setApplicationEventPublisher(ApplicationEventPublisher events) { this.events = events; } + + public void refund() { events.publishEvent(new OrderCancelled()); } +} diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/ShipListener.java b/graph/test/java/cases/65-spring-application-events/src/probe/ShipListener.java new file mode 100644 index 000000000..c87bd1206 --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/ShipListener.java @@ -0,0 +1,12 @@ +package probe; + +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +@Component +public class ShipListener implements ApplicationListener { + @Override + public void onApplicationEvent(OrderShipped event) { notifyWarehouse(); } + + void notifyWarehouse() { } +} diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/WidgetBuilt.java b/graph/test/java/cases/65-spring-application-events/src/probe/WidgetBuilt.java new file mode 100644 index 000000000..9b629bee8 --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/WidgetBuilt.java @@ -0,0 +1,8 @@ +package probe; + +import org.springframework.context.ApplicationEvent; + +// an unrelated event type: nothing in this case publishes it +public class WidgetBuilt extends ApplicationEvent { + public WidgetBuilt(Object source) { super(source); } +} diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/WidgetListener.java b/graph/test/java/cases/65-spring-application-events/src/probe/WidgetListener.java new file mode 100644 index 000000000..a37b021ff --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/WidgetListener.java @@ -0,0 +1,11 @@ +package probe; + +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +// control: an ApplicationListener for a type nothing publishes +@Component +public class WidgetListener implements ApplicationListener { + @Override + public void onApplicationEvent(WidgetBuilt event) { } +} diff --git a/graph/test/java/expected/65-spring-application-events.config b/graph/test/java/expected/65-spring-application-events.config new file mode 100644 index 000000000..9229e8d13 --- /dev/null +++ b/graph/test/java/expected/65-spring-application-events.config @@ -0,0 +1,52 @@ +── bean_def (8) ── + stereotype allEvents <- probe.AllEvents + stereotype audit <- probe.Audit + stereotype everythingLog <- probe.EverythingLog + stereotype localBus <- probe.LocalBus + stereotype orderService <- probe.OrderService + stereotype refunds <- probe.Refunds + stereotype shipListener <- probe.ShipListener + stereotype widgetListener <- probe.WidgetListener +── bean_origin (8) ── + client allEvents <- probe.AllEvents + client audit <- probe.Audit + client everythingLog <- probe.EverythingLog + client localBus <- probe.LocalBus + client orderService <- probe.OrderService + client refunds <- probe.Refunds + client shipListener <- probe.ShipListener + client widgetListener <- probe.WidgetListener +── inject_point (2) ── + ctor_param probe.OrderService#(ApplicationEventPublisher,ApplicationContext,Audit,LocalBus):audit : probe.Audit + ctor_param probe.OrderService#(ApplicationEventPublisher,ApplicationContext,Audit,LocalBus):bus : probe.LocalBus +── di_edge (2) ── + known_bean ctor_param probe.OrderService#(ApplicationEventPublisher,ApplicationContext,Audit,LocalBus):audit : probe.Audit <- audit (probe.Audit) + known_bean ctor_param probe.OrderService#(ApplicationEventPublisher,ApplicationContext,Audit,LocalBus):bus : probe.LocalBus <- localBus (probe.LocalBus) +── config_class_ref (2) ── + annotation @EventListener "OrderCancelled" -> probe.OrderCancelled [client] + annotation @EventListener "OrderPlaced" -> probe.OrderPlaced [client] +── config_key_ref (0) ── +── config_binding (0) ── +── config_affects_method (0) ── +── config_entry_point (11) ── + bean_ctor probe.OrderService#(ApplicationEventPublisher,ApplicationContext,Audit,LocalBus) + queue probe.AllEvents#onApplicationEvent(ApplicationEvent) + queue probe.Audit#afterCommit(OrderPlaced) + queue probe.Audit#on(OrderPlaced) + queue probe.Audit#onCancelled() + queue probe.Audit#onEither() + queue probe.Audit#onExpress(ExpressOrderPlaced) + queue probe.Audit#onWidget(WidgetBuilt) + queue probe.EverythingLog#any(Object) + queue probe.ShipListener#onApplicationEvent(OrderShipped) + queue probe.WidgetListener#onApplicationEvent(WidgetBuilt) +── bean_condition (0) ── +── config_unresolved [DECLARED UNKNOWNS] (0) ── +── remote_edge (0) ── +── remote_unserved [SENT, NO CONSUMER HERE] (0) ── +── remote_unsent [SERVED, NO PRODUCER HERE] (0) ── +── remote_undetermined [DECLARED UNKNOWNS] (0) ── +── persistence_query (0) ── +── persistence_entity (0) ── +── persistence_field (0) ── +── persistence_unresolved [DECLARED UNKNOWNS] (0) ── diff --git a/graph/test/java/expected/65-spring-application-events.edges b/graph/test/java/expected/65-spring-application-events.edges new file mode 100644 index 000000000..80f98c4f8 --- /dev/null +++ b/graph/test/java/expected/65-spring-application-events.edges @@ -0,0 +1,54 @@ +ambiguous_unknown ctor_delegate probe.OrderShipped#(Object) -> - +ambiguous_unknown ctor_delegate probe.WidgetBuilt#(Object) -> - +boundary_lib method probe.OrderService#place() -> external:org.springframework.context.ApplicationEventPublisher.publishEvent +boundary_lib method probe.OrderService#placeAll(List) -> external:java.util.List.forEach +boundary_lib method probe.OrderService#placeAll(List) -> external:org.springframework.context.ApplicationEventPublisher.publishEvent +boundary_lib method probe.OrderService#placeExpress() -> external:org.springframework.context.ApplicationEventPublisher.publishEvent +boundary_lib method probe.OrderService#relay(ApplicationEventPublisher,OrderCancelled) -> external:org.springframework.context.ApplicationEventPublisher.publishEvent +boundary_lib method probe.OrderService#ship() -> external:org.springframework.context.ApplicationContext.publishEvent +boundary_lib method probe.Refunds#refund() -> external:org.springframework.context.ApplicationEventPublisher.publishEvent +event_dispatch event probe.OrderService#place() -> probe.AllEvents#onApplicationEvent(ApplicationEvent) +event_dispatch event probe.OrderService#place() -> probe.Audit#afterCommit(OrderPlaced) +event_dispatch event probe.OrderService#place() -> probe.Audit#on(OrderPlaced) +event_dispatch event probe.OrderService#place() -> probe.Audit#onEither() +event_dispatch event probe.OrderService#place() -> probe.EverythingLog#any(Object) +event_dispatch event probe.OrderService#placeAll(List) -> probe.AllEvents#onApplicationEvent(ApplicationEvent) +event_dispatch event probe.OrderService#placeAll(List) -> probe.Audit#afterCommit(OrderPlaced) +event_dispatch event probe.OrderService#placeAll(List) -> probe.Audit#on(OrderPlaced) +event_dispatch event probe.OrderService#placeAll(List) -> probe.Audit#onEither() +event_dispatch event probe.OrderService#placeAll(List) -> probe.EverythingLog#any(Object) +event_dispatch event probe.OrderService#placeExpress() -> probe.AllEvents#onApplicationEvent(ApplicationEvent) +event_dispatch event probe.OrderService#placeExpress() -> probe.Audit#afterCommit(OrderPlaced) +event_dispatch event probe.OrderService#placeExpress() -> probe.Audit#on(OrderPlaced) +event_dispatch event probe.OrderService#placeExpress() -> probe.Audit#onEither() +event_dispatch event probe.OrderService#placeExpress() -> probe.Audit#onExpress(ExpressOrderPlaced) +event_dispatch event probe.OrderService#placeExpress() -> probe.EverythingLog#any(Object) +event_dispatch event probe.OrderService#relay(ApplicationEventPublisher,OrderCancelled) -> probe.AllEvents#onApplicationEvent(ApplicationEvent) +event_dispatch event probe.OrderService#relay(ApplicationEventPublisher,OrderCancelled) -> probe.Audit#onCancelled() +event_dispatch event probe.OrderService#relay(ApplicationEventPublisher,OrderCancelled) -> probe.Audit#onEither() +event_dispatch event probe.OrderService#relay(ApplicationEventPublisher,OrderCancelled) -> probe.EverythingLog#any(Object) +event_dispatch event probe.OrderService#ship() -> probe.AllEvents#onApplicationEvent(ApplicationEvent) +event_dispatch event probe.OrderService#ship() -> probe.EverythingLog#any(Object) +event_dispatch event probe.OrderService#ship() -> probe.ShipListener#onApplicationEvent(OrderShipped) +event_dispatch event probe.Refunds#refund() -> probe.AllEvents#onApplicationEvent(ApplicationEvent) +event_dispatch event probe.Refunds#refund() -> probe.Audit#onCancelled() +event_dispatch event probe.Refunds#refund() -> probe.Audit#onEither() +event_dispatch event probe.Refunds#refund() -> probe.EverythingLog#any(Object) +known_edge ctor_delegate probe.ExpressOrderPlaced#(String) -> probe.OrderPlaced#(String) +known_edge method probe.Audit#afterCommit(OrderPlaced) -> probe.Audit#record(String) +known_edge method probe.Audit#direct(OrderPlaced) -> probe.Audit#record(String) +known_edge method probe.Audit#on(OrderPlaced) -> probe.Audit#record(String) +known_edge method probe.Audit#onCancelled() -> probe.Audit#record(String) +known_edge method probe.Audit#onEither() -> probe.Audit#record(String) +known_edge method probe.Audit#onExpress(ExpressOrderPlaced) -> probe.Audit#record(String) +known_edge method probe.Audit#onWidget(WidgetBuilt) -> probe.Audit#record(String) +known_edge method probe.OrderService#placeDirect() -> probe.Audit#direct(OrderPlaced) +known_edge method probe.OrderService#placeLocal() -> probe.LocalBus#publishEvent(Object) +known_edge method probe.ShipListener#onApplicationEvent(OrderShipped) -> probe.ShipListener#notifyWarehouse() +known_edge new probe.OrderService#place() -> probe.OrderPlaced#(String) +known_edge new probe.OrderService#placeAll(List) -> probe.OrderPlaced#(String) +known_edge new probe.OrderService#placeDirect() -> probe.OrderPlaced#(String) +known_edge new probe.OrderService#placeExpress() -> probe.ExpressOrderPlaced#(String) +known_edge new probe.OrderService#placeLocal() -> probe.OrderPlaced#(String) +known_edge new probe.OrderService#ship() -> probe.OrderShipped#(Object) +known_edge new probe.Refunds#refund() -> probe.OrderCancelled#() diff --git a/graph/test/java/expected/65-spring-application-events.fields b/graph/test/java/expected/65-spring-application-events.fields new file mode 100644 index 000000000..c98eef021 --- /dev/null +++ b/graph/test/java/expected/65-spring-application-events.fields @@ -0,0 +1,16 @@ +known_edge read probe.Audit#direct(OrderPlaced) -> probe.OrderPlaced#id +known_edge read probe.Audit#on(OrderPlaced) -> probe.OrderPlaced#id +known_edge read probe.Audit#onExpress(ExpressOrderPlaced) -> probe.OrderPlaced#id +known_edge read probe.OrderService#place() -> probe.OrderService#publisher +known_edge read probe.OrderService#placeAll(List) -> probe.OrderService#publisher +known_edge read probe.OrderService#placeDirect() -> probe.OrderService#audit +known_edge read probe.OrderService#placeExpress() -> probe.OrderService#publisher +known_edge read probe.OrderService#placeLocal() -> probe.OrderService#bus +known_edge read probe.OrderService#ship() -> probe.OrderService#ctx +known_edge read probe.Refunds#refund() -> probe.Refunds#events +known_edge write probe.OrderPlaced#(String) -> probe.OrderPlaced#id +known_edge write probe.OrderService#(ApplicationEventPublisher,ApplicationContext,Audit,LocalBus) -> probe.OrderService#audit +known_edge write probe.OrderService#(ApplicationEventPublisher,ApplicationContext,Audit,LocalBus) -> probe.OrderService#bus +known_edge write probe.OrderService#(ApplicationEventPublisher,ApplicationContext,Audit,LocalBus) -> probe.OrderService#ctx +known_edge write probe.OrderService#(ApplicationEventPublisher,ApplicationContext,Audit,LocalBus) -> probe.OrderService#publisher +known_edge write probe.Refunds#setApplicationEventPublisher(ApplicationEventPublisher) -> probe.Refunds#events diff --git a/graph/test/java/expected/65-spring-application-events.type-use b/graph/test/java/expected/65-spring-application-events.type-use new file mode 100644 index 000000000..89490ac63 --- /dev/null +++ b/graph/test/java/expected/65-spring-application-events.type-use @@ -0,0 +1,56 @@ +ambiguous_unknown ANNOTATION_TYPE 0 probe.AllEvents [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.Audit [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.EverythingLog [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.LocalBus [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.OrderService [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.Refunds [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.ShipListener [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.WidgetListener [ANNOTATION] -> - +ambiguous_unknown FIELD_TYPE 0 probe.OrderPlaced [FIELD] -> - +ambiguous_unknown FIELD_TYPE 0 probe.OrderService [FIELD] -> - +ambiguous_unknown FIELD_TYPE 0 probe.Refunds [FIELD] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 0 probe.AllEvents [TYPE] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 0 probe.Refunds [TYPE] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 0 probe.ShipListener [TYPE] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 0 probe.WidgetListener [TYPE] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 1 probe.AllEvents [TYPE] -> - +ambiguous_unknown METHOD_PARAM 0 probe.AllEvents#onApplicationEvent(ApplicationEvent) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.Audit#record(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.EverythingLog#any(Object) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.ExpressOrderPlaced#(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.LocalBus#publishEvent(Object) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.OrderPlaced#(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.OrderService#(ApplicationEventPublisher,ApplicationContext,Audit,LocalBus) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.OrderService#placeAll(List) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.OrderService#relay(ApplicationEventPublisher,OrderCancelled) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.OrderShipped#(Object) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.Refunds#setApplicationEventPublisher(ApplicationEventPublisher) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.WidgetBuilt#(Object) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 1 probe.OrderService#placeAll(List) [METHOD_PARAM] -> - +ambiguous_unknown SUPER_TYPE 0 probe.OrderShipped [TYPE] -> - +ambiguous_unknown SUPER_TYPE 0 probe.WidgetBuilt [TYPE] -> - +known_edge ANNOTATION_PARAM 0 probe.Audit [ANNOTATION_ARGUMENT] -> probe.OrderCancelled +known_edge ANNOTATION_PARAM 0 probe.Audit [ANNOTATION_ARGUMENT] -> probe.OrderPlaced +known_edge FIELD_TYPE 0 probe.OrderService [FIELD] -> probe.Audit +known_edge FIELD_TYPE 0 probe.OrderService [FIELD] -> probe.LocalBus +known_edge IMPLEMENTS_INTERFACE 1 probe.ShipListener [TYPE] -> probe.OrderShipped +known_edge IMPLEMENTS_INTERFACE 1 probe.WidgetListener [TYPE] -> probe.WidgetBuilt +known_edge LOCAL_VARIABLE 0 probe.OrderService#ship() [LOCAL_VARIABLE] -> probe.OrderShipped +known_edge METHOD_PARAM 0 probe.Audit#afterCommit(OrderPlaced) [METHOD_PARAM] -> probe.OrderPlaced +known_edge METHOD_PARAM 0 probe.Audit#direct(OrderPlaced) [METHOD_PARAM] -> probe.OrderPlaced +known_edge METHOD_PARAM 0 probe.Audit#on(OrderPlaced) [METHOD_PARAM] -> probe.OrderPlaced +known_edge METHOD_PARAM 0 probe.Audit#onExpress(ExpressOrderPlaced) [METHOD_PARAM] -> probe.ExpressOrderPlaced +known_edge METHOD_PARAM 0 probe.Audit#onWidget(WidgetBuilt) [METHOD_PARAM] -> probe.WidgetBuilt +known_edge METHOD_PARAM 0 probe.OrderService#(ApplicationEventPublisher,ApplicationContext,Audit,LocalBus) [METHOD_PARAM] -> probe.Audit +known_edge METHOD_PARAM 0 probe.OrderService#(ApplicationEventPublisher,ApplicationContext,Audit,LocalBus) [METHOD_PARAM] -> probe.LocalBus +known_edge METHOD_PARAM 0 probe.OrderService#relay(ApplicationEventPublisher,OrderCancelled) [METHOD_PARAM] -> probe.OrderCancelled +known_edge METHOD_PARAM 0 probe.ShipListener#onApplicationEvent(OrderShipped) [METHOD_PARAM] -> probe.OrderShipped +known_edge METHOD_PARAM 0 probe.WidgetListener#onApplicationEvent(WidgetBuilt) [METHOD_PARAM] -> probe.WidgetBuilt +known_edge OBJECT_CREATION_TYPE 0 probe.OrderService#place() [EXPRESSION] -> probe.OrderPlaced +known_edge OBJECT_CREATION_TYPE 0 probe.OrderService#placeAll(List) [EXPRESSION] -> probe.OrderPlaced +known_edge OBJECT_CREATION_TYPE 0 probe.OrderService#placeDirect() [EXPRESSION] -> probe.OrderPlaced +known_edge OBJECT_CREATION_TYPE 0 probe.OrderService#placeExpress() [EXPRESSION] -> probe.ExpressOrderPlaced +known_edge OBJECT_CREATION_TYPE 0 probe.OrderService#placeLocal() [EXPRESSION] -> probe.OrderPlaced +known_edge OBJECT_CREATION_TYPE 0 probe.OrderService#ship() [EXPRESSION] -> probe.OrderShipped +known_edge OBJECT_CREATION_TYPE 0 probe.Refunds#refund() [EXPRESSION] -> probe.OrderCancelled +known_edge SUPER_TYPE 0 probe.ExpressOrderPlaced [TYPE] -> probe.OrderPlaced diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index 9e603a242..ccb188cee 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -168,6 +168,16 @@ def legend(tiers): 'registered': 'handed over as a value — the engine recorded the hand-off, not a call site', 'capped set': 'calls it, as one of a candidate set too large to enumerate — this is a sample of that set', } +# …and where the TIER says something more specific than its certainty. An event is not handed over as a +# value: the caller publishes or emits it, and the event system runs every handler whose event it is. +TIER_WHY = { + 'event_dispatch': 'publishes an event this handles: the event system runs it, no call site names it', +} + + +def direct_why(tier): + """what a DIRECT dependent row says for an edge of this tier.""" + return TIER_WHY.get(tier) or DIRECT_WHY.get(direct_cert(tier), 'calls it') # certainties that are backed by an edge the ENGINE asserted, as opposed to a name or a text match. # Consumers that used to test `cert == 'resolved'` to mean "this row claims an edge" test this diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 7a1b483c7..e4a519989 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -857,7 +857,7 @@ class Impact: # EVERY TIER IN THIS GRAPH GETS A ROW, including one the table has never seen — the call rules join # on cert_tier, and a tier with no row would drop its dependents out of the answer entirely rather # than mislabel them, which is the worse of the two failures. ax_edges.direct_cert has the default. - W('cert_tier', sorted({(t, ax_edges.direct_cert(t), ax_edges.DIRECT_WHY.get(ax_edges.direct_cert(t), 'calls it')) + W('cert_tier', sorted({(t, ax_edges.direct_cert(t), ax_edges.direct_why(t)) for (t,) in g.q("SELECT DISTINCT tier FROM call_edges")})) W('calls', [(r['caller_id'], r['callee_method_id'], r['tier'], g.site_file(r['file_path']) if r['file_path'] else '', r['start_line'] or 0) for r in g.q("SELECT 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")]) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index af63a841f..26bc7ff15 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -340,7 +340,9 @@ direct(q, c, "uses", why, cert, f, l) :- target(q, "method", m, _), calls(c, m, // check) reaches this caller at all: a call from inside the bean goes through `this` and never through the proxy. // This is a per-framework convention stated as such, and it fires only where the graph actually found a container. .decl bean_call(q:symbol, c:symbol, m:symbol) -bean_call(q, c, m) :- target(q, "method", m, _), owner(m, ot), bean(_, ot, _), calls(c, m, t, _, _), t != "multi_inferred". +// Not for an event either: the event system invokes a listener on the container's bean, so the proxy IS on that +// path, and "an instance it obtained itself" would be false (#1391). It keeps its tier's own row above. +bean_call(q, c, m) :- target(q, "method", m, _), owner(m, ot), bean(_, ot, _), calls(c, m, t, _, _), t != "multi_inferred", t != "event_dispatch". // the type that the container hands the bean to: a field injection names the type, a constructor or setter names the method .decl receives_bean(t:symbol, into:symbol) receives_bean(t, into) :- injected(t, into, _), typ(into, _, _). diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 53e8e4d0b..03ac38556 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -724,7 +724,9 @@ def type_of(sid): WHERE i.c2 <> '' AND COALESCE(s.id, o.id) IS NOT NULL"""): if t != ot: continue recv.add(tgt if tgt in istype else type_of(tgt)) - callers = {c for c, tier, _f, _l in sites if tier != 'multi_inferred'} # a bean call site, any tier but the set + # a bean call site, any tier but the set and an event: the event system invokes a listener on the container's + # bean, so the proxy IS on that path and "an instance it obtained itself" would be false (#1391) + callers = {c for c, tier, _f, _l in sites if tier not in ('multi_inferred', 'event_dispatch')} why = {} for c in callers: cot = type_of(c) @@ -771,7 +773,7 @@ def direct_for_method(q, ids, code=None, rel=None, at=None, lines=None): # edge is the engine's, the sentence is the registration's. A registration the ROUTE rules named keeps # their sentence whatever the tier — it is a more specific true thing than either default. why = routes.get((rel(f) if rel and f else f, l)) - if why is None: why = ax_edges.DIRECT_WHY.get(cert, 'calls it') + if why is None: why = ax_edges.direct_why(tier) rows.append((c, 'uses', why, cert, f or '', l or 0)) # …and for a caller into a container-managed bean, EVERY site of it — the three bean rules end in a bare # `calls(c, m, _, f, l)` with no tier test, so a multi_inferred site of a bean caller is a row here too. diff --git a/tests/cases/java/event-reaches-its-listeners/case.json b/tests/cases/java/event-reaches-its-listeners/case.json new file mode 100644 index 000000000..8dc1e79ad --- /dev/null +++ b/tests/cases/java/event-reaches-its-listeners/case.json @@ -0,0 +1,20 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a published Spring event reaches the @EventListener for its type: the chain is one event hop, not 'independent'", + "run": ["path", "OrderService.place", "Audit.on"], + "want": ["event_dispatch", "Audit.on"], + "avoid": ["independent in this graph"]}, + {"why": "an ApplicationListener implementation is reached through its type argument", + "run": ["path", "OrderService.ship", "ShipListener.onApplicationEvent"], + "want": ["event_dispatch"]}, + {"why": "the listener's answer names the publisher as an event, not as a value handed over and not as a call that bypasses the container proxy", + "run": ["impact", "Audit.on"], + "want": ["OrderService.place", "OrderService.placeExpress", "publishes an event this handles"], + "avoid": ["obtained itself", "handed over as a value", "the change is local"]}, + {"why": "control: a listener for a SUBTYPE does not receive the supertype's event", + "run": ["path", "OrderService.place", "Audit.onExpress"], + "expect_error": true, + "want": ["no chain"]}, + {"why": "control: a listener for a type nothing publishes has no publisher", + "run": ["impact", "WidgetListener.onApplicationEvent"], + "avoid": ["OrderService"]}]} diff --git a/tests/cases/java/event-reaches-its-listeners/src/probe/AllEvents.java b/tests/cases/java/event-reaches-its-listeners/src/probe/AllEvents.java new file mode 100644 index 000000000..f02e9bcd8 --- /dev/null +++ b/tests/cases/java/event-reaches-its-listeners/src/probe/AllEvents.java @@ -0,0 +1,14 @@ +package probe; + +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +// a listener for the framework base type: every published event reaches it, an +// ApplicationEvent subtype (OrderShipped) as itself and a plain object (OrderPlaced) +// wrapped in a PayloadApplicationEvent +@Component +public class AllEvents implements ApplicationListener { + @Override + public void onApplicationEvent(ApplicationEvent event) { } +} diff --git a/tests/cases/java/event-reaches-its-listeners/src/probe/Audit.java b/tests/cases/java/event-reaches-its-listeners/src/probe/Audit.java new file mode 100644 index 000000000..da8938d4b --- /dev/null +++ b/tests/cases/java/event-reaches-its-listeners/src/probe/Audit.java @@ -0,0 +1,34 @@ +package probe; + +import org.springframework.context.event.EventListener; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.stereotype.Component; + +@Component +public class Audit { + @EventListener + public void on(OrderPlaced e) { record(e.id); } + + @EventListener + public void onExpress(ExpressOrderPlaced e) { record(e.id); } + + @TransactionalEventListener + public void afterCommit(OrderPlaced e) { record("t"); } + + // the event type named on the annotation, no parameter + @EventListener(classes = OrderCancelled.class) + public void onCancelled() { record("c"); } + + // several types named on the annotation, in the array form + @EventListener({OrderPlaced.class, OrderCancelled.class}) + public void onEither() { record("either"); } + + // control: listens for a type nothing publishes + @EventListener + public void onWidget(WidgetBuilt w) { record("w"); } + + // control: the right parameter type, but not a listener + public void direct(OrderPlaced e) { record(e.id); } + + void record(String s) { } +} diff --git a/tests/cases/java/event-reaches-its-listeners/src/probe/ExpressOrderPlaced.java b/tests/cases/java/event-reaches-its-listeners/src/probe/ExpressOrderPlaced.java new file mode 100644 index 000000000..e8829c60a --- /dev/null +++ b/tests/cases/java/event-reaches-its-listeners/src/probe/ExpressOrderPlaced.java @@ -0,0 +1,6 @@ +package probe; + +// a subtype: a listener for OrderPlaced receives it, a listener for this type does not receive a plain OrderPlaced +public class ExpressOrderPlaced extends OrderPlaced { + public ExpressOrderPlaced(String id) { super(id); } +} diff --git a/tests/cases/java/event-reaches-its-listeners/src/probe/LocalBus.java b/tests/cases/java/event-reaches-its-listeners/src/probe/LocalBus.java new file mode 100644 index 000000000..eafdbb69e --- /dev/null +++ b/tests/cases/java/event-reaches-its-listeners/src/probe/LocalBus.java @@ -0,0 +1,9 @@ +package probe; + +import org.springframework.stereotype.Component; + +// control: a method called publishEvent on a project type; a call to it is an ordinary call +@Component +public class LocalBus { + public void publishEvent(Object event) { } +} diff --git a/tests/cases/java/event-reaches-its-listeners/src/probe/OrderCancelled.java b/tests/cases/java/event-reaches-its-listeners/src/probe/OrderCancelled.java new file mode 100644 index 000000000..468e17681 --- /dev/null +++ b/tests/cases/java/event-reaches-its-listeners/src/probe/OrderCancelled.java @@ -0,0 +1,3 @@ +package probe; + +public class OrderCancelled { } diff --git a/tests/cases/java/event-reaches-its-listeners/src/probe/OrderPlaced.java b/tests/cases/java/event-reaches-its-listeners/src/probe/OrderPlaced.java new file mode 100644 index 000000000..fefee7106 --- /dev/null +++ b/tests/cases/java/event-reaches-its-listeners/src/probe/OrderPlaced.java @@ -0,0 +1,6 @@ +package probe; + +public class OrderPlaced { + public final String id; + public OrderPlaced(String id) { this.id = id; } +} diff --git a/tests/cases/java/event-reaches-its-listeners/src/probe/OrderService.java b/tests/cases/java/event-reaches-its-listeners/src/probe/OrderService.java new file mode 100644 index 000000000..e26ba35f6 --- /dev/null +++ b/tests/cases/java/event-reaches-its-listeners/src/probe/OrderService.java @@ -0,0 +1,38 @@ +package probe; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; + +@Service +public class OrderService { + private final ApplicationEventPublisher publisher; + private final ApplicationContext ctx; + private final Audit audit; + private final LocalBus bus; + + public OrderService(ApplicationEventPublisher publisher, ApplicationContext ctx, Audit audit, LocalBus bus) { + this.publisher = publisher; this.ctx = ctx; this.audit = audit; this.bus = bus; + } + + // a field receiver and a `new` argument + public void place() { publisher.publishEvent(new OrderPlaced("1")); } + + // a subtype of OrderPlaced: reaches the OrderPlaced listeners AND the ExpressOrderPlaced one + public void placeExpress() { publisher.publishEvent(new ExpressOrderPlaced("2")); } + + // the context as publisher, and a local carrying the event + public void ship() { + OrderShipped shipped = new OrderShipped(this); + ctx.publishEvent(shipped); + } + + // a parameter as receiver and as event + public void relay(ApplicationEventPublisher p, OrderCancelled e) { p.publishEvent(e); } + + // control: a direct call, which resolved before this change + public void placeDirect() { audit.direct(new OrderPlaced("3")); } + + // control: a project class with its own publishEvent is not a Spring publisher + public void placeLocal() { bus.publishEvent(new OrderPlaced("4")); } +} diff --git a/tests/cases/java/event-reaches-its-listeners/src/probe/OrderShipped.java b/tests/cases/java/event-reaches-its-listeners/src/probe/OrderShipped.java new file mode 100644 index 000000000..3a2df8ee7 --- /dev/null +++ b/tests/cases/java/event-reaches-its-listeners/src/probe/OrderShipped.java @@ -0,0 +1,7 @@ +package probe; + +import org.springframework.context.ApplicationEvent; + +public class OrderShipped extends ApplicationEvent { + public OrderShipped(Object source) { super(source); } +} diff --git a/tests/cases/java/event-reaches-its-listeners/src/probe/ShipListener.java b/tests/cases/java/event-reaches-its-listeners/src/probe/ShipListener.java new file mode 100644 index 000000000..c87bd1206 --- /dev/null +++ b/tests/cases/java/event-reaches-its-listeners/src/probe/ShipListener.java @@ -0,0 +1,12 @@ +package probe; + +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +@Component +public class ShipListener implements ApplicationListener { + @Override + public void onApplicationEvent(OrderShipped event) { notifyWarehouse(); } + + void notifyWarehouse() { } +} diff --git a/tests/cases/java/event-reaches-its-listeners/src/probe/WidgetBuilt.java b/tests/cases/java/event-reaches-its-listeners/src/probe/WidgetBuilt.java new file mode 100644 index 000000000..9b629bee8 --- /dev/null +++ b/tests/cases/java/event-reaches-its-listeners/src/probe/WidgetBuilt.java @@ -0,0 +1,8 @@ +package probe; + +import org.springframework.context.ApplicationEvent; + +// an unrelated event type: nothing in this case publishes it +public class WidgetBuilt extends ApplicationEvent { + public WidgetBuilt(Object source) { super(source); } +} diff --git a/tests/cases/java/event-reaches-its-listeners/src/probe/WidgetListener.java b/tests/cases/java/event-reaches-its-listeners/src/probe/WidgetListener.java new file mode 100644 index 000000000..a37b021ff --- /dev/null +++ b/tests/cases/java/event-reaches-its-listeners/src/probe/WidgetListener.java @@ -0,0 +1,11 @@ +package probe; + +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +// control: an ApplicationListener for a type nothing publishes +@Component +public class WidgetListener implements ApplicationListener { + @Override + public void onApplicationEvent(WidgetBuilt event) { } +} diff --git a/tests/cases/java/next-step-reads-the-whole-answer/case.json b/tests/cases/java/next-step-reads-the-whole-answer/case.json index 5d439f594..507a60bdb 100644 --- a/tests/cases/java/next-step-reads-the-whole-answer/case.json +++ b/tests/cases/java/next-step-reads-the-whole-answer/case.json @@ -159,10 +159,10 @@ ] }, { - "why": "a publisher and a listener joined only through a library call are not called independent, and the call is named", + "why": "a publisher and a listener joined only through a library call are not called independent, and the call is named (an event typed Object matches no listener's declared type, so the event hop is not drawn)", "run": [ "path", - "OrderService.place", + "OrderService.relay", "Audit.on" ], "expect_error": true, @@ -182,7 +182,7 @@ "run": [ "path", "Audit.on", - "OrderService.place" + "OrderService.relay" ], "expect_error": true, "want": [ diff --git a/tests/cases/java/next-step-reads-the-whole-answer/src/com/example/OrderService.java b/tests/cases/java/next-step-reads-the-whole-answer/src/com/example/OrderService.java index f1567e34a..7d242e8b2 100644 --- a/tests/cases/java/next-step-reads-the-whole-answer/src/com/example/OrderService.java +++ b/tests/cases/java/next-step-reads-the-whole-answer/src/com/example/OrderService.java @@ -8,4 +8,6 @@ public class OrderService { public OrderService(ApplicationEventPublisher publisher, Helper helper) { this.publisher = publisher; this.helper = helper; } public void place(String id) { publisher.publishEvent(new OrderPlaced(id)); } public void direct(String id) { helper.run(id); } + // the event is typed Object here, so no listener type can be matched to it statically + public void relay(Object payload) { publisher.publishEvent(payload); } } From ac697703546c55e6f636d6c8eab1428926cd8c74 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:24:58 -0700 Subject: [PATCH 05/31] java: point the event-dispatch comment at knobs.dl section (20) after the rebase Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com --- graph/java/souffle/decls_all.dl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graph/java/souffle/decls_all.dl b/graph/java/souffle/decls_all.dl index 858323e87..2d975faec 100644 --- a/graph/java/souffle/decls_all.dl +++ b/graph/java/souffle/decls_all.dl @@ -913,7 +913,7 @@ .decl cfg_staged_lib_type(c0:symbol) .decl cfg_injectable_scope_type(c0:symbol) .decl gen_accessor_field(c0:symbol,c1:symbol) -// call-edge-generation/event_dispatch.dl + knobs.dl (17): Spring application events (#1391) +// call-edge-generation/event_dispatch.dl + knobs.dl (20): Spring application events (#1391) .decl cfg_event_publisher(c0:symbol,c1:symbol) .decl cfg_event_listener_ann(c0:symbol) .decl cfg_event_listener_arg(c0:symbol) From 63bfa13ebf4ff8caa38d72bdb91a8c2d0cc77192 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:28:28 -0700 Subject: [PATCH 06/31] impact: bump IMPACT_VERSION to 31, so a cache written before event dispatch and the fixture reading does not survive (#1391) --- plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index e4a519989..d6b47a600 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -683,7 +683,7 @@ class Impact: return [ln for ln in range(s['line'], min(s['end_line'], len(L)) + 1) if pat.search(L[ln - 1])] # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '30' # 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 = '31' # 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 From f150473eb478991390916e5c9065d56d98f91729 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:21:41 -0700 Subject: [PATCH 07/31] python: a route reaches its view through decorators, include() and as_view() (#1510) A Django-style route table dispatched only to an undecorated function named directly in `urlpatterns`. A view under @login_required, a route in a list mounted with include(), and a class-based view routed with .as_view() were not url entry points, so impact called a change to them local. A test that requested the URL reached the view only when the pattern started with a slash and the view was imported by name. Engine (dispatch.dl, knobs.dl): - a route names the def written under its decorators, not the wrapper - a list handed to include() (by name, inline, or in a (list, ns) tuple) is a route table - X.as_view() dispatches to X's HTTP-verb methods, found through the MRO - the url_dispatch edge carries the path the route serves, composed through every include() that mounts its table; a regular-expression pattern keeps the generic label Plugin: - routes are registration keys by the engine's served path, so a test that requests /api/v1/status/ reaches a view registered as "status/" under include() at "api/v1/"; a typed converter narrows the match and a verbatim key wins over a parameterised one - context answers "what does this URL run" from the served path; a test method that requests a URL is never said to serve it, and a pattern in a table the engine read is not taken for the whole path - impact's verification accepts the rules' own decorator-by-name hop Fixes #1510, #1511, #1513, #1482, #1483 --- .../python/engine/config-resolution/knobs.dl | 23 ++++ .../engine/framework-behavior/dispatch.dl | 115 +++++++++++++++-- graph/python/souffle/decls_all.dl | 4 + .../33-url-view-forms/src/catalog/__init__.py | 0 .../33-url-view-forms/src/catalog/urls.py | 7 ++ .../33-url-view-forms/src/catalog/views.py | 2 + .../33-url-view-forms/src/shop/__init__.py | 0 .../33-url-view-forms/src/shop/decorators.py | 11 ++ .../cases/33-url-view-forms/src/shop/urls.py | 43 +++++++ .../cases/33-url-view-forms/src/shop/views.py | 88 +++++++++++++ .../21-url-and-signal-dispatch.framework | 8 +- .../python/expected/33-url-view-forms.edges | 24 ++++ .../python/expected/33-url-view-forms.entries | 13 ++ .../expected/33-url-view-forms.framework | 18 +++ .../python/expected/33-url-view-forms.tiers | 30 +++++ .../axiomcode/scripts/ax_registration.py | 119 ++++++++++++++++-- .../axiomcode/scripts/axiomcode-context | 30 ++++- .../skills/axiomcode/scripts/axiomcode-impact | 13 +- .../skills/axiomcode/scripts/axiomcode-path | 12 +- .../python/route-table-reaches-view/case.json | 32 +++++ .../route-table-reaches-view/shop/__init__.py | 0 .../route-table-reaches-view/shop/urls.py | 15 +++ .../route-table-reaches-view/shop/views.py | 28 +++++ .../tests/test_orders.py | 22 ++++ 24 files changed, 622 insertions(+), 35 deletions(-) create mode 100644 graph/test/python/cases/33-url-view-forms/src/catalog/__init__.py create mode 100644 graph/test/python/cases/33-url-view-forms/src/catalog/urls.py create mode 100644 graph/test/python/cases/33-url-view-forms/src/catalog/views.py create mode 100644 graph/test/python/cases/33-url-view-forms/src/shop/__init__.py create mode 100644 graph/test/python/cases/33-url-view-forms/src/shop/decorators.py create mode 100644 graph/test/python/cases/33-url-view-forms/src/shop/urls.py create mode 100644 graph/test/python/cases/33-url-view-forms/src/shop/views.py create mode 100644 graph/test/python/expected/33-url-view-forms.edges create mode 100644 graph/test/python/expected/33-url-view-forms.entries create mode 100644 graph/test/python/expected/33-url-view-forms.framework create mode 100644 graph/test/python/expected/33-url-view-forms.tiers create mode 100644 tests/cases/python/route-table-reaches-view/case.json create mode 100644 tests/cases/python/route-table-reaches-view/shop/__init__.py create mode 100644 tests/cases/python/route-table-reaches-view/shop/urls.py create mode 100644 tests/cases/python/route-table-reaches-view/shop/views.py create mode 100644 tests/cases/python/route-table-reaches-view/tests/test_orders.py diff --git a/graph/python/engine/config-resolution/knobs.dl b/graph/python/engine/config-resolution/knobs.dl index 0cf83db79..c2f6c78f0 100644 --- a/graph/python/engine/config-resolution/knobs.dl +++ b/graph/python/engine/config-resolution/knobs.dl @@ -171,6 +171,29 @@ py_route_entry_fn("path"). py_route_entry_fn("re_path"). py_route_entry_fn("url"). +// ── py_route_include_fn(Name) — mounts another table under a prefix ────────── +// `path("orders/", include(order_patterns))`. The argument is a table like +// `urlpatterns`, however it is named. +py_route_include_fn("include"). + +// ── py_route_regex_syntax(Regex) — a route pattern that is a regular expression ── +// `re_path(r"^orders/(?P[0-9]+)/$", view)`. Such a pattern names no literal path, so +// no served path is composed through it. Matched against the whole pattern text. +py_route_regex_syntax(".*[$^()?|\\\\].*"). + +// ── py_class_view_entry_fn / py_view_handler_name ──────────────────────────── +// `path("orders/", OrderView.as_view())`. The function as_view() returns calls the +// class's method named for the request's HTTP verb, lower-cased. +py_class_view_entry_fn("as_view"). +py_view_handler_name("get"). +py_view_handler_name("post"). +py_view_handler_name("put"). +py_view_handler_name("patch"). +py_view_handler_name("delete"). +py_view_handler_name("head"). +py_view_handler_name("options"). +py_view_handler_name("trace"). + // ── py_signal_receiver_deco(Tail) — attaches a handler to a signal ─────────── // `@receiver(order_placed)`. The publisher writes `order_placed.send(sender=...)`; // the two ends are joined by the SIGNAL OBJECT and never by a call. diff --git a/graph/python/engine/framework-behavior/dispatch.dl b/graph/python/engine/framework-behavior/dispatch.dl index a2c155e78..7b3e7bfa3 100644 --- a/graph/python/engine/framework-behavior/dispatch.dl +++ b/graph/python/engine/framework-behavior/dispatch.dl @@ -356,10 +356,12 @@ py_route_entry_call(site, e) :- // The VALUE side of `urlpatterns = [...]`. The table name is a STORE target and the // entries are its sibling, not its descendant, so the two halves are joined through // assign_pair rather than by walking up from the name. -.decl py_route_table_value(val:symbol) -py_route_table_value(val) :- assign_pair("client", tgt, val), +.decl py_route_root_value(val:symbol) +py_route_root_value(val) :- assign_pair("client", tgt, val), expr_binding("client", b, "STORE", tgt), binding_decl("client", n, _, _, _, b), py_route_table(n). +.decl py_route_table_value(val:symbol) +py_route_table_value(val) :- py_route_root_value(val). // The entry expression IS that value, or sits transitively under it. // @@ -374,18 +376,117 @@ expr_in_route_table(e) :- py_route_table_value(e). expr_in_route_table(e) :- py_route_table_value(val), expr_parent("client", val, _, _, e). expr_in_route_table(e) :- expr_in_route_table(p), expr_parent("client", p, _, _, e). +// A LIST HANDED TO include() IS A TABLE TOO (#1511). `urlpatterns = [path("x/", +// include(extra_patterns))]` mounts every entry of `extra_patterns`, and the list is +// named through a binding rather than written inline, so the descent above never +// reaches it. The name is joined to the assignment that binds it; a binding join is +// same-module, which is where such a helper list lives. `include((extra_patterns, +// "ns"))` names it one level down, as an element of the tuple. +.decl py_route_include_name(inc:symbol, e:symbol) +py_route_include_name(inc, a) :- + expr_in_route_table(inc), call_decl("client", _, fn, _, inc, _), py_route_include_fn(fn), + call_arg(inc, _, a). +py_route_include_name(inc, x) :- + py_route_include_name(inc, a), expr_parent("client", a, "ELEMENT", _, x). +// py_route_mount(inc, val): the include() call `inc` mounts the table value `val`, either +// written inline (`include([path(...)])`) or named through a binding. +.decl py_route_mount(inc:symbol, val:symbol) +py_route_mount(inc, a) :- + py_route_include_name(inc, a), expr_parent("client", a, "ELEMENT", _, x), expr_in_route_table(x), + call_decl("client", _, fn, _, x, _), py_route_entry_fn(fn). +py_route_mount(inc, val) :- + py_route_include_name(inc, a), + expr_binding("client", b, ctx, a), ctx != "STORE", binding_lookup("client", b, b2), + expr_binding("client", b2, "STORE", tgt), assign_pair("client", tgt, val). +py_route_table_value(val) :- py_route_mount(_, val). + +// THE PATH A ROUTE SERVES (#1482, #1511). A table's entries are served under the prefix of +// the entry that mounts the table: `path("api/v1/", include(api_patterns))` serves +// `path("status/", views.api_status)` at `/api/v1/status/`, and a table mounted twice serves +// its entries at both. A table named like `urlpatterns` is taken as the root, served at +// `/`; one mounted by a module path string (`include("app.urls")`) is not followed, so +// its paths are those of the root and can be short of the real prefix. A pattern that is +// a regular expression is no literal path and stops the chain; a table that mounts +// itself stops at the length bound instead of growing without end. +.decl py_route_pattern_text(e:symbol, pat:symbol) +py_route_pattern_text(e, pat) :- + py_route_entry_call(_, e), call_arg(e, "0", a), expr_node("client", "LITERAL", _, pat, a). +.decl py_route_pattern_is_regex(e:symbol) +py_route_pattern_is_regex(e) :- + py_route_pattern_text(e, pat), py_route_regex_syntax(re), match(re, pat). +.decl py_route_pattern(e:symbol, pat:symbol) +py_route_pattern(e, pat) :- py_route_pattern_text(e, pat), !py_route_pattern_is_regex(e). +.decl py_route_entry_in(val:symbol, e:symbol) +py_route_entry_in(val, e) :- + py_route_table_value(val), expr_parent("client", val, "ELEMENT", _, e), py_route_entry_call(_, e). +.decl py_route_prefix(val:symbol, p:symbol) +py_route_prefix(val, "/") :- py_route_root_value(val). +py_route_prefix(val2, cat(p, pat)) :- + py_route_prefix(val, p), strlen(p) < 200, + py_route_entry_in(val, i), py_route_pattern(i, pat), + call_arg(i, _, inc), py_route_mount(inc, val2). +.decl py_route_served(site:symbol, path:symbol) +py_route_served(site, cat(p, pat)) :- + py_route_prefix(val, p), py_route_entry_in(val, e), py_route_pattern(e, pat), + py_route_entry_call(site, e). + +// The view an entry names. A route registers the declaration written under any +// decorator, not what the decorator returns (#1510): `@login_required def settings` +// is served at its route, and the name `settings` denoting the decorator's wrapper is +// a fact about calls through that name, not about which def the route dispatches to. +// So an argument that names a replaced def yields the def, and only then does the +// value semantics of expr_denotes_method answer. +.decl py_route_entry_arg(site:symbol, a:symbol) +py_route_entry_arg(site, a) :- py_route_entry_call(site, e), call_arg(e, _, a). + +.decl py_route_arg_names_replaced(a:symbol, m:symbol) +py_route_arg_names_replaced(a, m) :- + py_route_entry_arg(_, a), expr_attr_names_method("client", a, m), method_target_replaced("client", m). +py_route_arg_names_replaced(a, m) :- + py_route_entry_arg(_, a), + expr_binding("client", b, ctx, a), ctx != "STORE", binding_lookup("client", b, b2), + binding_binds_replaced_method("client", b2, m). +py_route_arg_names_replaced(a, m) :- + py_route_entry_arg(_, a), + expr_binding("client", b, ctx, a), ctx != "STORE", binding_lookup("client", b, b2), + import_binds_method("client", b2, m), method_target_replaced("client", m). + +.decl py_route_view(site:symbol, m:symbol) +py_route_view(site, m) :- py_route_entry_arg(site, a), py_route_arg_names_replaced(a, m). +py_route_view(site, m) :- + py_route_entry_arg(site, a), !py_route_arg_names_replaced(a, _), + expr_denotes_method("client", a, m), m != "". +// A CLASS-BASED VIEW (#1513). `views.OrderView.as_view()` is a call into the framework, +// which returns a function that dispatches on the request method to the handler of +// that name. The class is the receiver of the call; the handlers are its methods named +// for an HTTP verb, looked up through the MRO so a handler inherited from a project +// base class counts. Only client methods: a handler the framework's own base defines +// is not a change anyone here makes. +py_route_view(site, m) :- + py_route_entry_arg(site, a), call_decl("client", _, fn, _, a, _), py_class_view_entry_fn(fn), + call_receiver_object(a, obj), + ( expr_names_type("client", obj, t) ; expr_attr_names_type("client", obj, t) ), + py_view_handler_name(n), mro_lookup("client", t, n, m), + method_decl("client", _, _, _, _, m). + // The view is whichever argument of the entry DENOTES a def. A route entry takes the // pattern as a string and the view as a reference, so the string arguments simply do // not denote a method and drop out without a position rule. -.decl py_url_dispatch(from:symbol, to:symbol) -py_url_dispatch(from, to) :- - py_route_entry_call(site, e), call_arg(e, _, a), - expr_denotes_method("client", a, to), to != "", +.decl py_url_dispatch_site(site:symbol, from:symbol, to:symbol) +py_url_dispatch_site(site, from, to) :- + py_route_view(site, to), call_context("client", _, from, _, _, site), from != "". +.decl py_url_dispatch(from:symbol, to:symbol) +py_url_dispatch(from, to) :- py_url_dispatch_site(_, from, to). entry_point(m, "url") :- py_url_dispatch(_, m). +// The edge carries the path the route serves where the table says it, so "what does this +// URL run" and "where is this view routed" read the answer off the edge. A route whose +// path is not written as a literal keeps the generic label. +framework_edge(from, to, "url_dispatch", path, "registered") :- + py_url_dispatch_site(site, from, to), py_route_served(site, path). framework_edge(from, to, "url_dispatch", "route_table", "registered") :- - py_url_dispatch(from, to). + py_url_dispatch_site(site, from, to), !py_route_served(site, _). // ───────────────────────────────────────────────────────────────────────────── // 4. SIGNAL DISPATCH (publisher -> receiver) diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index 6a1396f3e..78fc34039 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -56,6 +56,10 @@ .decl py_argnames_lead(v:symbol) .decl py_route_table(v:symbol) .decl py_route_entry_fn(v:symbol) +.decl py_route_include_fn(v:symbol) +.decl py_route_regex_syntax(v:symbol) +.decl py_class_view_entry_fn(v:symbol) +.decl py_view_handler_name(v:symbol) .decl py_signal_receiver_deco(v:symbol) .decl py_signal_connect_method(v:symbol) .decl py_signal_send_method(v:symbol) diff --git a/graph/test/python/cases/33-url-view-forms/src/catalog/__init__.py b/graph/test/python/cases/33-url-view-forms/src/catalog/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/python/cases/33-url-view-forms/src/catalog/urls.py b/graph/test/python/cases/33-url-view-forms/src/catalog/urls.py new file mode 100644 index 000000000..667d1895b --- /dev/null +++ b/graph/test/python/cases/33-url-view-forms/src/catalog/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from catalog import views + +urlpatterns = [ + path("", views.catalog_index), +] diff --git a/graph/test/python/cases/33-url-view-forms/src/catalog/views.py b/graph/test/python/cases/33-url-view-forms/src/catalog/views.py new file mode 100644 index 000000000..96e995c73 --- /dev/null +++ b/graph/test/python/cases/33-url-view-forms/src/catalog/views.py @@ -0,0 +1,2 @@ +def catalog_index(request): + return "catalog" diff --git a/graph/test/python/cases/33-url-view-forms/src/shop/__init__.py b/graph/test/python/cases/33-url-view-forms/src/shop/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/python/cases/33-url-view-forms/src/shop/decorators.py b/graph/test/python/cases/33-url-view-forms/src/shop/decorators.py new file mode 100644 index 000000000..5cbe25556 --- /dev/null +++ b/graph/test/python/cases/33-url-view-forms/src/shop/decorators.py @@ -0,0 +1,11 @@ +"""A decorator the project declares itself. It returns its inner wrapper, so the name of +every function it decorates denotes that one shared wrapper.""" +import functools + + +def audited(view): + @functools.wraps(view) + def wrapper(request, *args, **kwargs): + return view(request, *args, **kwargs) + + return wrapper diff --git a/graph/test/python/cases/33-url-view-forms/src/shop/urls.py b/graph/test/python/cases/33-url-view-forms/src/shop/urls.py new file mode 100644 index 000000000..0d8047b2e --- /dev/null +++ b/graph/test/python/cases/33-url-view-forms/src/shop/urls.py @@ -0,0 +1,43 @@ +from django.urls import include, path, re_path + +from shop import views +from shop.views import order_settings + +report_patterns = [ + path("report/", views.order_report), +] + +export_patterns = [ + path("export/", views.order_export), +] + +draft_patterns = [ + path("draft/", views.order_draft), +] + +status_patterns = [ + path("", views.api_status), +] + +api_patterns = [ + path("status/", include(status_patterns)), +] + +urlpatterns = [ + path("orders/", views.order_list), + path("orders/settings/", order_settings), + path("orders/history/", views.order_history), + path( + "orders/cancel/", + views.order_cancel, + name="order-cancel", + ), + path("orders/extra/", include(report_patterns)), + path("orders/more/", include((export_patterns, "exports"))), + path("widgets/", views.WidgetView.as_view()), + path("catalog/", include("catalog.urls")), + path("credit/", include([path("notes/", views.order_credit_note)])), + path("api/v1/", include(api_patterns)), + path("api/v2/", include(api_patterns)), + re_path(r"^legacy/(?P[0-9]+)/$", views.order_legacy), +] diff --git a/graph/test/python/cases/33-url-view-forms/src/shop/views.py b/graph/test/python/cases/33-url-view-forms/src/shop/views.py new file mode 100644 index 000000000..2b1cede2d --- /dev/null +++ b/graph/test/python/cases/33-url-view-forms/src/shop/views.py @@ -0,0 +1,88 @@ +"""Views in the forms a route table names them. Each routed one is dispatched by the +framework on a request; nothing in this project calls it.""" +from django.contrib.auth.decorators import login_required +from django.views import View +from django.views.decorators.http import require_POST + +from shop.decorators import audited + + +def order_list(request): + return _render("list") + + +@login_required +def order_settings(request): + return _render("settings") + + +@login_required +@require_POST +def order_cancel(request): + return _render("cancelled") + + +@audited +def order_history(request): + return _render("history") + + +def order_report(request): + return _render("report") + + +def order_export(request): + return _render("export") + + +def order_credit_note(request): + return _render("credit") + + +def api_status(request): + """Mounted twice, two tables down: served at /api/v1/status/ and /api/v2/status/.""" + return _render("status") + + +def order_legacy(request, pk): + """A regular-expression route: routed, but no literal path to name.""" + return _render("legacy") + + +def order_view_like(request): + """Named like a view, routed nowhere: not an entry point.""" + return _render("unrouted") + + +@login_required +def order_audit(request): + """Decorated like the routed views, routed nowhere: not an entry point.""" + return _render("audit") + + +def order_draft(request): + """Only in a list no table mounts: not an entry point.""" + return _render("draft") + + +class BaseWidgetView(View): + def get(self, request): + return _render("widgets") + + +class WidgetView(BaseWidgetView): + def post(self, request): + return _render("created") + + def summarise(self): + """Not an HTTP verb: the framework never dispatches to it.""" + return _render("summary") + + +class UnroutedView(View): + def get(self, request): + return _render("never") + + +def _render(key): + return key diff --git a/graph/test/python/expected/21-url-and-signal-dispatch.framework b/graph/test/python/expected/21-url-and-signal-dispatch.framework index ec3e4fde2..24cf8eba5 100644 --- a/graph/test/python/expected/21-url-and-signal-dispatch.framework +++ b/graph/test/python/expected/21-url-and-signal-dispatch.framework @@ -2,11 +2,11 @@ signal_dispatch registered main.place_order (main.py) -> main.on_order_connected (main.py) [signal] signal_dispatch registered main.place_order (main.py) -> main.on_order_placed (main.py) [signal] signal_dispatch registered ship.ship_order (ship.py) -> handlers.on_shipped (handlers.py) [signal] - url_dispatch registered main. (main.py) -> main.detail (main.py) [route_table] - url_dispatch registered main. (main.py) -> main.index (main.py) [route_table] + url_dispatch registered main. (main.py) -> main.detail (main.py) [/items//] + url_dispatch registered main. (main.py) -> main.index (main.py) [/] url_dispatch registered main. (main.py) -> main.legacy (main.py) [route_table] - url_dispatch registered urls. (urls.py) -> views.profile (views.py) [route_table] - url_dispatch registered urls. (urls.py) -> views.settings_page (views.py) [route_table] + url_dispatch registered urls. (urls.py) -> views.profile (views.py) [/profile//] + url_dispatch registered urls. (urls.py) -> views.settings_page (views.py) [/settings/] ── framework_unjoined (2) ── 2 signal_dispatch no_receiver ── remote_edge (0) ── diff --git a/graph/test/python/expected/33-url-view-forms.edges b/graph/test/python/expected/33-url-view-forms.edges new file mode 100644 index 000000000..f5bb9f3f3 --- /dev/null +++ b/graph/test/python/expected/33-url-view-forms.edges @@ -0,0 +1,24 @@ +ambiguous_unknown DECORATOR_APPLICATION shop.decorators.audited -> - +ambiguous_unknown DECORATOR_BARE shop.views. -> - +ambiguous_unknown METHOD_CALL shop.urls. -> - +ambiguous_unknown SIMPLE_CALL catalog.urls. -> - +ambiguous_unknown SIMPLE_CALL shop.urls. -> - +boundary_lib DECORATOR_CALL shop.decorators.audited -> external:functools.wraps +known_edge DECORATOR_BARE shop.views. -> shop.decorators.audited +known_edge SIMPLE_CALL shop.decorators.audited..wrapper -> shop.views.order_history +known_edge SIMPLE_CALL shop.views.BaseWidgetView.get -> shop.views._render +known_edge SIMPLE_CALL shop.views.UnroutedView.get -> shop.views._render +known_edge SIMPLE_CALL shop.views.WidgetView.post -> shop.views._render +known_edge SIMPLE_CALL shop.views.WidgetView.summarise -> shop.views._render +known_edge SIMPLE_CALL shop.views.api_status -> shop.views._render +known_edge SIMPLE_CALL shop.views.order_audit -> shop.views._render +known_edge SIMPLE_CALL shop.views.order_cancel -> shop.views._render +known_edge SIMPLE_CALL shop.views.order_credit_note -> shop.views._render +known_edge SIMPLE_CALL shop.views.order_draft -> shop.views._render +known_edge SIMPLE_CALL shop.views.order_export -> shop.views._render +known_edge SIMPLE_CALL shop.views.order_history -> shop.views._render +known_edge SIMPLE_CALL shop.views.order_legacy -> shop.views._render +known_edge SIMPLE_CALL shop.views.order_list -> shop.views._render +known_edge SIMPLE_CALL shop.views.order_report -> shop.views._render +known_edge SIMPLE_CALL shop.views.order_settings -> shop.views._render +known_edge SIMPLE_CALL shop.views.order_view_like -> shop.views._render diff --git a/graph/test/python/expected/33-url-view-forms.entries b/graph/test/python/expected/33-url-view-forms.entries new file mode 100644 index 000000000..5e00ea76a --- /dev/null +++ b/graph/test/python/expected/33-url-view-forms.entries @@ -0,0 +1,13 @@ +── entry_point (12) ── + url catalog.views.catalog_index catalog/views.py:1 + url shop.views.BaseWidgetView.get shop/views.py:69 + url shop.views.WidgetView.post shop/views.py:74 + url shop.views.api_status shop/views.py:42 + url shop.views.order_cancel shop/views.py:21 + url shop.views.order_credit_note shop/views.py:38 + url shop.views.order_export shop/views.py:34 + url shop.views.order_history shop/views.py:26 + url shop.views.order_legacy shop/views.py:47 + url shop.views.order_list shop/views.py:10 + url shop.views.order_report shop/views.py:30 + url shop.views.order_settings shop/views.py:15 diff --git a/graph/test/python/expected/33-url-view-forms.framework b/graph/test/python/expected/33-url-view-forms.framework new file mode 100644 index 000000000..c266848e7 --- /dev/null +++ b/graph/test/python/expected/33-url-view-forms.framework @@ -0,0 +1,18 @@ +── framework_edge (13) ── + url_dispatch registered catalog.urls. (catalog/urls.py) -> catalog.views.catalog_index (catalog/views.py) [/] + url_dispatch registered shop.urls. (shop/urls.py) -> shop.views.BaseWidgetView.get (shop/views.py) [/widgets/] + url_dispatch registered shop.urls. (shop/urls.py) -> shop.views.WidgetView.post (shop/views.py) [/widgets/] + url_dispatch registered shop.urls. (shop/urls.py) -> shop.views.api_status (shop/views.py) [/api/v1/status/] + url_dispatch registered shop.urls. (shop/urls.py) -> shop.views.api_status (shop/views.py) [/api/v2/status/] + url_dispatch registered shop.urls. (shop/urls.py) -> shop.views.order_cancel (shop/views.py) [/orders/cancel/] + url_dispatch registered shop.urls. (shop/urls.py) -> shop.views.order_credit_note (shop/views.py) [/credit/notes/] + url_dispatch registered shop.urls. (shop/urls.py) -> shop.views.order_export (shop/views.py) [/orders/more/export/] + url_dispatch registered shop.urls. (shop/urls.py) -> shop.views.order_history (shop/views.py) [/orders/history/] + url_dispatch registered shop.urls. (shop/urls.py) -> shop.views.order_legacy (shop/views.py) [route_table] + url_dispatch registered shop.urls. (shop/urls.py) -> shop.views.order_list (shop/views.py) [/orders/] + url_dispatch registered shop.urls. (shop/urls.py) -> shop.views.order_report (shop/views.py) [/orders/extra/report/] + url_dispatch registered shop.urls. (shop/urls.py) -> shop.views.order_settings (shop/views.py) [/orders/settings/] +── framework_unjoined (0) ── +── remote_edge (0) ── +── remote_unserved (0) ── +── remote_unsent (0) ── diff --git a/graph/test/python/expected/33-url-view-forms.tiers b/graph/test/python/expected/33-url-view-forms.tiers new file mode 100644 index 000000000..2713fd477 --- /dev/null +++ b/graph/test/python/expected/33-url-view-forms.tiers @@ -0,0 +1,30 @@ +distinct call sites emitted: 51 + +--- by tier: edge ROWS, and the distinct SITES they cover --- + 32 rows 32 sites ambiguous_unknown + 1 rows 1 sites boundary_lib + 18 rows 18 sites known_edge + +--- edge rows by call kind --- + 1 DECORATOR_APPLICATION + 5 DECORATOR_BARE + 1 DECORATOR_CALL + 1 METHOD_CALL + 43 SIMPLE_CALL + +--- unresolved reasons --- + 1 decorator_factory_result_untyped + 27 no_rule + 4 unmodelled_decorator + +--- the engine's own conservation ledger --- + 51 _total_sites + 32 ambiguous_unknown + 1 boundary_lib + 18 known_edge + +--- reconciling rows against the conserved site count --- + edge rows 51 + minus extra rows from multi-target sites 0 + = tier/site pairs 51 + engine's conserved site total 51 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py index 3e1c9ce82..c452c453c 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py @@ -236,10 +236,17 @@ def value_route_registrations(q, site_file=None): for n, f, l in q(f"SELECT name, file, line FROM refs WHERE line > 0 AND entity_kind IN ({ek})", *CALLABLE_EK): if n in once: ref_at.setdefault((f, l), once[n]) + routed = _routed_views(q) + served = _served_paths(q) + names_at = {} + if routed: + for n, f, l in q("SELECT name, file, line FROM refs WHERE line > 0 AND name IS NOT NULL"): + if (f, n) in routed: + names_at.setdefault((f, l), set()).update(routed[(f, n)]) import re out = [] for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): - if not (isinstance(v, str) and v.startswith('/') and len(v) < 160): + if not (isinstance(v, str) and 0 < len(v) < 160): continue # A RESOURCE IS NOT A ROUTE. Measured on the JVM parser: the pair fired on # `connect(url).onResponseProgress(progressListener)` — a path-shaped literal and a handed-over callable on @@ -249,13 +256,82 @@ def value_route_registrations(q, site_file=None): last = v.split('?')[0].split('#')[0].rstrip('/').rsplit('/', 1)[-1] if '?' in v or re.search(r'\.[A-Za-z0-9]{1,6}$', last): continue - d = ref_at.get((f, l)) - if d: - out.append((d, sf(f) if f else '', l or 0, 'route', v, - f'registered at "{v}" here — the framework calls it, no call site does')) + # the views the engine's own url_dispatch rule says a route on this line dispatches to (#1483): a view named as + # a module attribute (`views.order_list`) is a reference the parser gives no callable entity kind, and a + # class-based view is named by its class, not by the handler the framework calls + routed_here = names_at.get((f, l), ()) + if v.startswith('/'): + ds, key = ({ref_at[(f, l)]} if (f, l) in ref_at else set()) | set(routed_here), v + # A ROUTE TABLE WRITES ITS PATTERN WITHOUT THE LEADING SLASH (#1482): `path("orders//", view)` + # serves `/orders/5/`. Such a string is not path-shaped on its own, since `name="order-list"` sits on the + # same line, so it is read only where the engine has already said the line routes to a view, only when it + # has a segment separator, and never as a regular expression. A view whose served path the engine wrote on + # the edge is keyed by that path below, prefix and all; this is the fallback for one it could not compose. + elif routed_here and '/' in v and not re.search(r'[\s^$\\]', v): + ds, key = set(routed_here) - set(served), '/' + v + else: + continue + for d in ds: + out.append((d, sf(f) if f else '', l or 0, 'route', key, + f'registered at "{key}" here — the framework calls it, no call site does')) + # THE PATH THE ROUTE SERVES, AS THE ENGINE COMPOSED IT (#1511): `path("status/", views.api_status)` in a table that + # `path("api/v1/", include(api_patterns))` mounts is served at `/api/v1/status/`, which no single line spells. The + # registration sits on the line that names the view. A bare `/` is not a key: a table the engine could not see + # mounted (`include("app.urls")`) is served under a prefix it does not know, and its empty pattern would join + # every request for the site root. + for d, keys in served.items(): + at = sorted(fl for fl, ds in names_at.items() if d in ds) + if not at: + continue + f, l = at[0] + for key in keys: + if key != '/': + out.append((d, sf(f) if f else '', l or 0, 'route', key, + f'registered at "{key}" here — the framework calls it, no call site does')) return sorted(set(out)) +def _served_paths(q): + """{declaration: {path}} — the paths the engine's url_dispatch edges say each view is served at.""" + if not all(_has(q, t) for t in ('ext_framework_edge', 'symbols')): + return {} + out = {} + for d, p in q("""SELECT s.id, e.c3 FROM ext_framework_edge e JOIN symbols s ON s.method_id = e.c1 + WHERE e.c2 = 'url_dispatch' AND e.c3 LIKE '/%'"""): + out.setdefault(d, set()).add(p) + return out + + +def _routed_views(q): + """{(route_file, name): {declaration}} — the views the engine dispatches a route table in route_file to. + + Keyed by every name a route entry spells the view with: a function view by its own name, a class-based view's + handler by its class or any subclass (`WidgetView.as_view()` dispatches to a `get` WidgetView inherits). A name + that means two different views from one route file identifies neither and is dropped. + """ + if not all(_has(q, t) for t in ('ext_framework_edge', 'methods', 'symbols')): + return {} + rows = q("""SELECT fm.file_path, s.id, t.name, t.owner_type_id FROM ext_framework_edge e + JOIN methods fm ON fm.id = e.c0 JOIN methods t ON t.id = e.c1 JOIN symbols s ON s.method_id = e.c1 + WHERE e.c2 = 'url_dispatch'""") + if not rows: + return {} + tname = {i: n for i, n in q("SELECT id, name FROM types")} if _has(q, 'types') else {} + subs = {} + if _has(q, 'type_ancestors'): + for t, a in q("SELECT type_id, ancestor_type_id FROM type_ancestors"): + subs.setdefault(a, set()).add(t) + by = {} # (file, name) -> {(view identity, declaration)} + for f, d, n, owner in rows: + if owner: + for t in {owner} | subs.get(owner, set()): + if t in tname: + by.setdefault((f, tname[t]), set()).add((t, d)) + else: + by.setdefault((f, n), set()).add((d, d)) + return {k: {d for _i, d in v} for k, v in by.items() if len({i for i, _d in v}) == 1} + + # ── the two spellings of one path ──────────────────────────────────────────────────────────────────────────── # A test asks for `/orders/o-1/price`; the handler is registered at `/orders/{order_id}/price`. Neither string # contains the other and no call site joins them — the router does, at run time, by matching the path. A path @@ -274,7 +350,32 @@ def route_matches(written, registered): return False segs = lambda p: p.split('?')[0].split('#')[0].rstrip('/').split('/') w, r = segs(written), segs(registered) - return len(w) == len(r) and all(a == b or _PATH_PARAM.fullmatch(b) for a, b in zip(w, r)) + return len(w) == len(r) and all(_segment_matches(a, b) for a, b in zip(w, r)) + + +def _segment_matches(written, registered): + """One segment: equal, or a parameter that accepts what is written there. + + A TYPED CONVERTER NARROWS WHAT IT ACCEPTS. `` serves `/orders/5/` and never `/orders/new/`: the router + tries the next pattern instead, so a test requesting `/orders/new/` does not reach the detail view. A written + segment that is itself a placeholder (`{}`, `%s`, ``) could be any value and still matches. + """ + if written == registered: + return True + if not _PATH_PARAM.fullmatch(registered): + return False + if registered.startswith('/`, the way a router serves the + literal route rather than capturing `settings` as a value.""" + if written in registered_keys: + return [written] + return [k for k in registered_keys if route_matches(written, k)] def all_registrations(q, site_file=None): @@ -312,9 +413,9 @@ def key_edges(q, at, site_file=None, cap=None, use_cap=None): capped = {k for k, ds in reg.items() if len(ds) > cap} | {k for k, cs in writes.items() if len(cs) > use_cap} out = set() for v, callers in writes.items(): - for key, decls in reg.items(): - if key in capped or not (v == key or route_matches(v, key)): continue + for key in route_candidates(v, reg): + if key in capped: continue for c in callers: - for d in decls: + for d in reg[key]: if c != d: out.add((c, d, key)) return sorted(out) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index 2bc36685c..0278839bb 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -292,14 +292,15 @@ def named_declarations(g, text): return out -ROUTE = re.compile(r'(?` (a Python route converter) + return [('*' if (p.startswith('{') or p.startswith(':') or p.startswith('<') or p == '*') else p) for p in r.strip('/').split('/') if p] def _route_eq(a, b): @@ -321,7 +322,19 @@ def route_seeds(g, text): hits = [] # (symbol id, route as written) def serve(mid, route): if mid in g.sym: hits.append((mid, route)) - for tbl in ('ext_remote_unserved', 'ext_remote_unsent'): + # the path a url_dispatch edge says its view is served at: the engine composed it through every include() that + # mounts the table, so it is the one source that names the handler of `/api/v1/status/` when no line spells it + tables = set() # route files whose every literal path the engine already composed + if g.has('ext_framework_edge') and g.has('symbols'): + for r in g.q("""SELECT s.id AS sid, e.c3 AS path, t.file AS tf FROM ext_framework_edge e + JOIN symbols s ON s.method_id = e.c1 JOIN symbols t ON t.method_id = e.c0 + WHERE e.c2 = 'url_dispatch' AND e.c3 LIKE '/%'"""): + tables.add(r['tf']) + if any(_route_eq(_route_parts(r['path']), a) for a in asked): serve(r['sid'], r['path']) + # a route the engine answered is not searched again as text: the literal scan below is the fallback for a route + # no edge carries, and on an answered one it only adds the table's module and every literal that spells a suffix + asked = [a for a in asked if not any(_route_eq(_route_parts(r), a) for _s, r in hits)] + for tbl in ('ext_remote_unserved', 'ext_remote_unsent') if asked else (): if g.has(tbl): for r in g.q(f"SELECT c0, c2 FROM {tbl}"): if any(_route_eq(_route_parts(r['c2']), a) for a in asked): serve(r['c0'], r['c2']) @@ -329,10 +342,10 @@ def route_seeds(g, text): # A class-level prefix (@RequestMapping("/api") over @GetMapping("/widgets")) is met by accepting a suffix match # when the rest of the asked route is itself a literal in the same file. lits = [] - if g.has('decorations'): + if asked and g.has('decorations'): for r in g.q("SELECT owner_id, text, file, line FROM decorations WHERE text LIKE '%/%' OR text LIKE '%\"%'"): for v in re.findall(r'"([^"]*)"', r['text'] or ''): lits.append((v, r['file'], r['line'], r['owner_id'])) - if g.has('literals'): + if asked and g.has('literals'): for r in g.q("SELECT value, file, line FROM literals WHERE value LIKE '%/%' OR length(value) < 40"): lits.append(((r['value'] or '').strip('"'), r['file'], r['line'], None)) by_file = collections.defaultdict(set) @@ -342,6 +355,9 @@ def route_seeds(g, text): if s.get('method_id') and s.get('line') and not (s.get('file') or '').startswith('<'): spans[s['file']].append((s['line'], s.get('end_line') or s['line'], sid)) for v, f, l, owner in lits: + # a pattern in a route table the engine read is one segment of a longer served path, not the path itself: + # `path("status/", ...)` under `include()` at `api/v1/` is not served at `/status/` + if f in tables: continue vp = _route_parts(v) if not vp or ('/' not in v and not owner): continue # a bare word in a string is not a route ok = any(_route_eq(vp, a) for a in asked) or any( @@ -349,7 +365,9 @@ def route_seeds(g, text): if not ok: continue if owner and owner in g.sym: serve(owner, v); continue inner = sorted((e - b, sid) for b, e, sid in spans.get(f, ()) if b <= (l or 0) <= e) - if inner: serve(inner[0][1], v) + # A TEST REQUESTS A ROUTE, IT DOES NOT SERVE ONE. `self.client.get("/api/v1/status/")` puts the literal inside + # a test method, and labelling that method "serves the route" sent a reader to the caller instead of the view. + if inner and not g.sym[inner[0][1]].get('is_test') and not TESTY.search(f or ''): serve(inner[0][1], v) out, seen = [], set() for sid, route in hits: if sid not in seen: seen.add(sid); out.append((sid, f"serves the route {route.strip()}")) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 7a1b483c7..52fac9263 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -948,8 +948,8 @@ class Impact: # string contains the other, and no call site joins them — the router does, at run time, by matching the # path. The segments are matched here because Soufflé cannot split a string; WHAT the match means is a rule. # A path parameter is whatever the framework spells it (`{id}` FastAPI, `` a Python web framework, `:id` Express), - # so a registered segment in any of those forms matches any written segment, and nothing is normalised. - PARAM = re.compile(r'\{.*\}|<.*>|:.+|\*.*') + # so a registered segment in any of those forms matches a written segment it accepts, and nothing is normalised. + # The segment rule and the precedence of a verbatim key are ax_registration's, shared with the SQL port. segs = lambda p: p.split('?')[0].split('#')[0].rstrip('/').split('/') written = collections.defaultdict(set); regd = collections.defaultdict(set) for c, v, f, l in lits: @@ -959,10 +959,12 @@ class Impact: for _d, _k, v in regk: if v.startswith('/'): regd[len(segs(v))].add(v) alias = set() + verbatim = {v for _d, _k, v in regk} for n, ws in written.items(): # bucketed by segment count: a mismatch there is no match for w in ws: + if w in verbatim: continue # a path registered verbatim is served by that route alone for r in regd.get(n, ()): # and only a REGISTERED path is ever the right-hand side - if w != r and all(a == b or PARAM.fullmatch(b) for a, b in zip(segs(w), segs(r))): alias.add((w, r)) + if w != r and ax_registration.route_matches(w, r): alias.add((w, r)) W('route_alias', sorted(alias)) # the BINDING SITE of a configuration key: the one line that connects the key to the code, written as a # placeholder inside a decoration (@Value("${app.server.prefix}"), @ConditionalOnProperty(name = "...")). @@ -1860,7 +1862,10 @@ def main(argv): hops += 1 # a `by key` hop is not in call_edges BY CONSTRUCTION — it is the hop the framework makes and the engine # never resolved — so it is verified the same way `dispatch` and `defines` are: the rules asserted it - if (a, b) not in real and not any(x == b and t in ('dispatch', 'defines', 'by key', 'decorator') for x, t in parent.get(a, [])): bad += 1 + # `decorator by name` is the rules' own hop from a decorated def to the decorator it names + # (dl/impact.dl fw_edge), asserted exactly as `decorator` is; leaving it out printed "✗ not found" on + # every answer whose test route passes through a project decorator + if (a, b) not in real and not any(x == b and t in ('dispatch', 'defines', 'by key', 'decorator', 'decorator by name') for x, t in parent.get(a, [])): bad += 1 asserted = collections.defaultdict(set) # the callee each direct row claims an edge to for c, callee in {(x[0], x[1]) for x in res['direct_edge']}: asserted[c].add(callee) for c, role, why, cert, loc, n in D: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index fa67aad7a..88bf73d7e 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -867,9 +867,11 @@ def framework_note(g, ids, writers=()): return [] out = [] try: - regs = [r for r in ax_registration.all_registrations(g.q, g.site_file) if r[0] in ids] + every = ax_registration.all_registrations(g.q, g.site_file) except Exception: return [] + regs = [r for r in every if r[0] in ids] + registered = {r[4] for r in every if r[4]} # a key registered verbatim elsewhere wins over a pattern keys = {} for decl, f, l, kind, key, why in regs[:4]: out.append(f" {g.disp(decl)} is {why} ({f}:{l})") @@ -887,10 +889,10 @@ def framework_note(g, ids, writers=()): hit = [] for v, f, l in g.q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): if not isinstance(v, str): continue - for key, decl in keys.items(): - if v == key or ax_registration.route_matches(v, key): - c = at(f, l) - if c in writers: hit.append((g.disp(c), v, key, g.site_file(f) if f else f, l)) + for key in ax_registration.route_candidates(v, registered): + if key not in keys: continue + c = at(f, l) + if c in writers: hit.append((g.disp(c), v, key, g.site_file(f) if f else f, l)) for c, v, key, f, l in sorted(set(hit))[:3]: out.append(f" {c} writes \"{v}\" at {f}:{l}, which is the key it is registered under" + (f' ("{key}")' if v != key else '') + " — the framework connects them, no call does") diff --git a/tests/cases/python/route-table-reaches-view/case.json b/tests/cases/python/route-table-reaches-view/case.json new file mode 100644 index 000000000..831cc5c36 --- /dev/null +++ b/tests/cases/python/route-table-reaches-view/case.json @@ -0,0 +1,32 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a route table writes its pattern without the leading slash and names the view as a module attribute; a test that requests the URL reaches the view and what it calls", + "run": ["impact", "describe", "--tests"], + "want": ["test_orders.py", "by key"]}, + {"why": "the same join lands on the view itself, and only from the URL its accepts: /orders/archive/ is no integer and /orders/settings/ is its own route", + "run": ["impact", "order_detail", "--tests"], + "want": ["test_order_detail", "by key"], + "avoid": ["test_order_archive", "test_widgets", "test_order_settings"]}, + {"why": "a view under a library decorator is still the def its route dispatches to", + "run": ["impact", "order_settings", "--tests"], + "want": ["test_order_settings", "by key"], + "avoid": ["test_order_detail"]}, + {"why": "a class-based view routed with .as_view() is reached through its verb handler, by the one test that requests its URL", + "run": ["impact", "WidgetView.get", "--tests"], + "want": ["[by key] 1 test(s)", "(1): test_orders.py::test_widgets"]}, + {"why": "a view in a table that include() mounts is served under the mounting prefix: the test that requests /api/v1/status/ reaches it, and one requesting the bare pattern /status/ does not", + "run": ["impact", "api_status", "--tests"], + "want": ["test_api_status", "by key"], + "avoid": ["test_status_without_prefix"]}, + {"why": "what a URL runs: the view the engine says is served at a path only an include() prefix composes, and never the test that requests that path", + "run": ["context", "what view handles the /api/v1/status/ URL"], + "want": ["serves the route /api/v1/status/"]}, + {"why": "CONTROL: /status/ is a pattern of a mounted table and a URL a test requests, but no route serves it: neither the table module nor the requesting test is said to serve it", + "run": ["context", "what runs for a GET of /status/"], + "avoid": ["serves the route"]}, + {"why": "a one-segment route with a trailing slash is a route too, and a class-based view is answered by its verb handler", + "run": ["context", "what view handles the /widgets/ URL"], + "want": ["WidgetView.get", "serves the route /widgets/"]}, + {"why": "CONTROL: a function named like a view but routed nowhere gains no test from a request for a URL that has no route", + "run": ["impact", "order_archive", "--tests"], + "avoid": ["test_order_archive", "by key"]}]} diff --git a/tests/cases/python/route-table-reaches-view/shop/__init__.py b/tests/cases/python/route-table-reaches-view/shop/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/route-table-reaches-view/shop/urls.py b/tests/cases/python/route-table-reaches-view/shop/urls.py new file mode 100644 index 000000000..a94328009 --- /dev/null +++ b/tests/cases/python/route-table-reaches-view/shop/urls.py @@ -0,0 +1,15 @@ +from django.urls import include, path + +from shop import views +from shop.views import order_settings + +api_patterns = [ + path("status/", views.api_status), +] + +urlpatterns = [ + path("orders//", views.order_detail, name="order-detail"), + path("orders/settings/", order_settings, name="order-settings"), + path("widgets/", views.WidgetView.as_view(), name="widgets"), + path("api/v1/", include(api_patterns)), +] diff --git a/tests/cases/python/route-table-reaches-view/shop/views.py b/tests/cases/python/route-table-reaches-view/shop/views.py new file mode 100644 index 000000000..3a387b998 --- /dev/null +++ b/tests/cases/python/route-table-reaches-view/shop/views.py @@ -0,0 +1,28 @@ +from django.contrib.auth.decorators import login_required +from django.views import View + + +def order_detail(request, pk): + return describe(pk) + + +@login_required +def order_settings(request): + return "settings" + + +def api_status(request): + return "ok" + + +def order_archive(request): + return "archive" + + +class WidgetView(View): + def get(self, request): + return "widgets" + + +def describe(pk): + return str(pk) diff --git a/tests/cases/python/route-table-reaches-view/tests/test_orders.py b/tests/cases/python/route-table-reaches-view/tests/test_orders.py new file mode 100644 index 000000000..a30dbb198 --- /dev/null +++ b/tests/cases/python/route-table-reaches-view/tests/test_orders.py @@ -0,0 +1,22 @@ +def test_order_detail(client): + assert client.get("/orders/5/").status_code == 200 + + +def test_order_settings(client): + assert client.get("/orders/settings/").status_code == 302 + + +def test_widgets(client): + assert client.get("/widgets/").status_code == 200 + + +def test_order_archive(client): + assert client.get("/orders/archive/").status_code == 404 + + +def test_api_status(client): + assert client.get("/api/v1/status/").status_code == 200 + + +def test_status_without_prefix(client): + assert client.get("/status/").status_code == 404 From af178c4fba96b758a4fd4aa66f788c3365e7dc66 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:23:04 -0700 Subject: [PATCH 08/31] csharp: a mediator Send or Publish reaches the handler for the request type (#1383) mediator.Send(new GetOrder(id)) runs IRequestHandler.Handle in process, and no call site names the handler, so path called the two independent and impact on the handler said only "entry point". framework-behavior/dispatch.dl now adds an event_dispatch call edge from the site to the handler chosen by the request's TYPE, beside the site's own row for the library method. One rule, keyed on cs_med_dispatch(SendMethod, HandlerBase, Fan) in knobs.dl, whose handler method is the one the cs_framework_callback table (the entry-point table) already names for that base: - Send -> IRequestHandler, the exact type, with a generic request's type arguments (GetById does not reach the handler of GetById; an open generic handler serves any); - Publish -> every INotificationHandler of the type and of each of its bases (the interface is contravariant); - Send -> IPipelineBehavior, IRequestPreProcessor, IRequestPostProcessor: the step for its own request, and an open generic step for every request; - a request declared as a base (not an interface) fans to the handler of each type in its family; one declared as the marker interface links nothing. The request's type is read by the rules that already read a bus message's (msg_arg_type_name), and where both ends resolve, the resolved type decides and not the simple name. A receiver that is a property of another value (services.Mediator.Send) is now named by the property's declared type, for every by-name framework receiver. A send whose request is a mock's argument matcher (Arg.Any(), It.IsAny()) links nothing: it is a stub, not a dispatch. Case 18-mediator-send with controls (a handler of another request type, a generic request closed over another type, a sibling notification, a notification Sent rather than Published, a non-mediator receiver, a mock stub); tools/mediator-dispatch-test.sh asserts the edges in the package-in-source and package-unstaged shapes. --- graph/bundle/SCHEMA.md | 3 +- graph/bundle/schema.ts | 3 +- .../engine/call-edge-generation/call_chain.dl | 4 + .../engine/framework-behavior/destinations.dl | 26 ++- .../engine/framework-behavior/dispatch.dl | 157 ++++++++++++++++++ .../csharp/engine/framework-behavior/knobs.dl | 36 +++- graph/csharp/souffle/decls_all.dl | 31 ++++ graph/test/csharp/README.md | 3 +- .../dispatch-only/LegacySender.cs | 16 ++ .../dispatch-only/Pipeline.cs | 23 +++ .../cases/18-mediator-send/expected.dispatch | 94 +++++++++++ .../src/18-mediator-send.csproj | 6 + .../cases/18-mediator-send/src/BlueSuite.cs | 22 +++ .../cases/18-mediator-send/src/Generic.cs | 39 +++++ .../cases/18-mediator-send/src/GreenSuite.cs | 23 +++ .../cases/18-mediator-send/src/Legacy.cs | 7 + .../cases/18-mediator-send/src/Mediator.cs | 52 ++++++ .../18-mediator-send/src/Notifications.cs | 66 ++++++++ .../cases/18-mediator-send/src/Requests.cs | 64 +++++++ .../cases/18-mediator-send/src/Senders.cs | 81 +++++++++ graph/test/csharp/engine-invariants.py | 1 + .../csharp/tools/mediator-dispatch-test.sh | 59 +++++++ .../skills/axiomcode/scripts/ax_edges.py | 2 +- 23 files changed, 804 insertions(+), 14 deletions(-) create mode 100644 graph/csharp/engine/framework-behavior/dispatch.dl create mode 100644 graph/test/csharp/cases/18-mediator-send/dispatch-only/LegacySender.cs create mode 100644 graph/test/csharp/cases/18-mediator-send/dispatch-only/Pipeline.cs create mode 100644 graph/test/csharp/cases/18-mediator-send/expected.dispatch create mode 100644 graph/test/csharp/cases/18-mediator-send/src/18-mediator-send.csproj create mode 100644 graph/test/csharp/cases/18-mediator-send/src/BlueSuite.cs create mode 100644 graph/test/csharp/cases/18-mediator-send/src/Generic.cs create mode 100644 graph/test/csharp/cases/18-mediator-send/src/GreenSuite.cs create mode 100644 graph/test/csharp/cases/18-mediator-send/src/Legacy.cs create mode 100644 graph/test/csharp/cases/18-mediator-send/src/Mediator.cs create mode 100644 graph/test/csharp/cases/18-mediator-send/src/Notifications.cs create mode 100644 graph/test/csharp/cases/18-mediator-send/src/Requests.cs create mode 100644 graph/test/csharp/cases/18-mediator-send/src/Senders.cs create mode 100755 graph/test/csharp/tools/mediator-dispatch-test.sh diff --git a/graph/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md index 7c96bcb6a..933238d9c 100644 --- a/graph/bundle/SCHEMA.md +++ b/graph/bundle/SCHEMA.md @@ -538,6 +538,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h | `boundary_generated` | csharp | A read of a property the compiler generated (a positional record's property). There is no written accessor to point at, so callee_method_id is NULL, callee_label names the member and callee_provenance is `generated`. A correct end, not a blind spot. | | `runtime_observed` | csharp | Only when a runtime trace is supplied: an edge the trace saw and the static pass did not name. Real by construction, and added only, never used to remove an edge. The site is the CALLER METHOD, not an expression (the tracer sees method entry, not the call site), and kind is `runtime`. | | `known_implicit_ctor` | csharp | `new Foo()` where Foo declares no constructor. The compiler supplies a parameterless one, so there is no user code to call and nothing resolving is the right answer. | +| `event_dispatch` | csharp | A mediator's `Send(request)` reaching the `Handle` of the handler registered for the request's type (`IRequestHandler`, with a generic request's type arguments), or `Publish(notification)` reaching every `INotificationHandler` for its type and each of its bases. Not the site's own callee: the site also keeps its row for the library method. The container picks the handler in process, before the call returns, so this is a call edge and not a remote_edge. A request declared as a base (not an interface) fans to the handler of each type in its family. | | `known_edge` | all | Exactly one target resolved. The strongest claim. | | `multi_inferred` | all | A sound SET of possible targets; each member is one row. The set over-approximates — every member is a real possibility, but not every member runs. HOW WIDE the set is differs by language: see the per-language notes on this table for whether the fan is narrowed by the instantiation set. | | `boundary_lib` | all | The target is outside the client (library, builtin, or unstaged external). The chain is not expanded past it here. | @@ -569,7 +570,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h - **python** — 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. - **java** — 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. - **python** — The reason a site is ambiguous_unknown is exported per site in ext_call_site_unresolved (site, caller, reason, detail). -- **all** — THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list. +- **all** — THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript; event_dispatch in C# too, where a mediator Send or Publish runs the handler for the request type, beside the row for the site itself. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list. - **java** — A multi_inferred fan is CHA-wide: it is every override the hierarchy admits, bounded only by the dispatch cap. type_instantiated is computed and exported but NOT read by any rule, so the fan is not narrowed to types the program constructs. Narrow it yourself by joining dispatch_candidates to type_instantiated — see the dispatch_envelope_of query. A receiver is ALSO typed by what flows into it (a local's initializer, the arguments callers pass to a parameter, the receivers callers invoke a method on for its `this`), and each flow-in type resolves its member directly, outside the fan: that is why a `fan_capped` site still carries edges, and why they are the types the program was seen to hand over, not the whole hierarchy. - **typescript** — A multi_inferred fan is CHA-wide, as in Java: type_instantiated is computed and exported but NOT read by any rule. The fan also has sources that are not virtual dispatch at all — an overload set or a union-typed receiver produces one too. - **python** — A multi_inferred fan IS narrowed by the instantiation set: type_instantiated_reachable (the constructed classes and their bases) bounds dispatch in resolution/dispatch.dl. Python is the only front end where that narrowing is applied, so a fan here is tighter than the same shape would be in Java or TypeScript. diff --git a/graph/bundle/schema.ts b/graph/bundle/schema.ts index 041ccc50f..ba1bcf6a0 100644 --- a/graph/bundle/schema.ts +++ b/graph/bundle/schema.ts @@ -374,6 +374,7 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'call_edges', column: 'tier', value: 'boundary_generated', languages: C, meaning: 'A read of a property the compiler generated (a positional record\'s property). There is no written accessor to point at, so callee_method_id is NULL, callee_label names the member and callee_provenance is `generated`. A correct end, not a blind spot.' }, { table: 'call_edges', column: 'tier', value: 'runtime_observed', languages: C, meaning: 'Only when a runtime trace is supplied: an edge the trace saw and the static pass did not name. Real by construction, and added only, never used to remove an edge. The site is the CALLER METHOD, not an expression (the tracer sees method entry, not the call site), and kind is `runtime`.' }, { table: 'call_edges', column: 'tier', value: 'known_implicit_ctor', languages: C, meaning: '`new Foo()` where Foo declares no constructor. The compiler supplies a parameterless one, so there is no user code to call and nothing resolving is the right answer.' }, + { table: 'call_edges', column: 'tier', value: 'event_dispatch', languages: C, meaning: 'A mediator\'s `Send(request)` reaching the `Handle` of the handler registered for the request\'s type (`IRequestHandler`, with a generic request\'s type arguments), or `Publish(notification)` reaching every `INotificationHandler` for its type and each of its bases. Not the site\'s own callee: the site also keeps its row for the library method. The container picks the handler in process, before the call returns, so this is a call edge and not a remote_edge. A request declared as a base (not an interface) fans to the handler of each type in its family.' }, { table: 'call_edges', column: 'callee_provenance', value: 'external', languages: 'all', meaning: 'The target is outside every staged IR. callee_method_id is NULL and callee_label names it, as `external:.`. A client-only run of a C# project reaches the BCL this way for most of its calls.' }, // run.key @@ -747,7 +748,7 @@ export const NOTES: readonly NoteSpec[] = [ { language: 'python', table: 'entry_points', note: 'EMPTY. The Python rule set does not derive entry points; entry_reachable is therefore empty too.' }, { language: 'python', table: 'overrides', note: 'EMPTY — this table is Java-shaped. The Python dispatch envelope is in dispatch_candidates with basis `mro`, and `value` for a function assigned onto an instance\'s member; the raw linearisation is in ext_mro_position.' }, { language: 'python', table: 'type_instantiated', note: 'Every row has how = `new`: the rule set records that some client call constructs the class, not which form.' }, - { language: 'all', table: 'call_edges', note: 'THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list.' }, + { language: 'all', table: 'call_edges', note: 'THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript; event_dispatch in C# too, where a mediator Send or Publish runs the handler for the request type, beside the row for the site itself. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list.' }, { language: 'java', table: 'call_edges', note: 'A multi_inferred fan is CHA-wide: it is every override the hierarchy admits, bounded only by the dispatch cap. type_instantiated is computed and exported but NOT read by any rule, so the fan is not narrowed to types the program constructs. Narrow it yourself by joining dispatch_candidates to type_instantiated — see the dispatch_envelope_of query. A receiver is ALSO typed by what flows into it (a local\'s initializer, the arguments callers pass to a parameter, the receivers callers invoke a method on for its `this`), and each flow-in type resolves its member directly, outside the fan: that is why a `fan_capped` site still carries edges, and why they are the types the program was seen to hand over, not the whole hierarchy.' }, { language: 'typescript', table: 'call_edges', note: 'A multi_inferred fan is CHA-wide, as in Java: type_instantiated is computed and exported but NOT read by any rule. The fan also has sources that are not virtual dispatch at all — an overload set or a union-typed receiver produces one too.' }, { language: 'python', table: 'call_edges', note: 'A multi_inferred fan IS narrowed by the instantiation set: type_instantiated_reachable (the constructed classes and their bases) bounds dispatch in resolution/dispatch.dl. Python is the only front end where that narrowing is applied, so a fan here is tighter than the same shape would be in Java or TypeScript.' }, diff --git a/graph/csharp/engine/call-edge-generation/call_chain.dl b/graph/csharp/engine/call-edge-generation/call_chain.dl index fce505f12..fdd5d50a5 100644 --- a/graph/csharp/engine/call-edge-generation/call_chain.dl +++ b/graph/csharp/engine/call-edge-generation/call_chain.dl @@ -34,6 +34,10 @@ // ambiguous_dynamic a call through `dynamic`. Unresolvable BY DESIGN: the DLR // decides at runtime. Separated from ambiguous_unknown so a // known-undecidable site is never counted as an engine failure. +// event_dispatch NOT the site's own target: a framework runs this method for the +// site in process (a mediator's Send reaching the handler for the +// request type). Added beside the site's row, by +// framework-behavior/dispatch.dl. // ============================================================================ // ── THE SITE UNIVERSE ─────────────────────────────────────────────────────── diff --git a/graph/csharp/engine/framework-behavior/destinations.dl b/graph/csharp/engine/framework-behavior/destinations.dl index b9170858a..4c7a0e875 100644 --- a/graph/csharp/engine/framework-behavior/destinations.dl +++ b/graph/csharp/engine/framework-behavior/destinations.dl @@ -45,6 +45,12 @@ call_recv_written_name(e, n) :- ref_denotes("client", r, "LOCAL_VARIABLE", v), var_is_implicit("client", v), var_value_initializer("client", v, init), expr_new_type_ref("client", init, tr), type_ref_name("client", tr, n). +// …and a property read off another value, `services.Mediator.Send(..)`: a minimal-API +// endpoint takes its services as one parameter object, and the receiver is that +// object's property, whose declared type name is what the project wrote. +call_recv_written_name(e, n) :- + call_receiver_expr("client", e, r), property_access("client", r, p, _), + property_type_name("client", p, n, _), n != "". // …and a `var` from the service locator, which holds its type argument call_recv_written_name(e, n) :- call_receiver_expr("client", e, r), @@ -706,24 +712,26 @@ msg_bus_arg(e, a) :- msg_bus_send_site(e, _, _), http_arg(e, 0, a), !msg_first_i msg_bus_arg(e, a) :- msg_bus_send_site(e, _, _), msg_first_is_address(e), http_arg(e, 1, a). msg_first_is_address(e) :- msg_bus_send_site(e, _, _), http_arg(e, 0, a0), http_arg(e, 1, _), expr_literal("client", a0, k, _), k != "NULL". -// the message's type NAME, however it is written -msg_arg_type_name(a, n) :- msg_bus_arg(_, a), expr_new_type_ref("client", a, tr), type_ref_name("client", tr, n0), last_segment(n0, n). -msg_arg_type_name(a, n) :- msg_bus_arg(_, a), expr_kind("client", a, "OBJECT_CREATION"), +// the message's type NAME, however it is written. Read for a bus send here and for an +// in-process mediator send (dispatch.dl), which carries its request the same way. +msg_typed_arg(a) :- msg_bus_arg(_, a). +msg_arg_type_name(a, n) :- msg_typed_arg(a), expr_new_type_ref("client", a, tr), type_ref_name("client", tr, n0), last_segment(n0, n). +msg_arg_type_name(a, n) :- msg_typed_arg(a), expr_kind("client", a, "OBJECT_CREATION"), call_callee_name("client", a, n0), last_segment(n0, n). -msg_arg_type_name(a, n) :- msg_bus_arg(_, a), expr_type("client", a, gk), type_group("client", t, gk), +msg_arg_type_name(a, n) :- msg_typed_arg(a), expr_type("client", a, gk), type_group("client", t, gk), type_decl("client", n, _, _, _, t). -msg_arg_type_name(a, n) :- msg_bus_arg(_, a), ref_denotes("client", a, "LOCAL_VARIABLE", v), +msg_arg_type_name(a, n) :- msg_typed_arg(a), ref_denotes("client", a, "LOCAL_VARIABLE", v), var_type_name("client", v, n0, _), n0 != "", last_segment(n0, n). -msg_arg_type_name(a, n) :- msg_bus_arg(_, a), ref_denotes("client", a, "PARAMETER", p), +msg_arg_type_name(a, n) :- msg_typed_arg(a), ref_denotes("client", a, "PARAMETER", p), param_type_name("client", p, n0, _), n0 != "", last_segment(n0, n). // A local declared as a BASE is usually assigned a concrete message on each branch, // Notice evt; if (ok) evt = new Accepted(..); else evt = new Rejected(..); // and those constructions, in the same method, are what it can hold. -msg_arg_type_name(a, n) :- msg_bus_arg(_, a), ref_denotes("client", a, "LOCAL_VARIABLE", v), +msg_arg_type_name(a, n) :- msg_typed_arg(a), ref_denotes("client", a, "LOCAL_VARIABLE", v), msg_local_assigned(v, c), msg_creation_name(c, n). -msg_arg_type_name(a, n) :- msg_bus_arg(_, a), ref_denotes("client", a, "LOCAL_VARIABLE", v), +msg_arg_type_name(a, n) :- msg_typed_arg(a), ref_denotes("client", a, "LOCAL_VARIABLE", v), var_value_initializer("client", v, c), msg_creation_name(c, n). -msg_local_assigned(v, c) :- msg_bus_arg(_, a), ref_denotes("client", a, "LOCAL_VARIABLE", v), +msg_local_assigned(v, c) :- msg_typed_arg(a), ref_denotes("client", a, "LOCAL_VARIABLE", v), expr_kind("client", asg, "ASSIGNMENT"), expr_child("client", asg, "ASSIGNMENT_TARGET", _, t), ref_denotes("client", t, "LOCAL_VARIABLE", v), expr_child("client", asg, "ASSIGNMENT_VALUE", _, c). msg_creation_name(c, n) :- expr_kind("client", c, "OBJECT_CREATION"), expr_new_type_ref("client", c, tr), diff --git a/graph/csharp/engine/framework-behavior/dispatch.dl b/graph/csharp/engine/framework-behavior/dispatch.dl new file mode 100644 index 000000000..8b8e07a1e --- /dev/null +++ b/graph/csharp/engine/framework-behavior/dispatch.dl @@ -0,0 +1,157 @@ +// ============================================================================ +// FRAMEWORK-BEHAVIOR · DISPATCH (the in-process hop no call site expresses) +// +// A mediator hands a request to its handler inside the same process: +// +// await mediator.Send(new GetOrder(id)); // the sender +// class GetOrderHandler : IRequestHandler +// { public Task Handle(GetOrder r, CancellationToken ct) ... } +// +// The container picks the handler by the request's TYPE, so no call site names it and +// every rule in call-edge-generation is silent: `path` called the two independent and +// `impact` on the handler said the change was local. +// +// WHY A CALL EDGE HERE, AND NOT remote_edge. destinations.dl joins the same shape for a +// message bus, where the two ends are in different processes, and emits remote_edge. +// This hop is on the sender's call stack: Handle runs before Send returns. So it is a +// call_chain_edge, labelled `event_dispatch` (the tier the JavaScript engine gives an +// emit reaching its handler), which consumers already rank below a resolved call and +// print as `registered` rather than `resolved`. It is ADDED beside the site's own row +// (the call to the library's Send); nothing the resolution phases derived changes. +// +// Every framework name is a knob (knobs.dl: cs_med_dispatch, over the cs_framework_callback +// table that makes a handler an entry point). The request's type name is read by the +// rules that already read a bus message's (destinations.dl, msg_arg_type_name). +// ============================================================================ + +// A handler: a type that implements or for a +// Base in cs_med_dispatch, and its method that cs_framework_callback names for that Base +// (the table that already makes it an entry point), whose first parameter is written as +// that request type. The parameter check keeps a class that handles two requests from +// answering both with each Handle. +med_handler(m, bn, tn, ar) :- heritage_slot("client", t, _, bn0, ba, href), (ba = "1" ; ba = "2"), + last_segment(bn0, bn), cs_med_dispatch(_, bn, _), cs_framework_callback(bn, mn, _), + type_ref_arg("client", href, "0", ar), type_ref_name("client", ar, tn0), last_segment(tn0, tn), + method_owner("client", t, m), method_name("client", m, mn), + param_decl("client", m, "0", _, p), param_type_name("client", p, pn0, _), last_segment(pn0, tn). + +// A send: a dispatching method on a mediator receiver, with the request as its first +// argument. The method decides which handler base answers it (Send: IRequestHandler, +// Publish: INotificationHandler), so a notification Sent reaches no notification handler. +med_send_site(e, from, bn, fan) :- msg_recv_name(e, rn), cs_med_sender_type(rn), + call_callee_name("client", e, sn), cs_med_dispatch(sn, bn, fan), call_from("client", e, from). +med_send_arg(e, a) :- med_send_site(e, _, _, _), http_arg(e, 0, a). +msg_typed_arg(a) :- med_send_arg(_, a). + +// A name is a BASE when some handled request type derives from it. A request declared +// as the base (a parameter, a local) is any of them at run time: it is not matched by +// name here, and med_declared_handler below fans it over the family instead. +med_base_name(n) :- med_handler(_, _, _, ar), type_ref_resolves("client", ar, hgk), type_ancestor("client", hgk, bgk), + type_group("client", t, bgk), type_decl("client", n, _, _, _, t). +med_send(e, from, n) :- med_send_site(e, from, _, _), med_send_arg(e, a), msg_arg_type_name(a, n), !med_base_name(n). +// ...but a request CONSTRUCTED at the send has exactly the type written after `new`. +med_send(e, from, n) :- med_send_site(e, from, _, _), med_send_arg(e, a), expr_kind("client", a, "OBJECT_CREATION"), + msg_creation_name(a, n). + +// THE NAME IS THE KEY, AND THE TYPE DECIDES WHEN BOTH ENDS HAVE ONE. A simple name is +// not unique: two test classes each declaring a nested `Ping` and `PingHandler` is the +// ordinary shape of a mediator's own tests, and matching by name linked every Send(new +// Ping()) to every PingHandler. So where the handler's type argument resolves and the +// request resolves to a type OF THAT NAME, the two types must be the same one. The name +// alone decides only where one end has no resolved type: the request comes from a +// package, or the send's value is typed by nothing the engine can see. +med_send_type(e, gk) :- med_send_arg(e, a), expr_type("client", a, gk). +med_send_type(e, gk) :- med_send_arg(e, a), ref_denotes("client", a, "LOCAL_VARIABLE", v), + msg_local_assigned(v, c), expr_type("client", c, gk). +med_send_type(e, gk) :- med_send_arg(e, a), ref_denotes("client", a, "LOCAL_VARIABLE", v), + var_value_initializer("client", v, c), expr_type("client", c, gk). +med_send_named_type(e, n, gk) :- med_send_type(e, gk), type_group("client", t, gk), type_decl("client", n, _, _, _, t). +med_send_has_named_type(e, n) :- med_send_named_type(e, n, _). +med_handler_type(ar, gk) :- med_handler(_, _, _, ar), type_ref_resolves("client", ar, gk). +med_handler_typed(ar) :- med_handler_type(ar, _). + +med_match(e, m, ar) :- med_send(e, _, n), med_send_site(e, _, bn, _), med_handler(m, bn, n, ar), + med_handler_type(ar, gk), med_send_named_type(e, n, gk). +med_match(e, m, ar) :- med_send(e, _, n), med_send_site(e, _, bn, _), med_handler(m, bn, n, ar), + !med_handler_typed(ar), med_written_agrees(e, n, ar). +med_match(e, m, ar) :- med_send(e, _, n), med_send_site(e, _, bn, _), med_handler(m, bn, n, ar), + !med_send_has_named_type(e, n), med_typed_agrees(e, n, ar). + +// A handler's argument that does not resolve may still be written QUALIFIED, +// `IRequestHandler`: the simple name is then not the evidence, the +// written tail is, and a request resolved to Shop.GetOrder is not Shop.Legacy.GetOrder. +med_written_agrees(e, n, ar) :- med_send(e, _, n), med_handler(_, _, n, ar), type_ref_name("client", ar, w), !contains(".", w). +med_written_agrees(e, n, ar) :- med_send(e, _, n), med_handler(_, _, n, ar), !med_send_has_named_type(e, n). +med_written_agrees(e, n, ar) :- med_handler(_, _, n, ar), type_ref_name("client", ar, w), contains(".", w), + med_send_named_type(e, n, gk), type_group("client", t, gk), type_decl("client", _, qn, _, _, t), + lq = strlen(qn), lw = strlen(w), lq > lw, substr(qn, lq - lw - 1, lw + 1) = cat(".", w). +// …and the same at the send: `Send(new Legacy.GetOrder(id))` names Shop.Legacy.GetOrder +// even where the engine does not resolve it, and a handler of Shop.GetOrder is not it. +med_send_written(e, w) :- med_send_arg(e, a), expr_new_type_ref("client", a, tr), type_ref_name("client", tr, w). +med_send_written(e, w) :- med_send_arg(e, a), ref_denotes("client", a, "LOCAL_VARIABLE", v), var_type_name("client", v, w, _), w != "". +med_send_written(e, w) :- med_send_arg(e, a), ref_denotes("client", a, "PARAMETER", p), param_type_name("client", p, w, _), w != "". +med_send_written(e, w) :- med_send_arg(e, a), ref_denotes("client", a, "LOCAL_VARIABLE", v), + msg_local_assigned(v, c), expr_new_type_ref("client", c, tr), type_ref_name("client", tr, w). +med_send_written(e, w) :- med_send_arg(e, a), ref_denotes("client", a, "LOCAL_VARIABLE", v), + var_value_initializer("client", v, c), expr_new_type_ref("client", c, tr), type_ref_name("client", tr, w). +med_send_qualified(e, n) :- med_send_written(e, w), contains(".", w), last_segment(w, n). +med_typed_agrees(e, n, ar) :- med_send(e, _, n), med_handler(_, _, n, ar), !med_send_qualified(e, n). +med_typed_agrees(e, n, ar) :- med_send_written(e, w), contains(".", w), last_segment(w, n), med_handler(_, _, n, ar), + med_handler_type(ar, gk), type_group("client", t, gk), type_decl("client", _, qn, _, _, t), + lq = strlen(qn), lw = strlen(w), lq > lw, substr(qn, lq - lw - 1, lw + 1) = cat(".", w). + +// A GENERIC request is one type to the resolver, `GetById`, and the handler is +// chosen with its arguments: `IRequestHandler, Order>` does not answer +// `Send(new GetById(id))`. Where both ends write an argument at the same position +// and the handler's is not its own type parameter (an open generic handler, +// `ListAllHandler : IRequestHandler, List>`, serves any), the names must agree. +med_send_new_ref(e, tr) :- med_send_arg(e, a), expr_new_type_ref("client", a, tr). +med_send_new_ref(e, tr) :- med_send_arg(e, a), ref_denotes("client", a, "LOCAL_VARIABLE", v), + msg_local_assigned(v, c), expr_new_type_ref("client", c, tr). +med_send_new_ref(e, tr) :- med_send_arg(e, a), ref_denotes("client", a, "LOCAL_VARIABLE", v), + var_value_initializer("client", v, c), expr_new_type_ref("client", c, tr). +med_arg_clash(e, ar) :- med_match(e, _, ar), med_send_new_ref(e, tr), + type_ref_arg("client", ar, i, ha), type_ref_arg("client", tr, i, sa), !type_ref_type_param("client", ha, _), + type_ref_name("client", ha, hn0), last_segment(hn0, hn), type_ref_name("client", sa, sn0), last_segment(sn0, sn), + hn != sn. +med_exact(e, m) :- med_match(e, m, ar), !med_arg_clash(e, ar). + +// Publish runs every handler whose type the notification CONVERTS to: INotificationHandler +// is contravariant, and a handler of OrderEvent runs for an OrderPlaced. Only where the +// notification's type is resolved, since a base is a resolved ancestor. +med_send_concrete(e, gk) :- med_send(e, _, n), med_send_named_type(e, n, gk). +med_base_handler(e, m) :- med_send_site(e, _, bn, "all"), med_send_concrete(e, gk), type_ancestor("client", gk, agk), + med_handler(m, bn, _, ar), med_handler_type(ar, agk). + +// A request DECLARED as a base, `Task Any(ShapeQuery q) => sender.Send(q)`, holds +// the base or any type derived from it, and the container picks the handler for that +// runtime type: the fan is the handler of each type in the family (and, for a Publish, +// of each base as well). Only where no construction is visible to name the value, and +// never for an interface, which would be every request of the project. +med_send_sees_new(e) :- med_send_arg(e, a), expr_kind("client", a, "OBJECT_CREATION"). +med_send_sees_new(e) :- med_send_new_ref(e, _). +med_send_declared(e, bgk) :- med_send_site(e, _, _, _), !med_send_sees_new(e), med_send_arg(e, a), + msg_arg_type_name(a, n), med_base_name(n), expr_type("client", a, bgk), + type_group("client", t, bgk), type_decl("client", n, _, _, kd, t), kd != "INTERFACE". +med_declared_handler(e, m) :- med_send_declared(e, bgk), med_send_site(e, _, bn, _), + med_handler(m, bn, _, ar), med_handler_type(ar, bgk). +med_declared_handler(e, m) :- med_send_declared(e, bgk), med_send_site(e, _, bn, _), + med_handler(m, bn, _, ar), med_handler_type(ar, hgk), type_ancestor("client", hgk, bgk). +med_declared_handler(e, m) :- med_send_declared(e, bgk), med_send_site(e, _, bn, "all"), + med_handler(m, bn, _, ar), med_handler_type(ar, agk), type_ancestor("client", bgk, agk). + +// A pipeline step written open over the request, `TimingBehavior : +// IPipelineBehavior`, is registered for every request and runs on every Send. +// Only for a "wrap" base: an open request HANDLER is not how a request is answered. +med_open_step(m, bn) :- cs_med_dispatch(_, bn, "wrap"), med_handler(m, bn, _, ar), type_ref_type_param("client", ar, _). +med_open_dispatch(e, m) :- med_send_site(e, _, bn, "wrap"), med_open_step(m, bn). + +med_dispatch(e, m) :- med_exact(e, m). +med_dispatch(e, m) :- med_open_dispatch(e, m). +med_dispatch(e, m) :- med_base_handler(e, m). +med_dispatch(e, m) :- med_declared_handler(e, m). +// A send whose request is an argument MATCHER is a mock's stub or verification, not a +// dispatch (knobs.dl, cs_mock_arg_matcher). +med_send_matcher(e) :- med_send_arg(e, a), call_callee_name("client", a, n0), last_segment(n0, n), cs_mock_arg_matcher(n). +call_chain_edge(e, from, "-", m, "client", "event_dispatch", kind) :- med_send_site(e, from, _, _), med_dispatch(e, m), + !med_send_matcher(e), from != m, invocation_site("client", e, kind). diff --git a/graph/csharp/engine/framework-behavior/knobs.dl b/graph/csharp/engine/framework-behavior/knobs.dl index aa4b3e4a2..a44b0be60 100644 --- a/graph/csharp/engine/framework-behavior/knobs.dl +++ b/graph/csharp/engine/framework-behavior/knobs.dl @@ -146,7 +146,8 @@ cs_servicebus_handler_event("ProcessMessageAsync"). // A handler implements (interface, method) for its message type; a sender calls one // of these methods on one of these receivers with a message instance. MediatR's // INotificationHandler and IMediator are deliberately ABSENT: they dispatch in -// process, and a remote edge there would be a wrong answer. +// process, and a remote edge there would be a wrong answer: Send and Publish are joined in +// process by cs_med_dispatch below. cs_msg_handler_iface("IConsumer", "Consume", "masstransit"). cs_msg_handler_iface("IHandleMessages", "Handle", "nservicebus"). cs_msg_handler_iface("IIntegrationEventHandler", "Handle", "event_bus"). @@ -168,6 +169,39 @@ cs_msg_bus_send("SendAsync"). cs_remote_confidence_binding("amqp_binding"). +// ── IN PROCESS: a mediator keyed on the request TYPE (dispatch.dl) ───────── +// `mediator.Send(new GetOrder(id))` runs Handle on the one handler the container holds +// for GetOrder: `IRequestHandler`, or `IRequestHandler` when the +// request answers nothing. `Publish(new OrderPlaced(id))` runs EVERY +// `INotificationHandler` whose T is OrderPlaced or one of its bases. Same process, +// same call stack, so the hop is a call edge and never a remote_edge. +// cs_med_dispatch(SendMethod, HandlerBase, Fan): the handler's method is the one +// cs_framework_callback (below) already names for HandlerBase, so a handler is an entry +// point and a sent handler also has its sender. Fan "one": the handler of the exact +// type (the container registers a request handler for the type it closes, and a base's +// handler is not looked up for a derived request); "all": every handler of the type and +// of each of its bases (INotificationHandler is contravariant); "wrap": a pipeline +// step, run around the handler for its own request type, and around EVERY request when +// it is open generic over the request (`TimingBehavior : IPipelineBehavior`). +cs_med_dispatch("Send", "IRequestHandler", "one"). +cs_med_dispatch("Send", "IPipelineBehavior", "wrap"). +cs_med_dispatch("Send", "IRequestPreProcessor", "wrap"). +cs_med_dispatch("Send", "IRequestPostProcessor", "wrap"). +cs_med_dispatch("CreateStream", "IStreamRequestHandler", "one"). +cs_med_dispatch("CreateStream", "IStreamPipelineBehavior", "wrap"). +cs_med_dispatch("Publish", "INotificationHandler", "all"). +// A test that stubs or verifies the mediator writes the send with an argument MATCHER, +// `mediator.Send(Arg.Any())` or `m.Send(It.IsAny(), ..)` inside a +// mock setup: nothing is dispatched, so no handler is linked from it. +cs_mock_arg_matcher("Any"). cs_mock_arg_matcher("Is"). // NSubstitute Arg.* +cs_mock_arg_matcher("IsAny"). cs_mock_arg_matcher("IsNotNull"). // Moq It.* +cs_mock_arg_matcher("That"). // FakeItEasy A.That +// the receivers a request or notification is sent through +cs_med_sender_type("IMediator"). +cs_med_sender_type("ISender"). +cs_med_sender_type("IPublisher"). +cs_med_sender_type("Mediator"). + // Kafka clients are built, `new ProducerBuilder(config).Build()`, usually into a // `var`: the builder's name is what says which client the local holds. cs_msg_builder("ProducerBuilder", "kafka_producer"). diff --git a/graph/csharp/souffle/decls_all.dl b/graph/csharp/souffle/decls_all.dl index d8993f6ff..37161bfd0 100644 --- a/graph/csharp/souffle/decls_all.dl +++ b/graph/csharp/souffle/decls_all.dl @@ -168,6 +168,9 @@ .decl cs_http_verb_any(c0:symbol) .decl cs_http_verb_attr(c0:symbol,c1:symbol) .decl cs_lower(c0:symbol,c1:symbol) +.decl cs_med_dispatch(c0:symbol,c1:symbol,c2:symbol) +.decl cs_med_sender_type(c0:symbol) +.decl cs_mock_arg_matcher(c0:symbol) .decl cs_msg_builder(c0:symbol,c1:symbol) .decl cs_msg_bus_send(c0:symbol) .decl cs_msg_bus_type(c0:symbol,c1:symbol) @@ -417,6 +420,33 @@ .decl map_handler(c0:symbol,c1:symbol) .decl marker_scrub(c0:symbol,c1:symbol) .decl mb_demand(c0:symbol) +.decl med_arg_clash(c0:symbol,c1:symbol) +.decl med_base_handler(c0:symbol,c1:symbol) +.decl med_base_name(c0:symbol) +.decl med_declared_handler(c0:symbol,c1:symbol) +.decl med_dispatch(c0:symbol,c1:symbol) +.decl med_exact(c0:symbol,c1:symbol) +.decl med_handler(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl med_handler_type(c0:symbol,c1:symbol) +.decl med_handler_typed(c0:symbol) +.decl med_match(c0:symbol,c1:symbol,c2:symbol) +.decl med_open_dispatch(c0:symbol,c1:symbol) +.decl med_open_step(c0:symbol,c1:symbol) +.decl med_send(c0:symbol,c1:symbol,c2:symbol) +.decl med_send_arg(c0:symbol,c1:symbol) +.decl med_send_concrete(c0:symbol,c1:symbol) +.decl med_send_declared(c0:symbol,c1:symbol) +.decl med_send_has_named_type(c0:symbol,c1:symbol) +.decl med_send_named_type(c0:symbol,c1:symbol,c2:symbol) +.decl med_send_matcher(c0:symbol) +.decl med_send_new_ref(c0:symbol,c1:symbol) +.decl med_send_qualified(c0:symbol,c1:symbol) +.decl med_send_sees_new(c0:symbol) +.decl med_send_site(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl med_send_type(c0:symbol,c1:symbol) +.decl med_send_written(c0:symbol,c1:symbol) +.decl med_typed_agrees(c0:symbol,c1:symbol,c2:symbol) +.decl med_written_agrees(c0:symbol,c1:symbol,c2:symbol) .decl member_access(c0:symbol,c1:symbol,c2:symbol) .decl member_access_is_callee(c0:symbol,c1:symbol) .decl member_access_resolved(c0:symbol,c1:symbol) @@ -532,6 +562,7 @@ .decl msg_recv_name(c0:symbol,c1:symbol) .decl msg_request_site(c0:symbol,c1:symbol,c2:symbol) .decl msg_type_arg(c0:symbol,c1:symbol) +.decl msg_typed_arg(c0:symbol) .decl name_to_type(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl nested_declares(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) .decl new_target_decl_name(c0:symbol,c1:symbol,c2:symbol,c3:symbol) diff --git a/graph/test/csharp/README.md b/graph/test/csharp/README.md index 760aaa0f3..78c6e880d 100644 --- a/graph/test/csharp/README.md +++ b/graph/test/csharp/README.md @@ -5,7 +5,7 @@ than the numbers. | | what it is | what it proves | cost | |---|---|---|---| -| `run-tests.sh` | 16 cases, scored against Roslyn | a construct resolves, and its control does not over-resolve | seconds | +| `run-tests.sh` | 18 cases, scored against Roslyn | a construct resolves, and its control does not over-resolve | seconds | | `corpus/run-corpus.sh` | 10 real projects, dev and holdout | the rules generalise beyond what they were written against | ~30 min | | `runtime-oracle/` | the subject's own test suite, traced | an edge was actually taken, and which taken edge was missed | ~5 min per subject | @@ -34,6 +34,7 @@ run-tests.sh [--only NN-slug] [--verbose N] | `15-external-generic-return` | in-source methods whose declared return type is an unstaged generic (`List`, `Dictionary`): a `var` holding the result and the call used directly as a receiver, for an invocation, a property read and an indexer, are each labelled `external:List.*` by the ROOT type name, never `external:List.*` (#1279). Controls: the element (`items[0].Clear()`, a `foreach`, an `await`) still resolves to `Item.Clear`, and an in-source extension on the returned type stays `known_edge` | | `16-target-typed-new-external` | a target-typed `new(...)` whose target type is unstaged, in each context `target-typed-new.dl` reads (a field, a property, an explicit local, a return statement, an expression body, an assignment) and with a keyword-alias target (`string s = new('a', 3)`): each is `external:.`, as the explicit `new List()` already is (#1290). Controls: an in-source target type still resolves to its constructor | | `17-lambda-parameters` | a lambda parameter with no written type, typed by the delegate the lambda is converted to: a generic delegate declared in source closed at the field (`ParseFn`), a non-generic one, `Func` and `Action`, through a field, property and local initializer, `=`, `??=` and the branches of `?:`; and a value typed by a constrained type parameter, as a parameter and as the argument closing a delegate (`ParseFn` where `T : ISchema`) (#1283). Controls: two type arguments of different types on a declared delegate and on `Func`, whose parameters call a member of the same name on each, so crossed positions fan; an explicitly typed lambda and method. The calls THROUGH a delegate member (`inst.I.Run = inst.I.Parse`) are in `tools/assigned-delegate-member-test.sh` | +| `18-mediator-send` | a mediator's `Send(request)` reaching the handler for the request's type, and `Publish(notification)` reaching every handler for its type and its bases, which no call site names (#1383): the request constructed at the call, through a `var`, through a local declared `object`, a request with no response (`IRequestHandler`), one class handling two requests, a derived request with a handled base, a generic request closed over a type (`GetById`) and one served by an open generic handler, a request or notification declared as its base (the handler of each type in the family), and two classes in two files each declaring its own nested `Ping` and `PingHandler`, where the resolved type and not the simple name decides. The Roslyn score cannot see the hop (it is not the site's own target), so `tools/mediator-dispatch-test.sh` asserts the `event_dispatch` edges exactly, with the stand-in contracts in the source and again with them removed, the package shape. Controls: a handler nothing sends, a sibling of a sent request, the same generic request closed over another type, a notification handler of a sibling and of an unrelated notification, a notification Sent rather than Published, a notification declared as the marker interface, a handler of another `GetOrder` written partly qualified (`Legacy.GetOrder`, which the engine does not resolve) and reached only by the send that writes it that way (that send is in `dispatch-only/`, outside the Roslyn score, because the engine leaves its construction unresolved), and the same request handed to a receiver that is not a mediator | ### The acceptance bar's own self-test diff --git a/graph/test/csharp/cases/18-mediator-send/dispatch-only/LegacySender.cs b/graph/test/csharp/cases/18-mediator-send/dispatch-only/LegacySender.cs new file mode 100644 index 000000000..462b28f2c --- /dev/null +++ b/graph/test/csharp/cases/18-mediator-send/dispatch-only/LegacySender.cs @@ -0,0 +1,16 @@ +using System.Threading.Tasks; +using MediatR; + +namespace Shop +{ + // Read by tools/mediator-dispatch-test.sh only, not by the Roslyn score: the engine + // does not resolve the partly qualified `new Legacy.GetOrder(id)`, and the score + // would count that construction as a site resolved to nothing. The send still names + // Shop.Legacy.GetOrder, so it reaches LegacyGetOrderHandler and not GetOrderHandler. + public class LegacyOrders + { + private readonly IMediator _mediator; + public LegacyOrders(IMediator mediator) { _mediator = mediator; } + public Task Get(int id) => _mediator.Send(new Legacy.GetOrder(id)); + } +} diff --git a/graph/test/csharp/cases/18-mediator-send/dispatch-only/Pipeline.cs b/graph/test/csharp/cases/18-mediator-send/dispatch-only/Pipeline.cs new file mode 100644 index 000000000..a71ca2d99 --- /dev/null +++ b/graph/test/csharp/cases/18-mediator-send/dispatch-only/Pipeline.cs @@ -0,0 +1,23 @@ +using System.Threading; +using System.Threading.Tasks; +using MediatR; + +namespace Shop +{ + // Read by tools/mediator-dispatch-test.sh only, not by the Roslyn score: the engine + // leaves `next()`, an invocation of a generic delegate parameter, unresolved, and the + // score would count it against the case. That gap is not the mediator's. + + // An OPEN pipeline behavior is registered for every request: every Send runs it. + public class TimingBehavior : IPipelineBehavior where TReq : notnull + { + public Task Handle(TReq request, RequestHandlerDelegate next, CancellationToken ct) => next(); + } + + // A CLOSED behavior runs only around its own request type: the sends of GetOrder, and + // (CONTROL) no other Send and no Publish. + public class AuditGetOrder : IPipelineBehavior + { + public Task Handle(GetOrder request, RequestHandlerDelegate next, CancellationToken ct) => next(); + } +} diff --git a/graph/test/csharp/cases/18-mediator-send/expected.dispatch b/graph/test/csharp/cases/18-mediator-send/expected.dispatch new file mode 100644 index 000000000..9929cb3e8 --- /dev/null +++ b/graph/test/csharp/cases/18-mediator-send/expected.dispatch @@ -0,0 +1,94 @@ +in-source Shop.BlueSuite.Run() Shop.BlueSuite.PingHandler.Handle(Ping,CancellationToken) +in-source Shop.BlueSuite.Run() Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.Catalog.Order(int) Shop.GetOrderById.Handle(GetById,CancellationToken) +in-source Shop.Catalog.Order(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.Catalog.Widgets(int) Shop.ListAllHandler.Handle(ListAll,CancellationToken) +in-source Shop.Catalog.Widgets(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.GreenSuite.Run() Shop.GreenSuite.PingHandler.Handle(Ping,CancellationToken) +in-source Shop.GreenSuite.Run() Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.LegacyOrders.Get(int) Shop.LegacyGetOrderHandler.Handle(Legacy.GetOrder,CancellationToken) +in-source Shop.LegacyOrders.Get(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrderCommands.Any(ShapeQuery) Shop.CircleQueryHandler.Handle(CircleQuery,CancellationToken) +in-source Shop.OrderCommands.Any(ShapeQuery) Shop.ShapeQueryHandler.Handle(ShapeQuery,CancellationToken) +in-source Shop.OrderCommands.Any(ShapeQuery) Shop.SquareQueryHandler.Handle(SquareQuery,CancellationToken) +in-source Shop.OrderCommands.Any(ShapeQuery) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrderCommands.Circle(double) Shop.CircleQueryHandler.Handle(CircleQuery,CancellationToken) +in-source Shop.OrderCommands.Circle(double) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrderCommands.Close(int) Shop.CloseOrderHandler.Handle(CloseOrder,CancellationToken) +in-source Shop.OrderCommands.Close(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrderCommands.Count(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrderCommands.Count(int) Shop.WidgetHandlers.Handle(CountWidgets,CancellationToken) +in-source Shop.OrderCommands.Shape(double) Shop.ShapeQueryHandler.Handle(ShapeQuery,CancellationToken) +in-source Shop.OrderCommands.Shape(double) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrderCommands.Weigh(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrderCommands.Weigh(int) Shop.WidgetHandlers.Handle(WeighWidgets,CancellationToken) +in-source Shop.OrderEvents.Announce(OrderEvent) Shop.AuditOnOrderPlaced.Handle(OrderPlaced,CancellationToken) +in-source Shop.OrderEvents.Announce(OrderEvent) Shop.EmailOnOrderPlaced.Handle(OrderPlaced,CancellationToken) +in-source Shop.OrderEvents.Announce(OrderEvent) Shop.LogOrderEvent.Handle(OrderEvent,CancellationToken) +in-source Shop.OrderEvents.Announce(OrderEvent) Shop.ShipOnOrderShipped.Handle(OrderShipped,CancellationToken) +in-source Shop.OrderEvents.Misrouted(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrderEvents.Place(int) Shop.AuditOnOrderPlaced.Handle(OrderPlaced,CancellationToken) +in-source Shop.OrderEvents.Place(int) Shop.EmailOnOrderPlaced.Handle(OrderPlaced,CancellationToken) +in-source Shop.OrderEvents.Place(int) Shop.LogOrderEvent.Handle(OrderEvent,CancellationToken) +in-source Shop.OrderEvents.PlaceAgain(int) Shop.AuditOnOrderPlaced.Handle(OrderPlaced,CancellationToken) +in-source Shop.OrderEvents.PlaceAgain(int) Shop.EmailOnOrderPlaced.Handle(OrderPlaced,CancellationToken) +in-source Shop.OrderEvents.PlaceAgain(int) Shop.LogOrderEvent.Handle(OrderEvent,CancellationToken) +in-source Shop.OrdersApi.GetOrder(OrderServices,int) Shop.AuditGetOrder.Handle(GetOrder,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrdersApi.GetOrder(OrderServices,int) Shop.GetOrderHandler.Handle(GetOrder,CancellationToken) +in-source Shop.OrdersApi.GetOrder(OrderServices,int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrdersController.Get(int) Shop.AuditGetOrder.Handle(GetOrder,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrdersController.Get(int) Shop.GetOrderHandler.Handle(GetOrder,CancellationToken) +in-source Shop.OrdersController.Get(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrdersController.GetLocal(int) Shop.AuditGetOrder.Handle(GetOrder,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrdersController.GetLocal(int) Shop.GetOrderHandler.Handle(GetOrder,CancellationToken) +in-source Shop.OrdersController.GetLocal(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrdersController.GetUntyped(int) Shop.AuditGetOrder.Handle(GetOrder,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrdersController.GetUntyped(int) Shop.GetOrderHandler.Handle(GetOrder,CancellationToken) +in-source Shop.OrdersController.GetUntyped(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.BlueSuite.Run() Shop.BlueSuite.PingHandler.Handle(Ping,CancellationToken) +unstaged Shop.BlueSuite.Run() Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.Catalog.Order(int) Shop.GetOrderById.Handle(GetById,CancellationToken) +unstaged Shop.Catalog.Order(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.Catalog.Widgets(int) Shop.ListAllHandler.Handle(ListAll,CancellationToken) +unstaged Shop.Catalog.Widgets(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.GreenSuite.Run() Shop.GreenSuite.PingHandler.Handle(Ping,CancellationToken) +unstaged Shop.GreenSuite.Run() Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.LegacyOrders.Get(int) Shop.LegacyGetOrderHandler.Handle(Legacy.GetOrder,CancellationToken) +unstaged Shop.LegacyOrders.Get(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrderCommands.Any(ShapeQuery) Shop.CircleQueryHandler.Handle(CircleQuery,CancellationToken) +unstaged Shop.OrderCommands.Any(ShapeQuery) Shop.ShapeQueryHandler.Handle(ShapeQuery,CancellationToken) +unstaged Shop.OrderCommands.Any(ShapeQuery) Shop.SquareQueryHandler.Handle(SquareQuery,CancellationToken) +unstaged Shop.OrderCommands.Any(ShapeQuery) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrderCommands.Circle(double) Shop.CircleQueryHandler.Handle(CircleQuery,CancellationToken) +unstaged Shop.OrderCommands.Circle(double) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrderCommands.Close(int) Shop.CloseOrderHandler.Handle(CloseOrder,CancellationToken) +unstaged Shop.OrderCommands.Close(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrderCommands.Count(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrderCommands.Count(int) Shop.WidgetHandlers.Handle(CountWidgets,CancellationToken) +unstaged Shop.OrderCommands.Shape(double) Shop.ShapeQueryHandler.Handle(ShapeQuery,CancellationToken) +unstaged Shop.OrderCommands.Shape(double) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrderCommands.Weigh(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrderCommands.Weigh(int) Shop.WidgetHandlers.Handle(WeighWidgets,CancellationToken) +unstaged Shop.OrderEvents.Announce(OrderEvent) Shop.AuditOnOrderPlaced.Handle(OrderPlaced,CancellationToken) +unstaged Shop.OrderEvents.Announce(OrderEvent) Shop.EmailOnOrderPlaced.Handle(OrderPlaced,CancellationToken) +unstaged Shop.OrderEvents.Announce(OrderEvent) Shop.LogOrderEvent.Handle(OrderEvent,CancellationToken) +unstaged Shop.OrderEvents.Announce(OrderEvent) Shop.ShipOnOrderShipped.Handle(OrderShipped,CancellationToken) +unstaged Shop.OrderEvents.Misrouted(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrderEvents.Place(int) Shop.AuditOnOrderPlaced.Handle(OrderPlaced,CancellationToken) +unstaged Shop.OrderEvents.Place(int) Shop.EmailOnOrderPlaced.Handle(OrderPlaced,CancellationToken) +unstaged Shop.OrderEvents.Place(int) Shop.LogOrderEvent.Handle(OrderEvent,CancellationToken) +unstaged Shop.OrderEvents.PlaceAgain(int) Shop.AuditOnOrderPlaced.Handle(OrderPlaced,CancellationToken) +unstaged Shop.OrderEvents.PlaceAgain(int) Shop.EmailOnOrderPlaced.Handle(OrderPlaced,CancellationToken) +unstaged Shop.OrderEvents.PlaceAgain(int) Shop.LogOrderEvent.Handle(OrderEvent,CancellationToken) +unstaged Shop.OrdersApi.GetOrder(OrderServices,int) Shop.AuditGetOrder.Handle(GetOrder,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrdersApi.GetOrder(OrderServices,int) Shop.GetOrderHandler.Handle(GetOrder,CancellationToken) +unstaged Shop.OrdersApi.GetOrder(OrderServices,int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrdersController.Get(int) Shop.AuditGetOrder.Handle(GetOrder,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrdersController.Get(int) Shop.GetOrderHandler.Handle(GetOrder,CancellationToken) +unstaged Shop.OrdersController.Get(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrdersController.GetLocal(int) Shop.AuditGetOrder.Handle(GetOrder,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrdersController.GetLocal(int) Shop.GetOrderHandler.Handle(GetOrder,CancellationToken) +unstaged Shop.OrdersController.GetLocal(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrdersController.GetUntyped(int) Shop.AuditGetOrder.Handle(GetOrder,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrdersController.GetUntyped(int) Shop.GetOrderHandler.Handle(GetOrder,CancellationToken) +unstaged Shop.OrdersController.GetUntyped(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) diff --git a/graph/test/csharp/cases/18-mediator-send/src/18-mediator-send.csproj b/graph/test/csharp/cases/18-mediator-send/src/18-mediator-send.csproj new file mode 100644 index 000000000..a7a09e8ad --- /dev/null +++ b/graph/test/csharp/cases/18-mediator-send/src/18-mediator-send.csproj @@ -0,0 +1,6 @@ + + + net8.0 + enable + + diff --git a/graph/test/csharp/cases/18-mediator-send/src/BlueSuite.cs b/graph/test/csharp/cases/18-mediator-send/src/BlueSuite.cs new file mode 100644 index 000000000..df8a03e8b --- /dev/null +++ b/graph/test/csharp/cases/18-mediator-send/src/BlueSuite.cs @@ -0,0 +1,22 @@ +using System.Threading; +using System.Threading.Tasks; +using MediatR; + +namespace Shop +{ + // Two classes, each in its own file, each declare their own Ping and PingHandler: the + // same simple names in two scopes, the shape of a mediator's own test suite. Each send + // reaches the handler of ITS Ping, not the other class's. + public class BlueSuite + { + public record Ping(string Text) : IRequest; + public class PingHandler : IRequestHandler + { + public Task Handle(Ping request, CancellationToken ct) => Task.FromResult("blue " + request.Text); + } + + private readonly IMediator _mediator; + public BlueSuite(IMediator mediator) { _mediator = mediator; } + public Task Run() => _mediator.Send(new Ping("x")); + } +} diff --git a/graph/test/csharp/cases/18-mediator-send/src/Generic.cs b/graph/test/csharp/cases/18-mediator-send/src/Generic.cs new file mode 100644 index 000000000..d0653b72d --- /dev/null +++ b/graph/test/csharp/cases/18-mediator-send/src/Generic.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediatR; + +namespace Shop +{ + public record Widget(int Id); + + // a GENERIC request: the handler is chosen by the request type WITH its arguments + public record GetById(int Id) : IRequest; + public class GetOrderById : IRequestHandler, OrderView> + { + public Task Handle(GetById request, CancellationToken ct) => + Task.FromResult(new OrderView(request.Id, "by id")); + } + // CONTROL: the same generic request closed over another type, and nothing sends it + public class GetWidgetById : IRequestHandler, Widget> + { + public Task Handle(GetById request, CancellationToken ct) => + Task.FromResult(new Widget(request.Id)); + } + + // an OPEN generic handler: one class serves the request closed over any type + public record ListAll(int Page) : IRequest>; + public class ListAllHandler : IRequestHandler, List> + { + public Task> Handle(ListAll request, CancellationToken ct) => Task.FromResult(new List()); + } + + public class Catalog + { + private readonly ISender _sender; + public Catalog(ISender sender) { _sender = sender; } + + public Task Order(int id) => _sender.Send(new GetById(id)); + public Task> Widgets(int page) => _sender.Send(new ListAll(page)); + } +} diff --git a/graph/test/csharp/cases/18-mediator-send/src/GreenSuite.cs b/graph/test/csharp/cases/18-mediator-send/src/GreenSuite.cs new file mode 100644 index 000000000..5a3e5cb3a --- /dev/null +++ b/graph/test/csharp/cases/18-mediator-send/src/GreenSuite.cs @@ -0,0 +1,23 @@ +using System.Threading; +using System.Threading.Tasks; +using MediatR; + +namespace Shop +{ + public class GreenSuite + { + public record Ping(string Text) : IRequest; + public class PingHandler : IRequestHandler + { + public Task Handle(Ping request, CancellationToken ct) => Task.FromResult("green " + request.Text); + } + + private readonly IMediator _mediator; + public GreenSuite(IMediator mediator) { _mediator = mediator; } + public Task Run() + { + var ping = new Ping("y"); + return _mediator.Send(ping); + } + } +} diff --git a/graph/test/csharp/cases/18-mediator-send/src/Legacy.cs b/graph/test/csharp/cases/18-mediator-send/src/Legacy.cs new file mode 100644 index 000000000..ceb284174 --- /dev/null +++ b/graph/test/csharp/cases/18-mediator-send/src/Legacy.cs @@ -0,0 +1,7 @@ +using MediatR; + +namespace Shop.Legacy +{ + // another request with the simple name GetOrder, in its own namespace and file + public record GetOrder(int Id) : IRequest; +} diff --git a/graph/test/csharp/cases/18-mediator-send/src/Mediator.cs b/graph/test/csharp/cases/18-mediator-send/src/Mediator.cs new file mode 100644 index 000000000..9d6174188 --- /dev/null +++ b/graph/test/csharp/cases/18-mediator-send/src/Mediator.cs @@ -0,0 +1,52 @@ +// Stand-ins for the mediator package's contracts, with its names and shapes, so the +// Roslyn oracle can compile the case. tools/mediator-dispatch-test.sh also runs the case +// with this file removed, which is the shape a real project has: the package unstaged. +using System.Threading; +using System.Threading.Tasks; + +namespace MediatR +{ + public interface IBaseRequest { } + public interface IRequest : IBaseRequest { } + public interface IRequest : IBaseRequest { } + + public interface IRequestHandler where TRequest : IRequest + { + Task Handle(TRequest request, CancellationToken cancellationToken); + } + + public interface IRequestHandler where TRequest : IRequest + { + Task Handle(TRequest request, CancellationToken cancellationToken); + } + + public interface ISender + { + Task Send(IRequest request, CancellationToken cancellationToken = default); + Task Send(TRequest request, CancellationToken cancellationToken = default) where TRequest : IRequest; + Task Send(object request, CancellationToken cancellationToken = default); + } + + public delegate Task RequestHandlerDelegate(); + + public interface IPipelineBehavior where TRequest : notnull + { + Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken); + } + + public interface INotification { } + + public interface INotificationHandler where TNotification : INotification + { + Task Handle(TNotification notification, CancellationToken cancellationToken); + } + + public interface IPublisher + { + Task Publish(object notification, CancellationToken cancellationToken = default); + Task Publish(TNotification notification, CancellationToken cancellationToken = default) + where TNotification : INotification; + } + + public interface IMediator : ISender, IPublisher { } +} diff --git a/graph/test/csharp/cases/18-mediator-send/src/Notifications.cs b/graph/test/csharp/cases/18-mediator-send/src/Notifications.cs new file mode 100644 index 000000000..8270b2f0b --- /dev/null +++ b/graph/test/csharp/cases/18-mediator-send/src/Notifications.cs @@ -0,0 +1,66 @@ +using System.Threading; +using System.Threading.Tasks; +using MediatR; + +namespace Shop +{ + // Publish runs EVERY handler for the notification's type, and INotificationHandler is + // contravariant: a handler of a base notification runs for each derived one too. + public record OrderEvent(int Id) : INotification; + public record OrderPlaced(int Id) : OrderEvent(Id); + public record OrderShipped(int Id) : OrderEvent(Id); + + public class EmailOnOrderPlaced : INotificationHandler + { + public Task Handle(OrderPlaced notification, CancellationToken ct) => Task.CompletedTask; + } + public class AuditOnOrderPlaced : INotificationHandler + { + public Task Handle(OrderPlaced notification, CancellationToken ct) => Task.CompletedTask; + } + // a handler of the BASE notification: every OrderEvent published reaches it + public class LogOrderEvent : INotificationHandler + { + public Task Handle(OrderEvent notification, CancellationToken ct) => Task.CompletedTask; + } + // CONTROL: a sibling of the published notification, handled, and nothing publishes it + public class ShipOnOrderShipped : INotificationHandler + { + public Task Handle(OrderShipped notification, CancellationToken ct) => Task.CompletedTask; + } + + // CONTROL: an unrelated notification, handled, and nothing publishes it + public record WidgetMoved(int Id) : INotification; + public class LogWidgetMoved : INotificationHandler + { + public Task Handle(WidgetMoved notification, CancellationToken ct) => Task.CompletedTask; + } + + public class OrderEvents + { + private readonly IPublisher _publisher; + private readonly IMediator _mediator; + public OrderEvents(IPublisher publisher, IMediator mediator) { _publisher = publisher; _mediator = mediator; } + + // the fan: both OrderPlaced handlers and the OrderEvent handler + public Task Place(int id) => _publisher.Publish(new OrderPlaced(id)); + + // through the mediator, and through a local + public Task PlaceAgain(int id) + { + var placed = new OrderPlaced(id); + return _mediator.Publish(placed); + } + + // declared as the base: every handler in the family, and not LogWidgetMoved + public Task Announce(OrderEvent notification) => _publisher.Publish(notification); + + // CONTROL: declared as the marker interface, which every notification implements; + // nothing is linked rather than every handler of the project + public Task Relay(INotification notification) => _publisher.Publish(notification); + + // CONTROL: a notification Sent is not Published; no notification handler runs (an + // open pipeline step is still linked: the engine does not check that it is a request) + public Task Misrouted(int id) => _mediator.Send(new OrderPlaced(id)); + } +} diff --git a/graph/test/csharp/cases/18-mediator-send/src/Requests.cs b/graph/test/csharp/cases/18-mediator-send/src/Requests.cs new file mode 100644 index 000000000..1151d0072 --- /dev/null +++ b/graph/test/csharp/cases/18-mediator-send/src/Requests.cs @@ -0,0 +1,64 @@ +using System.Threading; +using System.Threading.Tasks; +using MediatR; + +namespace Shop +{ + public record OrderView(int Id, string State); + + public record GetOrder(int Id) : IRequest; + public class GetOrderHandler : IRequestHandler + { + public Task Handle(GetOrder request, CancellationToken ct) => + Task.FromResult(new OrderView(request.Id, "open")); + } + + // a request that answers nothing: IRequestHandler + public record CloseOrder(int Id) : IRequest; + public class CloseOrderHandler : IRequestHandler + { + public Task Handle(CloseOrder request, CancellationToken ct) => Task.CompletedTask; + } + + // CONTROL: the same response type as GetOrder, and nothing sends it + public record ArchiveOrder(int Id) : IRequest; + public class ArchiveOrderHandler : IRequestHandler + { + public Task Handle(ArchiveOrder request, CancellationToken ct) => + Task.FromResult(new OrderView(request.Id, "archived")); + } + + // CONTROL: a handler for ANOTHER GetOrder, named by a partly qualified argument + public class LegacyGetOrderHandler : IRequestHandler + { + public Task Handle(Legacy.GetOrder request, CancellationToken ct) => + Task.FromResult(new OrderView(request.Id, "legacy")); + } + + // one class handling two requests: each send reaches only the Handle for its type + public record CountWidgets(int Bin) : IRequest; + public record WeighWidgets(int Bin) : IRequest; + public class WidgetHandlers : IRequestHandler, IRequestHandler + { + public Task Handle(CountWidgets request, CancellationToken ct) => Task.FromResult(3); + public Task Handle(WeighWidgets request, CancellationToken ct) => Task.FromResult(1.5); + } + + // a request family: the container looks the handler up by the RUNTIME type + public record ShapeQuery(double Size) : IRequest; + public record CircleQuery(double R) : ShapeQuery(R); + public record SquareQuery(double S) : ShapeQuery(S); + public class ShapeQueryHandler : IRequestHandler + { + public Task Handle(ShapeQuery request, CancellationToken ct) => Task.FromResult("shape"); + } + public class CircleQueryHandler : IRequestHandler + { + public Task Handle(CircleQuery request, CancellationToken ct) => Task.FromResult("circle"); + } + // CONTROL: handled, and nothing sends it + public class SquareQueryHandler : IRequestHandler + { + public Task Handle(SquareQuery request, CancellationToken ct) => Task.FromResult("square"); + } +} diff --git a/graph/test/csharp/cases/18-mediator-send/src/Senders.cs b/graph/test/csharp/cases/18-mediator-send/src/Senders.cs new file mode 100644 index 000000000..c7f96fc14 --- /dev/null +++ b/graph/test/csharp/cases/18-mediator-send/src/Senders.cs @@ -0,0 +1,81 @@ +using System.Threading.Tasks; +using MediatR; + +namespace Shop +{ + // CONTROL: a receiver that is not a mediator, with a Send of the same shape + public interface IMailer { Task Send(object message); } + + public class OrdersController + { + private readonly IMediator _mediator; + private readonly IMailer _mailer; + public OrdersController(IMediator mediator, IMailer mailer) { _mediator = mediator; _mailer = mailer; } + + // Send(IRequest) with the request constructed at the call + public Task Get(int id) => _mediator.Send(new GetOrder(id)); + + // through a `var` local + public async Task GetLocal(int id) + { + var query = new GetOrder(id); + return await _mediator.Send(query); + } + + // Send(object): the local is declared `object`, its value is a GetOrder + public async Task GetUntyped(int id) + { + object request = new GetOrder(id); + return await _mediator.Send(request); + } + + // CONTROL: the same request handed to a mailer reaches no handler + public Task Notify(int id) => _mailer.Send(new GetOrder(id)); + } + + // a minimal-API endpoint takes its services as one object: the mediator is a property + public class OrderServices + { + public OrderServices(IMediator mediator) { Mediator = mediator; } + public IMediator Mediator { get; } + } + + public static class OrdersApi + { + public static Task GetOrder(OrderServices services, int id) => + services.Mediator.Send(new GetOrder(id)); + } + + // a mocking library's argument matcher, as a test writes it + public static class Arg { public static T Any() => default!; } + + // CONTROL: a test stubbing the mediator dispatches nothing + public class OrdersControllerTest + { + private readonly IMediator _mediator; + public OrdersControllerTest(IMediator mediator) { _mediator = mediator; } + public Task StubGet() => _mediator.Send(Arg.Any()); + } + + public class OrderCommands + { + private readonly ISender _sender; + public OrderCommands(ISender sender) { _sender = sender; } + + // Send(TRequest) where TRequest : IRequest, through ISender + public Task Close(int id) => _sender.Send(new CloseOrder(id)); + + // a class that handles two requests + public Task Count(int bin) => _sender.Send(new CountWidgets(bin)); + public Task Weigh(int bin) => _sender.Send(new WeighWidgets(bin)); + + // constructed at the call, so exactly that type: the base's handler, and a derived + // request's own handler and not its base's + public Task Shape(double size) => _sender.Send(new ShapeQuery(size)); + public Task Circle(double r) => _sender.Send(new CircleQuery(r)); + + // declared as the base, so at run time it may be a ShapeQuery, a CircleQuery or a + // SquareQuery: each of the three handlers, and no handler outside the family + public Task Any(ShapeQuery query) => _sender.Send(query); + } +} diff --git a/graph/test/csharp/engine-invariants.py b/graph/test/csharp/engine-invariants.py index 123db45e5..e9f95a96e 100755 --- a/graph/test/csharp/engine-invariants.py +++ b/graph/test/csharp/engine-invariants.py @@ -33,6 +33,7 @@ "known_edge", "multi_inferred", "fan_capped", "boundary_lib", "boundary_generated", "ambiguous_unknown", "ambiguous_dynamic", "known_implicit_ctor", "known_builtin_operator", "runtime_observed", + "event_dispatch", } # A predefined alias that denotes NO TYPE. Every other name in the engine's alias diff --git a/graph/test/csharp/tools/mediator-dispatch-test.sh b/graph/test/csharp/tools/mediator-dispatch-test.sh new file mode 100755 index 000000000..98a086a77 --- /dev/null +++ b/graph/test/csharp/tools/mediator-dispatch-test.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# IN-PROCESS MEDIATOR DISPATCH (#1383): Send(request) -> the handler for its type, and +# Publish(notification) -> every handler for its type and its bases. +# +# cases/18-mediator-send is scored against Roslyn by run-tests.sh like every case, and +# that score cannot see this edge: it is not the site's own target, and the oracle binds +# Send to the mediator interface. So the edges are rendered by qualified name and diffed +# against cases/18-mediator-send/expected.dispatch, in two shapes: +# in-source the case as written, with the stand-in contracts in Mediator.cs +# unstaged the same case with Mediator.cs removed, which is what a real project has: +# the package is not in the source tree +# Both shapes also get cases/18-mediator-send/dispatch-only/, a send written partly +# qualified (`new Legacy.GetOrder(id)`) that the engine does not resolve as a type, which +# the Roslyn score would count against the case as a site resolved to nothing. +# Both shapes must give the same edges. The controls (a handler nothing sends, a generic +# request closed over another type, a Send on a receiver that is not a mediator, a +# notification Sent rather than Published or declared as the marker interface) are pinned +# by being absent from the golden. +# +# mediator-dispatch-test.sh [--bless] +# ───────────────────────────────────────────────────────────────────────────── +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(d="$HERE"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" +[ -f "$ROOT/parser/dist/index.js" ] || { echo "mediator-dispatch-test: SKIP (parser not built)"; exit 0; } +command -v souffle >/dev/null || { echo "mediator-dispatch-test: SKIP (no souffle)"; exit 0; } +CASE="$ROOT/graph/test/csharp/cases/18-mediator-send" +W="$(cd "$(mktemp -d)" && pwd -P)"; trap 'rm -rf "$W"' EXIT +mkdir -p "$W/in-source" "$W/unstaged" +cp "$CASE/src/"*.cs "$CASE/dispatch-only/"*.cs "$W/in-source/" +cp "$W/in-source/"*.cs "$W/unstaged/"; rm "$W/unstaged/Mediator.cs" +: > "$W/actual.dispatch" +for shape in in-source unstaged; do + src="$W/$shape" + node "$ROOT/parser/dist/index.js" "$src" "mediator-$shape" false "$W/out-$shape/ir" --per-language > "$W/$shape.parse.log" 2>&1 \ + || { echo " ✗ $shape: parser failed"; tail -3 "$W/$shape.parse.log"; exit 1; } + bash "$ROOT/graph/csharp/souffle/devrun.sh" "$W/out-$shape/ir" "$W/out-$shape/engine" > "$W/$shape.engine.log" 2>&1 \ + || { echo " ✗ $shape: engine failed"; grep -m3 '^Error' "$W/$shape.engine.log"; exit 1; } + python3 - "$W/out-$shape/ir/csharp" "$W/out-$shape/engine/out" "$shape" >> "$W/actual.dispatch" <<'PY' +import csv, os, sys +ir, out, shape = sys.argv[1:4] +# the signature too: one class may handle two requests, with one Handle for each +lbl = {r['csMethodUniqueHash']: r['qualifiedName'] + '(' + r['signature'] + ')' for r in csv.DictReader(open(os.path.join(ir, 'all-csharp-methods.csv'), newline='', encoding='utf-8'), delimiter='\t')} +rows = [r for r in csv.reader(open(os.path.join(out, 'call-chain-edges.csv'), newline='', encoding='utf-8'), delimiter='\t') if r] +print('\n'.join(sorted({f"{shape}\t{lbl.get(r[1], r[1])}\t{lbl.get(r[3], r[3])}" for r in rows if r[5] == 'event_dispatch'}))) +PY +done +if [ "${1:-}" = "--bless" ]; then cp "$W/actual.dispatch" "$CASE/expected.dispatch"; echo "mediator-dispatch-test: blessed ($(grep -c . "$CASE/expected.dispatch") rows)"; exit 0; fi +[ -f "$CASE/expected.dispatch" ] || { echo " ✗ no expected.dispatch (run with --bless)"; exit 1; } +a=$(grep -c '^in-source' "$CASE/expected.dispatch"); b=$(grep -c '^unstaged' "$CASE/expected.dispatch") +[ "$a" -ge 1 ] && [ "$b" -ge 1 ] || { echo " ✗ the golden has no edge in one of the shapes ($a in-source, $b unstaged)"; exit 1; } +[ "$(grep '^in-source' "$CASE/expected.dispatch" | cut -f2-)" = "$(grep '^unstaged' "$CASE/expected.dispatch" | cut -f2-)" ] \ + || { echo " ✗ the golden's two shapes disagree"; exit 1; } +if diff -q "$CASE/expected.dispatch" "$W/actual.dispatch" >/dev/null; then + echo "mediator-dispatch-test: ok ($a edges in each shape)" +else + echo " ✗ mediator dispatch edges changed"; diff -u "$CASE/expected.dispatch" "$W/actual.dispatch" | sed 's/^/ /' | head -40; exit 1 +fi diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index 9e603a242..8c0fe21a5 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -13,7 +13,7 @@ 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} - csharp + boundary_generated known_implicit_ctor known_builtin_operator ambiguous_dynamic fan_capped + 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 Two rules hold here, and they are the reason this module exists rather than a dict at the top of From 289a3175918826a1c2152132ac750c4cdbc55c9d Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:23:31 -0700 Subject: [PATCH 09/31] plugin: compile the query rules once per machine, never while a query waits (#1606) The compiled query programs (impact.dl, path*.dl) were cached inside the plugin's own dl/.cache, so every plugin copy recompiled identical rules, and the build compiled them only after publishing the graph, so the first query raced the compile and paid it (43-79 s). - dl_program: cache in ${XDG_CACHE_HOME:-~/.cache}/axiomcode/queries, keyed by the rules and the platform; an older dl/.cache binary is still used. A query with no binary answers from the Souffle interpreter and starts one detached background compile under a lock. - axiomcode-build starts that compile when the build starts, beside the engine, and only reports its state after publishing. - impact: the untyped-receiver caller sample is ordered by file and line, not by symbol id, so two builds of one tree show the same sample. - tests/query_rules.py and a python case with a control. --- .../skills/axiomcode/reference/impact.md | 9 +- .../skills/axiomcode/scripts/axiomcode-build | 15 +- .../skills/axiomcode/scripts/axiomcode-impact | 10 +- .../skills/axiomcode/scripts/dl_program.py | 225 ++++++++++++++---- skills/axiomcode/reference/impact.md | 9 +- tests/README.md | 4 + .../alpha.py | 2 + .../beta.py | 2 + .../case.json | 9 + .../gamma.py | 2 + .../mid.py | 2 + .../svc.py | 7 + .../zeta.py | 2 + tests/query_rules.py | 136 +++++++++++ 14 files changed, 378 insertions(+), 56 deletions(-) create mode 100644 tests/cases/python/untyped-receiver-sample-in-source-order/alpha.py create mode 100644 tests/cases/python/untyped-receiver-sample-in-source-order/beta.py create mode 100644 tests/cases/python/untyped-receiver-sample-in-source-order/case.json create mode 100644 tests/cases/python/untyped-receiver-sample-in-source-order/gamma.py create mode 100644 tests/cases/python/untyped-receiver-sample-in-source-order/mid.py create mode 100644 tests/cases/python/untyped-receiver-sample-in-source-order/svc.py create mode 100644 tests/cases/python/untyped-receiver-sample-in-source-order/zeta.py create mode 100644 tests/query_rules.py diff --git a/plugins/axiomcode/skills/axiomcode/reference/impact.md b/plugins/axiomcode/skills/axiomcode/reference/impact.md index 5d07da8ae..de376f493 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/impact.md +++ b/plugins/axiomcode/skills/axiomcode/reference/impact.md @@ -13,10 +13,11 @@ first uses the type). Several targets in one call are one change set. A name dec Every judgement is a rule in `dl/impact.dl`: the Python side exports facts from graph.sqlite once per graph (members, owners, extends, nesting, decorations, overrides, resolved and unresolved call sites, references with the qualifier written on the line, type references, string literals, tests and fixtures), writes the target and the few text-level facts for the query, and -runs one Soufflé program — compiled to a native binary by `axiomcode index` (~20 s once for all of them, cached by the -program's hash under `dl/.cache/` and shared by every repository on the machine; on first use if the index did not warm it, -the interpreter when there is no `c++`). Warming it at index time is what keeps a caller with a timeout — `hooks/changes.py` -runs impact with `timeout=14` on every edit — from killing the compile before it can finish and caching nothing. +runs one Soufflé program, compiled to a native binary once per machine (45-140 s for impact.dl), cached by the program's +hash under `~/.cache/axiomcode/queries/` and shared by every repository and every plugin copy with the same rules. `axiomcode +index` starts that compile in the background when the build starts; a query never waits for it: until it is done, and when +there is no `c++`, the same program runs in the Soufflé interpreter, with the same answer. The compile runs detached, so a +caller with a timeout (`hooks/changes.py` runs impact with `timeout=14` on every edit) cannot kill it half-way. Direct dependents, the contract, the seeds, the closure, the chains (`parent_up`) and the tests are all derived in the same run; nothing is recomputed a second way. What is verified afterwards is the export: every printed chain hop and every `[resolved]` entry is looked up again in `graph.sqlite` (the `verified:` line). diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build index a2c2b97d2..d323bf2ea 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build @@ -118,6 +118,11 @@ if ! python3 "$H/ax_fresh.py" lock 9 --try; then python3 "$H/ax_fresh.py" lock 9 || { echo "could not take the build lock ($REPO/.axiomcode/build.lock)"; exit 75; } fi BUILD_T0=$SECONDS # how long a build takes to a usable graph: a query decides from it whether to wait (#1595) +# THE QUERY RULES COMPILE BESIDE THE ENGINE, NOT AFTER THE GRAPH (#1606). They do not depend on the graph, only on this +# plugin's dl/*.dl, and impact.dl alone takes 45-140 s. Started once the graph was published, the compile raced the first +# query, which then paid it (44 s on a C# library). Now it starts here, detached, into the user's cache, where every +# plugin copy with the same rules finds it; a query that still comes first answers with the interpreter meanwhile. +python3 "$H/dl_program.py" --background 2>/dev/null || true GD="$(cd "$REPO" && git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)" [ -n "$GD" ] && mkdir -p "$GD/info" && { grep -qx '.axiomcode' "$GD/info/exclude" 2>/dev/null || echo '.axiomcode' >> "$GD/info/exclude"; } || true STAMP="$(cd "$REPO" && git rev-parse HEAD 2>/dev/null || echo nogit)-$LANGS-${AXIOMCODE_SRC:-.}-${AXIOMCODE_LIBRARY:-nolib}" @@ -229,9 +234,7 @@ if [ ! -f "$OUT/files.json" ] && [ -f "$OUT/stamp" ] && [ "$(cat "$OUT/stamp")" has_symbols "$OUT/graph.sqlite" || python3 "$H/axiomcode-index" "$REPO" python3 "$H/axiomcode-impact" --warm "$REPO" || true # export the graph's facts now (~9 s on 1,373 files): the first # query pays it otherwise, and for the plugin that first query is inside hooks/changes.py's timeout=14, several at once. -python3 "$H/dl_program.py" || true # compile the query rules once, here where ~20 s is noise against the build: -# a later query under a caller's timeout (hooks/changes.py runs impact with timeout=14) cannot outlast the compile, so -# left to first use it is killed every time, caches nothing, and repeats on the next edit forever. +python3 "$H/dl_program.py" --no-wait || true # say whether the query rules are compiled (started at the top); never wait # a graph from before the file table: it was built from this clean commit, so the table of the files now IS its table python3 "$H/ax_fresh.py" snapshot "$REPO" "$LANGS" "${AXIOMCODE_SRC:-}" > "$OUT/.files.json.new" 2>/dev/null && mv "$OUT/.files.json.new" "$OUT/files.json" || rm -f "$OUT/.files.json.new" echo "graph up to date ($LANG_ARG) at $OUT/graph.sqlite"; exit 0; fi @@ -385,9 +388,9 @@ for t, n in rows: print(f"{t:<{w}} {n}") PY python3 "$H/axiomcode-impact" --warm "$REPO" || true # export the graph's facts now (~9 s on 1,373 files): the first # query pays it otherwise, and for the plugin that first query is inside hooks/changes.py's timeout=14, several at once. - python3 "$H/dl_program.py" || true # compile the query rules once, here where ~20 s is noise against the build: - # a later query under a caller's timeout (hooks/changes.py runs impact with timeout=14) cannot outlast the compile, so - # left to first use it is killed every time, caches nothing, and repeats on the next edit forever. + # the query rules: their compile started at the top of the build, detached (a caller's timeout cannot kill it half-way, + # which cached nothing and repeated on every edit); this only says where it is. A query never waits for it. + python3 "$H/dl_program.py" --no-wait || true [ -n "$BASE_SAVED" ] && AXIOMCODE_GRAPH="$REPO/.axiomcode/base" python3 "$H/axiomcode-impact" --warm "$REPO" >/dev/null 2>&1 || true [ -z "$OTHER_LANGS" ] || [ ! -s "$PENDING" ] || echo "$LANG_ARG graph ready at $OUT/graph.sqlite; still building: $(cut -d' ' -f1 "$PENDING" | paste -sd, - | sed 's/,/, /g')" } diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 7a1b483c7..7c80fa8d5 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1733,6 +1733,12 @@ def main(argv): for s_, m, d in {(x[0], x[1], int(x[2])) for x in res['inherited_test']}: if m in tests and int(d) <= depth and s_ in g.sym: inh.setdefault(s_, []).append(m) byname_seeds = {x[0] for x in res['seed_byname'] if x[0] in g.sym} + # the callers on an untyped receiver are SAMPLED in the text answer, so their order decides which are shown. Symbol + # ids are not stable between two builds of the same tree, so ordering by id showed a different sample each build: + # they go in source order (file, line, then name), which only an edit changes. + def by_place(c): + f, _, ln = g.loc(c).rpartition(':') + return (f, int(ln), g.disp(c), c) if f and ln.isdigit() else (g.loc(c), 0, g.disp(c), c) sure_set = {x[0] for x in res['reach_sure']} # reached from the dependents that ARE an exact edge universe = sum(1 for _ in open(os.path.join(g.facts, 'impact', 'test_method.facts'))) # an `alongside` row is not a place the change is observed from: it must stay out of the entry points, as it stays out @@ -1881,7 +1887,7 @@ def main(argv): 'reached': [{'id': m, 'display': g.disp(m), 'hops': d, 'for': sorted(reach_from[m]), 'at': g.loc(m), 'test': bool(g.sym[m]['is_test'])} for m, d in sorted(reached.items(), key=lambda x: (x[1], g.disp(x[0]), g.loc(x[0]), x[0]))], 'tests': [{'id': m, 'display': g.disp(m), 'owner': g.sym[m]['owner'], 'name': g.sym[m]['name'], 'hops': d, 'via': g.disp(fx) if fx else None, 'at': g.loc(m), 'certainty': test_cert.get(m), 'chain': [g.disp(x) for x in chains.get(m, [])]} for m, (d, fx) in sorted(tests.items(), key=lambda kv: (kv[1][0], g.disp(kv[0]), g.loc(kv[0]), kv[0]))], 'inherited_tests': [{'id': s_, 'display': g.disp(s_), 'at': g.loc(s_), 'inherits': sorted(g.disp(m) for m in ms)} for s_, ms in sorted(inh.items(), key=lambda kv: (g.disp(kv[0]), g.loc(kv[0])))], - 'byname_callers': [{'id': c, 'display': g.disp(c), 'at': g.loc(c)} for c in sorted(byname_seeds, key=lambda x: (g.disp(x), x))], + 'byname_callers': [{'id': c, 'display': g.disp(c), 'at': g.loc(c)} for c in sorted(byname_seeds, key=by_place)], 'external': [{'at': f'{f}:{l}', 'name': n, 'how': how} for f, l, n, how in sorted({(r[0], int(r[1]), r[2], r[3]) for r in res.get('extbind', [])})], 'test_universe': universe, 'verified': bad == 0, 'checked_hops': hops, 'unresolved_inside': u}, indent=1)) return 0 @@ -2395,7 +2401,7 @@ def main(argv): if dunder and not reached: print(f"outside the graph: {', '.join(dunder)} is a protocol method — the language runtime calls it (an operator, a conversion, a lifecycle hook), which is no call site, so nothing reaching it here does NOT mean nothing does") if outside and lone: print("outside the graph: " + ', '.join(f"{o} extends / implements {a}, which the graph does not contain" for o, a in outside[:3]) + " — a library or framework can call this method, so " + ("what reaches it above is not all that does" if reached else "nothing reaching it here does NOT mean nothing does")) - if byname_seeds: print(f"through a call the engine could not resolve: {len(byname_seeds)} caller(s) write this method's name on an untyped receiver — counted in the reach above, so that part of it MAY not be real: " + ', '.join(f"{g.disp(c)} {g.loc(c)}" for c in sorted(byname_seeds)[:4])) + if byname_seeds: print(f"through a call the engine could not resolve: {len(byname_seeds)} caller(s) write this method's name on an untyped receiver — counted in the reach above, so that part of it MAY not be real: " + ', '.join(f"{g.disp(c)} {g.loc(c)}" for c in sorted(byname_seeds, key=by_place)[:4])) byname = [x for x in D if x[3] in ('one of a set', 'by name', 'text')] # a label the answer USED must be explained here; a label it did not use must not be, or the note # describes evidence the reader cannot find above it. `registered` is new and is the one most diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl_program.py b/plugins/axiomcode/skills/axiomcode/scripts/dl_program.py index 414e26d26..1200cc9bd 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl_program.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl_program.py @@ -1,7 +1,18 @@ """dl_program.py — a Datalog program compiled to a native binary once, cached by the program's hash. -Every query program goes through here: `dl/impact.dl` (axiomcode-impact), and `dl/path*.dl` (axiomcode-path). The cache is keyed by the RULES alone and lives next to them in -`dl/.cache/`, so it is shared by every repository on the machine and survives until the program itself changes. +Every query program goes through here: `dl/impact.dl` (axiomcode-impact), and `dl/path*.dl` (axiomcode-path). The cache +is keyed by the RULES alone (and the machine's platform) and lives in the user's cache, +`${XDG_CACHE_HOME:-~/.cache}/axiomcode/queries/` (AXIOMCODE_QUERY_CACHE overrides it), beside the engine's own Soufflé +cache. It used to live next to the rules in `dl/.cache/`, inside the plugin: every plugin copy (an update installs a new +versioned directory; a second checkout, a worktree) then recompiled byte-identical rules, 45-140 s for impact.dl alone, +and that was paid by whichever came first, the index or a query (#1606). A binary an older plugin left in `dl/.cache/` +is still used. + +A QUERY NEVER WAITS FOR A COMPILE. When the rules are not compiled yet, `program()` starts the compile in the +background (one per program per machine, under a lock) and answers this query with the Soufflé interpreter, which +derives the same relations from the same rules. The interpreter is at most ~2x slower per solve on the graphs measured; +the compile it replaces cost 45 s and more on the first question. `axiomcode index` starts the compiles when the build +starts, in parallel with the engine, so normally they are done before the graph is published. What this buys is the COMPILE STEP, not query speed: on a 5 MB fact set the binary ran the same program in 1.3 s against the interpreter's 2.5 s, but on apache/rocketmq (2,265 files, 184k edges) a query took 22.5 s compiled and @@ -15,11 +26,11 @@ which was the whole of that benchmark's wall-clock regression. A killed compile leaves no `.nocompile` marker either (it did not fail, it was killed), so it cannot even record its own defeat. """ -import hashlib, os, platform, shutil, subprocess, sys +import hashlib, os, platform, shutil, subprocess, sys, time HERE = os.path.dirname(os.path.abspath(__file__)) DL_DIR = os.path.join(HERE, 'dl') -CACHE = os.path.join(DL_DIR, '.cache') +LEGACY_CACHE = os.path.join(DL_DIR, '.cache') # where a plugin before #1606 compiled to: read, never written when the user cache works HOOKS_DIR = os.path.abspath(os.path.join(HERE, '..', '..', '..', 'hooks')) SCOPE = '@axiomcode' # graph/pipeline/engine.conf's ENGINE_PACKAGE_SCOPE EXE = '.exe' if os.name == 'nt' else '' @@ -169,46 +180,159 @@ def packaged(stem, key): return None -def program(dl, verbose=True): - """the argv prefix to run this program: [] when it was built from these rules (no - soufflé, no compiler — what an npm install gets), else [], or ['souffle',
] when there is no compiler, when a - compile has already failed here (the failure is recorded, so a broken toolchain costs one attempt rather than one - per query — the visible half of #816), or under AXIOMCODE_INTERPRET.""" - dl = resolve(dl) - # keyed by the RULES alone. The soufflé runtime is compiled INTO the binary, so the version that generated it does - # not matter at run time; and keying on it meant a cached (or shipped) binary was unusable without soufflé present. - key = rules_id(dl) - stem = os.path.splitext(os.path.basename(dl))[0] - if not os.environ.get('AXIOMCODE_INTERPRET'): - shipped = packaged(stem, key) - if shipped: return [shipped] - binp = os.path.join(CACHE, f'{stem}-{key}'); nope = binp + '.nocompile' - if os.path.exists(binp): return [binp] - if os.path.exists(nope) or not shutil.which('c++') or not shutil.which('souffle') or os.environ.get('AXIOMCODE_INTERPRET'): - return ['souffle', dl] +def cache_dir(): + """where compiled query programs are kept: AXIOMCODE_QUERY_CACHE, else the user's cache (the engine's Soufflé cache + sits beside it, see graph/pipeline/run-souffle.sh), else, with no writable HOME, the plugin's own dl/.cache""" + ov = os.environ.get('AXIOMCODE_QUERY_CACHE') + cands = [ov] if ov else [] + base = os.environ.get('XDG_CACHE_HOME') or (os.path.join(os.path.expanduser('~'), '.cache') if os.path.expanduser('~') != '~' else '') + if base and not ov: cands.append(os.path.join(base, 'axiomcode', 'queries')) + cands.append(LEGACY_CACHE) + for d in cands: + try: + os.makedirs(d, exist_ok=True) + if os.access(d, os.W_OK): return d + except OSError: pass + return LEGACY_CACHE + + +def cached_name(stem, key): + """the file a compile of these rules is kept as. The platform is in the name because the binary is built with + -march=native: a cache on a home directory shared by two kinds of machine must not hand one the other's binary.""" + return f"{stem}-{key}-{npm_platform() or sys.platform}{EXE}" + + +def _alive(pid): + # on Windows signal 0 is CTRL_C_EVENT, not a probe: there the lock's age alone decides (LOCK_STALE_S) + if os.name == 'nt': return True + try: os.kill(pid, 0); return True + except ProcessLookupError: return False + except (PermissionError, OSError): return True + + +LOCK_STALE_S = 1800 # a compile that has held its lock this long is dead, whatever its pid says (pids are reused) + +def _lock_holder(lock): + """the pid compiling behind `lock`, or None when nobody is (a missing lock, or one whose holder died)""" + try: + pid = int(open(os.path.join(lock, 'pid')).read().strip() or 0) + age = time.time() - os.path.getmtime(lock) + except (OSError, ValueError): + try: age = time.time() - os.path.getmtime(lock) + except OSError: return None + return -1 if age < 30 else None # just created, pid not written yet + return pid if pid and _alive(pid) and age < LOCK_STALE_S else None + + +def _take_lock(lock): + """one compile per program per machine: mkdir is atomic. A lock left by a dead compile is taken over.""" + for _ in range(2): + try: + os.mkdir(lock) + with open(os.path.join(lock, 'pid'), 'w') as fh: fh.write(str(os.getpid())) + return True + except FileExistsError: + if _lock_holder(lock) is not None: return False + shutil.rmtree(lock, ignore_errors=True) + except OSError: return False + return False + + +def compiled(dl): + """the compiled binary for these rules when one exists (the engine package's, the user cache's, or a legacy + dl/.cache one), else None. Does no work.""" + dl = resolve(dl); key = rules_id(dl); stem = os.path.splitext(os.path.basename(dl))[0] + shipped = packaged(stem, key) + if shipped: return shipped + for p in (os.path.join(cache_dir(), cached_name(stem, key)), os.path.join(LEGACY_CACHE, f'{stem}-{key}')): + if os.path.isfile(p) and os.access(p, os.X_OK): return p + return None + + +def _compile(dl, binp, nope, verbose): + """soufflé -g, then c++; the binary lands at binp by one atomic rename. Returns True on success. A failure is + recorded in `nope`, so a broken toolchain costs one attempt rather than one per query (the visible half of #816).""" inc = souffle_include() if not inc: try: open(nope, 'w').write("no souffle/CompiledSouffle.h found; set AXIOM_SOUFFLE_INCLUDE to the directory CONTAINING souffle/\n") except OSError: pass - print(" soufflé's headers were not found (set AXIOM_SOUFFLE_INCLUDE to the directory CONTAINING souffle/) — using the interpreter", file=sys.stderr) - return ['souffle', dl] - # two runs compiling the same program at once used to share one .cpp and one .tmp: the first to finish removed - # the file under the second, which then died with "no such file". Per process names, one atomic rename. - os.makedirs(CACHE, exist_ok=True); cpp = f"{binp}.{os.getpid()}.cpp"; out = f"{binp}.{os.getpid()}.tmp" - if verbose: print(f"compiling {os.path.basename(dl)} to a native binary once (~20 s) …", file=sys.stderr) + if verbose: print(" soufflé's headers were not found (set AXIOM_SOUFFLE_INCLUDE to the directory CONTAINING souffle/) — using the interpreter", file=sys.stderr) + return False + # per-process names and one atomic rename: two compiles of one program never share a .cpp or a .tmp + cpp = f"{binp}.{os.getpid()}.cpp"; out = f"{binp}.{os.getpid()}.tmp" + if verbose: print(f"compiling {os.path.basename(dl)} to a native binary once (45-140 s) …", file=sys.stderr) g1 = subprocess.run(['souffle', '-g', cpp, dl], capture_output=True, text=True) g2 = subprocess.run(['c++', '-std=c++17', '-O3', '-march=native', '-w', '-I', inc, cpp, '-o', out], capture_output=True, text=True) if g1.returncode == 0 else g1 try: os.remove(cpp) except OSError: pass - if g2.returncode == 0: os.replace(out, binp); return [binp] + if g2.returncode == 0: os.replace(out, binp); return True try: os.remove(out) except OSError: pass why = (g2.stderr or '').strip().split(chr(10))[-1][:200] - # record it: without this the next query repeats the whole failed attempt, which is what made a broken probe cost - # ~20 s on EVERY invocation instead of once (#816) try: open(nope, 'w').write(f"-I {inc}\n{why}\nremove this file to try again\n") except OSError: pass - print(f" could not compile ({why}) — using the interpreter; recorded in {os.path.basename(nope)}, remove it to retry", file=sys.stderr) + if verbose: print(f" could not compile {os.path.basename(dl)} ({why}) — using the interpreter; recorded in {nope}, remove it to retry", file=sys.stderr) + return False + + +def _can_compile(): + return bool(shutil.which('c++') and shutil.which('souffle') and not os.environ.get('AXIOMCODE_INTERPRET')) + + +def start_compile(dls): + """compile these programs in a detached process (its own session, so a caller's timeout or Ctrl-C does not kill + it half-way, which would cache nothing and repeat on every call). Programs already compiled, failed or being + compiled are skipped by the child. Returns at once.""" + dls = [resolve(d) for d in dls] + if not dls or not _can_compile(): return False + try: + subprocess.Popen([sys.executable, os.path.abspath(__file__), '--compile'] + dls, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, close_fds=True) + return True + except OSError: + return False + + +def ensure(dl, wait=True, verbose=True): + """compile one program unless it is compiled, failed before, or being compiled by another process. With wait, + a compile in another process is waited for. Returns the binary, or None.""" + dl = resolve(dl); key = rules_id(dl); stem = os.path.splitext(os.path.basename(dl))[0] + have = compiled(dl) + if have: return have + if not _can_compile(): return None + d = cache_dir(); binp = os.path.join(d, cached_name(stem, key)); nope = binp + '.nocompile'; lock = binp + '.lock' + while True: + if os.path.isfile(binp): return binp + if os.path.exists(nope): return None + if _take_lock(lock): + try: + if os.path.isfile(binp): return binp + return binp if _compile(dl, binp, nope, verbose) else None + finally: shutil.rmtree(lock, ignore_errors=True) + if not wait: return None + time.sleep(1) + + +_SAID = set(); _STARTED = set() # per process: say it once, start it once + +def program(dl, verbose=True): + """the argv prefix to run this program: [] when it was built from these rules (no + soufflé, no compiler: what an npm install gets), else [], else ['souffle',
]: the interpreter, + with a compile started in the background so the next query gets the binary. A query never waits for a compile + (see the module docstring); AXIOMCODE_INTERPRET forces the interpreter.""" + dl = resolve(dl) + if not os.environ.get('AXIOMCODE_INTERPRET'): + have = compiled(dl) + if have: return [have] + key = rules_id(dl); stem = os.path.splitext(os.path.basename(dl))[0] + binp = os.path.join(cache_dir(), cached_name(stem, key)) + if _can_compile() and not os.path.exists(binp + '.nocompile'): + if stem not in _STARTED and _lock_holder(binp + '.lock') is None: start_compile([dl]) + _STARTED.add(stem) + if verbose and stem not in _SAID: + _SAID.add(stem) + print(f" {stem} rules: compiling to a native binary in the background (once per machine); this answer " + f"uses the interpreter, same rules", file=sys.stderr) return ['souffle', dl] @@ -217,17 +341,32 @@ def all_programs(): return out -def warm(verbose=True): - """compile every query program that is not cached yet. Called at the end of `axiomcode index`, where ~20 s each is - noise against the build, so that no later query — least of all one under a hook's timeout — ever pays it.""" - done = [] - for dl in all_programs(): - pre = program(dl, verbose=False) - how = 'interpreter' if not pre or pre[0] == 'souffle' else 'cached' if pre[0].startswith(CACHE) else 'packaged' - done.append((os.path.basename(dl), how)) +def status(dl): + """how a query would run this program now: packaged, cached, compiling, or interpreter""" + have = compiled(dl) + if have: return 'packaged' if os.path.basename(os.path.dirname(have)) == 'queries' and os.sep + 'node_modules' + os.sep in have else 'cached' + dl = resolve(dl); stem = os.path.splitext(os.path.basename(dl))[0] + binp = os.path.join(cache_dir(), cached_name(stem, rules_id(dl))) + if _can_compile() and _lock_holder(binp + '.lock') is not None: return 'compiling' + return 'interpreter' + + +def warm(verbose=True, wait=True): + """compile every query program that is not cached yet. `axiomcode index` calls it twice: with --background when the + build starts, so the compiles run beside the engine, and at the end to report (waiting only with wait=True). + No query ever pays the compile: until it is done they are answered by the interpreter.""" + missing = [dl for dl in all_programs() if not compiled(dl)] + if missing: + if wait: + for dl in missing: ensure(dl, wait=True, verbose=False) + else: + start_compile([dl for dl in missing if status(dl) != 'compiling']) + done = [(os.path.basename(dl), status(dl)) for dl in all_programs()] if verbose: - ok = sum(1 for _, s in done if s != 'interpreter') + ok = sum(1 for _, s in done if s in ('cached', 'packaged')) print(f"datalog rules ready: {ok}/{len(done)} compiled (" + ', '.join(f'{n}={s}' for n, s in done) + ")") + if any(s == 'compiling' for _, s in done): + print(" the rest are compiling in the background; until they finish, queries use the interpreter (same answers)") # THE INTERPRETER WITHOUT SOUFFLÉ IS NOT A MODE, IT IS A FAILURE, and it has to be said here, where the index is, # not by the first `impact` after it: "0/4 compiled (…=interpreter)" read as a slower setting, and impact then # failed with advice for another operating system @@ -240,5 +379,11 @@ def warm(verbose=True): if __name__ == '__main__': if sys.argv[1:2] == ['--print-id']: for a in sys.argv[2:]: print(rules_id(a if os.path.exists(a) else resolve(a))) + elif sys.argv[1:2] == ['--compile']: # the detached child start_compile() runs + for a in sys.argv[2:] or all_programs(): ensure(a, wait=False, verbose=False) + elif sys.argv[1:2] == ['--background']: # start every missing compile and return at once + start_compile([dl for dl in all_programs() if not compiled(dl)]) + elif sys.argv[1:2] == ['--no-wait']: # report; start what is missing; never wait + warm(wait=False) else: warm() diff --git a/skills/axiomcode/reference/impact.md b/skills/axiomcode/reference/impact.md index 5d07da8ae..de376f493 100644 --- a/skills/axiomcode/reference/impact.md +++ b/skills/axiomcode/reference/impact.md @@ -13,10 +13,11 @@ first uses the type). Several targets in one call are one change set. A name dec Every judgement is a rule in `dl/impact.dl`: the Python side exports facts from graph.sqlite once per graph (members, owners, extends, nesting, decorations, overrides, resolved and unresolved call sites, references with the qualifier written on the line, type references, string literals, tests and fixtures), writes the target and the few text-level facts for the query, and -runs one Soufflé program — compiled to a native binary by `axiomcode index` (~20 s once for all of them, cached by the -program's hash under `dl/.cache/` and shared by every repository on the machine; on first use if the index did not warm it, -the interpreter when there is no `c++`). Warming it at index time is what keeps a caller with a timeout — `hooks/changes.py` -runs impact with `timeout=14` on every edit — from killing the compile before it can finish and caching nothing. +runs one Soufflé program, compiled to a native binary once per machine (45-140 s for impact.dl), cached by the program's +hash under `~/.cache/axiomcode/queries/` and shared by every repository and every plugin copy with the same rules. `axiomcode +index` starts that compile in the background when the build starts; a query never waits for it: until it is done, and when +there is no `c++`, the same program runs in the Soufflé interpreter, with the same answer. The compile runs detached, so a +caller with a timeout (`hooks/changes.py` runs impact with `timeout=14` on every edit) cannot kill it half-way. Direct dependents, the contract, the seeds, the closure, the chains (`parent_up`) and the tests are all derived in the same run; nothing is recomputed a second way. What is verified afterwards is the export: every printed chain hop and every `[resolved]` entry is looked up again in `graph.sqlite` (the `verified:` line). diff --git a/tests/README.md b/tests/README.md index c75741715..89eeb14a7 100644 --- a/tests/README.md +++ b/tests/README.md @@ -70,6 +70,10 @@ One check needs no graph and is its own script: when the last one is: the others are held back, a query answers and names what it cannot see, a first query does not wait for them, one language alone is unchanged (#1555; builds real graphs, needs the engine) + python3 tests/query_rules.py the query rules compile once per machine, never while a query waits: a first query + answers from the interpreter and starts one background compile into the user + cache, a second plugin copy reuses it, rules one line apart do not, an older + plugin's dl/.cache binary is still used (#1606; needs soufflé and c++, no engine) python3 tests/tiers.py every call_edges tier the schema documents is ranked, labelled and given a certainty by the frontend, so a new tier cannot read as the weakest claim diff --git a/tests/cases/python/untyped-receiver-sample-in-source-order/alpha.py b/tests/cases/python/untyped-receiver-sample-in-source-order/alpha.py new file mode 100644 index 000000000..38f0c5665 --- /dev/null +++ b/tests/cases/python/untyped-receiver-sample-in-source-order/alpha.py @@ -0,0 +1,2 @@ +def use_alpha(obj): + return obj.ping() diff --git a/tests/cases/python/untyped-receiver-sample-in-source-order/beta.py b/tests/cases/python/untyped-receiver-sample-in-source-order/beta.py new file mode 100644 index 000000000..0864f822f --- /dev/null +++ b/tests/cases/python/untyped-receiver-sample-in-source-order/beta.py @@ -0,0 +1,2 @@ +def use_beta(obj): + return obj.ping() diff --git a/tests/cases/python/untyped-receiver-sample-in-source-order/case.json b/tests/cases/python/untyped-receiver-sample-in-source-order/case.json new file mode 100644 index 000000000..8552dcb7d --- /dev/null +++ b/tests/cases/python/untyped-receiver-sample-in-source-order/case.json @@ -0,0 +1,9 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "the callers on an untyped receiver are sampled in the text answer, four of them, in source order (file, then line), so two builds of the same tree show the same four; ordered by symbol id the sample changed from build to build", + "run": ["impact", "Pinger.ping"], + "want": ["write this method's name on an untyped receiver", "use_alpha alpha.py:1, use_beta beta.py:1, use_gamma gamma.py:1, use_mid mid.py:1"], + "avoid": ["use_zeta zeta.py:1"]}, + {"why": "control: the sample only shortens that one line; every by-name caller is still a row, the one left out of the sample included", + "run": ["impact", "Pinger.ping"], + "want": ["[by name] use_zeta zeta.py:2", "[by name] use_alpha alpha.py:2", "[resolved] direct svc.py:7"]}]} diff --git a/tests/cases/python/untyped-receiver-sample-in-source-order/gamma.py b/tests/cases/python/untyped-receiver-sample-in-source-order/gamma.py new file mode 100644 index 000000000..bee63e424 --- /dev/null +++ b/tests/cases/python/untyped-receiver-sample-in-source-order/gamma.py @@ -0,0 +1,2 @@ +def use_gamma(obj): + return obj.ping() diff --git a/tests/cases/python/untyped-receiver-sample-in-source-order/mid.py b/tests/cases/python/untyped-receiver-sample-in-source-order/mid.py new file mode 100644 index 000000000..1a6a54225 --- /dev/null +++ b/tests/cases/python/untyped-receiver-sample-in-source-order/mid.py @@ -0,0 +1,2 @@ +def use_mid(obj): + return obj.ping() diff --git a/tests/cases/python/untyped-receiver-sample-in-source-order/svc.py b/tests/cases/python/untyped-receiver-sample-in-source-order/svc.py new file mode 100644 index 000000000..9a37e3936 --- /dev/null +++ b/tests/cases/python/untyped-receiver-sample-in-source-order/svc.py @@ -0,0 +1,7 @@ +class Pinger: + def ping(self): + return 1 + + +def direct(): + return Pinger().ping() diff --git a/tests/cases/python/untyped-receiver-sample-in-source-order/zeta.py b/tests/cases/python/untyped-receiver-sample-in-source-order/zeta.py new file mode 100644 index 000000000..bdc6eb3cf --- /dev/null +++ b/tests/cases/python/untyped-receiver-sample-in-source-order/zeta.py @@ -0,0 +1,2 @@ +def use_zeta(obj): + return obj.ping() diff --git a/tests/query_rules.py b/tests/query_rules.py new file mode 100644 index 000000000..a82a00af1 --- /dev/null +++ b/tests/query_rules.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""tests/query_rules.py: the query rules are compiled once per MACHINE, never while a query waits (#1606). + +The query programs (dl/impact.dl, dl/path*.dl) are compiled to native binaries keyed by their rules. They were kept in +the plugin's own dl/.cache, so every plugin copy (an update, a second checkout, a worktree) recompiled identical rules, +and the first query after a build could pay the whole compile (44 s for impact.dl on a C# library). Checked here on a +tiny program in throwaway plugin copies, with the user cache pointed at a temporary XDG_CACHE_HOME: + + - a query with nothing compiled answers at once, from the interpreter, and starts ONE background compile + - the binary lands in the user cache, not in the plugin; a second plugin copy with the same rules reuses it + - control: a copy whose rules differ by one line does not + - a binary an older plugin left in dl/.cache is still used + - three queries at once all answer, and leave one binary and no lock, .cpp or .tmp behind + - the interpreter and the binary derive the same rows + - warm() (what `axiomcode index` calls) reports every program compiled + + python3 tests/query_rules.py [-v] + +Needs soufflé and a C++ compiler; without them it says so and passes nothing. +""" +import os, shutil, subprocess, sys, tempfile, time + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SCRIPTS = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts') +RULES = '.decl e(a:number, b:number)\n.input e\n.decl r(a:number, b:number)\n.output r\nr(a, b) :- e(a, b).\nr(a, c) :- r(a, b), e(b, c).\n' +RULES_OTHER = RULES + 'r(a, a) :- e(a, _).\n' +fails = [] +passed = 0 +verbose = '-v' in sys.argv + + +def check(ok, what, detail=''): + global passed + if ok: passed += 1 + else: fails.append(what) + print((' ok ' if ok else ' FAIL ') + what + ('' if ok or not detail else '\n ' + str(detail)[:600].replace('\n', '\n '))) + + +def plugin(work, name, rules=RULES): + d = os.path.join(work, name, 'scripts'); os.makedirs(os.path.join(d, 'dl')) + shutil.copy(os.path.join(SCRIPTS, 'dl_program.py'), d) + with open(os.path.join(d, 'dl', 'q.dl'), 'w') as fh: fh.write(rules) + return d + + +def py(scripts, code, env): + t = time.time() + r = subprocess.run([sys.executable, '-c', 'import sys; sys.path.insert(0, sys.argv[1]); import dl_program as d\n' + code, scripts], + capture_output=True, text=True, env=env, timeout=300) + return r.stdout.strip(), r.stderr, time.time() - t + + +def wait_for(pred, secs=240): + end = time.time() + secs + while time.time() < end: + if pred(): return True + time.sleep(0.5) + return False + + +def main(): + if not (shutil.which('souffle') and shutil.which('c++')): + print('soufflé or c++ missing: nothing to check'); return 0 + work = tempfile.mkdtemp(prefix='ax-query-rules-') + try: + env = dict(os.environ, XDG_CACHE_HOME=os.path.join(work, 'xdg')); env.pop('AXIOMCODE_QUERY_CACHE', None); env.pop('AXIOMCODE_INTERPRET', None) + qdir = os.path.join(work, 'xdg', 'axiomcode', 'queries') + a = plugin(work, 'copy-a') + + # 1. nothing compiled: the interpreter at once, a compile in the background + out, err, took = py(a, "print(' '.join(d.program('q.dl')))", env) + check(out.startswith('souffle ') and took < 10, f'a first query answers from the interpreter at once ({took:.1f}s)', out + err) + check('compiling to a native binary in the background' in err, 'and says the rules are compiling in the background', err) + done = wait_for(lambda: any(n.startswith('q-') and not n.endswith(('.lock', '.nocompile', '.cpp', '.tmp')) for n in os.listdir(qdir)) if os.path.isdir(qdir) else False) + check(done, 'the background compile finishes into the user cache', os.listdir(qdir) if os.path.isdir(qdir) else 'no cache dir') + check(not os.path.exists(os.path.join(a, 'dl', '.cache')), 'nothing is written inside the plugin (dl/.cache)') + wait_for(lambda: not any(n.endswith('.lock') for n in os.listdir(qdir)), 30) + binp, _, _ = py(a, "print(d.program('q.dl')[0])", env) + check(binp.startswith(qdir) and os.access(binp, os.X_OK), 'the next query runs the compiled binary', binp) + + # 2. another plugin copy with the same rules: no compile + b = plugin(work, 'copy-b') + out, err, took = py(b, "print(d.program('q.dl')[0])", env) + check(out == binp and 'compiling' not in err, f'a second plugin copy with the same rules reuses that binary ({took:.1f}s)', out + err) + # control: rules one line apart are not the same program + c = plugin(work, 'copy-c', RULES_OTHER) + out, err, _ = py(c, "print(' '.join(d.program('q.dl')))", env) + check(out.startswith('souffle ') and binp not in out, 'control: a copy whose rules differ gets no binary of the other rules', out) + + # 3. a binary an older plugin compiled into its own dl/.cache is still used + e = plugin(work, 'copy-e'); key, _, _ = py(e, "print(d.rules_id(d.resolve('q.dl')))", env) + os.makedirs(os.path.join(e, 'dl', '.cache')); legacy = os.path.join(e, 'dl', '.cache', f'q-{key}'); shutil.copy(binp, legacy) + env2 = dict(env, XDG_CACHE_HOME=os.path.join(work, 'xdg-empty')) + out, err, _ = py(e, "print(d.program('q.dl')[0])", env2) + check(out == legacy, "a binary left in the plugin's dl/.cache by an older version is used", out + err) + + # 4. three queries at once on a cold cache: one compile, nothing left behind + env3 = dict(env, XDG_CACHE_HOME=os.path.join(work, 'xdg-race')); q3 = os.path.join(work, 'xdg-race', 'axiomcode', 'queries') + f = plugin(work, 'copy-f') + procs = [subprocess.Popen([sys.executable, '-c', "import sys; sys.path.insert(0, sys.argv[1]); import dl_program as d; print(d.program('q.dl')[0])", f], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env3) for _ in range(3)] + outs = [p.communicate()[0].strip() for p in procs] + check(all(o == 'souffle' for o in outs), 'three queries at once all answer from the interpreter, none waits', outs) + def settled(): + return os.path.isdir(q3) and any(n.startswith('q-') and '.' not in n for n in os.listdir(q3)) \ + and not any(n.endswith('.lock') for n in os.listdir(q3)) + wait_for(settled) + left = sorted(os.listdir(q3)) if os.path.isdir(q3) else [] + check(len([n for n in left if n.startswith('q-') and '.' not in n]) == 1 and not [n for n in left if n.endswith(('.lock', '.cpp', '.tmp', '.nocompile'))], + 'they leave one binary and no lock, .cpp, .tmp or failure marker', left) + + # 5. the interpreter and the binary derive the same rows + facts = os.path.join(work, 'facts'); os.makedirs(facts) + with open(os.path.join(facts, 'e.facts'), 'w') as fh: fh.write('1\t2\n2\t3\n3\t4\n7\t8\n') + rows = {} + for how, argv in (('interpreter', ['souffle', os.path.join(a, 'dl', 'q.dl')]), ('binary', [binp])): + o = os.path.join(work, 'out-' + how); os.makedirs(o) + subprocess.run(argv + ['-F', facts, '-D', o], check=True, capture_output=True) + rows[how] = sorted(l for l in open(os.path.join(o, 'r.csv')).read().split('\n') if l) + check(rows['interpreter'] == rows['binary'] and len(rows['binary']) >= 7, f"the interpreter and the binary derive the same {len(rows['binary'])} rows", rows) + + # 6. what `axiomcode index` prints + out, err, _ = py(b, "d.warm()", env) + check('datalog rules ready: 1/1 compiled (q.dl=cached)' in out, 'warm() reports every program compiled', out + err) + out, err, _ = py(c, "d.warm(wait=False)", env) + check('q.dl=compiling' in out or 'q.dl=cached' in out, 'warm(wait=False) on new rules starts their compile and does not wait', out + err) + wait_for(lambda: not any(n.endswith('.lock') for n in os.listdir(qdir)), 240) + finally: + if verbose: print('kept', work) + else: shutil.rmtree(work, ignore_errors=True) + print(f"{passed} passed, {len(fails)} FAILED" if fails else f'{passed} passed, all checks held') + return 1 if fails or passed < 1 else 0 + + +if __name__ == '__main__': + sys.exit(main()) From 6502b9baf76746f37aa4a519796e49aa92b184ac Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:22:54 -0700 Subject: [PATCH 10/31] changed / test-impact: read a branch from its merge-base, name what they cannot analyse - --range a..b reads from `git merge-base a b`, not a's tip, and says so when a has moved: commits the base branch received after the fork were reported as the branch's own changes and their tests selected. `a` alone is a..HEAD. - a clean working tree over committed work answers "no change" and names the --range that holds the commits; not on a base branch itself. - a copy without git is refused with what to pass, instead of a diff against an empty text. Named files (`changed `, `test-impact `, MCP files=[...]) count whole when there is no edit to diff. - a file the base does not have is one `added ` line, plus each new declaration something outside the file already calls. Docstring lines are never read as headers, and an added declaration carries its own line. - an insertion strictly inside a callable is its body change; an insertion slides past equal lines so a new type starts after the brace above it; package / import / using / namespace lines declare nothing. - changed files outside every indexed language are named, never dropped; test-impact lists the test files whose text names them, and code that is also run as a program is looked for by name, with a lower-bound note when no test names it. An edited test file is itself a test to run. - extensionless scripts the graph holds are no longer dropped from the diff; the name tier stays in the changed file's language; .cs picks dotnet test; --in no longer reaches changed as a file list; an answer with no rows past page 1 is not paged; --impact output follows the list it belongs to. - MCP descriptions, SKILL.md and reference/changed-and-tests.md describe it. --- plugins/axiomcode/mcp/server.py | 8 +- plugins/axiomcode/skills/axiomcode/SKILL.md | 8 +- .../axiomcode/reference/changed-and-tests.md | 22 +- .../skills/axiomcode/scripts/ax_pages.py | 13 +- .../skills/axiomcode/scripts/axiomcode | 5 +- .../axiomcode/scripts/axiomcode-changed | 302 +++++++++++++++--- .../axiomcode/scripts/axiomcode-test-impact | 219 +++++++++++-- skills/axiomcode/SKILL.md | 8 +- .../axiomcode/reference/changed-and-tests.md | 22 +- .../using-line-is-not-a-field/case.json | 6 + .../csharp/using-line-is-not-a-field/new.cs | 16 + .../csharp/using-line-is-not-a-field/old.cs | 14 + .../using-line-is-not-a-field/src/Cart.cs | 14 + tests/cases/java/insert-inside-body/case.json | 10 + .../java/insert-inside-body/new-between.java | 20 ++ tests/cases/java/insert-inside-body/new.java | 18 ++ tests/cases/java/insert-inside-body/old.java | 16 + .../java/insert-inside-body/src/pkg/Svc.java | 16 + .../cases/java/new-type-below-type/case.json | 6 + tests/cases/java/new-type-below-type/new.java | 19 ++ tests/cases/java/new-type-below-type/old.java | 11 + .../java/new-type-below-type/src/pkg/A.java | 11 + .../java/new-type-between-types/case.json | 6 + .../java/new-type-between-types/new.java | 15 + .../java/new-type-between-types/old.java | 10 + .../new-type-between-types/src/pkg/A.java | 10 + tests/changed_range.py | 166 ++++++++++ 27 files changed, 902 insertions(+), 89 deletions(-) create mode 100644 tests/cases/csharp/using-line-is-not-a-field/case.json create mode 100644 tests/cases/csharp/using-line-is-not-a-field/new.cs create mode 100644 tests/cases/csharp/using-line-is-not-a-field/old.cs create mode 100644 tests/cases/csharp/using-line-is-not-a-field/src/Cart.cs create mode 100644 tests/cases/java/insert-inside-body/case.json create mode 100644 tests/cases/java/insert-inside-body/new-between.java create mode 100644 tests/cases/java/insert-inside-body/new.java create mode 100644 tests/cases/java/insert-inside-body/old.java create mode 100644 tests/cases/java/insert-inside-body/src/pkg/Svc.java create mode 100644 tests/cases/java/new-type-below-type/case.json create mode 100644 tests/cases/java/new-type-below-type/new.java create mode 100644 tests/cases/java/new-type-below-type/old.java create mode 100644 tests/cases/java/new-type-below-type/src/pkg/A.java create mode 100644 tests/cases/java/new-type-between-types/case.json create mode 100644 tests/cases/java/new-type-between-types/new.java create mode 100644 tests/cases/java/new-type-between-types/old.java create mode 100644 tests/cases/java/new-type-between-types/src/pkg/A.java create mode 100644 tests/changed_range.py diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 86783fa4a..7aacf5727 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -158,14 +158,14 @@ def axiomcode_impact(targets: list[str], repo: str = ".", tests: bool = False, w @srv.tool() def axiomcode_changed(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, impact: bool = False, page: int = 1) -> str: - """Which declarations an edit changed and HOW — signature (parameters added / removed / retyped, return type), field (its type, name, initializer), type header, body only, removed, added — the working tree against the commit the graph was built from (default), two commits (range='a..b'), or the index (staged=True); each with the target impact takes. impact=True runs impact on all of them as one change set and returns its answer.""" + """Which declarations an edit changed and HOW — signature (parameters added / removed / retyped, return type), field (its type, name, initializer), type header, body only, removed, added (a new file is one `added` line) — the working tree against the commit the graph was built from (default), your branch's commits (range='a..b': read from `git merge-base a b`, so commits a received after you branched are not yours; a note says so when a has moved), or the index (staged=True); each with the target impact takes. When the working tree is clean but HEAD has commits of its own, it says which range=... to ask. files=[...] limits it to those files; on a copy without git (which it refuses otherwise) every declaration in a named file counts as changed. Changed files outside every indexed language (fixtures, case data, a schema) are named, never dropped. impact=True runs impact on all of them as one change set and returns its answer.""" a = ['changed', repo, *files] + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--impact'] if impact else []) + (['--page', str(page)] if page and page != 1 else []) return run(a) @srv.tool() -def axiomcode_test_impact(repo: str = ".", range: str = '', staged: bool = False, in_path: str = '', limit: int = 0, why: bool = False, page: int = 1) -> str: - """Which tests actually have to run for the edit in front of you: the test files that reach any changed declaration, with the chain, so the selection can be checked rather than trusted. Working tree by default, or range='a..b', or staged=True. Conservative by design — a test reached only through an edge the graph does not encode (reflection, a service loader, a runtime-built case) will NOT appear, so it is a lower bound. why=True prints the chain for each.""" - a = ['test-impact', repo] + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--in', in_path] if in_path else []) + (['--limit', str(limit)] if limit else []) + (['--why'] if why else []) + (['--page', str(page)] if page and page != 1 else []) +def axiomcode_test_impact(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, in_path: str = '', limit: int = 0, why: bool = False, page: int = 1) -> str: + """Which tests actually have to run for the edit in front of you: the test files that reach any changed declaration, with the chain, so the selection can be checked rather than trusted, and the command that runs them. Working tree by default; range='a..b' for your branch's commits (from `git merge-base a b`, so a base branch that moved on is not counted as your change); staged=True for the index; files=[...] for named files (a named file with no edit, or any on a copy without git, counts whole: the tests of everything in it). An edited test file is itself listed to run. Changed files outside every indexed language (fixtures, case data) are named with the test files that name them in their text. Conservative by design — a test reached only through an edge the graph does not encode (reflection, a service loader, a subprocess, a runtime-built case) will NOT appear, so it is a lower bound. why=True prints the chain for each.""" + a = ['test-impact', repo, *files] + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--in', in_path] if in_path else []) + (['--limit', str(limit)] if limit else []) + (['--why'] if why else []) + (['--page', str(page)] if page and page != 1 else []) return run(a) @srv.tool() diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index eaba7b67f..1b9c9c739 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -81,9 +81,11 @@ keys, injected beans and handlers registered as values — none has a call site. ## changed · test-impact — from an edit -`axiomcode changed [--impact] [--staged | --range a..b]` says how each declaration changed (`signature`, `body`, -`field`, `type`, `removed`, `added`). `axiomcode test-impact [--why]` lists the tests the edit reaches and the -command to run them. It is a **lower bound**: skipping what it does not name is your risk decision, since reflection +`axiomcode changed [--impact] [--staged | --range a..b] […]` says how each declaration changed (`signature`, `body`, +`field`, `type`, `removed`, `added`). `axiomcode test-impact [--why] […]` lists the tests the edit reaches and the +command to run them. For your branch's commits ask `--range ..HEAD`: it reads from the merge-base, so a base +that moved on is not counted as yours. On a copy without git, name the files you edited. Changed fixtures and other +files no graph reads are named, with the tests whose text names them. It is a **lower bound**: skipping what it does not name is your risk decision, since reflection and service loaders are invisible. Detail: `reference/changed-and-tests.md`. ## path — asking the graph diff --git a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md b/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md index e90684db0..906e52d4c 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md +++ b/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md @@ -11,6 +11,23 @@ commits (when the graph is at the newer side, the declarations are the new text' `--staged` the index, `--old/--new/--file` two texts of one file. Each line ends with the target `impact` takes for it — a signature with one parameter changed is `Owner.m(param)` — and `--impact` runs impact on all of them as one change set. +What to pass, and what the answer says when the question cannot be answered the way it was asked: + +| situation | ask | what comes back | +|---|---|---| +| uncommitted edits | `changed` · `test-impact` | the edits against the baseline | +| your branch's commits | `changed --range ..HEAD` (MCP `range='..HEAD'`) | read from `git merge-base HEAD`, not from ``'s tip: commits the base branch received after you branched are not yours and are left out. A `note: range base: merge-base …` line says so whenever `` has moved. `a...b` means the same; `a` alone is `a..HEAD` | +| committed work, clean tree | `changed` | `no change …` followed by `next: … HEAD is N commit(s) ahead of — ask --range ..HEAD` | +| a copy without git | `changed` | a refusal: no base to diff against. Name the files instead | +| named files | `changed …` · `test-impact …` (MCP `files=[…]`) | each file's edit; a named file with no edit (or any named file on a copy without git) counts **whole**: every callable declared in it is `named`, and test-impact selects the tests of all of them | +| a file the base does not have | (any) | one line, `added — new file, N declaration(s)`, plus each new declaration something outside the file already calls, with its impact target. Never its parameters or docstring words | +| fixtures, case data, a schema | (any) | named as `outside every indexed language`, never "no change"; test-impact lists the test files whose text names them (the path, the file name, or a quoted directory), as a `[text]` tier, and says when no test names them | + +`test-impact` also lists an edited or new **test file** as one to run, and adds it to the command. Code that is also run as a +program (`if __name__ == '__main__'`, `static void main`, `Main`) is looked for by name in the tests, since a test that starts +it as a subprocess or drives it from case data has no call edge to it; when no test names it the answer says the selection is +a lower bound for it. + Measured against 270 real fixes (a Java defect-benchmark arena: the fix applied to the buggy files, the declarations it reports against the benchmark's own scanner's reading of the same hunks, its class-level state expansion taken out): exact agreement on 255, 465 declarations reported for the scanner's 473 — recall 0.968, precision 0.985. Every remaining @@ -55,7 +72,7 @@ absent, never wrong, and the `? n` count says how many. ## test-impact — which tests this edit reaches -`axiomcode test-impact` takes the edit (the working tree by default, `--range a..b` or `--staged`), maps it onto the +`axiomcode test-impact` takes the edit (the working tree by default, `--range a..b`, `--staged`, or named files), maps it onto the declarations through `changed`, asks `impact` which tests reach any of them, and prints the test files with the runner command that runs exactly those. It is `changed` + `impact --tests` with the answer shaped for a pipeline rather than for a reader. @@ -75,5 +92,6 @@ library a `[sound]` route (every hop a single resolved target) was right **29 ti `[by name]` 0 in 1; a `[fixture]` route is right 30 times in 30 on a service where a fixture is the only way in and about 1 in 4 on a framework where every test builds an app. Run the sound rung first, and decide about the rest with the number in front of you. Skipping what it does not name is a decision about risk that this tool cannot make for -you: a test reached only through reflection, a service loader, or a case built at runtime does not appear here. +you: a test reached only through reflection, a service loader, a subprocess, or a case built at runtime does not appear +here (the `[text]` tier above recovers the ones whose test names the file it loads). diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py index 2ae8e4773..43a10398b 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py @@ -73,6 +73,10 @@ def totals_for(shown): if i > 0: cur.append(title + ' (continued)'); used += len(title) + 13 cur.append(l); used += len(l) + 1 if cur: pages.append(cur) + # AN ANSWER WITH NO ROWS BEYOND ITS FIRST PAGE IS NOT PAGED: a refusal or a note list longer than a page came back as + # "page 1 of 2 ... 1 more page, 0 rows", a footer promising more of an answer that had none + if len(pages) > 1 and page == 1 and not any(l.startswith(' ') for pg in pages[1:] for l in pg): + return text n = len(pages) if page < 1 or page > n: return f"page {page} does not exist: this answer has {n} page(s) at {budget_flag} {budget}\n" @@ -180,12 +184,15 @@ def next_context(text): "to it reaches. The other files are ranked context, not a reading list") def next_changed(text): - if re.search(r'^no change', text, re.M): return '' + if re.search(r'^(no change|no git base)', text, re.M): return '' return "next: `test-impact` names the tests this edit reaches and the command that runs exactly those; `impact ` for a signature or field change above" def next_test_impact(text): - m = re.search(r'^\s*((?:\./gradlew|gradle|mvn|\./mvnw|npx|npm|pytest|python -m pytest|dotnet|go) [^\n]+)$', text, re.M) - return f"next: run {m.group(1).strip()} — only the tests above; a test reached through reflection or a service loader is not among them" if m else '' + # the LAST command printed: when a text tier adds the tests that load a changed fixture, it prints the command for + # both, and the first one alone left those tests out of the step an agent takes + ms = list(re.finditer(r'^\s*(?:with the tests above: )?((?:\./gradlew|gradle|mvn|\./mvnw|npx|npm|pytest|python -m pytest|dotnet|go) [^\n]+)$', text, re.M)) + m = ms[-1] if ms else None + return f"next: run {m.group(1).strip()} — only the tests above; a test reached through reflection, a service loader or a subprocess is not among them" if m else '' NEXT = {'path': next_path, 'context': next_context, 'changed': next_changed, 'test-impact': next_test_impact} diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index f4e6b18d9..2bb9181d8 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -32,8 +32,9 @@ # axiomcode changed [] […] [--range a..b | --staged] [--impact] [--json] # which declarations an edit changed and HOW (signature: params added / removed / retyped, return type; field: its type, # name, initializer; type header; body only; removed; added) — the working tree against the commit the graph was built -# from, or two commits — each with the target `impact` takes; --impact runs impact on all of them as one change set. -# axiomcode test-impact [] [--range .. | --staged] [--in ] [--limit N] [--json] [--why] +# from, or a branch's commits (--range a..b reads from `git merge-base a b`) — each with the target `impact` takes; +# --impact runs impact on all of them as one change set. Without git, name the files. +# axiomcode test-impact [] […] [--range .. | --staged] [--in ] [--limit N] [--json] [--why] # which tests actually have to run for this edit: the test files that reach any changed declaration, with the # chain, so a selection can be checked rather than trusted. Conservative by design — a test reached only # through an edge the graph does not encode (reflection, a service loader) will NOT appear. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index 379b72c42..acf3b8636 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -29,6 +29,62 @@ G, die = P.G, P.die def sh(*a, cwd=None): r = subprocess.run(a, capture_output=True, text=True, cwd=cwd); return r.stdout if r.returncode == 0 else None +# the extensions a graph can hold a declaration in; any other changed file is outside every indexed language +CODE_EXT = r'\.(java|ts|tsx|js|mjs|cjs|py|cs)$' + +def is_git(repo): + return sh('git', 'rev-parse', '--git-dir', cwd=repo) is not None + +def resolve_range(repo, rng): + """(old rev, new rev, note) for `--range`. `a..b` means what an agent asking about ITS commits means: what b added since + it forked from a, so the old side is `git merge-base a b`, as `a...b` already is. Diffing the two TIPS, as this did, + reported every commit a received after the fork (the base branch moving under a feature branch) as the agent's own + change, and test-impact selected their tests. `a` alone is a..HEAD. The note says when the base is not a itself.""" + if '...' in rng: a, b, dots = *rng.split('...', 1), 3 + elif '..' in rng: a, b, dots = *rng.split('..', 1), 2 + else: a, b, dots = rng, 'HEAD', 2 + a, b = a or 'HEAD', b or 'HEAD' + ha = (sh('git', 'rev-parse', '--verify', '-q', f'{a}^{{commit}}', cwd=repo) or '').strip() + hb = (sh('git', 'rev-parse', '--verify', '-q', f'{b}^{{commit}}', cwd=repo) or '').strip() + if not ha or not hb: + die(f"--range {rng}: {a if not ha else b!r} is not a commit in {repo}" + ('' if is_git(repo) else ' (it is not a git checkout)') + + " — name the edited files instead: `changed …` / `test-impact …`") + mb = (sh('git', 'merge-base', ha, hb, cwd=repo) or '').strip() + if not mb: + return ha, hb, f"range: {a} and {b} share no history, so this compares the two tips" + if mb == ha: return ha, hb, None # a is an ancestor of b: exactly the old answer + moved = (sh('git', 'rev-list', '--count', f'{mb}..{ha}', cwd=repo) or '?').strip() + return mb, hb, (f"range base: merge-base {mb[:10]} of {a} and {b}, not {a} itself — {a} has moved {moved} commit(s) since " + f"{b} forked from it, and those are not changes of {b}'s; this answers for {b}'s own commits" + + (" (a...b already means this)" if dots == 3 else '')) + +def branch_suggestion(repo): + """when the working tree matches the baseline but HEAD holds commits of its own: the nearest ref HEAD forked from, and + how many commits HEAD has since, so the answer can say `--range ..HEAD` instead of a bare 'no change'. The + nearest is the candidate ref (the remote's default, the usual base names, then recent branches; never this branch's + own remote copy, which is where its commits are pushed, not where it forked) with the fewest commits between it and + HEAD, and at least one.""" + head = (sh('git', 'rev-parse', '-q', '--verify', 'HEAD', cwd=repo) or '').strip() + if not head: return None + fmt = '--format=%(refname:short) %(objectname)' + usual = [f'refs/{k}/{n}' for k in ('heads', 'remotes/origin') for n in ('main', 'master', 'dev', 'develop')] + rows = (sh('git', 'for-each-ref', fmt, *usual, cwd=repo) or '').split('\n') + rows += (sh('git', 'for-each-ref', '--sort=-committerdate', '--count=40', fmt, 'refs/heads', 'refs/remotes', cwd=repo) or '').split('\n') + cur = (sh('git', 'rev-parse', '--abbrev-ref', 'HEAD', cwd=repo) or '').strip() + # on a base branch itself (or its remote's default) there is no fork to name: its commits are the base's own + dflt = (sh('git', 'symbolic-ref', '-q', '--short', 'refs/remotes/origin/HEAD', cwd=repo) or '').strip() + if cur in ('main', 'master', 'dev', 'develop', 'trunk') or (dflt and cur == dflt.split('/', 1)[-1]): return None + best, seen = None, set() + for row in rows: + if ' ' not in row: continue + r, h = row.split(' ', 1) + if h in seen or h == head or r in (cur, f'origin/{cur}') or r.endswith('/HEAD'): continue + seen.add(h) + # commits HEAD has that the ref does not: HEAD's own since it forked from the ref (one git call per ref) + n = int((sh('git', 'rev-list', '--count', f'{h}..{head}', cwd=repo) or '0').strip() or 0) + if n >= 1 and (best is None or n < best[1]): best = (r, n) + return best + def strip_code(t, strings=True, hash_comments=True): """the text with comments and string literals blanked, same length and line breaks — a brace in a string is not a brace. strings=False blanks only the comments: a string is still skipped, so a `//` or `/*` inside one is not a comment. @@ -121,7 +177,8 @@ class Changed: # ── the two texts of a file ──────────────────────────────────────────────────────────────────────────────── def texts(self, rel, mode, rng): if mode == 'range': - a, b = rng.split('..'); return sh('git', 'show', f'{a}:{rel}', cwd=self.repo) or '', sh('git', 'show', f'{b}:{rel}', cwd=self.repo) or '' + a, b = self.range_old, self.range_new # the merge-base, not a's tip (resolve_range) + return sh('git', 'show', f'{a}:{rel}', cwd=self.repo) or '', sh('git', 'show', f'{b}:{rel}', cwd=self.repo) or '' base = self.base_tree or (self.built_at if self.built_at and self.built_at != 'nogit' and sh('git', 'cat-file', '-e', self.built_at, cwd=self.repo) is not None else 'HEAD') old = sh('git', 'show', f'{base}:{rel}', cwd=self.repo) or '' if mode == 'staged': new = sh('git', 'show', f':{rel}', cwd=self.repo) or '' @@ -130,8 +187,12 @@ class Changed: except OSError: new = '' return old, new def files(self, mode, rng, given): - if given: return [self.rel(f) for f in given] - if mode == 'range': out = sh('git', 'diff', '--name-only', rng, cwd=self.repo) + self.outside = [] + if given: + rels = [self.rel(f) for f in given] + self.outside = [r for r in rels if not self.is_code(r)] + return [r for r in rels if self.is_code(r)] + if mode == 'range': out = sh('git', 'diff', '--name-only', self.range_old, self.range_new, cwd=self.repo) elif mode == 'staged': out = sh('git', 'diff', '--name-only', '--cached', *([self.base_tree] if self.base_tree else []), cwd=self.repo) else: base = self.base_tree or (self.built_at if self.built_at and self.built_at != 'nogit' else 'HEAD') @@ -142,15 +203,28 @@ class Changed: # counts when the baseline does not have it, or has it with other content (an explicit index records # untracked files in the tree it indexed). extra = [] + other = 0 for u in (sh('git', 'ls-files', '--others', '--exclude-standard', cwd=self.repo) or '').split('\n'): - if not re.search(r'\.(java|ts|tsx|js|mjs|cjs|py|cs)$', u): continue + if not u.strip() or u.startswith('.axiomcode/'): continue + if not self.is_code(u): # a new non-source file: named, not analysed + other += 1 + if other > 200: continue had = (sh('git', 'rev-parse', '--verify', '-q', f'{base}:{u}', cwd=self.repo) or '').strip() if not had or had != (sh('git', 'hash-object', u, cwd=self.repo) or '').strip(): extra.append(u) out = (out or '') + ''.join('\n' + u for u in extra) # the extension list is the ONLY gate on what a diff can contain, so a language missing from it is # not degraded here, it is absent: `changed` answers [] for every edit and test-impact, which is # `changed` plus impact --tests, can never select anything from it (#1083) - return [l for l in (out or '').split('\n') if re.search(r'\.(java|ts|tsx|js|mjs|cjs|py|cs)$', l)] + # A FILE OUTSIDE EVERY INDEXED LANGUAGE IS STILL A CHANGE (#1470). Fixtures, case data, a schema, a template: + # dropped here, an edit to them answered "no change" and test-impact "no test can be selected", which reads as + # "nothing to test". They are kept apart, named, and test-impact looks for the tests that load them. + lines = [l for l in (out or '').split('\n') if l.strip()] + self.outside = [l for l in lines if not self.is_code(l)] + return [l for l in lines if self.is_code(l)] + def is_code(self, rel): + """a file a graph can hold declarations in: a source extension, or a file this graph holds whatever its name (a + script with a python shebang and no extension, #1376)""" + return bool(re.search(CODE_EXT, rel)) or rel in self.spans # ── the text-level structure of a declaration ───────────────────────────────────────────────────────────── @staticmethod @@ -212,10 +286,19 @@ class Changed: got = {i + 1 for i in range(i1, i2) if not noise(OL[i])}; old_changed |= got; removed_lines |= got elif tag == 'insert': if any(not noise(NL[j]) for j in range(j1, j2)): added.append((i1, j1 + 1, j2)) # after old line i1, new lines j1+1..j2 + # AN INSERTION IS PLACED AS LATE AS THE TEXT ALLOWS. When a block's first line equals the line after it, the same + # diff can put it one line later; difflib picks the earliest, so a type added below another began with that + # type's own `}` and sat INSIDE it: its interface and fields were named as members of the type above. Sliding + # past the equal lines puts it after the closing brace (git's diff does the same with its sliders) + slid = [] + for after, s_, e_ in added: + while e_ < len(NL) and after < len(OL) and NL[s_ - 1] == NL[e_] and OL[after] == NL[e_]: + new_of[after + 1] = s_; after += 1; s_ += 1; e_ += 1 + slid.append((after, s_, e_)) + added = slid decls = self.decl_spans(rel, getattr(self, 'mode', 'worktree')) - if not decls and not old.strip() and new.strip(): # a new file: its declarations, all added - decls = []; added = [(0, 1, len(NL))]; old_changed = set(); removed_lines = set(); new_of = {}; replaced = [] - elif not decls: return [], [(rel, 'no declarations known here (not in the graph, or a different file spelling)', None)] + if not old.strip() and new.strip(): return self.new_file(rel, new, decls, is_py), [] + if not decls: return [], [(rel, 'no declarations known here (not in the graph, or a different file spelling)', None)] hits = {} def narrowest(ln, kinds=None): best = None @@ -337,6 +420,7 @@ class Changed: else: entry['target'] = None out.append(entry) adds = [] + SL = strip_code(new, hash_comments=is_py).split('\n') # the new text, strings and comments blanked ndepth = [0] * (len(NL) + 2) # brace depth at the START of each new line for i, text in enumerate(strip_code(new).split('\n'), 1): ndepth[i] = ndepth[i - 1] + text.count('{') - text.count('}') for after, j1, j2 in added + [(a, b, c) for a, b, c in replaced]: @@ -354,12 +438,25 @@ class Changed: # a Python docstring is the first statement of the body, so an insertion right after it is inside the # callable just as the brace above is; rejecting it charged the edit to the class (#1358) if after <= len(OL) and owner and owner[3] != 'module' and owner[0] < after and OL[after - 1].strip().startswith(TRIPLE): anchored = True + # STRICTLY INSIDE A CALLABLE IS INSIDE IT, whatever the anchor line holds. An insertion after the `}` that + # closes an inner block, a blank line or a comment in the body is still that callable's body; rejected as + # "not anchored" it came back as "N new line(s) at file:518" and changed --impact / test-impact never + # followed it. The anchor test still decides at the callable's first line, and a module is never a body + if owner and owner[3] != 'module' and owner[0] < after < owner[1]: anchored = True in_body = bool(is_add and owner and owner[0] <= after < owner[1] and anchored) if in_body: # strictly inside a callable's body: that callable changed if not any(e['id'] == owner[2] for e in out): out.append(dict(kind='body', symbol=owner[4], id=owner[2], file=rel, line=owner[0], end=owner[1], old_lines=[after], target=owner[4], target_kind='method')) # and the inserted lines may still declare something at the type's level (a method extracted from this body) t = narrowest(after, {'class', 'interface', 'enum', 'type', 'namespace'}) if after else None + # an insertion after the line that CLOSES a type is outside it: a new interface written below a class was + # named as a member of that class, and its fields with it. (Python has no closing line: a type's last line is + # body, so there the inserted text's own indentation decides, below) + if t and after >= t[1] and not is_py: t = None + if t and after >= t[1] and is_py: + first = next((NL[j - 1] for j in range(j1, j2 + 1) if NL[j - 1].strip()), '') + hdr = OL[t[0] - 1] if t[0] <= len(OL) else '' + if len(first) - len(first.lstrip()) <= len(hdr) - len(hdr.lstrip()): t = None # a declaration the new text introduces: named from its header, owner = the type around the insertion point names = []; hdrs = {} # the type's body level in the NEW text: brace depth just after its header (indentation for Python); a declaration @@ -371,18 +468,23 @@ class Changed: else: body_depth = 0 for j in range(j1, j2 + 1): text = NL[j - 1]; st = re.sub(r'\s*(//.*|/\*.*?\*/\s*)$', '', text).strip() + # a line wholly inside a docstring, a string or a comment declares nothing: read as code, a docstring + # sentence at the body's indentation (`works (see below)`) was listed as an added method + if not SL[j - 1].strip(): continue st = re.sub(r'^(@[\w.]+(\([^)]*\))?\s+)+(?=\w)', '', st) # `@Override public …` on one line here = (ndepth[j - 1] if not py else len(text) - len(text.lstrip())) if here != body_depth or not st or st.startswith(('//', '*', '/*', '@', '#', 'return', 'if', 'for', 'while', 'switch', 'else', 'throw', 'new ', 'case', 'try', 'catch')): continue + # a file's own header lines declare nothing: `package a.b.c;` and `import a.b.C;` read as a field `c` / `C` + if re.match(r'(package|import|using|namespace|from|module|export\s+\*)\b', st): continue tm = re.match(r'^(?:(?:public|private|protected|static|final|abstract|sealed)\s+)*(class|interface|enum|record|@interface)\s+([A-Za-z_$][\w$]*)', st) - if tm: names.append((tm.group(2), tm.group(1).lstrip('@') + ' (with its members)')); hdrs[tm.group(2)] = st; continue - m = re.match(r'^(?:def|function|async function)\s+([A-Za-z_$][\w$]*)\s*\(', st) or \ + if tm: names.append((tm.group(2), tm.group(1).lstrip('@') + ' (with its members)', j)); hdrs[tm.group(2)] = st; continue + m = re.match(r'^(?:def|async def|function|async function)\s+([A-Za-z_$][\w$]*)\s*\(', st) or (not py and ( re.match(r'^(?:[\w<>\[\],.?$ ]+\s+)?([A-Za-z_$][\w$]*)\s*\([^;=]*\)\s*(?:throws[\w.,\s]+)?\s*(\{|;|$)', st) or \ - re.match(r'^(?:[\w<>\[\],.?$ ]+\s+)?([A-Za-z_$][\w$]*)\s*\([^;={]*$', st) + re.match(r'^(?:[\w<>\[\],.?$ ]+\s+)?([A-Za-z_$][\w$]*)\s*\([^;={]*$', st))) if m and not re.match(r'^(if|for|while|switch|catch|synchronized|return|new|super|this|else)$', m.group(1)) and '=' not in st.split('(')[0]: - names.append((m.group(1), 'method')); hdrs[m.group(1)] = ' '.join(x.strip() for x in NL[j - 1:self.header_end(NL, j)]); continue + names.append((m.group(1), 'method', j)); hdrs[m.group(1)] = ' '.join(x.strip() for x in NL[j - 1:self.header_end(NL, j)]); continue ty, nm = self.field_parts(st) - if nm and ty and st.endswith(';') and '(' not in st.split('=')[0]: names.append((nm, 'field')) + if nm and ty and st.endswith(';') and '(' not in st.split('=')[0]: names.append((nm, 'field', j)) # a declaration the old file already has — same owner, same name, same arity — is not new (an added OVERLOAD is) def types_of(hdr): """the parameter types of a header, as simple names: `List xs, int n` → [List, int]; a signature without names too""" @@ -407,7 +509,7 @@ class Changed: if ('...' in old_hdr) != ('...' in hdr): continue return True return False - for nm, k in names: + for nm, k, jn in names: if old_has(nm, hdrs.get(nm, '')): continue # an added method whose name already exists on this owner is an OVERLOAD: the compiler may rebind # existing call sites of that name to it, and a call site records no argument types, so which of them @@ -419,14 +521,14 @@ class Changed: sib += [r[0] for r in self.g.q("SELECT DISTINCT s.display FROM call_edges e JOIN symbols s ON s.id = e.caller_id WHERE e.callee_method_id = ?", sy['method_id'])] note = f'new {k}' if sib: note += f"; an overload: {len(set(sib))} existing call site(s) of {(t[4] + '.') if t else ''}{nm} may rebind to it (a call site records no argument types, so which of them is NOT decidable here): " + ', '.join(sorted(set(sib))[:4]) + (' …' if len(set(sib)) > 4 else '') - out.append(dict(kind='added', symbol=(t[4] + '.' if t else '') + nm, id=None, file=rel, line=j1, end=j2, old_lines=[], detail=note, + out.append(dict(kind='added', symbol=(t[4] + '.' if t else '') + nm, id=None, file=rel, line=jn, end=jn, old_lines=[], detail=note, target=((t[4] + '.' if t else '') + nm) if sib else None)) if 'members' in k: # the new type's own declarations, one level deeper — its whole body in the new text hj = next((j for j in range(j1, j2 + 1) if re.search(rf'\b{re.escape(nm)}\b', NL[j - 1])), j1); j = hj + 1 while j <= len(NL) and (ndepth[j - 1] > body_depth if not py else (not NL[j - 1].strip() or len(NL[j - 1]) - len(NL[j - 1].lstrip()) > body_depth)): st2 = re.sub(r'^(@[\w.]+(\([^)]*\))?\s+)+(?=\w)', '', re.sub(r'\s*(//.*|/\*.*?\*/\s*)$', '', NL[j - 1]).strip()); here2 = (ndepth[j - 1] if not py else len(NL[j - 1]) - len(NL[j - 1].lstrip())); j += 1 - if here2 != body_depth + (1 if not py else 4) or not st2 or st2.startswith(('//', '*', '/*', '@', '#', '}', '{')): continue - mm = re.match(r'^(?:[\w<>\[\],.?$ ]+\s+)?([A-Za-z_$][\w$]*)\s*\([^;={]*', st2) or re.match(r'^(?:def|function)\s+([A-Za-z_$][\w$]*)\s*\(', st2) + if here2 != body_depth + (1 if not py else 4) or not st2 or st2.startswith(('//', '*', '/*', '@', '#', '}', '{')) or not SL[j - 2].strip(): continue + mm = re.match(r'^(?:async\s+)?(?:def|function)\s+([A-Za-z_$][\w$]*)\s*\(', st2) or (None if py else re.match(r'^(?:[\w<>\[\],.?$ ]+\s+)?([A-Za-z_$][\w$]*)\s*\([^;={]*', st2)) if mm and mm.group(1) not in ('if', 'for', 'while', 'switch', 'catch', 'return', 'new', 'super', 'this'): out.append(dict(kind='added', symbol=f"{(t[4] + '.' if t else '')}{nm}.{mm.group(1)}", id=None, file=rel, line=j - 1, end=j - 1, old_lines=[], detail='new method', target=None)); continue ty2, nm2 = self.field_parts(st2) if nm2 and ty2 and st2.endswith(';') and '(' not in st2.split('=')[0]: out.append(dict(kind='added', symbol=f"{(t[4] + '.' if t else '')}{nm}.{nm2}", id=None, file=rel, line=j - 1, end=j - 1, old_lines=[], detail='new field', target=None)) @@ -446,6 +548,48 @@ class Changed: q = self.qualified(e.get('id'), e.get('kind')) if t and q and t.startswith(e['symbol']): e['target'] = q + t[len(e['symbol']):]; e['shown_target'] = t return out, adds + def new_file(self, rel, new, decls, is_py): + """A FILE THE BASE DOES NOT HAVE is one change: `added (N declarations)`. Read line by line as an insertion, + a new module listed every function at line 1, each parameter list as a change and docstring words as methods. What + can be analysed is what already has a caller outside the file (the graph was built from the new text): those + declarations are listed with their impact target; the rest have no dependents yet. A new test file is named as a + test, so test-impact runs it.""" + callables = [d for d in decls if d[3] in ('method', 'function', 'constructor')] + types = [d for d in decls if d[3] in ('class', 'interface', 'enum', 'type', 'namespace')] + n = len(callables) + len(types) + if not decls: # not in the graph yet: count the headers in the text + code = strip_code(new, hash_comments=is_py) + n = len(re.findall(r'^\s*(?:async\s+)?(?:def|class)\s+\w', code, re.M)) if is_py else \ + len(re.findall(r'^\s*(?:[\w<>\[\],.?]+\s+)*(?:class|interface|enum|record|struct)\s+\w', code, re.M)) + is_test = bool(re.search(r'(^|[/_.-])(test|tests|spec|specs|__tests__|it)([/_.-]|$)', rel, re.I)) or \ + any(self.g.all_sym.get(d[2], {}).get('is_test') for d in callables) + out = [dict(kind='added', symbol=rel, id=None, file=rel, line=1, end=len(new.split('\n')), old_lines=[], target=None, new_file=True, + is_test=is_test, detail=f"new file, {n} declaration(s)" + ('' if decls else ' by its text (the graph does not hold it yet)') + + (' — a test file: run it' if is_test else ''))] + ids = [d[2] for d in callables if isinstance(d[2], (int, str)) and not str(d[2]).startswith('f:')] + mids = {self.g.all_sym.get(i, {}).get('method_id'): i for i in ids if self.g.all_sym.get(i, {}).get('method_id') is not None} + if mids: + q = ','.join('?' * len(mids)) + callers = {} + for r in self.g.q(f"SELECT e.callee_method_id m, s.file f, s.display d FROM call_edges e JOIN symbols s ON s.id = e.caller_id WHERE e.callee_method_id IN ({q})", *mids): + if r['f'] != rel: callers.setdefault(r['m'], set()).add(r['d']) + for m, cs in callers.items(): + i = mids[m]; sy = self.g.all_sym[i] + out.append(dict(kind='added', symbol=sy['display'], id=i, file=rel, line=sy['line'], end=sy['end_line'] or sy['line'], old_lines=[], + detail=f"new, and already called from outside this file ({len(cs)} caller(s): {', '.join(sorted(cs)[:3])}{' …' if len(cs) > 3 else ''})", + target=self.qualified(i, 'method') or sy['display'], target_kind='method', referenced=True)) + return out + def whole_file(self, rel, why): + """every callable the graph declares in a NAMED file, as changed: what `test-impact ` means when there is no + edit to diff (no git, or the file matches its base). The types stand in for a file that declares no callable.""" + spans = self.spans.get(rel, []) + pick = [d for d in spans if d[3] in ('method', 'function', 'constructor')] or [d for d in spans if d[3] in ('class', 'interface', 'enum', 'type')] + out = [] + for a, b, i, k, d, n in pick: + out.append(dict(kind='named', symbol=d, id=i, file=rel, line=a, end=b, old_lines=[], detail=why, + target=self.qualified(i, 'method') or d, target_kind='type' if k in ('class', 'interface', 'enum', 'type') else 'method')) + if not out: out.append(dict(kind='named', symbol=rel, id=None, file=rel, line=1, end=1, old_lines=[], detail=why + '; the graph declares nothing in it', target=None)) + return out def qualified(self, i, kind): if i is None: return None r = self.g.all_sym.get(i) @@ -459,9 +603,36 @@ class Changed: def target(kind, d, k, n): return d +NO_GIT = ("no git base: {repo} is not a git checkout (a copy without .git), so there is no earlier version to diff the " + "working tree against, and nothing can say what changed. Name the files you edited: `axiomcode changed …` " + "or `axiomcode test-impact …` (MCP files=[…]) counts every declaration in each named file as changed.") + +def outside_note(files, suite=None): + """one line naming the changed files no graph here can read; test-impact adds the tests that load them""" + fs = sorted(files) + return (f"{len(fs)} changed file(s) outside every indexed language, so no declaration in them is analysed: " + + ', '.join(fs[:8]) + (f' … +{len(fs) - 8}' if len(fs) > 8 else '') + + " — `test-impact` names the tests that load them by name; an edit here is not 'no change'") + +def held_extensions(repo): + """the file extensions some graph of this repository holds (the main one and every per-language one)""" + import sqlite3, glob + ext = set() + for db in [os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')] + glob.glob(os.path.join(repo, '.axiomcode', 'lang', '*', 'out', 'graph.sqlite')): + try: + con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) + ext |= {os.path.splitext(r[0])[1] for r in con.execute("SELECT DISTINCT rel FROM paths") if r[0]} + con.close() + except Exception: pass + return ext + def main(argv): a = list(argv); mode = 'worktree'; rng = None; want_impact = '--impact' in a; as_json = '--json' in a - a = [x for x in a if x not in ('--impact', '--json')] + whole = '--whole' in a # a NAMED file with no diff counts whole (test-impact : "the tests of this file") + # the branch suggestion costs a few git calls per candidate ref: asked for by a reader (prose) or by test-impact + # (--suggest), never by the edit hooks, which read --json on every prompt + want_suggest = not as_json or '--suggest' in a + a = [x for x in a if x not in ('--impact', '--json', '--whole', '--suggest')] old_f = new_f = one = None for flag in ('--range', '--old', '--new', '--file'): if flag in a: @@ -473,6 +644,16 @@ def main(argv): if '--staged' in a: mode = 'staged'; a.remove('--staged') repo = '.' if a and os.path.isdir(a[0]): repo = a.pop(0) + rrepo = os.path.realpath(repo) + # NO GIT, NO BASE. On a copy without .git every file read as new against an empty old text, so `changed` listed every + # function as added at line 1 and docstring words as methods. Without named files there is no answer to give: say so + # and say what to pass. With named files, each counts whole (below). + git = is_git(rrepo) + if not git and not (old_f or new_f) and not a: + msg = NO_GIT.format(repo=rrepo) + if as_json: print(json.dumps({'built_at': None, 'changed': [], 'notes': [], 'refused': msg, 'no_git': True}, indent=1)) + else: print(msg) + return 2 # an EDIT is measured against the baseline, and read from the graph that describes it (#1305): after a background # refresh that is the graph kept in .axiomcode/base, not the current one. Given two texts (--old/--new, the edit # hook's before-the-edit check) the current graph is the one whose spans are in the current file's lines. @@ -481,41 +662,57 @@ def main(argv): baseline = None; fanout_empty = False sys.path.insert(0, HERE); import ax_fresh if mode != 'range' and not (old_f or new_f): - bg = ax_fresh.baseline_graph(os.path.realpath(repo)); given = os.environ.get('AXIOMCODE_GRAPH') + bg = ax_fresh.baseline_graph(rrepo); given = os.environ.get('AXIOMCODE_GRAPH') # the dispatcher names the CURRENT graph of the language it is asking (AXIOMCODE_GRAPH_LANG): that is not a # graph chosen for this edit, so the language's baseline is still looked for, as it is for the main one - if given and os.path.realpath(given) == os.path.realpath(ax_fresh.graph_dir(os.path.realpath(repo))): given = None + if given and os.path.realpath(given) == os.path.realpath(ax_fresh.graph_dir(rrepo)): given = None if bg and (not given or os.path.realpath(given) == os.path.realpath(bg)): baseline = bg; os.environ['AXIOMCODE_GRAPH'] = bg # also for --impact, which runs impact below C = Changed(repo, baseline=bool(baseline)); C.mode = 'files' if (old_f or new_f) else mode - results = []; notes = [] + range_note = None + if mode == 'range': C.range_old, C.range_new, range_note = resolve_range(C.repo, rng) + results = []; notes = []; outside = []; suggest = None + main_graph = not ax_fresh.graph_lang() if old_f or new_f: if not one: die("--old/--new need --file ") rel = C.rel(one); old = open(old_f, errors='replace').read() if old_f else ''; new = open(new_f, errors='replace').read() if new_f else '' r, n = C.file_changes(rel, old, new); results += r; notes += n else: files = C.files(mode, rng, a) + outside = list(C.outside) + # a source file in a language no graph here holds (a .java file beside a Python-only index) is outside the index too + held = held_extensions(C.repo) + if held: + outside += [f for f in files if os.path.splitext(f)[1] not in held and f not in C.spans] + files = [f for f in files if f not in outside] # A REPOSITORY IN SEVERAL LANGUAGES has a graph per language, and the dispatcher asks each (ax_langs.py). Each # answers for the files it owns, so an edit is reported once, by the graph of its language, and not also as - # "no declarations known here" by every other. + # "no declarations known here" by every other. Files outside every language are named once, by the main graph. if os.path.isdir(os.path.join(C.repo, '.axiomcode', 'lang')): import ax_langs mine = ax_fresh.graph_lang() files = [f for f, o in ax_langs.owners(C.repo, files).items() if o == mine] - if not files and os.environ.get('AXIOMCODE_FANOUT'): fanout_empty = True + if not main_graph: outside = [] + if not files and not outside and os.environ.get('AXIOMCODE_FANOUT'): fanout_empty = True # the graph describes ONE commit. For a range whose newer side is that commit, the declarations are read on the new # text and the classification runs with the two texts swapped, then its direction is turned around flipped = False - if mode == 'range' and C.built_at and rng: - b = rng.split('..')[1]; hb = sh('git', 'rev-parse', b, cwd=C.repo) - flipped = bool(hb) and hb.strip().startswith(C.built_at[:10]) is False and hb.strip() == C.built_at - if hb and not flipped and hb.strip()[:10] == C.built_at[:10]: flipped = True + if mode == 'range' and C.built_at: + hb = C.range_new + flipped = bool(hb) and hb[:10] == C.built_at[:10] for rel in files: + if a and not git: # a named file, no git: every declaration in it + results += C.whole_file(rel, 'no git base to diff against'); continue old, new = C.texts(rel, mode, rng) - if old == new: continue - if flipped: + if old == new: + if a and whole: results += C.whole_file(rel, f"no edit against {'the index' if mode == 'staged' else 'the baseline'}: the file was named") + continue + if flipped and not old.strip() and new.strip(): # a file the range adds: one line, not its decls reversed + results += C.new_file(rel, new, C.decl_spans(rel, mode), rel.endswith(('.py', '.pyi')) or bool(re.match(r'#![^\n]*\bpython', new))) + elif flipped: r, n = C.file_changes(rel, new, old) for e in r: + if e.get('new_file'): e['kind'] = 'removed'; e['detail'] = 'the file is gone at the newer side'; continue if e['kind'] == 'removed': e['kind'] = 'added (declaration)'; e['target'] = None if e.get('detail'): e['detail'] = ' → '.join(reversed(e['detail'].split(' → '))) if ' → ' in e['detail'] and ':' not in e['detail'] else re.sub(r'(\w+): (.+?) → (.+?)(,|$)', r'\1: \3 → \2\4', e['detail']) @@ -525,25 +722,54 @@ def main(argv): else: r, n = C.file_changes(rel, old, new); results += r; notes += n if flipped: notes.append(('', f"the graph is built at the newer commit {C.built_at[:10]}: declarations are the new text's; a declaration the range removed is not in it", None)) - if as_json: print(json.dumps({'built_at': C.built_at, 'changed': results, 'notes': [x[1] for x in notes]}, indent=1)); return 3 if fanout_empty else 0 - if not results and not notes: print("no change to a declaration the graph knows" + (f" (working tree against {C.built_at[:10]})" if C.built_at and mode == 'worktree' else '')); return 3 if fanout_empty else 0 + # COMMITTED WORK IS NOT IN THE WORKING TREE. After a commit the baseline follows HEAD, so the working tree matches + # it and the answer was a bare "no change", while the agent's changes sat in commits a --range would have read + if want_suggest and mode == 'worktree' and not a and not results and not notes and not outside and git and main_graph: + s_ = branch_suggestion(C.repo) + if s_: suggest = dict(ref=s_[0], commits=s_[1], range=f"{s_[0]}..HEAD") + if outside: notes.append(('', outside_note(outside), None)) + base_desc = (f"working tree against {C.built_at[:10] if C.built_at and C.built_at != 'nogit' else 'HEAD'}" if mode == 'worktree' else + f"{rng} (from {C.range_old[:10]} to {C.range_new[:10]})" if mode == 'range' else mode) + sugg_line = (f"your commits are not in the working tree: HEAD is {suggest['commits']} commit(s) ahead of {suggest['ref']} — " + f"ask `changed --range {suggest['range']}` (MCP range='{suggest['range']}') for them") if suggest else None + if as_json: + print(json.dumps({'built_at': C.built_at, 'changed': results, 'notes': [x[1] for x in notes], 'outside_index': sorted(outside), + 'range_base': (C.range_old if mode == 'range' else None), 'range_note': range_note, + 'suggest_range': suggest, 'suggest_note': sugg_line}, indent=1)) + return 3 if fanout_empty else 0 + if range_note: print(f"note: {range_note}") + if not results and not notes: + print(f"no change to a declaration the graph knows ({base_desc})") + if sugg_line: print(f"next: {sugg_line}") + return 3 if fanout_empty else 0 against = (f" — against the tree the graph was indexed from at the last `axiomcode index` (commit {C.built_at[:10]} plus the edits that were uncommitted then)" if C.tree_differs - else f" — against the graph's commit {C.built_at[:10]}") - print(f"changed declarations ({len(results)})" + (against if C.built_at and mode == 'worktree' else (f" — {rng}" if rng else '')) + ":") - for e in results: - print(f" {e['kind']:<10} {e['symbol']} {e['file']}:{e['line']}" + (f" — {e['detail']}" if e.get('detail') else '') + (f" → impact {e.get('shown_target') or e['target']}" if e.get('target') else (' (no declaration of that name existed before, so nothing in the old tree names it)' if e['kind'] == 'added' else ''))) + else f" — against the graph's commit {C.built_at[:10]}" if C.built_at != 'nogit' else " — against HEAD (the graph was built before this was a git checkout)") + named = [e for e in results if e['kind'] == 'named']; shown = [e for e in results if e['kind'] != 'named'] + print(f"changed declarations ({len(results)})" + (" — the two texts given" if (old_f or new_f) else against if C.built_at and mode == 'worktree' and not named else (f" — {rng}" if rng else '')) + ":") + for e in shown: + tail = (f" → impact {e.get('shown_target') or e['target']}" if e.get('target') else + ('' if e.get('new_file') else ' (no declaration of that name existed before, so nothing in the old tree names it)' if e['kind'] == 'added' else '')) + print(f" {e['kind']:<10} {e['symbol']}" + ('' if e.get('new_file') else f" {e['file']}:{e['line']}") + (f" — {e['detail']}" if e.get('detail') else '') + tail) + by_file = {} + for e in named: by_file.setdefault(e['file'], []).append(e) + for f, es in by_file.items(): + print(f" {'named':<10} {f} — {len(es)} declaration(s) count as changed ({es[0]['detail']}): " + + ', '.join(e['symbol'] for e in es[:10]) + (f" … +{len(es) - 10}" if len(es) > 10 else '')) # a note with a file is an addition in that file; one without is a caveat about the whole answer, and printing it # under `added` made "the graph is built at the newer commit …" read as a new declaration for f, n, _ in notes: print(f" {'added':<10} {n}" if f else f"note: {n}") groups = {} for e in results: - if e.get('target') and e['kind'] != 'added': + if e.get('target') and (e['kind'] != 'added' or e.get('referenced')): k = 'method' if e.get('target_kind') == 'param' and '(' not in e['target'] else e.get('target_kind', 'method') groups.setdefault('' if '(' in e['target'] else k, []).append(e['target']) if want_impact and groups: for k, ts in groups.items(): # one impact run per kind (a field named like a method needs to be told apart) - print(); sys.stdout.flush() - subprocess.run([sys.executable, os.path.join(HERE, 'axiomcode-impact'), *dict.fromkeys(ts), C.repo] + (['--kind', k] if k else [])) + # captured and printed here, so it lands AFTER the list above: written straight to the terminal it came out + # before this process's own (paged, buffered) lines, and a range note or the list itself sank to page 2 + r = subprocess.run([sys.executable, os.path.join(HERE, 'axiomcode-impact'), *dict.fromkeys(ts), C.repo, '--page', 'all'] + (['--kind', k] if k else []), + capture_output=True, text=True) + print(); print(r.stdout.rstrip('\n')); sys.stderr.write(r.stderr) return 0 if __name__ == '__main__': diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact index 9154a8f96..d579b2659 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact @@ -1,5 +1,13 @@ #!/usr/bin/env python3 -"""axiomcode test-impact [] [--range .. | --staged] [--in ] [--limit N] [--json] [--why] +"""axiomcode test-impact [] […] [--range .. | --staged] [--in ] [--limit N] [--json] [--why] + + … only these files; a named file with no edit against its base (or any named file on a copy + without git) counts WHOLE: the tests of everything declared in it + --range a..b b's own commits: compared from `git merge-base a b`, so commits a received after b forked + from it are not counted (a note says so when a has moved) + files outside every indexed language (fixtures, case data, a schema) are named, with the test files that + name them in their text; a changed file that is also run as a program (__main__, main) is looked for by + name in the tests too, since a test that starts it as a subprocess has no call edge to it WHICH TESTS ACTUALLY HAVE TO RUN for the edit in front of you. @@ -152,6 +160,12 @@ def stems_of(rel): return {x for x in out if len(x) > 2} +def family(rel): + """the language a file's extension belongs to, for the name tier (TypeScript and JavaScript read each other)""" + e = os.path.splitext(rel)[1] + return {'.py': 'py', '.pyi': 'py', '.java': 'java', '.cs': 'cs'}.get(e, 'js' if e in ('.ts', '.tsx', '.js', '.mjs', '.cjs', '.jsx') else ('py' if not e else e)) + + def package_tier(repo, changed_files, limit=6000): """Two tiers of proximity, for when the call graph named nothing. Neither is a call-graph answer. @@ -169,6 +183,7 @@ def package_tier(repo, changed_files, limit=6000): for f in changed_files: if f: want_stems |= stems_of(f) if not pkgs and not want_stems: return {}, {}, pkgs + fams = {family(f) for f in changed_files if f} by_pkg = collections.defaultdict(list) by_name = [] seen = 0 @@ -177,15 +192,79 @@ def package_tier(repo, changed_files, limit=6000): for f in fs: if not f.endswith(('.ts', '.tsx', '.js', '.mjs', '.java', '.py', '.cs')): continue # a list that omits an extension makes both tiers below silently inert for that language (#1083's shape) rel = os.path.relpath(os.path.join(root, f), repo).replace(os.sep, '/') - if not TESTY.search(rel): continue + if not TESTY.search(rel) or rel in changed_files: continue # a changed test file is run as edited, not found here seen += 1 if seen > limit: return dict(by_pkg), {'files': sorted(by_name)}, pkgs - if stems_of(rel) & want_stems: by_name.append(rel) + # a test named after a changed file in ANOTHER language is not its test (server.js for server.py) + if stems_of(rel) & want_stems and family(rel) in fams: by_name.append(rel) p = package_of(rel) if p in pkgs: by_pkg[p].append(rel) return dict(by_pkg), ({'files': sorted(by_name)} if by_name else {}), pkgs +CODE = ('.ts', '.tsx', '.js', '.mjs', '.java', '.py', '.cs') +PROGRAM = re.compile(r'''^if\s+__name__\s*==\s*['"]__main__['"]|^#!.*\bpython|\bstatic\s+(?:async\s+)?(?:void|int|Task(?:)?)\s+[Mm]ain\s*\(''', re.M) + + +def test_texts(repo, limit=6000, cap=200_000): + """(test file, its text) for every test file under the repository, the way package_tier finds them""" + seen = 0 + for root, dirs, fs in os.walk(repo): + dirs[:] = [d for d in dirs if d not in ('node_modules', '.git', 'dist', 'build', 'target', '.axiomcode', 'bin', 'obj')] + for f in fs: + if not f.endswith(CODE): continue + rel = os.path.relpath(os.path.join(root, f), repo).replace(os.sep, '/') + if not TESTY.search(rel): continue + seen += 1 + if seen > limit: return + try: yield rel, open(os.path.join(repo, rel), errors='replace').read(cap) + except OSError: continue + + +def needles(rel, program=False): + """what a test that loads `rel` without calling it writes in its text, most specific first: the path, its tails (at + least two parts), then the file name, then (data only) the name of each directory holding it. A test runner that + globs `cases/*/case.json` names the file name and the directory, never the path.""" + parts = rel.split('/') + out = ['/'.join(parts[i:]) for i in range(len(parts) - 1)] + base = parts[-1]; stem = os.path.splitext(base)[0] + out.append(base) + if program: + out.append(stem) # `python -m pkg.cli`, [sys.executable, 'tool'] + if rel.endswith('.py'): out.append('.'.join(os.path.splitext(rel)[0].split('/')[-2:])) + elif TESTY.search(os.path.dirname(rel) + '/'): + # data under a test tree (fixtures, cases): a runner that globs it names its directory, not the file. Anywhere + # else a directory name is every file in it, and a doc under `skills/axiomcode/` matched every test naming the tool + out += [d for d in reversed(parts[:-1]) if len(d) > 2 and not TESTY.fullmatch(d)] + return [n for n in dict.fromkeys(out) if len(n) > 3] + + +def loaders(repo, wanted, edited=()): + """{changed file: (the needle that matched, [test files naming it])} for files no call edge can reach a test from: + changed files outside every indexed language, and code that also runs as a program. The most specific needle any + test names wins, and only the tests naming it are kept, so a bare directory name is used only when nothing names the + file itself. A text match, not a call: printed as its own tier.""" + if not wanted: return {} + want = {f: needles(f, prog) for f, prog in wanted.items()} + # a path or a file name stands alone (not inside a longer name); a bare word (a directory, a program's name) only + # where the text quotes it or puts it in a path, or `cases` would match every sentence about test cases + pats = {n: re.compile((r'(? needle index -> [tests] + for t, text in test_texts(repo): + if t in wanted or t in edited: continue + for f, ns in want.items(): + for k, n in enumerate(ns): + if n in text and re.search(pats[n], text): + hits[f].setdefault(k, []).append(t); break + out = {} + for f, by in hits.items(): + if by: + k = min(by); out[f] = (want[f][k], sorted(by[k])) + return out + + def read_answer(out): """impact's JSON document, even when a diagnostic was printed in front of it. @@ -213,6 +292,9 @@ def read_answer(out): return None, None +VALUED = ('--range', '--in', '--limit') + + def main(argv): as_json = '--json' in argv why = '--why' in argv @@ -234,30 +316,62 @@ def main(argv): if '--range' not in passthru and (not given or os.path.realpath(given) == os.path.realpath(ax_fresh.graph_dir(repo_))): bg = ax_fresh.baseline_graph(repo_) if bg: os.environ['AXIOMCODE_GRAPH'] = bg - rc, out, err = run('axiomcode-changed', passthru + ['--json']) - if rc != 0: - sys.stderr.write(err or out); return rc + # NAMED FILES: `test-impact …` is "the tests of these files". A named file with an edit is read as that edit; one + # with none (or any, on a copy without git) counts whole, which `changed` does when told --whole + pos, skip = [], False + for x in passthru: + if skip: skip = False; continue + if x in VALUED: skip = True; continue + if not x.startswith('-') and not os.path.isdir(x): pos.append(x) + changed_args = [] # --in narrows the TESTS, not the edit: changed never sees it + for k, x in enumerate(passthru): + if x == '--in' or (k and passthru[k - 1] == '--in'): continue + changed_args.append(x) + rc, out, err = run('axiomcode-changed', changed_args + (['--whole'] if pos else []) + ['--json', '--suggest']) try: changed = json.loads(out) except Exception: - sys.stderr.write(out); return 2 + changed = None + if changed and changed.get('refused'): + print(changed['refused'].replace('`axiomcode changed …` or ', '')) + return 2 + if rc != 0 or changed is None: + sys.stderr.write(err or out); return rc or 2 entries = changed.get('changed') or [] targets, by_target = [], {} for e in entries: t = e.get('target') - if not t: continue + if not t or (e.get('kind') == 'added' and not e.get('referenced')): continue if t not in by_target: by_target[t] = e; targets.append(t) - if not targets: - print("no changed declaration the graph can name, so no test can be selected from it." - + ("\n " + "; ".join(changed.get('notes') or []) if changed.get('notes') else "")) + repo = next((a for a in passthru if os.path.isdir(a)), '.') + heads = [f"note: {changed['range_note']}"] if changed.get('range_note') else [] + # A CHANGED TEST FILE IS ITSELF A TEST TO RUN: a new test file, or an edited one, has no test reaching it — it is one + edited = sorted({e['file'] for e in entries if e.get('file', '').endswith(CODE) and (e.get('is_test') or TESTY.search(e['file']))}) + # WHAT NO CALL EDGE REACHES: files outside every indexed language (fixtures, case data), and changed code that is + # also started as a program (a test that runs it as a subprocess has no edge to it). Looked for by name in the tests + outside = changed.get('outside_index') or [] + progs = {} + for f in sorted({e['file'] for e in entries if e.get('file')} - set(edited)): + try: txt = open(os.path.join(repo, f), errors='replace').read(400_000) + except OSError: continue + if PROGRAM.search(txt): progs[f] = True + loaded = loaders(os.path.realpath(repo), dict({f: False for f in outside}, **progs), edited) if (outside or progs) else {} + loaded_files = sorted({t for _, ts in loaded.values() for t in ts}) + + if not targets and not edited and not loaded and not outside: + for h in heads: print(h) + print("no changed declaration the graph can name, so no test can be selected from it.") + for n in (changed.get('notes') or [])[:8]: print(f" {n}") + if len(changed.get('notes') or []) > 8: print(f" … +{len(changed['notes']) - 8} more note(s)") + if changed.get('suggest_note'): print(f"next: {changed['suggest_note']}".replace('`changed --range', '`test-impact --range')) return 0 # `impact` is asked once per changed declaration rather than once for all of them: a single call # unions the answers and loses which change pulled in which test, and that is exactly what a reader - # needs to decide whether a selection is believable. - repo = next((a for a in passthru if os.path.isdir(a)), '.') + # needs to decide whether a selection is believable. Past a handful (a whole named file) the processes cost more than + # the attribution is worth: one call for all of them, each test credited to the declaration its chain ends at. scope = [] if '--in' in passthru: i = passthru.index('--in'); scope = ['--in', passthru[i + 1]] @@ -266,24 +380,27 @@ def main(argv): unreadable, notes = [], [] pulled = collections.defaultdict(set) unseen = [] - for t in targets: - rc, o, e = run('axiomcode-impact', [t, repo, '--tests', '--json'] + scope) + batches = [[t] for t in targets] if len(targets) <= 8 else [targets[k:k + 60] for k in range(0, len(targets), 60)] + disp = {by_target[t].get('symbol'): t for t in targets} + for batch in batches: + rc, o, e = run('axiomcode-impact', batch + [repo, '--tests', '--json'] + scope) if rc != 0: - unseen.append(t); continue + unseen.extend(batch); continue d, note = read_answer(o) if d is None: - unreadable.append((t, (o or e or '').strip().splitlines()[:1])) + unreadable.extend((t, (o or e or '').strip().splitlines()[:1]) for t in batch) continue if note: - notes.append((t, note)) + notes.append((batch[0] if len(batch) == 1 else f"{len(batch)} declarations", note)) for rec in d.get('tests') or []: tests.setdefault(rec['id'], rec) - pulled[rec['id']].add(t) + end = (rec.get('chain') or [None])[-1] + pulled[rec['id']].add(batch[0] if len(batch) == 1 else disp.get(end, end or batch[0])) for rec in d.get('inherited_tests') or []: rid = rec.get('id') if rid and rid not in tests: tests[rid] = dict(rec, hops=None, certainty='inherited') - pulled[rid].add(t) + pulled[rid].add(batch[0]) files = sorted({r.get('at', '').split(':')[0] for r in tests.values() if r.get('at')}) # A selected test carrying no location drops out of `files`, and `files` is what the command PRINTS and @@ -295,34 +412,44 @@ def main(argv): # only when the graph found nothing, or found nothing in the changed files' own package: a text tier # offered alongside a good call-graph answer is noise, offered instead of silence it is the difference # between "run these first" and "there is nothing to run" - changed_files = [e.get('file') for e in entries if e.get('file')] - pkg_hits, name_hits, pkgs = package_tier(repo, changed_files) if not files else ({}, {}, set()) + changed_files = [e.get('file') for e in entries if e.get('file') and e.get('file').endswith(CODE)] + pkg_hits, name_hits, pkgs = package_tier(repo, changed_files) if not files and changed_files else ({}, {}, set()) classes = sorted({r.get('owner') for r in tests.values() if r.get('owner')}) # A row the answer named that carries no owner still has to reach the reader. Dropping it for being # malformed is how a selection gets quietly shorter, which is the one failure this command exists to # prevent — the same shape as a dropped edge or an answer that could not be parsed. ownerless = [r for r in tests.values() if not r.get('owner') and r.get('at')] - lang = (changed.get('lang') or '').lower() or _guess_lang(files) + run_files = sorted(set(files) | set(edited)) + lang = (changed.get('lang') or '').lower() or _guess_lang(run_files or changed_files) + cmd_all = command_for(lang, run_files, classes + ([os.path.splitext(os.path.basename(f))[0] for f in edited] if classes else [])) if as_json: print(json.dumps({'changed': [{'symbol': by_target[t]['symbol'], 'kind': by_target[t]['kind'], 'target': t} for t in targets], 'test_files': files, 'test_classes': classes, 'tests': [dict(r, pulled_in_by=sorted(pulled[i])) for i, r in tests.items()], - 'command': command_for(lang, files, classes), + 'edited_test_files': edited, + 'command': cmd_all, 'not_reached_from': unseen, 'unreadable_answers': [t for t, _ in unreadable], 'same_package_only': pkg_hits, 'same_name_only': name_hits.get('files') or [], + 'outside_index': outside, + 'named_in_test_text': {f: {'needle': n, 'tests': ts} for f, (n, ts) in loaded.items()}, + 'range_note': changed.get('range_note'), 'bound': 'a lower bound: a test reached only by reflection, a service loader, a ' - 'framework instantiating by name, or a case built at runtime is not here'}, + 'framework instantiating by name, a subprocess, or a case built at runtime is not here'}, indent=1)) return 0 + for h in heads: print(h) print(f"changed declarations ({len(targets)}): " + ", ".join( f"{by_target[t]['symbol']} [{by_target[t]['kind']}]" for t in targets[:8]) + (f" … +{len(targets)-8}" if len(targets) > 8 else "")) - if not tests: + if edited: + print(f"\nedited test file(s) ({len(edited)}), run as they are:") + for f in edited[:limit]: print(f" {f}") + if not tests and targets: print("\nno test in the graph reaches any of them.") print(" that is not the same as 'no test covers this': a function invoked through an array, a") print(" table or a plugin list has no resolved caller, so a test that drives it looks unrelated.") @@ -346,7 +473,9 @@ def main(argv): for p, fs in sorted(pkg_hits.items()): for f in sorted(fs)[:limit]: print(f" {f}") - else: + if edited and cmd_all: + print(f"\n {cmd_all}") + elif tests: print(f"\ntests to run: {len(tests)} test(s) in {len(files)} file(s)") for f in files[:limit]: n = sum(1 for r in tests.values() if r.get('at', '').startswith(f + ':')) @@ -358,9 +487,35 @@ def main(argv): for i, r in sorted(tests.items(), key=lambda kv: (kv[1].get('hops') or 99, kv[1].get('display', '')))[:limit]: chain = " -> ".join(r.get('chain') or []) or "reaches it directly" print(f" {r.get('display')} [{r.get('certainty')}] {chain}") - cmd = command_for(lang, files, classes) - if cmd: - print(f"\n {cmd}") + if cmd_all: + print(f"\n {cmd_all}") + elif edited and cmd_all: + print(f"\n {cmd_all}") + if outside or loaded: + # NOT A CALL: a file no graph reads, or a program a test starts by name. The tests that name it are the lead + if outside: + print(f"\n{len(outside)} changed file(s) outside every indexed language (no declaration to follow): " + + ", ".join(outside[:8]) + (f" … +{len(outside) - 8}" if len(outside) > 8 else "")) + if loaded: + print(f" [text] {len(loaded_files)} test file(s) name them in their text — they load or start them; run these too:") + for f, (n, ts) in sorted(loaded.items()): + print(f" {f} named as '{n}' by: " + ", ".join(ts[:6]) + (f" … +{len(ts) - 6}" if len(ts) > 6 else "")) + both = sorted(set(run_files) | set(loaded_files)) + cmd = command_for(_guess_lang(both) or lang, both[:limit], classes + [os.path.splitext(os.path.basename(f))[0] for f in loaded_files] if classes else []) + if cmd: print(f"\n with the tests above: {cmd}") + missed = [f for f in outside if f not in loaded] + if missed: + print(f" no test names {', '.join(missed[:6])}{' …' if len(missed) > 6 else ''} in its text: which suite reads " + "it is not visible here, so 'no test' is NOT an answer for it — grep the test tree for its directory") + prog_missed = [f for f in progs if f not in loaded] + if prog_missed: + print(f"\n {', '.join(prog_missed[:4])} also run(s) as a program (a __main__ / main entry): a test that starts it " + "as a subprocess, or drives it from case data, is not a call edge and no test names it by file — the " + "selection is a lower bound for it") + elif progs: + print(f"\n {', '.join(list(progs)[:4])} also run(s) as a program (a __main__ / main entry): a test that starts it " + "as a subprocess, or drives it from case data, is not a call edge and no test names it by file — the " + "selection is a lower bound for it") if unlocated: print(f"\n {len(unlocated)} selected test(s) carry no location, so they are named here rather than" f" in the list above and cannot appear in the command:") @@ -385,7 +540,7 @@ def main(argv): print(f" {t} {(first or [''])[0][:100]}") print(" the selection above is incomplete — do not act on it as though it were the whole answer.") print("\nbound: this is a LOWER bound. A test that reaches the change only through something the graph " - "does not encode — reflection, a service loader, a framework that instantiates by name, a case " + "does not encode — reflection, a service loader, a framework that instantiates by name, a subprocess, a case " "built at runtime — does not appear here. Run these first; do not skip the rest on this alone.") return 0 @@ -395,7 +550,7 @@ def _guess_lang(files): if not ext: return '' top = ext.most_common(1)[0][0] return {'.java': 'java', '.py': 'python', '.ts': 'typescript', '.tsx': 'typescript', - '.js': 'javascript', '.mjs': 'javascript'}.get(top, '') + '.js': 'javascript', '.mjs': 'javascript', '.cs': 'csharp'}.get(top, '') if __name__ == '__main__': diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index b2923a602..d6b71019c 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -81,9 +81,11 @@ keys, injected beans and handlers registered as values — none has a call site. ## changed · test-impact — from an edit -`axiomcode changed [--impact] [--staged | --range a..b]` says how each declaration changed (`signature`, `body`, -`field`, `type`, `removed`, `added`). `axiomcode test-impact [--why]` lists the tests the edit reaches and the -command to run them. It is a **lower bound**: skipping what it does not name is your risk decision, since reflection +`axiomcode changed [--impact] [--staged | --range a..b] […]` says how each declaration changed (`signature`, `body`, +`field`, `type`, `removed`, `added`). `axiomcode test-impact [--why] […]` lists the tests the edit reaches and the +command to run them. For your branch's commits ask `--range ..HEAD`: it reads from the merge-base, so a base +that moved on is not counted as yours. On a copy without git, name the files you edited. Changed fixtures and other +files no graph reads are named, with the tests whose text names them. It is a **lower bound**: skipping what it does not name is your risk decision, since reflection and service loaders are invisible. Detail: `reference/changed-and-tests.md`. ## path — asking the graph diff --git a/skills/axiomcode/reference/changed-and-tests.md b/skills/axiomcode/reference/changed-and-tests.md index e90684db0..906e52d4c 100644 --- a/skills/axiomcode/reference/changed-and-tests.md +++ b/skills/axiomcode/reference/changed-and-tests.md @@ -11,6 +11,23 @@ commits (when the graph is at the newer side, the declarations are the new text' `--staged` the index, `--old/--new/--file` two texts of one file. Each line ends with the target `impact` takes for it — a signature with one parameter changed is `Owner.m(param)` — and `--impact` runs impact on all of them as one change set. +What to pass, and what the answer says when the question cannot be answered the way it was asked: + +| situation | ask | what comes back | +|---|---|---| +| uncommitted edits | `changed` · `test-impact` | the edits against the baseline | +| your branch's commits | `changed --range ..HEAD` (MCP `range='..HEAD'`) | read from `git merge-base HEAD`, not from ``'s tip: commits the base branch received after you branched are not yours and are left out. A `note: range base: merge-base …` line says so whenever `` has moved. `a...b` means the same; `a` alone is `a..HEAD` | +| committed work, clean tree | `changed` | `no change …` followed by `next: … HEAD is N commit(s) ahead of — ask --range ..HEAD` | +| a copy without git | `changed` | a refusal: no base to diff against. Name the files instead | +| named files | `changed …` · `test-impact …` (MCP `files=[…]`) | each file's edit; a named file with no edit (or any named file on a copy without git) counts **whole**: every callable declared in it is `named`, and test-impact selects the tests of all of them | +| a file the base does not have | (any) | one line, `added — new file, N declaration(s)`, plus each new declaration something outside the file already calls, with its impact target. Never its parameters or docstring words | +| fixtures, case data, a schema | (any) | named as `outside every indexed language`, never "no change"; test-impact lists the test files whose text names them (the path, the file name, or a quoted directory), as a `[text]` tier, and says when no test names them | + +`test-impact` also lists an edited or new **test file** as one to run, and adds it to the command. Code that is also run as a +program (`if __name__ == '__main__'`, `static void main`, `Main`) is looked for by name in the tests, since a test that starts +it as a subprocess or drives it from case data has no call edge to it; when no test names it the answer says the selection is +a lower bound for it. + Measured against 270 real fixes (a Java defect-benchmark arena: the fix applied to the buggy files, the declarations it reports against the benchmark's own scanner's reading of the same hunks, its class-level state expansion taken out): exact agreement on 255, 465 declarations reported for the scanner's 473 — recall 0.968, precision 0.985. Every remaining @@ -55,7 +72,7 @@ absent, never wrong, and the `? n` count says how many. ## test-impact — which tests this edit reaches -`axiomcode test-impact` takes the edit (the working tree by default, `--range a..b` or `--staged`), maps it onto the +`axiomcode test-impact` takes the edit (the working tree by default, `--range a..b`, `--staged`, or named files), maps it onto the declarations through `changed`, asks `impact` which tests reach any of them, and prints the test files with the runner command that runs exactly those. It is `changed` + `impact --tests` with the answer shaped for a pipeline rather than for a reader. @@ -75,5 +92,6 @@ library a `[sound]` route (every hop a single resolved target) was right **29 ti `[by name]` 0 in 1; a `[fixture]` route is right 30 times in 30 on a service where a fixture is the only way in and about 1 in 4 on a framework where every test builds an app. Run the sound rung first, and decide about the rest with the number in front of you. Skipping what it does not name is a decision about risk that this tool cannot make for -you: a test reached only through reflection, a service loader, or a case built at runtime does not appear here. +you: a test reached only through reflection, a service loader, a subprocess, or a case built at runtime does not appear +here (the `[text]` tier above recovers the ones whose test names the file it loads). diff --git a/tests/cases/csharp/using-line-is-not-a-field/case.json b/tests/cases/csharp/using-line-is-not-a-field/case.json new file mode 100644 index 000000000..6c7040099 --- /dev/null +++ b/tests/cases/csharp/using-line-is-not-a-field/case.json @@ -0,0 +1,6 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "an added `using X;` line declares nothing (it read as a new field X); control: a field added inside the class is still a new field of that class", + "run": ["changed", "{repo}", "--old", "{repo}/old.cs", "--new", "{repo}/new.cs", "--file", "src/Cart.cs"], + "want": ["Cart.limit", "new field"], + "avoid": ["Pricing ", "added Pricing"]}]} diff --git a/tests/cases/csharp/using-line-is-not-a-field/new.cs b/tests/cases/csharp/using-line-is-not-a-field/new.cs new file mode 100644 index 000000000..55d3c051a --- /dev/null +++ b/tests/cases/csharp/using-line-is-not-a-field/new.cs @@ -0,0 +1,16 @@ +using System; +using Pricing; + +namespace Shop +{ + public class Cart + { + private int count; + private int limit; + + public int Count() + { + return count; + } + } +} diff --git a/tests/cases/csharp/using-line-is-not-a-field/old.cs b/tests/cases/csharp/using-line-is-not-a-field/old.cs new file mode 100644 index 000000000..4083f51dc --- /dev/null +++ b/tests/cases/csharp/using-line-is-not-a-field/old.cs @@ -0,0 +1,14 @@ +using System; + +namespace Shop +{ + public class Cart + { + private int count; + + public int Count() + { + return count; + } + } +} diff --git a/tests/cases/csharp/using-line-is-not-a-field/src/Cart.cs b/tests/cases/csharp/using-line-is-not-a-field/src/Cart.cs new file mode 100644 index 000000000..4083f51dc --- /dev/null +++ b/tests/cases/csharp/using-line-is-not-a-field/src/Cart.cs @@ -0,0 +1,14 @@ +using System; + +namespace Shop +{ + public class Cart + { + private int count; + + public int Count() + { + return count; + } + } +} diff --git a/tests/cases/java/insert-inside-body/case.json b/tests/cases/java/insert-inside-body/case.json new file mode 100644 index 000000000..a7c52bfac --- /dev/null +++ b/tests/cases/java/insert-inside-body/case.json @@ -0,0 +1,10 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "lines added after an inner block's closing brace, or after a comment, inside a method are that method's body change, not anonymous new lines", + "run": ["changed", "{repo}", "--old", "{repo}/old.java", "--new", "{repo}/new.java", "--file", "src/pkg/Svc.java"], + "want": ["body Svc.build"], + "avoid": ["new line(s)", "Svc.other"]}, + {"why": "control: a method inserted after a method's closing brace is an addition, and the method above it is not a body change", + "run": ["changed", "{repo}", "--old", "{repo}/old.java", "--new", "{repo}/new-between.java", "--file", "src/pkg/Svc.java"], + "want": ["added", "Svc.extra"], + "avoid": ["body Svc.build"]}]} diff --git a/tests/cases/java/insert-inside-body/new-between.java b/tests/cases/java/insert-inside-body/new-between.java new file mode 100644 index 000000000..993f4f274 --- /dev/null +++ b/tests/cases/java/insert-inside-body/new-between.java @@ -0,0 +1,20 @@ +package pkg; +public class Svc { + public int build(int n) { + int r = 0; + if (n > 0) { + r = n; + } + + // keep r + return r; + } + + public int extra() { + return 2; + } + + public int other() { + return 1; + } +} diff --git a/tests/cases/java/insert-inside-body/new.java b/tests/cases/java/insert-inside-body/new.java new file mode 100644 index 000000000..e03a9f87f --- /dev/null +++ b/tests/cases/java/insert-inside-body/new.java @@ -0,0 +1,18 @@ +package pkg; +public class Svc { + public int build(int n) { + int r = 0; + if (n > 0) { + r = n; + } + r += 2; + + // keep r + r *= 1; + return r; + } + + public int other() { + return 1; + } +} diff --git a/tests/cases/java/insert-inside-body/old.java b/tests/cases/java/insert-inside-body/old.java new file mode 100644 index 000000000..d056266cc --- /dev/null +++ b/tests/cases/java/insert-inside-body/old.java @@ -0,0 +1,16 @@ +package pkg; +public class Svc { + public int build(int n) { + int r = 0; + if (n > 0) { + r = n; + } + + // keep r + return r; + } + + public int other() { + return 1; + } +} diff --git a/tests/cases/java/insert-inside-body/src/pkg/Svc.java b/tests/cases/java/insert-inside-body/src/pkg/Svc.java new file mode 100644 index 000000000..d056266cc --- /dev/null +++ b/tests/cases/java/insert-inside-body/src/pkg/Svc.java @@ -0,0 +1,16 @@ +package pkg; +public class Svc { + public int build(int n) { + int r = 0; + if (n > 0) { + r = n; + } + + // keep r + return r; + } + + public int other() { + return 1; + } +} diff --git a/tests/cases/java/new-type-below-type/case.json b/tests/cases/java/new-type-below-type/case.json new file mode 100644 index 000000000..7227e5afc --- /dev/null +++ b/tests/cases/java/new-type-below-type/case.json @@ -0,0 +1,6 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a type written below another type's closing brace is its own, with its own fields; an added import line declares nothing. Control: a field added inside the class is still that class's", + "run": ["changed", "{repo}", "--old", "{repo}/old.java", "--new", "{repo}/new.java", "--file", "src/pkg/A.java"], + "want": ["added B ", "B.LIMIT", "A.y"], + "avoid": ["A.B", "A.LIMIT", "Map", "util"]}]} diff --git a/tests/cases/java/new-type-below-type/new.java b/tests/cases/java/new-type-below-type/new.java new file mode 100644 index 000000000..a48d72cf4 --- /dev/null +++ b/tests/cases/java/new-type-below-type/new.java @@ -0,0 +1,19 @@ +package pkg; + +import java.util.List; +import java.util.Map; + +public class A { + int x; + + void f(List xs) { + x = xs.size(); + } + + int y; +} + +interface B { + int LIMIT = 3; + void m(); +} diff --git a/tests/cases/java/new-type-below-type/old.java b/tests/cases/java/new-type-below-type/old.java new file mode 100644 index 000000000..6a4d08cf2 --- /dev/null +++ b/tests/cases/java/new-type-below-type/old.java @@ -0,0 +1,11 @@ +package pkg; + +import java.util.List; + +public class A { + int x; + + void f(List xs) { + x = xs.size(); + } +} diff --git a/tests/cases/java/new-type-below-type/src/pkg/A.java b/tests/cases/java/new-type-below-type/src/pkg/A.java new file mode 100644 index 000000000..6a4d08cf2 --- /dev/null +++ b/tests/cases/java/new-type-below-type/src/pkg/A.java @@ -0,0 +1,11 @@ +package pkg; + +import java.util.List; + +public class A { + int x; + + void f(List xs) { + x = xs.size(); + } +} diff --git a/tests/cases/java/new-type-between-types/case.json b/tests/cases/java/new-type-between-types/case.json new file mode 100644 index 000000000..af722ab6c --- /dev/null +++ b/tests/cases/java/new-type-between-types/case.json @@ -0,0 +1,6 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a type added between two others is its own, with its own members: the diff put it before the upper type's closing brace, which named its field and method as members of that type", + "run": ["changed", "{repo}", "--old", "{repo}/old.java", "--new", "{repo}/new.java", "--file", "src/pkg/A.java"], + "want": ["added C ", "C.MAX", "C.file"], + "avoid": ["A.MAX", "A.file", "D.MAX"]}]} diff --git a/tests/cases/java/new-type-between-types/new.java b/tests/cases/java/new-type-between-types/new.java new file mode 100644 index 000000000..b09c1b3b4 --- /dev/null +++ b/tests/cases/java/new-type-between-types/new.java @@ -0,0 +1,15 @@ +package pkg; + +public class A { + int x; +} + +interface C { + int MAX = 1; + String file(); +} + +/** the D type */ +class D { + int z; +} diff --git a/tests/cases/java/new-type-between-types/old.java b/tests/cases/java/new-type-between-types/old.java new file mode 100644 index 000000000..53d441c3f --- /dev/null +++ b/tests/cases/java/new-type-between-types/old.java @@ -0,0 +1,10 @@ +package pkg; + +public class A { + int x; +} + +/** the D type */ +class D { + int z; +} diff --git a/tests/cases/java/new-type-between-types/src/pkg/A.java b/tests/cases/java/new-type-between-types/src/pkg/A.java new file mode 100644 index 000000000..53d441c3f --- /dev/null +++ b/tests/cases/java/new-type-between-types/src/pkg/A.java @@ -0,0 +1,10 @@ +package pkg; + +public class A { + int x; +} + +/** the D type */ +class D { + int z; +} diff --git a/tests/changed_range.py b/tests/changed_range.py new file mode 100644 index 000000000..5866985a8 --- /dev/null +++ b/tests/changed_range.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""tests/changed_range.py — what `changed` and `test-impact` answer about a BRANCH, a new file, a copy without git, and a +changed file no graph reads. + +Each behaviour has a near-miss control that must keep today's answer: + + --range a..b after a moved only b's own commits (read from the merge-base), and a note saying a moved + control a range whose base IS an ancestor: the same answer, and no note + a clean tree over commits "no change", then the --range that holds them + control on the base branch itself: no suggestion + a new module one `added ` line; no docstring word, no parameter, nothing at line 1 + control a function added to an existing file keeps its own line + a copy without git a refusal naming what to pass, not invented "added" declarations + control the same copy with a named file: every declaration in it counts, and its tests are named + a changed fixture named as outside the index, with the test file that names it + control a data file no test names: said so, never "no change" + +Each run builds a real graph in a throwaway git repository (Python, so the rules compile once and are cached). + + python3 tests/changed_range.py +""" +import os, shutil, subprocess, sys, tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +AX = os.path.join(ROOT, 'bin', 'axiomcode') + +FILES = { + 'app/__init__.py': '', + 'app/pricing.py': 'def price(q):\n return q * 2\n\n\ndef discount(q):\n return q - 1\n', + 'app/stock.py': 'def level(n):\n return n\n', + 'tests/__init__.py': '', + 'tests/test_pricing.py': ('from app.pricing import price, discount\n\n\ndef test_price():\n assert price(2) == 4\n\n\n' + 'def test_discount():\n assert discount(2) == 1\n'), + 'tests/test_stock.py': 'from app.stock import level\n\n\ndef test_level():\n assert level(1) == 1\n', + 'tests/test_cases.py': ("import json, os\nHERE = os.path.dirname(__file__)\n\n\ndef test_cases():\n" + " for c in os.listdir(os.path.join(HERE, 'cases')):\n" + " json.load(open(os.path.join(HERE, 'cases', c, 'case.json')))\n"), + 'tests/cases/one/case.json': '{"q": 1}\n', + 'data/lookup.csv': 'a,1\n', +} +# a new module whose docstrings hold words a line-by-line reading took for methods (`works (…)`, `side(effect)`) +NEWMOD = '''"""New module. + +It works (see below): import it, and +side(effects) happen. +""" +from app.pricing import price + + +def total(a, b): + """total works + side(effect) + """ + return price(a) + b + + +def unused(x, y, z): + return x +''' + + +def sh(cwd, *cmd, env=None): + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, env=env) + + +def main(): + fails = [] + def check(ok, why, detail=''): + print(('ok ' if ok else 'FAIL ') + why + ('' if ok else '\n ' + detail.strip().replace('\n', '\n '))) + if not ok: fails.append(why) + + work = tempfile.mkdtemp(prefix='axiomcode-changed-range-') + env = dict(os.environ, AXIOMCODE_ENGINE=ROOT) + G = ('git', '-c', 'user.email=t@t', '-c', 'user.name=t') + def write(repo, rel, text): + p = os.path.join(repo, rel); os.makedirs(os.path.dirname(p), exist_ok=True); open(p, 'w').write(text) + def ax(repo, *a): + r = sh(repo, AX, *a, env=env); return r.returncode, r.stdout + r.stderr + try: + repo = os.path.join(work, 'repo'); os.makedirs(repo) + for rel, text in FILES.items(): write(repo, rel, text) + write(repo, '.gitignore', '.axiomcode/\n') + sh(repo, 'git', 'init', '-q', '-b', 'main'); sh(repo, 'git', 'add', '-A'); sh(repo, *G, 'commit', '-qm', 'base') + built = sh(repo, AX, 'index', '.', '--lang', 'python', env=env) + check(built.returncode == 0, 'the graph builds', built.stdout + built.stderr) + + # the branch: a body edit to discount, a new module with its test, a fixture edit + sh(repo, 'git', 'checkout', '-qb', 'feature') + write(repo, 'app/pricing.py', FILES['app/pricing.py'].replace('q - 1', 'q - 2')) + write(repo, 'app/newmod.py', NEWMOD) + write(repo, 'tests/test_newmod.py', 'from app.newmod import total\n\n\ndef test_total():\n assert total(1, 1) == 3\n') + write(repo, 'tests/cases/one/case.json', '{"q": 2}\n') + sh(repo, 'git', 'add', '-A'); sh(repo, *G, 'commit', '-qm', 'feature') + # the base branch moves on: an unrelated change to stock.py + sh(repo, 'git', 'checkout', '-q', 'main') + write(repo, 'app/stock.py', 'def level(n):\n return n + 1\n') + sh(repo, 'git', 'add', '-A'); sh(repo, *G, 'commit', '-qm', 'upstream') + sh(repo, 'git', 'checkout', '-q', 'feature') + + rc, out = ax(repo, 'changed', '.', '--range', 'main..feature') + check(rc == 0 and 'Traceback' not in out, 'changed --range answers', out) + check('range base: merge-base' in out and 'main has moved 1 commit' in out, 'a moved base is named: the answer reads from the merge-base', out) + check('app/stock.py' not in out, "upstream's stock.py is not reported as the branch's change", out) + check('discount' in out, "the branch's own body edit is reported", out) + check('added app/newmod.py' in out and 'new file, 2 declaration(s)' in out, 'a new module is one added line', out) + rows = [l.split() for l in out.split('\n') if l.startswith(' ') and len(l.split()) >= 2] + for word in ('works', 'side', 'import', 'It', 'unused', 'a', 'b', 'x'): + check(not any(r[1] == word or r[1].endswith('.' + word) for r in rows), f"no docstring word, unreferenced function or parameter listed from the new module ({word!r})", out) + check('app/newmod.py:1 ' not in out, 'nothing from the new module at line 1', out) + check('total app/newmod.py:9' in out, 'a new declaration already called from outside its file is listed at its own line', out) + check('outside every indexed language' in out and 'tests/cases/one/case.json' in out, 'the changed fixture is named, not dropped', out) + + rc, out = ax(repo, 'test-impact', '.', '--range', 'main..feature') + check(rc == 0 and 'tests/test_pricing.py' in out, 'test-impact --range selects the tests of the branch edit', out) + check('test_stock' not in out, "test-impact --range does not select the tests of upstream's commit", out) + check('tests/test_newmod.py' in out, 'a new test file is itself a test to run', out) + check("named as 'case.json' by: tests/test_cases.py" in out, 'the test that loads the changed fixture is named', out) + check('page 1 of' not in out, 'no page footer', out) + + # control: a range whose base is an ancestor keeps its answer and gets no note + rc, out = ax(repo, 'changed', '.', '--range', 'feature~1..feature') + check(rc == 0 and 'range base' not in out and 'discount' in out, 'control: an ancestor range has no merge-base note', out) + + # a clean tree whose work is committed: suggest the range + rc, out = ax(repo, 'changed', '.') + check('no change' in out and '--range main..HEAD' in out, 'a clean tree over committed work names the range to ask', out) + rc, out = ax(repo, 'test-impact', '.') + check('test-impact --range main..HEAD' in out, 'test-impact on a clean tree names the range to ask', out) + # control: on main itself there is nothing of its own to suggest + sh(repo, 'git', 'checkout', '-q', 'main') + rc, out = ax(repo, 'changed', '.') + check('no change' in out and '--range' not in out, 'control: the base branch gets no range suggestion', out) + # control: a function ADDED to an existing file keeps its own line + write(repo, 'app/stock.py', 'def level(n):\n return n + 1\n\n\ndef restock(n):\n return n\n') + rc, out = ax(repo, 'changed', '.') + check('restock app/stock.py:5' in out, 'control: an added function in an existing file is at its own line', out) + sh(repo, 'git', 'checkout', '-q', '--', '.') + # control: a data file no test names is said to be unnamed, never "no change" + write(repo, 'data/lookup.csv', 'a,2\n') + rc, out = ax(repo, 'test-impact', '.') + check('data/lookup.csv' in out and 'no test names data/lookup.csv' in out, 'control: an unnamed data file is reported as unnamed', out) + check('no changed declaration the graph can name' not in out, "control: it does not read as 'nothing to test'", out) + sh(repo, 'git', 'checkout', '-q', '--', '.') + + # a copy without git + copy = os.path.join(work, 'copy') + shutil.copytree(repo, copy, ignore=shutil.ignore_patterns('.git', '.axiomcode')) + built = sh(copy, AX, 'index', '.', '--lang', 'python', env=env) + check(built.returncode == 0, 'the copy builds', built.stdout + built.stderr) + write(copy, 'app/pricing.py', FILES['app/pricing.py'].replace('q * 2', 'q * 3')) + rc, out = ax(copy, 'changed', '.') + check(rc != 0 and 'no git base' in out and 'added' not in out, 'no git: changed refuses, naming what to pass', out) + rc, out = ax(copy, 'test-impact', '.') + check(rc != 0 and 'no git base' in out and 'page 1 of' not in out, 'no git: test-impact refuses, with no page footer', out) + rc, out = ax(copy, 'test-impact', '.', 'app/pricing.py') + check(rc == 0 and 'tests/test_pricing.py' in out and 'test_stock' not in out, 'control: test-impact on the copy names that file\'s tests', out) + rc, out = ax(copy, 'changed', '.', 'app/pricing.py') + check('named' in out and 'price' in out and 'discount' in out, 'control: changed on the copy counts its declarations', out) + finally: + shutil.rmtree(work, ignore_errors=True) + print(f"\n{'ok' if not fails else f'{len(fails)} FAILED'}") + return 1 if fails else 0 + + +if __name__ == '__main__': + sys.exit(main()) From b88d7326b8f1b63f8276b02d960739a43d293815 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:27:29 -0700 Subject: [PATCH 11/31] graph: draw the existing graph, and every rebuild keeps the languages the index chose `axiomcode graph` ran axiomcode-build with no flags, which detects every language in the tree. On a repository indexed with --lang it rebuilt the whole graph on every call, started JavaScript/TypeScript compiles for stray files, left a graph for each, and recorded the detected languages as the index's choice, so every later refresh solved them too. - graph draws from graph.sqlite when the file table says it is current, and otherwise rebuilds with the recorded --lang/--src/--library (ax_fresh.rebuild_env, shared with the background refresh) - axiomcode-build: a build that names no language keeps an explicit --lang (and --src) recorded in the file table (ax_fresh.py chosen), so the refresh, a broken-graph repair, a bare index and graph never solve a language the index left out; detected languages are still detected again - the page draws every language graph the repository has - prose counts, the page's absolute path, and `axiomcode index --library` as the advice; help, SKILL.md, the MCP description and README agree - tests/graph_verb.py: 21 checks with a detected-languages control --- README.md | 2 +- plugins/axiomcode/mcp/server.py | 2 +- plugins/axiomcode/skills/axiomcode/SKILL.md | 5 +- .../skills/axiomcode/scripts/ax_fresh.py | 29 +- .../skills/axiomcode/scripts/axiomcode | 16 +- .../skills/axiomcode/scripts/axiomcode-build | 22 +- .../skills/axiomcode/scripts/axiomcode-graph | 298 ++++++++++++------ skills/axiomcode/SKILL.md | 5 +- tests/README.md | 3 + tests/graph_verb.py | 165 ++++++++++ 10 files changed, 421 insertions(+), 126 deletions(-) create mode 100644 tests/graph_verb.py diff --git a/README.md b/README.md index 93413fa5b..3bd2a0187 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,7 @@ about one change had to read 95 of 43,793 methods, and every true direct caller | `axiomcode test-impact` | which tests have to run for the current edit, with the chain that reaches each | | `axiomcode changed` | which declarations an edit changed, and how (signature, type, body, added, removed). `--impact` adds what that reaches | | `axiomcode context ""` | where a task's words land in the code, when you have a problem statement and not yet a name | -| `axiomcode graph` | the whole graph as one self-contained HTML page, at `.axiomcode/graph/graph.html` | +| `axiomcode graph` | the whole graph as one self-contained HTML page, at `.axiomcode/graph/graph.html`, drawn from the existing graph (rebuilt first only when stale, with the flags it was indexed with) | | `axiomcode index` | build or rebuild the graph explicitly; `--lang`, `--src` and `--library` narrow it | | `axiomcode mcp` | serve the graph to an agent as MCP tools over stdio | diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 86783fa4a..806610123 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -170,7 +170,7 @@ def axiomcode_test_impact(repo: str = ".", range: str = '', staged: bool = False @srv.tool() def axiomcode_graph(repo: str = ".", out: str = '') -> str: - """Draw the graph as one interactive HTML page (/.axiomcode/graph/graph.html, or out=); runs index first if there is no graph yet.""" + """Draw the graph as one interactive HTML page, for a person: every language the repository was indexed in, at /.axiomcode/graph/graph.html or out=. Drawn from the existing graph when it is up to date (seconds, no engine run); a graph that is out of date is rebuilt first with the --lang, --src and --library it was indexed with, never for a language the index left out; with no graph yet the repository is indexed first. Answers with what it drew, in prose, and the page's absolute path.""" return run(['graph', repo] + (['--out', out] if out else [])) if __name__ == '__main__': diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index eaba7b67f..90fc4af3e 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -27,7 +27,7 @@ Read / Grep results as `graph: …` lines. | "how does A reach B" · "everything that reaches X" | `axiomcode path A B` · `axiomcode path '*' X` | | "what did my edit touch" · "which tests do I run" | `axiomcode changed --impact` · `axiomcode test-impact` | | "is it safe to delete X" | `axiomcode impact X --delete` | -| the graph as a page for a human · this repo should prefer the graph, once | `axiomcode graph` · `axiomcode install` | +| the graph as a page for a human · this repo should prefer the graph, once | `axiomcode graph` (drawn from the existing graph in seconds; a stale one is rebuilt first with the flags it was indexed with; prints the page's absolute path) · `axiomcode install` | Rules that decide whether an answer means anything: @@ -38,7 +38,8 @@ Rules that decide whether an answer means anything: briefly on its own only when the answer touches an edited file and the rebuild is nearly done. - **Before a delete or a rename, ask with `--fresh`** (MCP `fresh=true`): it waits for the rebuild, printing its progress, and answers from a graph that includes every edit. - A manual `index` with different flags (e.g. dropping `--library`) rebuilds a worse graph over the good one. + A manual `index` with different flags rebuilds a worse graph over the good one. A bare `index`, the background + refresh and `graph` keep the `--lang` (and `--src`, `--library`) the graph was indexed with; pass `--lang` to change it. - A repo in several languages is indexed in all of them, one graph each, and every query asks each graph; calls are not followed from one language to another. `--lang` restricts it, `--src src` narrows it; `--library ` so calls into dependencies resolve (without it they are `ambiguous_unknown` — do not quote that resolution rate). diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index 82e9850d7..2c09172de 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -27,6 +27,7 @@ ax_fresh.py worker the worker itself (what kick detaches) ax_fresh.py lock take the build lock on an fd the calling shell holds open ax_fresh.py count the source files of each language, walked as the parser walks + ax_fresh.py chosen the --lang and --src an explicit index chose, which a rebuild keeps Environment: AXIOMCODE_NO_REFRESH=1 turns every trigger off; AXIOMCODE_REFRESH_DEBOUNCE (seconds, default 2) is the quiet window; AXIOMCODE_FRESH_WAIT (seconds, default 30) is the most a query whose answer touches an edited file @@ -309,6 +310,18 @@ def legacy_params(repo): src = os.path.relpath(os.path.realpath(run.get('source_dir') or repo), os.path.realpath(repo)) return dict(lang=run.get('language', ''), src_arg='' if src == '.' or src.startswith('..') else src, library=run.get('library_roots') or '') +def rebuild_env(t, **extra): + """the environment that rebuilds the graph a file table `t` describes, with the language, --src and --library it + was built with: what the background refresh runs, and what `axiomcode graph` runs when the graph is stale. Languages + DETECTED are detected again (a language the repository gained since gets its graph); only a --lang the user gave is + kept. A table from before this was recorded counts as detected: it was built with one language even where the + repository had several, and keeping that would keep the others out for good.""" + env = dict(os.environ, AXIOMCODE_LANG='' if t.get('lang_auto', True) else t.get('lang', ''), AXIOMCODE_SRC=t.get('src_arg', ''), **extra) + env.pop('AXIOMCODE_LIBRARY', None) + env.pop('AXIOMCODE_LANG_AUTO', None); env.pop('AXIOMCODE_GRAPH_LANG', None) + if t.get('library'): env['AXIOMCODE_LIBRARY'] = t['library'] + return env + def _flock(fd, block): """an advisory lock on fd; the OS releases it when the last holder of the file description exits, so a killed build never leaves a lock behind (no stale-lock timeout to guess)""" @@ -447,14 +460,7 @@ def worker(repo): if st.get('failed_table') == fp: return 0 n = sum(len(x) for x in c) why = (f"{n} file(s) changed" if n else f"HEAD moved to {(head(repo) or '')[:10]}") + f", found by {os.environ.get('AXIOMCODE_REFRESH_TRIGGER') or 'an edit'}" - # languages DETECTED are detected again (a language the repository gained since gets its graph); only a - # --lang the user gave is kept. A table from before this was recorded counts as detected: it was built with - # one language even where the repository had several, and keeping that would keep the others out for good - env = dict(os.environ, AXIOMCODE_LANG='' if t.get('lang_auto', True) else t.get('lang', ''), AXIOMCODE_SRC=t.get('src_arg', ''), - AXIOMCODE_BACKGROUND='1', AXIOMCODE_REFRESH_REASON=why) - env.pop('AXIOMCODE_LIBRARY', None) - env.pop('AXIOMCODE_LANG_AUTO', None); env.pop('AXIOMCODE_GRAPH_LANG', None) - if t.get('library'): env['AXIOMCODE_LIBRARY'] = t['library'] + env = rebuild_env(t, AXIOMCODE_BACKGROUND='1', AXIOMCODE_REFRESH_REASON=why) t0 = time.time(); write_state(repo, state='building', started=t0, files=sum(len(x) for x in c)) if any(c): print(f"{time.strftime('%H:%M:%S')} refresh: {sum(len(x) for x in c)} file(s) changed ({', '.join((c[0] + c[1] + c[2])[:5])}) — rebuilding", flush=True) else: print(f"{time.strftime('%H:%M:%S')} refresh: HEAD moved — moving the baseline to it", flush=True) @@ -826,6 +832,13 @@ def main(argv): subdirs[:] = [x for x in subdirs if not prunes('python', x, os.path.basename(d))] n += sum(1 for f in files if python_script(os.path.join(d, f), f)) print(n); return 0 + if cmd == 'chosen': + # the --lang (and --src) the graph here was indexed with, as "\t", when they were CHOSEN (an explicit + # --lang); nothing when its languages were detected, or there is no graph. axiomcode-build keeps them for a rebuild + # that names no language, so no rebuild path solves a language the index left out + t = load_table(repo) + if has_graph(repo) and t and t.get('lang_auto') is False and t.get('lang'): print(f"{t['lang']}\t{t.get('src_arg', '')}") + return 0 if cmd == 'uptodate': # exit 0 when the recorded table was built with this language / --src / --library and no file differs lang, src_arg, lib = argv[3], argv[4] if len(argv) > 4 else '', argv[5] if len(argv) > 5 else '' diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index f4e6b18d9..b5e608584 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -10,10 +10,12 @@ # --in is repeatable and takes a list, and several are COMBINED, not intersected, so a change that # spans two roots is answerable in one call. # Ends by saying what it could not see. Every other verb needs a name you already have; this one does not. -# axiomcode graph [] [--out ] [same flags] -# the graph as one page. Runs the pipeline only when the repo has no up-to-date graph (~1 s otherwise). -# defaults to the current directory; the page goes to /.axiomcode/graph/graph.html, or to --out: a folder -# gets /.html, a .html path is used as given. The page embeds the sources, so a node opens its code. +# axiomcode graph [] [--out ] +# the graph as one page, every language the repository was indexed in. Drawn from the existing graph when it is up +# to date (seconds, no engine run); rebuilt first only when a source file changed, and then with the --lang, --src +# and --library it was indexed with, never for a language the index left out; indexed first when there is none. +# Prints what it drew and the page's absolute path: /.axiomcode/graph/graph.html, or --out (a folder gets +# /.html, a .html path is used as given). The page embeds the sources, so a node opens its code. # axiomcode path [] [--every | --paths N] [--all] [--limit N] [--in ] [--json] [--fresh] # the shortest chain of calls from A to B per reached target, and with --every all the routes; through what? # '*' as one endpoint: path '*' X = everything that can reach X, with the entry points among them; path X '*' = everything X reaches. Endpoints exactly as written in the code: Owner.method, @@ -115,8 +117,8 @@ case "${ARGS[0]:-}" in -h|--help) if [ -n "$cmd" ] && [ "${#ARGS[@]}" = 1 ]; the # check and an exec. The repository is the last argument that is a directory, as the verbs themselves take it, else the # working directory. No graph: nothing here, the verb builds its first one. # `changed` and `test-impact` ask about an edit, against the graph of the baseline (kept aside by a refresh), so they do -# not wait for the graph; they wait only when HEAD moved and the baseline has to follow it. `graph` builds (axiomcode-build) -# before it draws, so it is current by construction. +# not wait for the graph; they wait only when HEAD moved and the baseline has to follow it. `graph` checks the graph +# itself: it draws from it when it is current, and rebuilds it with its own recorded flags only when it is not. # A flag's VALUE is never the repository: `--in plugins/core` names a directory under it, and taking that for the # repository found no .axiomcode/lang there, so a repository in several languages was asked in its main graph alone and # a scope that only another language's graph holds was refused as absent. @@ -143,7 +145,7 @@ case "$cmd" in impact) exec python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-impact" ${ARGS[@]+"${ARGS[@]}"} ;; changed) exec python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-changed" ${ARGS[@]+"${ARGS[@]}"} ;; test-impact|tests) exec python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-test-impact" ${ARGS[@]+"${ARGS[@]}"} ;; - graph) exec python3 "$H/axiomcode-graph" build ${ARGS[@]+"${ARGS[@]}"} ;; + graph) exec python3 "$H/axiomcode-graph" ${ARGS[@]+"${ARGS[@]}"} ;; install) exec python3 "$H/axiomcode-install" ${ARGS[@]+"${ARGS[@]}"} ;; --verbs) verbs ;; # what this dispatcher answers to; bin/axiomcode asks, so the two cannot drift ""|-h|--help|help) if [ ${#ARGS[@]} -gt 0 ]; then verbhelp "${ARGS[0]}"; else helptext; fi ;; diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build index a2c2b97d2..decd239ea 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build @@ -5,7 +5,9 @@ # AXIOMCODE_ENGINE checkout of axiomcodegraph (default: the one this skill lives in) # AXIOMCODE_SRC subtree to analyse (e.g. one module of a monorepo); the graph still lives at /.axiomcode # AXIOMCODE_LANG restrict to java | typescript | python | javascript | csharp, or a comma list of them, the first being -# the main one (default: every language with files; the main one is the language with the most) +# the main one (default: every language with files; the main one is the language with the most). +# A rebuild that names no language keeps the ones an earlier --lang chose (and its --src); pass +# --lang again, or AXIOMCODE_REINDEX=1, to choose again # AXIOMCODE_LIBRARY comma-separated library roots (source trees or IR) passed as --library; without them, calls into # dependencies are declared unknown rather than guessed # A repository in several languages gets one graph per language. The main language's is the one it always had, @@ -15,7 +17,23 @@ # language's compile; until the last one is in, .axiomcode/out/building names the languages still to come. set -euo pipefail H="$(cd "$(dirname "$0")" && pwd)" -REPO="$(cd "${1:-.}" && pwd)"; SRC="$REPO/${AXIOMCODE_SRC:-}"; SRC="${SRC%/}" +REPO="$(cd "${1:-.}" && pwd)" +# A REBUILD THAT NAMES NO LANGUAGE KEEPS THE LANGUAGES THE INDEX CHOSE. Every rebuild comes here: an explicit `index`, +# the background refresh after an edit or a commit, a query that repairs a broken graph, `graph`. One that named no +# language detected every language in the tree, so on a repository indexed with --lang csharp it solved a few stray +# .ts and .js files too (a JavaScript Souffle compile of up to an hour), left a graph for each that later queries +# answered from, and recorded the detected list as the index's choice, so every refresh after it did the same. When +# the graph here was built with an explicit --lang (lang_auto false in its file table), a request with no language +# keeps that --lang, and the --src with it when none is given; a graph whose languages were detected is detected again. +if [ -z "${AXIOMCODE_LANG:-}" ] && [ -z "${AXIOMCODE_REINDEX:-}" ]; then + KEPT="$(python3 "$H/ax_fresh.py" chosen "$REPO" 2>/dev/null || true)" + if [ -n "$KEPT" ]; then + AXIOMCODE_LANG="${KEPT%%$'\t'*}"; export AXIOMCODE_LANG + [ -n "${AXIOMCODE_SRC:-}" ] || { AXIOMCODE_SRC="${KEPT#*$'\t'}"; export AXIOMCODE_SRC; } + [ -n "${AXIOMCODE_BACKGROUND:-}" ] || echo "keeping the languages this graph was indexed with (--lang $AXIOMCODE_LANG${AXIOMCODE_SRC:+ --src $AXIOMCODE_SRC}); pass --lang, or AXIOMCODE_REINDEX=1, to choose again" + fi +fi +SRC="$REPO/${AXIOMCODE_SRC:-}"; SRC="${SRC%/}" # THE ENGINE IS CHOSEN THE SAME WAY EVERY TIME, AND A BUILD THAT FINDS NONE SAYS WHERE IT LOOKED. The background # refresh runs this script from a hook, with the hook's PATH, the hook's Node and, installed from a marketplace, from a # plugin COPIED outside any checkout; an explicit `axiomcode index` runs it from inside the npm package. Each found an diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph index bd414596c..38a3b38a9 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph @@ -1,13 +1,25 @@ #!/usr/bin/env python3 -"""axiomcode-graph — the graph of a codebase, from the engine, as one page. +"""axiomcode graph — the graph of a codebase as one interactive page. - axiomcode-graph build [] [--library [,…]] [--out ] run the engine (axiomcode-build) then write the page - axiomcode-graph export [] [--out ] write the page from an existing graph.sqlite (no engine run) + axiomcode graph [] [--out ] + axiomcode graph [] --lang [,…] [--src ] [--library [,…]] [--out …] ---library (or AXIOMCODE_LIBRARY) names the dependency roots — for Java the JDK and the third-party sources/IR. With them a call -into a dependency resolves to a BLACK BOX (one per library type, drawn outside the disc in its own colour); without them it -is unresolved, and the resolution rate is understated. The rate the page reports is over calls into client code; external -calls are counted separately. +With no flags the page is drawn from the graph `axiomcode index` built, and the engine does not run: + - the graph is up to date (no source file differs from the ones it was built from): the page is written from it + in seconds. + - it is out of date: it is rebuilt first exactly as it was indexed, with the same --lang, --src and --library, so + only the languages the index chose are solved and no graph appears for a language it left out. + - there is no graph yet: the repository is indexed first (every language present, or the flags given). +Flags given here are a request for that graph, as `axiomcode index` takes them. + +A repository indexed in several languages is drawn in all of them, on one page. The command prints what it drew as +prose, and the page's absolute path last. `axiomcode graph export []` writes the page from the graph as it is, +with no freshness check. + +--library names the dependency roots (for Java the JDK and the third-party sources/IR), and is given to +`axiomcode index`. With them a call into a dependency resolves to a BLACK BOX (one per library type, drawn outside the +disc in its own colour); without them it is unresolved, and the resolution rate is understated. The rate the page +reports is over calls into client code; external calls are counted separately. The page (default /.axiomcode/graph/graph.html; one file, no network) draws the codebase as a disc: directories in the centre, their files on the next ring, the types in those files, and every method on the @@ -31,57 +43,29 @@ def _b36(n): return s -def export(repo, page=None): +def graph_dbs(repo, gdir): + """every graph to draw: the main language's, then each other language's (.axiomcode/lang/), as the query verbs + ask them. A graph named in AXIOMCODE_GRAPH was chosen by the caller and is drawn alone.""" + dbs = [os.path.join(gdir, 'out', 'graph.sqlite')] + if not os.environ.get('AXIOMCODE_GRAPH'): + ld = os.path.join(repo, '.axiomcode', 'lang') + for l in sorted(os.listdir(ld)) if os.path.isdir(ld) else (): + d = os.path.join(ld, l, 'out', 'graph.sqlite') + if os.path.exists(d): dbs.append(d) + return dbs + + +def export(repo, page=None, how=''): repo = os.path.realpath(repo or '.') gdir = os.environ.get('AXIOMCODE_GRAPH') or os.path.join(repo, '.axiomcode') db = os.path.join(gdir, 'out', 'graph.sqlite') # the same door every other verb goes through: built on demand under the CLI, refused in words # that name the one command that helps under anything else. if not os.path.exists(db) and not ax_contract.ensure_graph(repo, db): sys.exit(ax_contract.no_graph(repo, db)) - con = sqlite3.connect(db); con.row_factory = sqlite3.Row - q = lambda sql: con.execute(sql).fetchall() - has = lambda t: bool(con.execute("SELECT 1 FROM sqlite_master WHERE name=?", (t,)).fetchone()) - run = {r['key']: r['value'] for r in q("SELECT key, value FROM run")} - src = run.get('source_dir') or repo - def rel(p): - a = p if os.path.isabs(p) else os.path.join(src, p) - a = os.path.realpath(a) if os.path.exists(a) else os.path.normpath(a) - r = os.path.relpath(a, repo).replace(os.sep, '/'); return p.lstrip('/') if r.startswith('..') else r - - # ── declarations: types and methods, with the display names the query skill uses when the index is there ── - nodes = {} - owner = {r['id']: r['owner_type_id'] for r in q("SELECT id, owner_type_id FROM methods WHERE provenance='client'")} - if has('symbols'): - for r in q("SELECT * FROM symbols WHERE (method_id IS NOT NULL OR type_id IS NOT NULL)"): - if r['method_id'] and r['kind'] in METHOD_KINDS: - nodes[r['method_id']] = dict(id=r['method_id'], k='method', sk=r['kind'], l=r['display'], f=r['file'], a=r['line'], b=r['end_line'], t=int(bool(r['is_test'])), sig=r['signature'] or '') - elif r['type_id'] and r['kind'] in TYPE_KINDS: - nodes[r['type_id']] = dict(id=r['type_id'], k='type', sk=r['kind'], l=r['display'], f=r['file'], a=r['line'], b=r['end_line'], t=int(bool(r['is_test']))) - else: - cat = lambda c: 'interface' if 'INTERFACE' in c else 'enum' if 'ENUM' in c else 'namespace' if 'NAMESPACE' in c else 'type' if 'ALIAS' in c or 'TYPEDEF' in c else 'class' - for r in q("SELECT * FROM types WHERE provenance='client'"): - f = rel(r['file_path']); nodes[r['id']] = dict(id=r['id'], k='type', sk=cat(r['category']), l=r['name'].replace('$', '.'), f=f, a=r['start_line'], b=r['end_line'], t=int(bool(TESTRE.search(f)))) - for r in q("SELECT * FROM methods WHERE provenance='client'"): - f = rel(r['file_path']); o = (r['owner_qualified_name'] or '').replace('#', '.').split('.')[-1].replace('$', '.') - sk = 'constructor' if 'CONSTRUCTOR' in r['kind'] else 'module' if 'MODULE' in r['kind'] else 'method' if r['owner_type_id'] else 'function' - nodes[r['id']] = dict(id=r['id'], k='method', sk=sk, l=f"{o}.{r['name']}" if o else r['name'], f=f, a=r['start_line'], b=r['end_line'], t=int(bool(TESTRE.search(f))), sig=r['signature'] or '') - lib = {r['id']: (r['owner_qualified_name'] or r['qualified_name'].rsplit('.', 1)[0], r['name']) for r in q("SELECT id, qualified_name, name, owner_qualified_name FROM methods WHERE provenance<>'client'")} - - # ── containment: dir → file → type → method (nested types under their outer; free functions under the file) ── - nesting = {r['type_id']: r['outer_type_id'] for r in q("SELECT type_id, outer_type_id FROM nesting")} if has('nesting') else {} - for n in list(nodes.values()): - fid = 'F:' + n['f'] - if fid not in nodes: - d = os.path.dirname(n['f']) or '.' - nodes[fid] = dict(id=fid, k='file', l=n['f'], f=n['f'], t=int(bool(TESTRE.search(n['f']))), p='D:' + d) - if 'D:' + d not in nodes: nodes['D:' + d] = dict(id='D:' + d, k='dir', l=d, f='', t=int(bool(TESTRE.search(d + '/')))) - if n['k'] == 'type': n['p'] = nesting[n['id']] if nesting.get(n['id']) in nodes else fid - elif n['k'] == 'method': o = owner.get(n['id']); n['p'] = o if o in nodes else fid - - # ── calls: one edge per (caller, callee) with the best tier and the site count; unresolved → a count on the caller ── - # only an ambiguous_* tier is unresolved. boundary_lib is a call into a library; the *_terminal, implicit_constructor and - # fan_capped tiers are correct ends (the platform, a synthesised constructor, a computed target) — the same rule as the - # query skill, so the two never disagree about what is a blind spot + # A REPOSITORY IN SEVERAL LANGUAGES is drawn in all of them: one page, each language's graph merged into it (their ids + # carry the language, so none collide; a directory holding two languages' files is one node). No edge joins two + # languages, as no query follows one. + nodes, owners, lib, nesting, anc, langs, runs = {}, {}, {}, {}, collections.defaultdict(set), [], [] E, unresolved, terminal, LE = {}, collections.Counter(), collections.Counter(), {} boxes = collections.defaultdict(lambda: dict(sites=0, callers=set(), methods=collections.Counter())) # one black box per library type def box(c, owner, name): @@ -90,39 +74,92 @@ def export(repo, page=None): sites = collections.defaultdict(list) # per caller: [line, written name, target id ('' = none), tier] — what makes a call in the source a link def site(c, r, target): if r['line'] and len(sites[c]) < 400: sites[c].append([r['line'], r['written'] or '', target, r['tier']]) - for r in q("SELECT e.caller_id, e.callee_method_id, e.tier, e.callee_label, s.start_line AS line, s.callee_name AS written FROM call_edges e LEFT JOIN call_sites s ON s.id = e.call_site_id"): - c, t = r['caller_id'], r['callee_method_id'] - if c not in nodes: continue - if not t or t not in nodes: - if t in lib: box(c, *lib[t]); site(c, r, 'L:' + lib[t][0]) - elif r['tier'] == 'boundary_lib' and r['callee_label']: - # Python / JavaScript: the library target is a label — builtin:len, external:asyncio.get_running_loop — not a method row - prov, _, name = r['callee_label'].partition(':'); owner, _, meth = name.rpartition('.') - owner = owner or (('builtins' if prov == 'builtin' else name)); box(c, owner, meth or name); site(c, r, 'L:' + owner) - elif (r['tier'] or '').startswith('ambiguous'): unresolved[c] += 1; site(c, r, '') - else: terminal[c] += 1 - continue - site(c, r, t) - e = E.get((c, t)) - if e is None: E[(c, t)] = dict(s=c, t=t, tier=r['tier'], w=1) + skipped, tiers = None, collections.Counter() + for db in graph_dbs(repo, gdir): + con = sqlite3.connect(db); con.row_factory = sqlite3.Row + q = lambda sql: con.execute(sql).fetchall() + has = lambda t: bool(con.execute("SELECT 1 FROM sqlite_master WHERE name=?", (t,)).fetchone()) + run = {r['key']: r['value'] for r in q("SELECT key, value FROM run")}; runs.append(run) + if run.get('language') and run['language'] not in langs: langs.append(run['language']) + src = run.get('source_dir') or repo + def rel(p, src=src): + a = p if os.path.isabs(p) else os.path.join(src, p) + a = os.path.realpath(a) if os.path.exists(a) else os.path.normpath(a) + r = os.path.relpath(a, repo).replace(os.sep, '/'); return p.lstrip('/') if r.startswith('..') else r + + # ── declarations: types and methods, with the display names the query skill uses when the index is there ── + owners.update({r['id']: r['owner_type_id'] for r in q("SELECT id, owner_type_id FROM methods WHERE provenance='client'")}) + if has('symbols'): + for r in q("SELECT * FROM symbols WHERE (method_id IS NOT NULL OR type_id IS NOT NULL)"): + if r['method_id'] and r['kind'] in METHOD_KINDS: + nodes[r['method_id']] = dict(id=r['method_id'], k='method', sk=r['kind'], l=r['display'], f=r['file'], a=r['line'], b=r['end_line'], t=int(bool(r['is_test'])), sig=r['signature'] or '') + elif r['type_id'] and r['kind'] in TYPE_KINDS: + nodes[r['type_id']] = dict(id=r['type_id'], k='type', sk=r['kind'], l=r['display'], f=r['file'], a=r['line'], b=r['end_line'], t=int(bool(r['is_test']))) else: - e['w'] += 1 - if r['tier'] == 'known_edge': e['tier'] = 'known_edge' - # a call through a holder the engine could name (#1206): the caller reaches every function the holder may hold. The call - # site itself resolves to the holder's bodiless base (Function.apply), so without these the stored function has no caller - if has('ext_fn_value_call'): - for r in q("SELECT DISTINCT c0, c1 FROM ext_fn_value_call"): - c, t = r[0], r[1] - if c in nodes and t in nodes and (c, t) not in E: E[(c, t)] = dict(s=c, t=t, tier='fn_value', w=1) + cat = lambda c: 'interface' if 'INTERFACE' in c else 'enum' if 'ENUM' in c else 'namespace' if 'NAMESPACE' in c else 'type' if 'ALIAS' in c or 'TYPEDEF' in c else 'class' + for r in q("SELECT * FROM types WHERE provenance='client'"): + f = rel(r['file_path']); nodes[r['id']] = dict(id=r['id'], k='type', sk=cat(r['category']), l=r['name'].replace('$', '.'), f=f, a=r['start_line'], b=r['end_line'], t=int(bool(TESTRE.search(f)))) + for r in q("SELECT * FROM methods WHERE provenance='client'"): + f = rel(r['file_path']); o = (r['owner_qualified_name'] or '').replace('#', '.').split('.')[-1].replace('$', '.') + sk = 'constructor' if 'CONSTRUCTOR' in r['kind'] else 'module' if 'MODULE' in r['kind'] else 'method' if r['owner_type_id'] else 'function' + nodes[r['id']] = dict(id=r['id'], k='method', sk=sk, l=f"{o}.{r['name']}" if o else r['name'], f=f, a=r['start_line'], b=r['end_line'], t=int(bool(TESTRE.search(f))), sig=r['signature'] or '') + lib.update({r['id']: (r['owner_qualified_name'] or r['qualified_name'].rsplit('.', 1)[0], r['name']) for r in q("SELECT id, qualified_name, name, owner_qualified_name FROM methods WHERE provenance<>'client'")}) + if has('nesting'): nesting.update({r['type_id']: r['outer_type_id'] for r in q("SELECT type_id, outer_type_id FROM nesting")}) + + # ── calls: one edge per (caller, callee) with the best tier and the site count; unresolved → a count on the caller ── + # only an ambiguous_* tier is unresolved. boundary_lib is a call into a library; the *_terminal, implicit_constructor and + # fan_capped tiers are correct ends (the platform, a synthesised constructor, a computed target) — the same rule as the + # query skill, so the two never disagree about what is a blind spot + for r in q("SELECT e.caller_id, e.callee_method_id, e.tier, e.callee_label, s.start_line AS line, s.callee_name AS written FROM call_edges e LEFT JOIN call_sites s ON s.id = e.call_site_id"): + c, t = r['caller_id'], r['callee_method_id'] + if c not in nodes: continue + if not t or t not in nodes: + if t in lib: box(c, *lib[t]); site(c, r, 'L:' + lib[t][0]) + elif r['tier'] == 'boundary_lib' and r['callee_label']: + # Python / JavaScript: the library target is a label — builtin:len, external:asyncio.get_running_loop — not a method row + prov, _, name = r['callee_label'].partition(':'); owner, _, meth = name.rpartition('.') + owner = owner or (('builtins' if prov == 'builtin' else name)); box(c, owner, meth or name); site(c, r, 'L:' + owner) + elif (r['tier'] or '').startswith('ambiguous'): unresolved[c] += 1; site(c, r, '') + else: terminal[c] += 1 + continue + site(c, r, t) + e = E.get((c, t)) + if e is None: E[(c, t)] = dict(s=c, t=t, tier=r['tier'], w=1) + else: + e['w'] += 1 + if r['tier'] == 'known_edge': e['tier'] = 'known_edge' + # a call through a holder the engine could name (#1206): the caller reaches every function the holder may hold. The call + # site itself resolves to the holder's bodiless base (Function.apply), so without these the stored function has no caller + if has('ext_fn_value_call'): + for r in q("SELECT DISTINCT c0, c1 FROM ext_fn_value_call"): + c, t = r[0], r[1] + if c in nodes and t in nodes and (c, t) not in E: E[(c, t)] = dict(s=c, t=t, tier='fn_value', w=1) + for r in q("SELECT method_id, reason FROM entry_points"): + if r['method_id'] in nodes: nodes[r['method_id']].setdefault('e', []).append(r['reason']) + for r in q("SELECT type_id, ancestor_type_id FROM type_ancestors"): anc[r['type_id']].add(r['ancestor_type_id']) + # two shapes share the name: the bundle's (file_path, reason, construct, start_line, …) and the index's (file, reason), which + # the index writes over the bundle's — read whichever is there + if has('skipped'): + cols = {r[1] for r in q("PRAGMA table_info(skipped)")}; col = lambda c: c if c in cols else 'NULL' + fc = 'file_path' if 'file_path' in cols else 'file' + skipped = (skipped or []) + [dict(f=rel(r[0]), r=r[1] or '', a=r[2], d=r[3] or '') for r in q(f"SELECT {fc}, reason, {col('start_line')}, {col('detail')} FROM skipped WHERE {fc} <> '' ORDER BY reason, {fc}")] + tiers.update(r[0] for r in q("SELECT tier FROM call_edges")) + con.close() + + # ── containment: dir → file → type → method (nested types under their outer; free functions under the file) ── + for n in list(nodes.values()): + fid = 'F:' + n['f'] + if fid not in nodes: + d = os.path.dirname(n['f']) or '.' + nodes[fid] = dict(id=fid, k='file', l=n['f'], f=n['f'], t=int(bool(TESTRE.search(n['f']))), p='D:' + d) + if 'D:' + d not in nodes: nodes['D:' + d] = dict(id='D:' + d, k='dir', l=d, f='', t=int(bool(TESTRE.search(d + '/')))) + if n['k'] == 'type': n['p'] = nesting[n['id']] if nesting.get(n['id']) in nodes else fid + elif n['k'] == 'method': o = owners.get(n['id']); n['p'] = o if o in nodes else fid for m, n in unresolved.items(): nodes[m]['u'] = n for m, ss in sites.items(): nodes[m]['cs'] = ss for owner, b in boxes.items(): nodes['L:' + owner] = dict(id='L:' + owner, k='lib', l=owner, f='', sites=b['sites'], callers=len(b['callers']), methods=[f"{k}×{v}" if v > 1 else k for k, v in b['methods'].most_common(8)]) - for r in q("SELECT method_id, reason FROM entry_points"): - if r['method_id'] in nodes: nodes[r['method_id']].setdefault('e', []).append(r['reason']) X = [] # extends / implements: direct ancestors only (type_ancestors is a closure) - anc = collections.defaultdict(set) - for r in q("SELECT type_id, ancestor_type_id FROM type_ancestors"): anc[r['type_id']].add(r['ancestor_type_id']) for t, A in anc.items(): if t in nodes: for a in A: @@ -133,21 +170,13 @@ def export(repo, page=None): for n in nodes.values(): if deg[n['id']]: n['d'] = deg[n['id']] - # two shapes share the name: the bundle's (file_path, reason, construct, start_line, …) and the index's (file, reason), which - # the index writes over the bundle's — read whichever is there - skipped = None - if has('skipped'): - cols = {r[1] for r in q("PRAGMA table_info(skipped)")}; col = lambda c: c if c in cols else 'NULL' - fc = 'file_path' if 'file_path' in cols else 'file' - skipped = [dict(f=rel(r[0]), r=r[1] or '', a=r[2], d=r[3] or '') for r in q(f"SELECT {fc}, reason, {col('start_line')}, {col('detail')} FROM skipped WHERE {fc} <> '' ORDER BY reason, {fc}")] - tiers = collections.Counter(r[0] for r in q("SELECT tier FROM call_edges")) sites = sum(tiers.values()); unres = sum(v for t, v in tiers.items() if t.startswith('ambiguous')) client = tiers.get('known_edge', 0) + tiers.get('multi_inferred', 0); external = sum(b['sites'] for b in boxes.values()) terminal_n = sites - unres - client - external - data = dict(meta=dict(repo=os.path.basename(repo), language=run.get('language', '?'), built=time.strftime('%Y-%m-%d %H:%M'), sites=sites, + data = dict(meta=dict(repo=os.path.basename(repo), language=', '.join(langs) or '?', built=time.strftime('%Y-%m-%d %H:%M'), sites=sites, client_sites=client, external_sites=external, terminal_sites=terminal_n, unresolved=unres, boxes=len(boxes), client_rate=round(100 * client / max(1, sites - external - terminal_n)), overall_rate=round(100 * (sites - unres) / max(1, sites)), - tiers=dict(tiers), library_roots=run.get('library_roots', '') or (run.get('language') != 'java' and 'n/a') or '', counts=dict(collections.Counter(n['k'] for n in nodes.values())), edges=len(E)), + tiers=dict(tiers), library_roots=runs[0].get('library_roots', '') or ('java' not in langs and 'n/a') or '', counts=dict(collections.Counter(n['k'] for n in nodes.values())), edges=len(E)), nodes=list(nodes.values()), calls=list(E.values()), libcalls=list(LE.values()), extends=X, skipped=skipped) data['meta']['fn_value_edges'] = sum(1 for e in E.values() if e['tier'] == 'fn_value') short = {} @@ -174,29 +203,92 @@ def export(repo, page=None): data['files'] = files; data['meta']['repo_path'] = repo; data['meta']['sources'] = bool(files) tpl = open(os.path.join(HERE, 'viewer.html'), encoding='utf-8').read() open(page, 'w', encoding='utf-8').write(tpl.replace('/*__DATA__*/null', json.dumps(data, separators=(',', ':')).replace(',`")) - print(f"page: {os.path.relpath(page)}") + m, n = data['meta'], data['meta']['counts'] + # PROSE, NOT A DICT: an agent relays this line to a person, and a raw {'dir': …} or a ../../../ path it had to resolve + # itself was what it relayed + if how: print(how) + k = lambda v, one, many: f"{v:,} {one if v == 1 else many}" + print(f"graph of {repo} ({m['language']}): {k(n.get('method', 0), 'function or method', 'functions and methods')}, " + f"{k(n.get('type', 0), 'type', 'types')}, {k(n.get('file', 0), 'file', 'files')} in {k(n.get('dir', 0), 'directory', 'directories')}; " + f"{k(m['edges'], 'call edge', 'call edges')}, {len(X):,} extends/implements.") + print(f"calls: {k(m['sites'], 'call site', 'call sites')}. Of the calls into this repository's own code {m['client_rate']}% resolved " + f"({m['client_sites']:,} resolved, {m['unresolved']:,} unresolved); {m['external_sites']:,} go into " + f"{k(m['boxes'], 'library type', 'library types')}, drawn as black boxes; {m['terminal_sites']:,} end at the platform.") + if not m['library_roots']: + print(f"indexed without library roots: calls into the JDK and other dependencies cannot resolve and count as unresolved. " + f"To resolve them, index again with them: axiomcode index {repo} --library ,") + print(f"page: {os.path.abspath(page)}") -def build(repo, page, library=None): - repo = os.path.realpath(repo or '.') - if library: os.environ['AXIOMCODE_LIBRARY'] = ','.join(os.path.abspath(os.path.expanduser(x)) for x in library.split(',')) +def run_build(repo, env=None): + """axiomcode-build on repo, its progress on this process's stdout; exits with its status when it fails""" cands = [os.path.join(HERE, 'axiomcode-build'), os.path.join(os.environ.get('AXIOMCODE_ENGINE', ''), 'skills', 'axiomcode', 'scripts', 'axiomcode-build')] b = next((os.path.realpath(c) for c in cands if os.path.isfile(c)), None) if not b: sys.exit("axiomcode-build not found beside this script (set AXIOMCODE_ENGINE to the engine checkout)") - r = subprocess.run([os.environ.get('AXIOMCODE_BASH') or 'bash', b, repo]) # a bash script: Windows cannot exec it itself + sys.stdout.flush() + r = subprocess.run([os.environ.get('AXIOMCODE_BASH') or 'bash', b, repo], env=env) # a bash script: Windows cannot exec it itself if r.returncode: sys.exit(r.returncode) - export(repo, page) + + +def recorded(repo): + """the file table of the graph that is there: the languages, --src and --library it was indexed with. A graph from + before the table has its main language, --src and --library in its run table; its other languages are the graphs + beside it. None when there is nothing to read them from.""" + import ax_fresh + t = ax_fresh.load_table(repo) + if t: return t + t = ax_fresh.legacy_params(repo) + if not t: return None + ld = os.path.join(repo, '.axiomcode', 'lang') + others = sorted(l for l in (os.listdir(ld) if os.path.isdir(ld) else ()) if os.path.exists(os.path.join(ld, l, 'out', 'graph.sqlite'))) + return dict(t, lang=','.join([t['lang']] + [l for l in others if l != t['lang']]), lang_auto=False, legacy=True) + + +def build(repo, page, library=None): + """the page, from the graph that is there when it is current, else from a rebuild of it with the flags it was indexed + with. THE ENGINE RUNS ONLY WHEN IT HAS TO, AND ONLY FOR THE LANGUAGES THE INDEX CHOSE. This called axiomcode-build with + no flags, which detects every language in the tree: on a repository indexed with --lang python its answer never matched + the recorded graph, so every call rebuilt the graph from scratch in every language present, a JavaScript or TypeScript + compile for a few stray files included, and left a graph for each of them that later queries answered from.""" + import ax_fresh + repo = os.path.realpath(repo or '.') + if library: os.environ['AXIOMCODE_LIBRARY'] = ','.join(os.path.abspath(os.path.expanduser(x)) for x in library.split(',')) + db = os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite') + asked = [f"{k}={os.environ[v]}" for k, v in (('--lang', 'AXIOMCODE_LANG'), ('--src', 'AXIOMCODE_SRC'), ('--library', 'AXIOMCODE_LIBRARY')) if os.environ.get(v)] + if os.environ.get('AXIOMCODE_GRAPH'): return export(repo, page) # a graph someone else built and placed: drawn as it is + if not os.path.lexists(db): + # no graph yet: the first index, with what was asked for (every language present, when nothing was) + t0 = time.time(); run_build(repo) + return export(repo, page, how=f"no graph yet: indexed {repo}{' with ' + ' '.join(asked) if asked else ''} in {time.time() - t0:.0f} s.") + if asked: + # flags given here are a request for that graph: axiomcode-build keeps the one there when it already is that graph + t0 = time.time(); run_build(repo) + return export(repo, page, how=f"graph built with {' '.join(asked)} ({time.time() - t0:.0f} s).") + t = recorded(repo) + if t is None: + return export(repo, page, how="drawn from the graph as it is: it records no index flags to rebuild it with.") + c = None if t.get('legacy') else ax_fresh.changes(repo, t) + langs = (t.get('lang') or '?').replace(',', ', ') + if c is not None and not any(c) and not ax_fresh.graph_broken(repo): + return export(repo, page, how=f"graph up to date ({langs}): drawn from it, no rebuild.") + n = sum(len(x) for x in c) if c else 0 + flags = ' '.join(f for f in (('--lang ' + t['lang']) if not t.get('lang_auto', True) else 'every language present', + ('--src ' + t['src_arg']) if t.get('src_arg') else '', ('--library ' + t['library']) if t.get('library') else '') if f) + print(f"the graph is out of date ({n} file(s) changed since it was built): rebuilding it as it was indexed ({flags}) …" if n else + f"rebuilding the graph as it was indexed ({flags}) …", flush=True) + t0 = time.time(); run_build(repo, ax_fresh.rebuild_env(t, AXIOMCODE_REFRESH_REASON='axiomcode graph')) + return export(repo, page, how=f"graph rebuilt ({flags}) in {time.time() - t0:.0f} s.") if __name__ == '__main__': a = sys.argv[1:]; page = None; library = None if '--out' in a: i = a.index('--out'); page = os.path.abspath(os.path.expanduser(a[i + 1])); del a[i:i + 2] if '--library' in a: i = a.index('--library'); library = a[i + 1]; del a[i:i + 2] - verb, pos = (a[0] if a else ''), a[1:] - if verb == 'build': build(pos[0] if pos else '.', page, library) - elif verb == 'export': export(pos[0] if pos else '.', page) - else: print(__doc__.strip()); sys.exit(0 if verb in ('', '-h', '--help') else 2) + if a and a[0] in ('-h', '--help'): print(__doc__.strip()); sys.exit(0) + # `axiomcode graph []` is the verb; `build` and `export` are the older spellings, still taken. A directory + # named `build` or `export` is a repository, not a subcommand + verb = a[0] if a and a[0] in ('build', 'export', 'draw') and not os.path.isdir(a[0]) else 'draw' + pos = a[1:] if a and verb == a[0] else a + if len(pos) > 1 or any(x.startswith('-') for x in pos): print(__doc__.strip(), file=sys.stderr); sys.exit(2) + if verb == 'export': export(pos[0] if pos else '.', page, how="drawn from the graph as it is (export: no freshness check, no rebuild).") + else: build(pos[0] if pos else '.', page, library) diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index b2923a602..80ea06323 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -27,7 +27,7 @@ Read / Grep results as `graph: …` lines. | "how does A reach B" · "everything that reaches X" | `axiomcode path A B` · `axiomcode path '*' X` | | "what did my edit touch" · "which tests do I run" | `axiomcode changed --impact` · `axiomcode test-impact` | | "is it safe to delete X" | `axiomcode impact X --delete` | -| the graph as a page for a human · this repo should prefer the graph, once | `axiomcode graph` · `axiomcode install` | +| the graph as a page for a human · this repo should prefer the graph, once | `axiomcode graph` (drawn from the existing graph in seconds; a stale one is rebuilt first with the flags it was indexed with; prints the page's absolute path) · `axiomcode install` | Rules that decide whether an answer means anything: @@ -38,7 +38,8 @@ Rules that decide whether an answer means anything: briefly on its own only when the answer touches an edited file and the rebuild is nearly done. - **Before a delete or a rename, ask with `--fresh`** (MCP `fresh=true`): it waits for the rebuild, printing its progress, and answers from a graph that includes every edit. - A manual `index` with different flags (e.g. dropping `--library`) rebuilds a worse graph over the good one. + A manual `index` with different flags rebuilds a worse graph over the good one. A bare `index`, the background + refresh and `graph` keep the `--lang` (and `--src`, `--library`) the graph was indexed with; pass `--lang` to change it. - A repo in several languages is indexed in all of them, one graph each, and every query asks each graph; calls are not followed from one language to another. `--lang` restricts it, `--src src` narrows it; `--library ` so calls into dependencies resolve (without it they are `ambiguous_unknown` — do not quote that resolution rate). diff --git a/tests/README.md b/tests/README.md index c75741715..f0d520e6c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -18,6 +18,9 @@ One check needs no graph and is its own script: python3 tests/refresh.py the graph refreshes itself after an edit in every language: a query sees the edit, `changed` answers the same before and after, a burst costs one rebuild and queries during it answer (#1305; builds real graphs, needs the engine) + python3 tests/graph_verb.py `axiomcode graph` draws the existing graph and rebuilds a stale one with the flags it + was indexed with; no rebuild path (refresh, repair, bare index) solves a language an + explicit --lang left out (builds real graphs, needs the engine) python3 tests/indexed_tree.py changed compares against the tree the graph was indexed from, so an index taken with uncommitted edits reports only later edits (#1222) python3 tests/mcp.py `axiomcode mcp` answers initialize, lists every tool and runs one, diff --git a/tests/graph_verb.py b/tests/graph_verb.py new file mode 100644 index 000000000..5c9fa025f --- /dev/null +++ b/tests/graph_verb.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""tests/graph_verb.py — `axiomcode graph` draws the graph the index built, and rebuilds it only as it was indexed. + +It used to run axiomcode-build with no flags. That detects every language in the tree, so on a repository indexed with +--lang python the build never matched the recorded graph: every call rebuilt it from scratch in every language +present, a JavaScript or TypeScript compile for a few stray files included, and left a graph for each of them that +later queries answered from. Its output was a raw Python dict and a relative ../../ page path. + +Two throwaway repositories: + + indexed Python under app/, with a stray JavaScript file beside it, indexed with --lang python --src app + current the page is drawn from the graph: no engine run (the graph file is untouched), no graph for another + language, prose counts and the page's absolute path + stale after an edit the graph is rebuilt with --lang python --src app, the page shows the edit, and still no + JavaScript graph appears + control Python and Java, indexed with no --lang (every language): the page draws both, and both graphs stay; + detected languages are not pinned, so a rebuild still detects them + rebuilds C# with a stray TypeScript front end, indexed with --lang csharp: an edit plus a commit (the background + refresh), a bare `index` and a query that repairs a broken graph each solve C# alone + surfaces `axiomcode help graph` and the MCP tool's description describe this, not `axiomcode-graph build` + + python3 tests/graph_verb.py [-v] +""" +import os, shutil, subprocess, sys, tempfile, time + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +AX = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') +MCP = os.path.join(ROOT, 'plugins', 'axiomcode', 'mcp', 'server.py') +FRESH = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'ax_fresh.py') + +INDEXED = { + 'app/shop/__init__.py': '', + 'app/shop/orders.py': 'def price(n):\n return n * 3\n\n\ndef order_total(n):\n return price(n) + 1\n', + 'app/shop/cli.py': 'from shop.orders import order_total\n\n\ndef main():\n return order_total(2)\n', + 'app/static/widget.js': 'function strayWidget(a) {\n return a + 1\n}\n\nmodule.exports = { strayWidget }\n', + 'tools/other.py': 'def outside_src():\n return 1\n', +} +CSHARP = { + 'App/App.csproj': '\n net8.0\n\n', + 'App/Orders.cs': 'namespace App;\n\npublic class OrderService\n{\n public decimal Total(int widgets) => Price(widgets) * 2;\n\n private decimal Price(int widgets) => widgets * 3m;\n}\n', + 'App/ClientApp/tsconfig.json': '{ "compilerOptions": { "strict": true } }\n', + 'App/ClientApp/main.ts': 'export function strayFrontEnd(): number {\n return 1\n}\n', +} +MIXED = { + 'pom.xml': '\n 4.0.0\n example\n mixed\n 1.0\n\n', + 'src/main/java/app/Billing.java': 'package app;\n\npublic class Billing {\n public int chargeAccount(int n) {\n return fee(n) + n;\n }\n\n int fee(int n) {\n return 2;\n }\n}\n', + 'src/main/java/app/Invoice.java': 'package app;\n\npublic class Invoice {\n public int issueInvoice() {\n return new Billing().chargeAccount(3);\n }\n}\n', + 'scripts/report/__init__.py': '', + 'scripts/report/make.py': 'def render_report(x):\n return str(x)\n\n\ndef main():\n return render_report(1)\n', +} + + +def sh(cwd, *cmd, env=None): + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, env=env) + + +def make(root, files): + for rel, text in files.items(): + os.makedirs(os.path.dirname(os.path.join(root, rel)), exist_ok=True) + open(os.path.join(root, rel), 'w').write(text) + for cmd in (('git', 'init', '-q'), ('git', 'add', '-A'), ('git', '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'base')): + sh(root, *cmd) + + +def main(argv): + verbose = '-v' in argv + fails = [] + + def check(ok, why, detail=''): + print(('ok ' if ok else 'FAIL ') + why + ('' if ok and not verbose or not detail else '\n ' + detail.strip()[-1500:].replace('\n', '\n '))) + if not ok: fails.append(why) + + work = os.path.realpath(tempfile.mkdtemp(prefix='axiomcode-graphverb-')) # macOS: /var is /private/var, and the page is named by its real path + # the engine is this checkout's, unless the caller names a built one (a worktree whose parser is not built) + env = dict(os.environ, AXIOMCODE_ENGINE=os.environ.get('AXIOMCODE_ENGINE') or ROOT, AXIOMCODE_NO_REFRESH='1') + for k in ('AXIOMCODE_LANG', 'AXIOMCODE_SRC', 'AXIOMCODE_LIBRARY', 'AXIOMCODE_GRAPH'): env.pop(k, None) + try: + # ── indexed with --lang python --src app ────────────────────────────────────────────────────────────── + repo = os.path.join(work, 'indexed'); make(repo, INDEXED) + ax = os.path.join(repo, '.axiomcode'); db = os.path.join(ax, 'out', 'graph.sqlite') + b = sh(repo, 'bash', AX, 'index', repo, '--lang', 'python', '--src', 'app', env=env) + check(b.returncode == 0 and os.path.exists(db), 'setup: the repository indexes with --lang python --src app', b.stdout + b.stderr) + if b.returncode: return 1 + before = (os.path.realpath(db), os.stat(os.path.realpath(db)).st_mtime_ns) + + t0 = time.time(); g = sh(repo, 'bash', AX, 'graph', repo, env=env); took = time.time() - t0 + page = os.path.join(ax, 'graph', 'graph.html') + check(g.returncode == 0 and os.path.exists(page), 'current: the page is written', g.stdout + g.stderr) + check((os.path.realpath(db), os.stat(os.path.realpath(db)).st_mtime_ns) == before and 'no rebuild' in g.stdout and 'building' not in g.stdout, + f'current: the page is drawn from the graph there, the engine does not run ({took:.1f} s)', g.stdout + g.stderr) + check(not os.path.exists(os.path.join(ax, 'lang')) and not os.path.exists(os.path.join(ax, 'out', 'javascript')), + 'current: no graph appears for the stray JavaScript file the index left out', g.stdout) + check(f'page: {page}' in g.stdout and '../' not in g.stdout and "{'" not in g.stdout and ' functions and methods' in g.stdout, + 'current: the output is prose, and names the page by its absolute path', g.stdout) + check('axiomcode-graph build' not in g.stdout + g.stderr, 'current: it advises no command an agent does not have', g.stdout + g.stderr) + html = open(page).read() if os.path.exists(page) else '' + check('order_total' in html and 'strayWidget' not in html and 'outside_src' not in html, + 'current: the page holds the indexed Python and nothing outside --lang / --src', '') + + # an edit: the graph is stale, and is rebuilt as it was indexed + with open(os.path.join(repo, 'app', 'shop', 'orders.py'), 'a') as f: f.write('\n\ndef refund_order(n):\n return -order_total(n)\n') + g = sh(repo, 'bash', AX, 'graph', repo, env=env) + html = open(page).read() if os.path.exists(page) else '' + check(g.returncode == 0 and 'refund_order' in html and 'rebuilt (--lang python --src app)' in g.stdout, + 'stale: the graph is rebuilt with the --lang and --src it was indexed with, and the page shows the edit', g.stdout + g.stderr) + check(not os.path.exists(os.path.join(ax, 'lang')) and 'building python graph' in g.stdout and 'javascript graph' not in g.stdout and '+ javascript' not in g.stdout, + 'stale: the rebuild solves Python alone; no JavaScript graph appears', g.stdout) + check('outside_src' not in html, 'stale: the rebuild keeps --src (a file outside it is not drawn)', '') + + # ── control: indexed with no --lang, every language present ────────────────────────────────────────── + mixed = os.path.join(work, 'mixed'); make(mixed, MIXED) + b = sh(mixed, 'bash', AX, 'index', mixed, env=env) + mx = os.path.join(mixed, '.axiomcode') + both = [os.path.exists(os.path.join(mx, 'out', 'graph.sqlite')), os.path.exists(os.path.join(mx, 'lang', 'python', 'out', 'graph.sqlite'))] + check(b.returncode == 0 and all(both), 'control: a Java and Python repository indexed with no --lang has both graphs', b.stdout + b.stderr) + g = sh(mixed, 'bash', AX, 'graph', mixed, env=env) + mpage = os.path.join(mx, 'graph', 'graph.html') + html = open(mpage).read() if os.path.exists(mpage) else '' + check(g.returncode == 0 and 'chargeAccount' in html and 'render_report' in html and any(l.startswith('graph of ') and 'java' in l and 'python' in l for l in g.stdout.splitlines()), + 'control: the page draws every language the repository was indexed in', g.stdout + g.stderr) + check(os.path.exists(os.path.join(mx, 'lang', 'python', 'out', 'graph.sqlite')) and 'no rebuild' in g.stdout, + 'control: drawing it keeps both graphs and rebuilds neither', g.stdout) + c = sh(mixed, sys.executable, FRESH, 'chosen', mixed, env=env) + check(c.returncode == 0 and c.stdout.strip() == '', 'control: languages that were detected are not pinned; a rebuild detects them again', c.stdout) + + # ── every rebuild path: C# indexed with --lang csharp, a stray TypeScript front end beside it ───────────── + cs = os.path.join(work, 'csharp'); make(cs, CSHARP); cx = os.path.join(cs, '.axiomcode') + b = sh(cs, 'bash', AX, 'index', cs, '--lang', 'csharp', env=env) + check(b.returncode == 0 and os.path.exists(os.path.join(cx, 'out', 'graph.sqlite')) and not os.path.exists(os.path.join(cx, 'lang')), + 'rebuilds: a C# repository indexes with --lang csharp and no TypeScript graph', b.stdout + b.stderr) + c = sh(cs, sys.executable, FRESH, 'chosen', cs, env=env) + check(c.stdout.strip() == 'csharp', 'rebuilds: the file table records csharp as chosen', c.stdout) + # an edit, committed, then the background refresh: the path a hook or the MCP server's timer starts + with open(os.path.join(cs, 'App', 'Orders.cs'), 'a') as f: f.write('\n// committed edit\n') + sh(cs, 'git', '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qam', 'edit') + w = sh(cs, sys.executable, FRESH, 'worker', cs, env=dict(env, AXIOMCODE_REFRESH_DEBOUNCE='0.3')) + stamp = lambda: open(os.path.join(cx, 'out', 'stamp')).read().strip() if os.path.exists(os.path.join(cx, 'out', 'stamp')) else '' + no_ts = lambda: not os.path.exists(os.path.join(cx, 'lang')) and not os.path.exists(os.path.join(cx, 'out', 'typescript')) + check(w.returncode == 0 and 'rebuilding' in w.stdout and '-csharp-' in stamp() and no_ts(), + 'rebuilds: an edit plus a commit refreshes C# alone; no TypeScript graph, no TypeScript solve', w.stdout + stamp()) + # a bare index, as an agent re-runs one "to make sure" + b = sh(cs, 'bash', AX, 'index', cs, env=env) + check(b.returncode == 0 and 'keeping the languages this graph was indexed with (--lang csharp)' in b.stdout and '-csharp-' in stamp() and no_ts(), + 'rebuilds: a bare index keeps --lang csharp and says so', b.stdout + b.stderr) + # a query that finds the graph pointer broken repairs it, with the same languages + ptr = os.path.join(cx, 'out', 'graph.sqlite'); tgt = os.path.realpath(ptr) + os.rename(tgt, tgt + '.gone') + q = sh(cs, 'bash', AX, 'impact', 'OrderService.Total', cs, env=env) + check(q.returncode == 0 and os.path.exists(ptr) and '-csharp-' in stamp() and no_ts(), + 'rebuilds: a query repairing a broken graph builds C# alone', q.stdout[-600:] + q.stderr[-600:]) + + # ── surfaces ────────────────────────────────────────────────────────────────────────────────────────── + h = sh(ROOT, 'bash', AX, 'help', 'graph', env=env) + check(h.returncode == 0 and 'axiomcode graph []' in h.stdout and 'axiomcode-graph build' not in h.stdout and 'as it was indexed' in h.stdout, + '`axiomcode help graph` names the verb agents call and says a stale graph is rebuilt as it was indexed', h.stdout) + doc = open(MCP).read(); i = doc.index('def axiomcode_graph('); doc = doc[i:i + 1200] + check('it was indexed with' in doc and 'absolute path' in doc, 'the MCP axiomcode_graph description says the same', doc) + finally: + shutil.rmtree(work, ignore_errors=True) + print(f"\n{'FAIL' if fails else 'ok'}: {len(fails)} of the checks above failed" if fails else '\nok: every check passed') + return 1 if fails else 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv)) From 16831fd49d13d7aa74b3f14a282c30ed72cbc1e7 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:29:18 -0700 Subject: [PATCH 12/31] context: equal-scoring entry points are ordered by what the question asks, not by symbol ids When several declarations matched the task's words with the same score (a mapper method, a repository method, a service method and a query method all named findByX), the first seed and its three siblings were whichever came first in the graph's symbol ids. Those ids are hashes that change with the directory a project is indexed in, so the same question on the same code named the mapper method and its XML in one copy and dropped both in another. - pick_seeds breaks a tie by: a declaration bound by a text file of the kind the question asks about (the mapper method whose XML holds the SQL, for a SQL question); then the one whose own name holds more of the matched words; then its name and place. A question that asks about no text file walks no tree and gets no preference. - The text-binding ring takes an interface seed's implementation callees at their nearer distance. A mapper call one hop inside the implementation was skipped whenever the closure walk had already placed it at hop 3. - context --source on a SQL or config question crashed: the printed code replaced the question text before the text-file check read it. Test: java case context-sql-tie-picks-the-bound-method (four equal matches that sort before the mapper by name; near misses: another mapper XML with the same statement id under a different namespace, and the same words without a SQL question, which lists no XML). --- .../axiomcode/scripts/axiomcode-context | 48 +++++++++++++++---- .../case.json | 15 ++++++ .../src/main/java/demo/order/Order.java | 8 ++++ .../src/main/java/demo/order/OrderAudit.java | 12 +++++ .../src/main/java/demo/order/OrderCache.java | 12 +++++ .../src/main/java/demo/order/OrderLabel.java | 12 +++++ .../src/main/java/demo/order/OrderMapper.java | 8 ++++ .../main/java/demo/order/OrderService.java | 11 +++++ .../src/main/resources/mapper/OrderMapper.xml | 4 ++ .../main/resources/mapper/WidgetMapper.xml | 4 ++ 10 files changed, 124 insertions(+), 10 deletions(-) create mode 100644 tests/cases/java/context-sql-tie-picks-the-bound-method/case.json create mode 100644 tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/Order.java create mode 100644 tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderAudit.java create mode 100644 tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderCache.java create mode 100644 tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderLabel.java create mode 100644 tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderMapper.java create mode 100644 tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderService.java create mode 100644 tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/resources/mapper/OrderMapper.xml create mode 100644 tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/resources/mapper/WidgetMapper.xml diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index 2bc36685c..84a68b905 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -209,7 +209,7 @@ def rank_dirs(g, terms, depth=3, topk=DIR_EVIDENCE): return ranked -def pick_seeds(g, scored, terms, want, named=()): +def pick_seeds(g, scored, terms, want, named=(), prefer=frozenset()): """The entry points, in four passes, each only filling what the one before left: 1. what the question NAMES: a declaration it spells out (`IRouter.RouteAsync`), a route it quotes @@ -221,6 +221,13 @@ def pick_seeds(g, scored, terms, want, named=()): 3. the best symbol for each term still uncovered, as before, so a task naming unrelated concepts still enters the graph at each of them; 4. the best overall. + + Equal scores are common: `findByCode` on a mapper, a repository, a service and a query class all match the same + two words the same way. A tie used to fall to the graph's symbol ids, which are hashes that change with the + directory a project is indexed in, so the same question on the same code picked a different four of nine from one + copy to the next. A tie now goes first to `prefer` (a declaration bound by a text file of the kind the question + asks about: the mapper method whose XML holds the SQL, when the question asks which SQL runs), then to the one + whose own name holds more of the matched words, then to its name and place, which do not move. """ seeds, seen, covered = [], set(), set() def take(sid, why, sc): @@ -228,10 +235,14 @@ def pick_seeds(g, scored, terms, want, named=()): seen.add(sid); seeds.append((sid, why, sc)); covered.update(scored.get(sid, (0, []))[1]); return True for sid, why in named: take(sid, why, scored.get(sid, (0, []))[0]) - ranked = sorted(scored.items(), key=lambda kv: -kv[1][0]) + # the task's words in the declaration's OWN name beat the same words reached through its owner or its path: + # `OrderCreatedEvent` says more about "where is an order created" than `Order.getCreatedAt` does + own = lambda sid: sum(t in ((g.sym.get(sid) or {}).get('name') or '').lower() for t in scored.get(sid, (0, []))[1]) + tie = lambda sid: (sid not in prefer, -own(sid), g.disp(sid) or '', g.loc(sid) or '', sid) + ranked = sorted(scored.items(), key=lambda kv: (-kv[1][0],) + tie(kv[0])) floor = 0.05 * ranked[0][1][0] if ranked else 0 multi = [(sid, sc, m) for sid, (sc, m) in ranked if len(m) >= 2 and sc >= floor] - multi.sort(key=lambda r: (-len(r[2]), -r[1])) + multi.sort(key=lambda r: (-len(r[2]), -r[1]) + tie(r[0])) for i, (sid, sc, m) in enumerate(multi): if len(seeds) >= want: break if not set(m) - covered: continue @@ -241,7 +252,7 @@ def pick_seeds(g, scored, terms, want, named=()): if sib >= FLOW_FAN: break if m2 == m and sc2 >= 0.5 * sc and take(sid2, "matches '" + "' + '".join(m2) + "'", sc2): sib += 1 best = {} - for sid, (sc, matched) in scored.items(): + for sid, (sc, matched) in ranked: for t in matched: cur = best.get(t) if cur is None or sc > cur[0]: best[t] = (sc, sid) @@ -567,6 +578,19 @@ def text_bindings(g, sids, under=(), want_ext=()): return [r for _k, r in sorted(out)] +def asked_bound(g, scored, text, under=(), cap=300): + """{symbol id} among the multi-term matches that a text file of the kind the question asks about binds by name. + Empty unless the question asks about such a file (SQL, configuration, a template): only then is the tree walked, + and only then does it break a tie between equally scored entry points (pick_seeds).""" + kinds = asked_exts(text) + if not kinds or not NONSOURCE_ASK.search(text or ''): return frozenset() + cands = sorted((sid for sid, (_sc, m) in scored.items() if len(m) >= 2), key=lambda s: -scored[s][0])[:cap] + by_disp = collections.defaultdict(set) + for sid in cands: by_disp[g.disp(sid)].add(sid) + return frozenset(sid for f, _l, d, _n in text_bindings(g, cands, under=under, want_ext=kinds) + if f.endswith(kinds) for sid in by_disp.get(d, ())) + + _SRC = {} def source_lines(repo, f): """The file's lines, read once. The graph stores repo-relative paths; a run may be started anywhere.""" @@ -972,7 +996,7 @@ def main(argv): # themselves, and on Python every class has one, so they can be a third of this list while # naming nowhere to go. scored = {sid: v for sid, v in scored.items() if not is_synthetic(g.sym.get(sid, {}).get('name'))} - seeds = pick_seeds(g, scored, terms, seeds_wanted, named) + seeds = pick_seeds(g, scored, terms, seeds_wanted, named, prefer=asked_bound(g, scored, body, text_dirs)) RESULT['terms'] = list(terms) RESULT['scope'], RESULT['scope_offered'] = (scopes[0] if scopes else None), bool(scope_offered) @@ -1105,10 +1129,10 @@ def main(argv): sp = span_of.get((f, nm)) if not sp: continue a, b = sp - body = source_lines(g.repo, f)[a - 1:min(b, a + SOURCE_LINES)] - if not body: continue + code = source_lines(g.repo, f)[a - 1:min(b, a + SOURCE_LINES)] # not `body`: that is the question + if not code: continue print(f" {'':22} {nm} ({f}:{a})") - for k, ln in enumerate(body, a): + for k, ln in enumerate(code, a): print(f" {'':22} {k:5} | {ln.rstrip()[:110]}") if b > a + SOURCE_LINES: print(f" {'':22} {'':5} | … +{b - a - SOURCE_LINES} more line(s)") if foot and budget > 0: print(foot + ", tests and benchmarks not listed") @@ -1126,9 +1150,13 @@ def main(argv): if g.has('overrides') and seed_ids: ph = ','.join('?' * len(seed_ids)) impls = {r[0] for r in g.q(f"SELECT overriding_method_id FROM overrides WHERE method_id IN ({ph})", *seed_ids)} - seed_ids + # nearer through the implementation than through the seeds' own closure counts as nearer: the mapper call + # in the impl is one hop from it however far the closure walk happened to place it (hop 3 is outside the ring) for x, d in neighbourhood(g, [i for i in impls if i in g.sym], cap=1).items(): - if x not in seed_ids and x in g.sym and x not in depth: depth[x] = d + 1; ring.append(x) - near = list(seed_ids) + sorted(ring, key=lambda x: (not bodiless(g, x), depth[x], x))[:300] + if x in seed_ids or x not in g.sym or depth.get(x, d + 2) <= d + 1: continue + if depth.get(x, 3) > 2: ring.append(x) + depth[x] = d + 1 + near = list(seed_ids) + sorted(ring, key=lambda x: (not bodiless(g, x), depth[x], g.disp(x) or '', x))[:300] kinds = asked_exts(body) binds = text_bindings(g, near, under=text_dirs, want_ext=kinds) # the question named a kind of file (SQL: mapper XML or .sql): when some binding is of that kind, the others diff --git a/tests/cases/java/context-sql-tie-picks-the-bound-method/case.json b/tests/cases/java/context-sql-tie-picks-the-bound-method/case.json new file mode 100644 index 000000000..2f13107a3 --- /dev/null +++ b/tests/cases/java/context-sql-tie-picks-the-bound-method/case.json @@ -0,0 +1,15 @@ +{"lang": "java", + "checks": [ + {"why": "entry points that match the task's words equally are ordered by what the question asks for, not by symbol ids: a SQL question puts the mapper method its XML binds first, and lists that XML, though four other declarations match the same two words and sort before it by name", + "run": ["context", "which SQL runs when an order is loaded by number"], + "want": ["entry points", " OrderMapper.findByNumber ", "text files that name these declarations", + "src/main/resources/mapper/OrderMapper.xml:3", "names findByNumber (OrderMapper.findByNumber)"], + "avoid": ["WidgetMapper.xml"]}, + {"why": "near miss: the same words without asking about SQL get no text-file section, so the XML is not listed and no tree walk is paid for", + "run": ["context", "where is an order loaded by number"], + "want": ["entry points", "Order.toNumber"], + "avoid": ["text files that name", "OrderMapper.xml", "WidgetMapper.xml"]}, + {"why": "--source with a SQL question prints the code and still lists the XML (the printed code once replaced the question text, and the text-file check crashed on it)", + "run": ["context", "which SQL runs when an order is loaded by number", "--source"], + "want": ["src/main/resources/mapper/OrderMapper.xml:3"], + "avoid": ["Traceback", "WidgetMapper.xml"]}]} diff --git a/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/Order.java b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/Order.java new file mode 100644 index 000000000..dde47d435 --- /dev/null +++ b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/Order.java @@ -0,0 +1,8 @@ +package demo.order; + +public class Order { + public long id; + public String ref; + + public static String toNumber(String ref) { return ref.toUpperCase(); } +} diff --git a/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderAudit.java b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderAudit.java new file mode 100644 index 000000000..6b76a5cd5 --- /dev/null +++ b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderAudit.java @@ -0,0 +1,12 @@ +package demo.order; + +import java.util.HashMap; +import java.util.Map; + +public class OrderAudit { + private final Map seen = new HashMap<>(); + + public Order findByNumber(String number) { + return seen.get(Order.toNumber(number)); + } +} diff --git a/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderCache.java b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderCache.java new file mode 100644 index 000000000..1d16e4a04 --- /dev/null +++ b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderCache.java @@ -0,0 +1,12 @@ +package demo.order; + +import java.util.HashMap; +import java.util.Map; + +public class OrderCache { + private final Map seen = new HashMap<>(); + + public Order findByNumber(String number) { + return seen.get(Order.toNumber(number)); + } +} diff --git a/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderLabel.java b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderLabel.java new file mode 100644 index 000000000..a17d9424f --- /dev/null +++ b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderLabel.java @@ -0,0 +1,12 @@ +package demo.order; + +import java.util.HashMap; +import java.util.Map; + +public class OrderLabel { + private final Map seen = new HashMap<>(); + + public Order findByNumber(String number) { + return seen.get(Order.toNumber(number)); + } +} diff --git a/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderMapper.java b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderMapper.java new file mode 100644 index 000000000..8a015bb82 --- /dev/null +++ b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderMapper.java @@ -0,0 +1,8 @@ +package demo.order; + +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface OrderMapper { + Order findByNumber(String number); +} diff --git a/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderService.java b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderService.java new file mode 100644 index 000000000..0e18d89a6 --- /dev/null +++ b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderService.java @@ -0,0 +1,11 @@ +package demo.order; + +public class OrderService { + private final OrderMapper mapper; + + public OrderService(OrderMapper mapper) { this.mapper = mapper; } + + public Order resolve(String number) { + return mapper.findByNumber(number); + } +} diff --git a/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/resources/mapper/OrderMapper.xml b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/resources/mapper/OrderMapper.xml new file mode 100644 index 000000000..1d95075e1 --- /dev/null +++ b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/resources/mapper/OrderMapper.xml @@ -0,0 +1,4 @@ + + + + diff --git a/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/resources/mapper/WidgetMapper.xml b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/resources/mapper/WidgetMapper.xml new file mode 100644 index 000000000..21538f20c --- /dev/null +++ b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/resources/mapper/WidgetMapper.xml @@ -0,0 +1,4 @@ + + + + From c34fec2aa48e08556d3250d1ecf153c1f53d84fc Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 06:15:59 -0700 Subject: [PATCH 13/31] typescript: a decorator container's constructor slots are injection points, and impact names who receives a provider (#1109) The decorator relation already gave Nest and Angular their roots (route handlers, container-built constructors, lifecycle hooks). What it did not give was the wiring: in `@Controller() class C { constructor(private svc: OrderService) {} }` nothing in the repository passes an OrderService to C, the container does, so a change to OrderService read as if no class received it. config-resolution/di.dl reads the decorator projection once more: - bean_def: a class carrying @Injectable is a provider, keyed by its own class. - inject_point: a constructor parameter of a container-owned class (the same class decorators the roots use), typed by its declared class. A parameter carrying @Inject(TOKEN), or an integration's @Inject(...), is wired by token, not by type, and is not a slot. - di_edge: known_bean when a provider of exactly that class exists, unsatisfied otherwise. Both containers key by class, so a subtype never satisfies a slot; an abstract class bound with `{ provide, useClass }` is reported, not guessed. inject_point and di_edge are exported under Java's names and column orders, so the query layer, which already reads ext_inject_point for every language, lists the receiving constructor as "receives it by dependency injection (ctor_param)" with no change there. bean_def stays internal: the query layer reads it as a proxying container, which neither of these is. Measured: case 68 fixture di_edge 0 -> 4 (3 known_bean, 1 unsatisfied); edges and entries of the existing code unchanged a real Nest app di_edge 0 -> 23 (17 known_bean, 6 unsatisfied: every one an abstract repository bound by useClass in a module); call edges 2023 = 2023, entry points 164 = 164; index 7.0s -> 6.2s a real Angular app di_edge 0 -> 19 known_bean; call edges 1947 = 1947, entry points 289 = 289; index 5.75s -> 5.68s Controls: an undecorated class with the same constructor parameter, and an @Inject(TOKEN) parameter whose declared type is an interface, produce no slot. Case 68's entries golden now prints di_edge (tools/entry_report.py); with the inject_point rule removed it fails. The plugin case typescript/injected-provider checks impact on a provider (fails without the rule), on an @InjectRepository slot typed by a provider (no injection row; fails without the Inject match), and on a plain class passed to an ordinary constructor (no injection row). Closes #1109 Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../src/container.ts | 29 +++++++++++ .../68-decorator-framework-roots.edges | 9 ++++ .../68-decorator-framework-roots.entries | 10 +++- .../68-decorator-framework-roots.fields | 5 ++ ...68-decorator-framework-roots.fields-oracle | 10 ++-- ...68-decorator-framework-roots.known-missing | 6 ++- .../68-decorator-framework-roots.oracle | 4 +- .../68-decorator-framework-roots.type-use | 7 +++ .../68-decorator-framework-roots.types-oracle | 12 ++--- graph/test/typescript/tools/entry_report.py | 27 ++++++++++ .../typescript/engine/config-resolution/di.dl | 52 +++++++++++++++++++ .../engine/config-resolution/knobs.dl | 17 ++++++ graph/typescript/souffle/decls_all.dl | 7 +++ graph/typescript/souffle/export_manifest.tsv | 2 + .../typescript/injected-provider/case.json | 43 +++++++++++++++ .../src/catalog.controller.ts | 22 ++++++++ .../injected-provider/src/catalog.service.ts | 14 +++++ .../injected-provider/src/stock.service.ts | 15 ++++++ 18 files changed, 276 insertions(+), 15 deletions(-) create mode 100644 graph/typescript/engine/config-resolution/di.dl create mode 100644 tests/cases/typescript/injected-provider/case.json create mode 100644 tests/cases/typescript/injected-provider/src/catalog.controller.ts create mode 100644 tests/cases/typescript/injected-provider/src/catalog.service.ts create mode 100644 tests/cases/typescript/injected-provider/src/stock.service.ts diff --git a/graph/test/typescript/cases/68-decorator-framework-roots/src/container.ts b/graph/test/typescript/cases/68-decorator-framework-roots/src/container.ts index 0a4ee48dd..ae6b362c6 100644 --- a/graph/test/typescript/cases/68-decorator-framework-roots/src/container.ts +++ b/graph/test/typescript/cases/68-decorator-framework-roots/src/container.ts @@ -40,6 +40,35 @@ export class OrdersView { refresh(): void {} } +// Dependency injection. The container, not the code, hands AuditService its +// OrderService and OrderController its OrderService: both slots are keyed by the class. +// The Clock slot is keyed by a TOKEN, so its declared type (an interface nobody +// provides) says nothing about what arrives, and it must not be reported as a slot. +function Inject(_token: string) { return (_t: object, _k: string | symbol | undefined, _i: number): void => {}; } +interface Clock { now(): number; } + +@Injectable() +export class AuditService { + constructor(private readonly orders: OrderService, @Inject("CLOCK") private readonly clock: Clock) {} + record(id: string): string { return this.orders.find(id) + this.clock.now(); } +} + +// A slot on a container-owned class that no provider answers: reported, never dropped. +export class Unprovided { ping(): void {} } +@Controller("/audit") +export class AuditController { + constructor(private readonly audit: AuditService, private readonly extra: Unprovided) {} + @Get(":id") + one(id: string): string { this.extra.ping(); return this.audit.record(id); } +} + +// The DI control: the same constructor parameter on a class no decorator marks. Its +// caller is in the code (`new HandBuilt(svc)`), so it is NOT an injection point. +export class HandBuilt { + constructor(private readonly svc: OrderService) {} + run(): string { return this.svc.find("x"); } +} + // A PLAIN class, the negative control. It carries no framework decorator, so its // constructor must NOT be a root and its ngOnInit must NOT be a lifecycle hook even // though the name matches exactly. diff --git a/graph/test/typescript/expected/68-decorator-framework-roots.edges b/graph/test/typescript/expected/68-decorator-framework-roots.edges index f5395c282..f01d2cdfb 100644 --- a/graph/test/typescript/expected/68-decorator-framework-roots.edges +++ b/graph/test/typescript/expected/68-decorator-framework-roots.edges @@ -1,8 +1,17 @@ +known_edge DECORATOR_CALL AuditController#one(string) @L61 -> container#Get(string) +known_edge DECORATOR_CALL AuditService#(OrderService,Clock) @L52 -> container#Inject(string) known_edge DECORATOR_CALL OrderController#add(string) @L29 -> container#Post(string) known_edge DECORATOR_CALL OrderController#getOne(string) @L26 -> container#Get(string) known_edge DECORATOR_CALL container#() @L15 -> container#Injectable() known_edge DECORATOR_CALL container#() @L22 -> container#Controller(string) known_edge DECORATOR_CALL container#() @L36 -> container#Component({ selector: string }) +known_edge DECORATOR_CALL container#() @L50 -> container#Injectable() +known_edge DECORATOR_CALL container#() @L58 -> container#Controller(string) +known_edge METHOD_CALL AuditController#one(string) @L62 -> AuditService#record(string) +known_edge METHOD_CALL AuditController#one(string) @L62 -> Unprovided#ping() +known_edge METHOD_CALL AuditService#record(string) @L53 -> Clock#now() +known_edge METHOD_CALL AuditService#record(string) @L53 -> OrderService#find(string) +known_edge METHOD_CALL HandBuilt#run() @L69 -> OrderService#find(string) known_edge METHOD_CALL OrderController#add(string) @L30 -> OrderService#create(string) known_edge METHOD_CALL OrderController#getOne(string) @L27 -> OrderService#find(string) known_edge METHOD_CALL OrderService#create(string) @L19 -> OrderService#find(string) diff --git a/graph/test/typescript/expected/68-decorator-framework-roots.entries b/graph/test/typescript/expected/68-decorator-framework-roots.entries index 92e9a12f1..ba24c10e8 100644 --- a/graph/test/typescript/expected/68-decorator-framework-roots.entries +++ b/graph/test/typescript/expected/68-decorator-framework-roots.entries @@ -1,8 +1,16 @@ -── entry_point (7) ── +── entry_point (10) ── + bean_ctor container#AuditController. container.ts:60 + bean_ctor container#AuditService. container.ts:52 bean_ctor container#OrderController. container.ts:24 bean_ctor container#OrderService. container.ts:15 bean_ctor container#OrdersView. container.ts:36 + http container#AuditController.one container.ts:61 http container#OrderController.add container.ts:29 http container#OrderController.getOne container.ts:26 lifecycle container#OrdersView.ngOnInit container.ts:39 unimported_module container# container.ts:1 +── di_edge (4) ── + known_bean container#AuditController.(audit: AuditService) <- AuditService + known_bean container#AuditService.(orders: OrderService) <- OrderService + known_bean container#OrderController.(svc: OrderService) <- OrderService + unsatisfied container#AuditController.(extra: Unprovided) <- - diff --git a/graph/test/typescript/expected/68-decorator-framework-roots.fields b/graph/test/typescript/expected/68-decorator-framework-roots.fields index 3ba442ec2..cc318c0fc 100644 --- a/graph/test/typescript/expected/68-decorator-framework-roots.fields +++ b/graph/test/typescript/expected/68-decorator-framework-roots.fields @@ -1,2 +1,7 @@ +known_edge read AuditController#one(string) -> AuditController#audit +known_edge read AuditController#one(string) -> AuditController#extra +known_edge read AuditService#record(string) -> AuditService#clock +known_edge read AuditService#record(string) -> AuditService#orders +known_edge read HandBuilt#run() -> HandBuilt#svc known_edge read OrderController#add(string) -> OrderController#svc known_edge read OrderController#getOne(string) -> OrderController#svc diff --git a/graph/test/typescript/expected/68-decorator-framework-roots.fields-oracle b/graph/test/typescript/expected/68-decorator-framework-roots.fields-oracle index bc03d45ad..06422fb1e 100644 --- a/graph/test/typescript/expected/68-decorator-framework-roots.fields-oracle +++ b/graph/test/typescript/expected/68-decorator-framework-roots.fields-oracle @@ -1,7 +1,7 @@ 68-decorator-framework-roots [fields] - precision 1.0000 (2 correct, 0 wrong) - recall 1.0000 (2 of 2 the compiler resolved) - sites 2 resolved 2 (100.0%) - tiers known_edge=2 - access read=2 + precision 1.0000 (7 correct, 0 wrong) + recall 1.0000 (7 of 7 the compiler resolved) + sites 7 resolved 7 (100.0%) + tiers known_edge=7 + access read=7 not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/68-decorator-framework-roots.known-missing b/graph/test/typescript/expected/68-decorator-framework-roots.known-missing index 0f22aca41..8b0563608 100644 --- a/graph/test/typescript/expected/68-decorator-framework-roots.known-missing +++ b/graph/test/typescript/expected/68-decorator-framework-roots.known-missing @@ -2,17 +2,19 @@ # compiler resolves and this engine does not. A NEW missing edge fails the suite; # a line here that STARTS working also fails, so the debt cannot rot. # -# All five are one gap, tracked as #233: a decorator FACTORY is called and gets its +# All seven are one gap, tracked as #233: a decorator FACTORY is called and gets its # edge (call_kind DECORATOR_CALL), but the function that factory RETURNS is then # applied to the decorated declaration, and that second hop has no call site. The # compiler sees it, so every legacy decorator in this ecosystem loses one edge. # # This case does not depend on the gap. The roots it exists to test are derived from # the decorator's NAME through annotation_on, never from the application edge, which -# is why they are correct while these five are still absent. When #233 lands, these +# is why they are correct while these seven are still absent. When #233 lands, these # lines start working and this file must be emptied in the same commit. OrderController#add(string) -> container#(object,string,PropertyDescriptor) OrderController#getOne(string) -> container#(object,string,PropertyDescriptor) container#() -> container#(Function) container#() -> container#(Function) container#() -> container#(Function) +AuditController#one(string) -> container#(object,string,PropertyDescriptor) +AuditService#(OrderService,Clock) -> container#(object,string | symbol | undefined,number) diff --git a/graph/test/typescript/expected/68-decorator-framework-roots.oracle b/graph/test/typescript/expected/68-decorator-framework-roots.oracle index 2f5b72ae5..892447a7d 100644 --- a/graph/test/typescript/expected/68-decorator-framework-roots.oracle +++ b/graph/test/typescript/expected/68-decorator-framework-roots.oracle @@ -1,4 +1,6 @@ -oracle=14 engine=9 agree=9 missing=5 (known 5, NEW 0) extra=0 +oracle=23 engine=16 agree=16 missing=7 (known 7, NEW 0) extra=0 + known AuditController#one(string) -> container#(object,string,PropertyDescriptor) + known AuditService#(OrderService,Clock) -> container#(object,string | symbol | undefined,number) known OrderController#add(string) -> container#(object,string,PropertyDescriptor) known OrderController#getOne(string) -> container#(object,string,PropertyDescriptor) known container#() -> container#(Function) diff --git a/graph/test/typescript/expected/68-decorator-framework-roots.type-use b/graph/test/typescript/expected/68-decorator-framework-roots.type-use index d09c15409..df0c9abcf 100644 --- a/graph/test/typescript/expected/68-decorator-framework-roots.type-use +++ b/graph/test/typescript/expected/68-decorator-framework-roots.type-use @@ -1,5 +1,12 @@ +ambiguous_unknown DECORATOR_TYPE 0 AuditController [DECORATOR] -> - +ambiguous_unknown DECORATOR_TYPE 0 AuditService [DECORATOR] -> - ambiguous_unknown DECORATOR_TYPE 0 OrderController [DECORATOR] -> - ambiguous_unknown DECORATOR_TYPE 0 OrderService [DECORATOR] -> - ambiguous_unknown DECORATOR_TYPE 0 OrdersView [DECORATOR] -> - ambiguous_unknown METHOD_PARAM 0 container [METHOD_PARAM] -> - +known_edge METHOD_PARAM 0 AuditController [METHOD_PARAM] -> AuditService +known_edge METHOD_PARAM 0 AuditController [METHOD_PARAM] -> Unprovided +known_edge METHOD_PARAM 0 AuditService [METHOD_PARAM] -> Clock +known_edge METHOD_PARAM 0 AuditService [METHOD_PARAM] -> OrderService +known_edge METHOD_PARAM 0 HandBuilt [METHOD_PARAM] -> OrderService known_edge METHOD_PARAM 0 OrderController [METHOD_PARAM] -> OrderService diff --git a/graph/test/typescript/expected/68-decorator-framework-roots.types-oracle b/graph/test/typescript/expected/68-decorator-framework-roots.types-oracle index 39ce39da7..5a8e71359 100644 --- a/graph/test/typescript/expected/68-decorator-framework-roots.types-oracle +++ b/graph/test/typescript/expected/68-decorator-framework-roots.types-oracle @@ -1,7 +1,7 @@ 68-decorator-framework-roots [types] - precision 1.0000 (1 correct, 0 wrong) - recall 1.0000 (1 of 1 the compiler resolved) - sites 11 resolved 1 (9.1%) - tiers ambiguous_unknown=10 known_edge=1 - contexts DECORATOR_TYPE=5 METHOD_PARAM=6 - not scored: 10 rows whose target is not a client declaration + precision 1.0000 (6 correct, 0 wrong) + recall 1.0000 (6 of 6 the compiler resolved) + sites 20 resolved 6 (30.0%) + tiers ambiguous_unknown=14 known_edge=6 + contexts DECORATOR_TYPE=9 METHOD_PARAM=11 + not scored: 14 rows whose target is not a client declaration diff --git a/graph/test/typescript/tools/entry_report.py b/graph/test/typescript/tools/entry_report.py index 3d16bd964..a9308c2e7 100755 --- a/graph/test/typescript/tools/entry_report.py +++ b/graph/test/typescript/tools/entry_report.py @@ -69,6 +69,33 @@ def main(): print(f"── entry_point ({len(seen)}) ──") for reason, who in sorted(seen): print(f" {reason:10s} {who}") + + # di_edge is the other half of a container-owned class: WHO the container hands + # each object to. Like an entry point it has no call site, so no other golden sees + # it. Printed only when the case has a slot, so a case without DI keeps its golden. + tname = {} + for t in rows(os.path.join(ir, 'all-typescript-types.csv')): + h = t.get('tsTypeUniqueHash') + if h: + tname[h] = t.get('name') or h + pname = {} + for p in rows(os.path.join(ir, 'all-typescript-method-parameters.csv')): + h = p.get('tsMethodParameterUniqueHash') + if h: + pname[h] = (p.get('paramName') or h, p.get('tsMethodLinkHash') or '') + di = set() + p = os.path.join(out, 'config-di-edge.csv') + if os.path.exists(p): + with open(p, newline='') as fh: + for r in csv.reader(fh, delimiter='\t'): + if len(r) >= 6: + pn, m = pname.get(r[0], (r[0], '')) + who = name.get(m, m).split(' ')[0] + di.add((r[5], f"{who}({pn}: {tname.get(r[2], r[2])})", tname.get(r[4], r[4]))) + if di: + print(f"── di_edge ({len(di)}) ──") + for status, slot, bean in sorted(di): + print(f" {status:11s} {slot} <- {bean}") return 0 diff --git a/graph/typescript/engine/config-resolution/di.dl b/graph/typescript/engine/config-resolution/di.dl new file mode 100644 index 000000000..32834f4d8 --- /dev/null +++ b/graph/typescript/engine/config-resolution/di.dl @@ -0,0 +1,52 @@ +// ============================================================================ +// Config-resolution · DEPENDENCY INJECTION (Nest, Angular) +// +// entry-points.dl makes the constructor of a container-owned class a root. What it +// cannot say is WHERE the container sends the objects it builds: in +// `@Controller() class C { constructor(private svc: OrderService) {} }` nothing in the +// repository passes an OrderService to C, the container does, and so a change to +// OrderService looked as if no class received it. +// +// Both containers wire by CLASS TOKEN: a constructor parameter's declared type is the +// key the container looks up, and the object it hands over is the provider registered +// under exactly that class. So a slot is satisfied by a provider whose type IS the +// declared type, never by a subtype. An abstract class used as a key is bound to its +// implementation in a module's providers array (`{ provide: A, useClass: B }`), which +// the parser delivers as unsplit text (see decorator_arg_class_ref), so that slot is +// reported unsatisfied rather than guessed. +// +// The exports are Java's relation names and column orders, deliberately: the query +// layer reads ext_inject_point for every language, so a TypeScript slot shows up in +// impact as "receives it by dependency injection" with no change there. bean_def is +// NOT exported. The query layer reads it as a proxying container (a Spring bean call +// crosses a proxy), which neither of these containers is. +// ============================================================================ + +// ── bean_def(Name, Type, Kind, Site) — a class the container hands out ─────── +// Name is the class's own name: both containers key a class provider by the class. +bean_def(name, t, "injectable", h) :- annotation_on(_, dn, _, "CLASS_DECLARATION", t, h), + ts_provider_class_decorator(dn), type_decl(_, name, _, _, _, _, t). + +// ── inject_point(SiteKind, Site, DeclaredType, OwnerType, Method) ──────────── +// A constructor parameter of a container-owned class. The class decorator is what +// makes the container emit the parameter types it reads at runtime; the same +// constructor on a plain class is an ordinary constructor whose caller is in the code. +ts_di_ctor(m, t) :- annotation_on(_, cn, _, "CLASS_DECLARATION", t, _), + ts_framework_class_decorator(cn), + method_owner(_, t, m), method_kind(_, "CONSTRUCTOR", _, m). +ts_di_token_param(p) :- annotation_on(_, dn, _, "PARAMETER_DECLARATION", p, _), + ts_token_param_decorator(dn). +ts_di_token_param(p) :- annotation_on(_, dn, _, "PARAMETER_DECLARATION", p, _), + match("Inject[A-Z].*", dn). +inject_point("ctor_param", p, dt, t, m) :- ts_di_ctor(m, t), + param_decl(_, _, _, _, m, p), param_type(p, _, dt), !ts_di_token_param(p). + +// ── di_edge(Site, SiteKind, DeclaredType, BeanName, BeanType, Status) ──────── +// Status ∈ { known_bean, unsatisfied }. There is no multi_bean: the key is exact, so +// at most one provider type answers it. +ts_di_fits(site, name, dt) :- inject_point(_, site, dt, _, _), bean_def(name, dt, _, _). +ts_di_satisfied(site) :- ts_di_fits(site, _, _). +di_edge(site, kind, dt, name, dt, "known_bean") :- inject_point(kind, site, dt, _, _), + ts_di_fits(site, name, dt). +di_edge(site, kind, dt, "-", "-", "unsatisfied") :- inject_point(kind, site, dt, _, _), + !ts_di_satisfied(site). diff --git a/graph/typescript/engine/config-resolution/knobs.dl b/graph/typescript/engine/config-resolution/knobs.dl index 7ab8f5e44..944fa0171 100644 --- a/graph/typescript/engine/config-resolution/knobs.dl +++ b/graph/typescript/engine/config-resolution/knobs.dl @@ -97,6 +97,23 @@ ts_framework_class_decorator("Pipe"). ts_framework_class_decorator("NgMod ts_framework_class_decorator("Module"). ts_framework_class_decorator("Catch"). ts_framework_class_decorator("Resolver"). +// ── ts_provider_class_decorator(Name) — the container HANDS this class out ── +// A narrower set than the one above. A controller or a component is constructed by +// the container but never injected into anything; a provider is what a constructor +// parameter receives. Both containers spell it the same way. +.decl ts_provider_class_decorator(c0:symbol) +ts_provider_class_decorator("Injectable"). + +// ── ts_token_param_decorator(Name) — the slot is wired by TOKEN, not by type ── +// `@Inject(TOKEN) x: Foo` asks the container for whatever TOKEN names, and Foo is +// only the static type of the result, often an interface. Matching the declared type +// there would invent a wiring, so a parameter carrying one is not an injection point. +// The integrations spell their tokens the same way (`@InjectRepository(User)`, +// `@InjectModel(Cat.name)`, `@InjectQueue('mail')`), so di.dl also treats any +// `Inject…` name as a token decorator rather than listing each package. +.decl ts_token_param_decorator(c0:symbol) +ts_token_param_decorator("Inject"). + // ── ts_framework_lifecycle_method(Name) — the container CALLS this by name ── // A hook is invoked by the framework through an interface it checks for at runtime, // so no call site exists anywhere in the repository. The name is the whole contract, diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 88668c603..61f1e2616 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -592,3 +592,10 @@ .decl method_kind_is_assignable_signature(c0:symbol) .decl package_entry(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol) .decl cfg_published_module(c0:symbol) +.decl bean_def(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl inject_point(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) +.decl di_edge(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol) +.decl ts_di_ctor(c0:symbol,c1:symbol) +.decl ts_di_token_param(c0:symbol) +.decl ts_di_fits(c0:symbol,c1:symbol,c2:symbol) +.decl ts_di_satisfied(c0:symbol) diff --git a/graph/typescript/souffle/export_manifest.tsv b/graph/typescript/souffle/export_manifest.tsv index 06425c8b7..fb938b8fa 100644 --- a/graph/typescript/souffle/export_manifest.tsv +++ b/graph/typescript/souffle/export_manifest.tsv @@ -48,3 +48,5 @@ type_instantiated resolution-type-instantiated.csv field_access field-access.csv type_use type-use.csv field_site_excluded field-site-excluded.csv +inject_point config-inject-point.csv +di_edge config-di-edge.csv diff --git a/tests/cases/typescript/injected-provider/case.json b/tests/cases/typescript/injected-provider/case.json new file mode 100644 index 000000000..7aa5e93d9 --- /dev/null +++ b/tests/cases/typescript/injected-provider/case.json @@ -0,0 +1,43 @@ +{ + "lang": "typescript", + "src": "src", + "checks": [ + { + "why": "a Nest provider is handed to a controller's constructor by the container, with no call site; impact on the provider has to name the class that receives it", + "run": [ + "impact", + "CatalogService" + ], + "want": [ + "receives it by dependency injection (ctor_param)", + "CatalogController." + ] + }, + { + "why": "near miss: the same constructor parameter on a class no decorator marks is an ordinary constructor call, not an injection", + "run": [ + "impact", + "PriceTable" + ], + "want": [ + "Quote.total" + ], + "avoid": [ + "dependency injection" + ] + }, + { + "why": "near miss: a parameter wired by an integration's token decorator (@InjectRepository) is not a slot for its declared type, even when that type is itself a provider", + "run": [ + "impact", + "StockRepo" + ], + "want": [ + "StockService.inStock" + ], + "avoid": [ + "dependency injection" + ] + } + ] +} diff --git a/tests/cases/typescript/injected-provider/src/catalog.controller.ts b/tests/cases/typescript/injected-provider/src/catalog.controller.ts new file mode 100644 index 000000000..0bbae05d8 --- /dev/null +++ b/tests/cases/typescript/injected-provider/src/catalog.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { CatalogService, PriceTable } from './catalog.service'; + +@Controller('catalog') +export class CatalogController { + constructor(private readonly catalog: CatalogService) {} + + @Get(':sku') + findOne(@Param('sku') sku: string) { + return this.catalog.lookup(sku); + } +} + +// The same constructor shape on a class no decorator marks: its caller is below. +export class Quote { + constructor(private readonly prices: PriceTable) {} + total(sku: string): number { return this.prices.priceOf(sku); } +} + +export function quote(sku: string): number { + return new Quote(new PriceTable()).total(sku); +} diff --git a/tests/cases/typescript/injected-provider/src/catalog.service.ts b/tests/cases/typescript/injected-provider/src/catalog.service.ts new file mode 100644 index 000000000..515993096 --- /dev/null +++ b/tests/cases/typescript/injected-provider/src/catalog.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class CatalogService { + private items: string[] = []; + lookup(sku: string): string | undefined { + return this.items.find((i) => i === sku); + } +} + +// Handed to a constructor too, but only by code: nothing marks it as a provider. +export class PriceTable { + priceOf(sku: string): number { return sku.length; } +} diff --git a/tests/cases/typescript/injected-provider/src/stock.service.ts b/tests/cases/typescript/injected-provider/src/stock.service.ts new file mode 100644 index 000000000..d36bb7e73 --- /dev/null +++ b/tests/cases/typescript/injected-provider/src/stock.service.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +@Injectable() +export class StockRepo { + count(sku: string): number { return sku.length; } +} + +// Wired by a TOKEN the decorator names, not by the declared type: StockRepo is only +// the static type here, so this slot must not read as "receives StockRepo". +@Injectable() +export class StockService { + constructor(@InjectRepository(Object) private readonly repo: StockRepo) {} + inStock(sku: string): boolean { return this.repo.count(sku) > 0; } +} From c098e1c3ef0dbb3a5a982888290df11de2508f53 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:04:28 -0700 Subject: [PATCH 14/31] typescript: a string literal that is a key of a `keyof` constraint selects the generic overload (#334) `create(kind: K)` declared before `create(kind: string)`: the compiler takes the generic for `create('video')` and the widening signature for `create(k)` with `k: string`. Once an interface's overloads shared a declarationGroupKey, specificity decided both sites for `string`, because the literal matched `string` concretely and matched nothing in `K`. The overload rules now evaluate the constraint: a string literal matches a type-variable parameter constrained by `keyof X` when X declares that key (a field or a method). Both signatures then match and declaration order decides, as the compiler does. A general string and a literal that is not a key still fall to `string`. Case 24's accepted gap is removed. New case 78 covers library fields, library methods and a client class, with controls for a non-key literal, a string variable and the widening signature declared first. Every site agrees with the compiler. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../78-keyof-constrained-overload/lib/bus.ts | 20 +++++++ .../78-keyof-constrained-overload/src/app.ts | 48 ++++++++++++++++ .../24-hedged-overload-return-union.lib.edges | 2 +- ...ed-overload-return-union.lib.known-missing | 38 ------------- ...24-hedged-overload-return-union.lib.oracle | 4 +- .../78-keyof-constrained-overload.edges | 7 +++ .../78-keyof-constrained-overload.entries | 9 +++ .../78-keyof-constrained-overload.fields | 0 ...8-keyof-constrained-overload.fields-oracle | 7 +++ .../78-keyof-constrained-overload.lib.edges | 7 +++ .../78-keyof-constrained-overload.lib.oracle | 1 + .../78-keyof-constrained-overload.oracle | 1 + .../78-keyof-constrained-overload.type-use | 4 ++ ...78-keyof-constrained-overload.types-oracle | 7 +++ .../engine/expression-resolution/overload.dl | 56 +++++++++++++++++++ graph/typescript/souffle/decls_all.dl | 2 + 16 files changed, 171 insertions(+), 42 deletions(-) create mode 100644 graph/test/typescript/cases/78-keyof-constrained-overload/lib/bus.ts create mode 100644 graph/test/typescript/cases/78-keyof-constrained-overload/src/app.ts delete mode 100644 graph/test/typescript/expected/24-hedged-overload-return-union.lib.known-missing create mode 100644 graph/test/typescript/expected/78-keyof-constrained-overload.edges create mode 100644 graph/test/typescript/expected/78-keyof-constrained-overload.entries create mode 100644 graph/test/typescript/expected/78-keyof-constrained-overload.fields create mode 100644 graph/test/typescript/expected/78-keyof-constrained-overload.fields-oracle create mode 100644 graph/test/typescript/expected/78-keyof-constrained-overload.lib.edges create mode 100644 graph/test/typescript/expected/78-keyof-constrained-overload.lib.oracle create mode 100644 graph/test/typescript/expected/78-keyof-constrained-overload.oracle create mode 100644 graph/test/typescript/expected/78-keyof-constrained-overload.type-use create mode 100644 graph/test/typescript/expected/78-keyof-constrained-overload.types-oracle diff --git a/graph/test/typescript/cases/78-keyof-constrained-overload/lib/bus.ts b/graph/test/typescript/cases/78-keyof-constrained-overload/lib/bus.ts new file mode 100644 index 000000000..9ffeabcc5 --- /dev/null +++ b/graph/test/typescript/cases/78-keyof-constrained-overload/lib/bus.ts @@ -0,0 +1,20 @@ +// The declarations live in the case's LIBRARY, parsed separately and handed to the engine +// with --library. Every overload pair here is ONE member with two signatures, so the +// parser gives both the same declarationGroupKey and the group rules can fire (#334). +export interface OpenEvent { at: number; } +export interface CloseEvent { code: number; } +export interface EventMap { open: OpenEvent; close: CloseEvent; } +export interface Handlers { start(): void; stop(): void; } + +export interface Bus { + // A typed map's keys are its FIELDS. + emit(type: K): EventMap[K]; + emit(type: string): unknown; + // …and its METHODS are keys too. + run(name: K): void; + run(name: string): void; + // Control: the WIDENING signature is declared first, so it is the first applicable + // one for any string and the compiler takes it even for a key. + pick(type: string): void; + pick(type: K): void; +} diff --git a/graph/test/typescript/cases/78-keyof-constrained-overload/src/app.ts b/graph/test/typescript/cases/78-keyof-constrained-overload/src/app.ts new file mode 100644 index 000000000..992212281 --- /dev/null +++ b/graph/test/typescript/cases/78-keyof-constrained-overload/src/app.ts @@ -0,0 +1,48 @@ +import type { Bus } from '../lib/bus'; + +// WHAT THIS CASE PINS. A generic overload whose parameter is `K extends keyof X`, beside +// a widening `string` sibling. The compiler takes the generic when the argument is a +// string literal that IS a key of X, and the `string` one otherwise. The engine used to +// take `string` at every site, because the literal matched `string` and matched nothing +// concretely in `K` (#334). + +export function viaKey(b: Bus): void { + b.emit('open'); +} + +export function viaMethodKey(b: Bus): void { + b.run('start'); +} + +// Control: a literal that is NOT a key — only `emit(string)` applies. +export function viaNonKey(b: Bus): void { + b.emit('other'); +} + +// Control: a general string is not a keyof — `emit(string)`. +export function viaString(b: Bus, t: string): void { + b.emit(t); +} + +// Control: the widening signature is declared FIRST, so it wins even for a key. +export function viaWideningFirst(b: Bus): void { + b.pick('open'); +} + +// The same shape declared in CLIENT code, on a class with an implementation. +interface Slots { a: number; b: string; } + +class Registry { + get(k: K): Slots[K]; + get(k: string): unknown; + get(k: string | number): unknown { return k; } +} + +export function viaClassKey(r: Registry): unknown { + return r.get('a'); +} + +// Control: not a key of Slots. +export function viaClassNonKey(r: Registry): unknown { + return r.get('zzz'); +} diff --git a/graph/test/typescript/expected/24-hedged-overload-return-union.lib.edges b/graph/test/typescript/expected/24-hedged-overload-return-union.lib.edges index da41b1a4a..3638f8d05 100644 --- a/graph/test/typescript/expected/24-hedged-overload-return-union.lib.edges +++ b/graph/test/typescript/expected/24-hedged-overload-return-union.lib.edges @@ -1,4 +1,4 @@ -boundary_lib METHOD_CALL consumer#viaVideo(Factory) @L17 -> Factory#create(string) +boundary_lib METHOD_CALL consumer#viaVideo(Factory) @L17 -> Factory#create(T) boundary_lib METHOD_CALL consumer#viaVideo(Factory) @L18 -> BaseWidget#attach(string) boundary_lib METHOD_CALL consumer#viaVideo(Factory) @L18 -> SourceWidget#attach(string) boundary_lib METHOD_CALL consumer#viaVideo(Factory) @L18 -> VideoWidget#attach(string) diff --git a/graph/test/typescript/expected/24-hedged-overload-return-union.lib.known-missing b/graph/test/typescript/expected/24-hedged-overload-return-union.lib.known-missing deleted file mode 100644 index 316b7936b..000000000 --- a/graph/test/typescript/expected/24-hedged-overload-return-union.lib.known-missing +++ /dev/null @@ -1,38 +0,0 @@ -# Accepted gap for 24-hedged-overload-return-union (client->library pass). A NEW missing -# edge fails the suite; a line here that STARTS working also fails, so the debt cannot rot. -# -# THE PREREQUISITE IS CONSTRAINT APPLICABILITY. This records the trade rather than paying -# for it with accuracy. The parser now groups a reopened type's members, so the two -# `create` signatures finally share a declarationGroupKey. That is correct, and it is a -# large gain: the group-based selection rules, which could never fire for an interface's -# signatures while the column was empty, now commit where the engine used to hedge — -# EXACT 13,168 -> 13,656 on the held-out project, with MISSED unchanged at 749. -# -# What it also does is decide this site wrongly: -# -# create(kind: K) declared first -# create(kind: string) -# -# f.create('video') the compiler picks the GENERIC — a literal IS a keyof -# f.create(k) the compiler picks (string) — a general string is not -# -# `arg_matches_concretely` answers identically at both sites: the string signature -# matches, the generic does not, because the proxy compares the argument against a -# PARAMETER's type and that type is `K`. So no rule built on it can be right at both, and -# the oracle requires one edge from each. Separating them needs `K extends keyof -# WidgetMap` evaluated against the argument. overload.dl's header already says it: "the -# prerequisite is applicability that separates these sets, not the group key." -# -# Two suppressions were measured and both cost far more than this line, which is why the -# rules are unchanged (#334): -# -# restrict the rules to FUNCTION_DECLARATION groups — their entire domain before the -# parser change: EXACT 13,656 -> 13,089 (-567) -# leave any group holding a GENERIC signature unpruned: -# EXACT 13,656 -> 13,465 (-191) -# and 19-overload-gauntlet's `widen` set stops committing, extra 1 -> 3 -# -# Both recover exactly this one edge and nothing else: corpus MISSED is 749 in every -# variant. The shape is real and hermetic, and chasing it is not worth paying for until -# constraints are testable. -consumer#viaVideo(Factory) -> Factory#create(T) diff --git a/graph/test/typescript/expected/24-hedged-overload-return-union.lib.oracle b/graph/test/typescript/expected/24-hedged-overload-return-union.lib.oracle index 16fa65f23..0514eace3 100644 --- a/graph/test/typescript/expected/24-hedged-overload-return-union.lib.oracle +++ b/graph/test/typescript/expected/24-hedged-overload-return-union.lib.oracle @@ -1,7 +1,5 @@ -oracle=4 engine=8 agree=3 missing=1 (known 1, NEW 0) extra=5 - known consumer#viaVideo(Factory) -> Factory#create(T) +oracle=4 engine=8 agree=4 missing=0 (known 0, NEW 0) extra=4 extra consumer#viaVideo(Factory) -> BaseWidget#attach(string) - extra consumer#viaVideo(Factory) -> Factory#create(string) extra consumer#viaVideo(Factory) -> SourceWidget#attach(string) extra consumer#viaWidening(Factory,string) -> SourceWidget#attach(string) extra consumer#viaWidening(Factory,string) -> VideoWidget#attach(string) diff --git a/graph/test/typescript/expected/78-keyof-constrained-overload.edges b/graph/test/typescript/expected/78-keyof-constrained-overload.edges new file mode 100644 index 000000000..f2c67286a --- /dev/null +++ b/graph/test/typescript/expected/78-keyof-constrained-overload.edges @@ -0,0 +1,7 @@ +ambiguous_unknown METHOD_CALL app#viaKey(Bus) @L10 -> - +ambiguous_unknown METHOD_CALL app#viaMethodKey(Bus) @L14 -> - +ambiguous_unknown METHOD_CALL app#viaNonKey(Bus) @L19 -> - +ambiguous_unknown METHOD_CALL app#viaString(Bus,string) @L24 -> - +ambiguous_unknown METHOD_CALL app#viaWideningFirst(Bus) @L29 -> - +known_edge METHOD_CALL app#viaClassKey(Registry) @L42 -> Registry#get(T) +known_edge METHOD_CALL app#viaClassNonKey(Registry) @L47 -> Registry#get(string) diff --git a/graph/test/typescript/expected/78-keyof-constrained-overload.entries b/graph/test/typescript/expected/78-keyof-constrained-overload.entries new file mode 100644 index 000000000..42001f526 --- /dev/null +++ b/graph/test/typescript/expected/78-keyof-constrained-overload.entries @@ -0,0 +1,9 @@ +── entry_point (8) ── + exported_from_entry_module app#viaClassKey app.ts:41 + exported_from_entry_module app#viaClassNonKey app.ts:46 + exported_from_entry_module app#viaKey app.ts:9 + exported_from_entry_module app#viaMethodKey app.ts:13 + exported_from_entry_module app#viaNonKey app.ts:18 + exported_from_entry_module app#viaString app.ts:23 + exported_from_entry_module app#viaWideningFirst app.ts:28 + unimported_module app# app.ts:1 diff --git a/graph/test/typescript/expected/78-keyof-constrained-overload.fields b/graph/test/typescript/expected/78-keyof-constrained-overload.fields new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/typescript/expected/78-keyof-constrained-overload.fields-oracle b/graph/test/typescript/expected/78-keyof-constrained-overload.fields-oracle new file mode 100644 index 000000000..340173828 --- /dev/null +++ b/graph/test/typescript/expected/78-keyof-constrained-overload.fields-oracle @@ -0,0 +1,7 @@ +78-keyof-constrained-overload [fields] + precision 0.0000 (0 correct, 0 wrong) + recall 0.0000 (0 of 0 the compiler resolved) + sites 0 resolved 0 + tiers + access + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/78-keyof-constrained-overload.lib.edges b/graph/test/typescript/expected/78-keyof-constrained-overload.lib.edges new file mode 100644 index 000000000..95fe6ea54 --- /dev/null +++ b/graph/test/typescript/expected/78-keyof-constrained-overload.lib.edges @@ -0,0 +1,7 @@ +boundary_lib METHOD_CALL app#viaKey(Bus) @L10 -> Bus#emit(T) +boundary_lib METHOD_CALL app#viaMethodKey(Bus) @L14 -> Bus#run(T) +boundary_lib METHOD_CALL app#viaNonKey(Bus) @L19 -> Bus#emit(string) +boundary_lib METHOD_CALL app#viaString(Bus,string) @L24 -> Bus#emit(string) +boundary_lib METHOD_CALL app#viaWideningFirst(Bus) @L29 -> Bus#pick(string) +known_edge METHOD_CALL app#viaClassKey(Registry) @L42 -> Registry#get(T) +known_edge METHOD_CALL app#viaClassNonKey(Registry) @L47 -> Registry#get(string) diff --git a/graph/test/typescript/expected/78-keyof-constrained-overload.lib.oracle b/graph/test/typescript/expected/78-keyof-constrained-overload.lib.oracle new file mode 100644 index 000000000..fa4000b6a --- /dev/null +++ b/graph/test/typescript/expected/78-keyof-constrained-overload.lib.oracle @@ -0,0 +1 @@ +oracle=7 engine=7 agree=7 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/typescript/expected/78-keyof-constrained-overload.oracle b/graph/test/typescript/expected/78-keyof-constrained-overload.oracle new file mode 100644 index 000000000..036d48375 --- /dev/null +++ b/graph/test/typescript/expected/78-keyof-constrained-overload.oracle @@ -0,0 +1 @@ +oracle=2 engine=2 agree=2 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/typescript/expected/78-keyof-constrained-overload.type-use b/graph/test/typescript/expected/78-keyof-constrained-overload.type-use new file mode 100644 index 000000000..3ca059ffe --- /dev/null +++ b/graph/test/typescript/expected/78-keyof-constrained-overload.type-use @@ -0,0 +1,4 @@ +ambiguous_unknown METHOD_PARAM 0 app [METHOD_PARAM] -> - +known_edge METHOD_PARAM 0 app [METHOD_PARAM] -> Registry +known_edge TYPE_ELEMENT 1 Registry [METHOD] -> Slots +known_edge TYPE_ELEMENT 1 Registry [TYPE_PARAMETER] -> Slots diff --git a/graph/test/typescript/expected/78-keyof-constrained-overload.types-oracle b/graph/test/typescript/expected/78-keyof-constrained-overload.types-oracle new file mode 100644 index 000000000..79655ad19 --- /dev/null +++ b/graph/test/typescript/expected/78-keyof-constrained-overload.types-oracle @@ -0,0 +1,7 @@ +78-keyof-constrained-overload [types] + precision 1.0000 (2 correct, 0 wrong) + recall 1.0000 (2 of 2 the compiler resolved) + sites 9 resolved 4 (44.4%) + tiers ambiguous_unknown=5 known_edge=4 + contexts METHOD_PARAM=7 TYPE_ELEMENT=2 + not scored: 5 rows whose target is not a client declaration diff --git a/graph/typescript/engine/expression-resolution/overload.dl b/graph/typescript/engine/expression-resolution/overload.dl index 1fbafef19..ee1170ed1 100644 --- a/graph/typescript/engine/expression-resolution/overload.dl +++ b/graph/typescript/engine/expression-resolution/overload.dl @@ -686,6 +686,62 @@ arg_matches_concretely(ce, m, pos) :- expr_call_candidate(ce, m), param_decl(_, _, pos, tn, m, _), tn = cat("\"", cat(v, "\"")). +// A STRING LITERAL THAT IS A KEY OF THE CONSTRAINT — `(kind: K)`. +// The typed-registry shape: `create(kind: K)` declared before +// `create(kind: string)`, as `document.createElement` and every typed event map declare +// it. The compiler picks the generic for `create('video')`, because the literal IS a +// `keyof WidgetMap`, and the widening one for `create(k)` with `k: string`, because a +// general string is not. +// +// Without this the parameter's type is `K`, which matches nothing concretely, while +// `string` matches the literal — so specificity removed the generic at BOTH sites. That +// stayed hidden while an interface's overloads carried no declarationGroupKey and no +// group rule could fire; once the parser grouped them it became a missing edge (#334). +// Evaluating the constraint against the literal separates the two sites on evidence: +// at `create('video')` both signatures now match concretely and declaration order +// decides, as the compiler does; at `create(k)` and at a literal that is NOT a key the +// generic still does not match and `string` wins, exactly as before. +// +// Keyed on the exact variable link first and on the name in the method's own scope +// where the link is absent. The key set is the constraint object's DECLARED members, +// fields and methods both: a literal naming neither never matches, so an unresolved +// `keyof` object leaves the generic as unmatched as it always was. +param_keyof_object(m, pos, obj) :- expr_call_candidate(_, m), + param_decl(_, _, pos, _, m, p), + param_type_ref(_, r, p), + type_ref(_, "TYPE_VARIABLE", _, _, _, _, _, r), + type_ref_type_param(_, tp, r), + type_param_constraint(_, cref, tp), + keyof_operand_target(cref, obj). +param_keyof_object(m, pos, obj) :- expr_call_candidate(_, m), + param_decl(_, _, pos, _, m, p), + param_type_ref(_, r, p), + type_ref(_, "TYPE_VARIABLE", _, tn, _, _, _, r), + !type_ref_type_param(_, _, r), + type_param(_, tn, _, _, m, tp), + type_param_constraint(_, cref, tp), + keyof_operand_target(cref, obj). +// `keyof X` is a TYPE_OPERATOR whose written text starts with the operator; `readonly` +// and `unique` share the kind and are told apart by that text, not assumed away. +keyof_operand_target(cref, obj) :- type_ref(_, "TYPE_OPERATOR", _, _, ctn, _, _, cref), + strlen(ctn) > 6, + substr(ctn, 0, 6) = "keyof ", + type_ref_parent(_, cref, _, oref), + ref_type_target(oref, _, obj). + +arg_matches_concretely(ce, m, pos) :- expr_call_candidate(ce, m), + expr_child("client", ce, "ARGUMENT", pos, a), + expr_literal_type("client", "STRING", a), + expr_literal_value("client", v, a), + param_keyof_object(m, pos, obj), + declared_field(obj, v, _, _). +arg_matches_concretely(ce, m, pos) :- expr_call_candidate(ce, m), + expr_child("client", ce, "ARGUMENT", pos, a), + expr_literal_type("client", "STRING", a), + expr_literal_value("client", v, a), + param_keyof_object(m, pos, obj), + declared_method(obj, v, _, _). + // BETTER SOMEWHERE AND WORSE NOWHERE — both halves, which is the discipline // java/overload.dl states as `win_strictly_better` AND `!win_worse_anywhere`. Firing on a // single position alone is not domination: a candidate matching at argument 0 would beat diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 88668c603..3c162e677 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -3,6 +3,7 @@ .decl ancestor_of_merged_group(c0:symbol,c1:symbol,c2:symbol) .decl arg_compatible_at(c0:symbol,c1:symbol,c2:symbol) .decl arg_matches_concretely(c0:symbol,c1:symbol,c2:symbol) +.decl keyof_operand_target(c0:symbol,c1:symbol) .decl specificity_beaten(c0:symbol,c1:symbol) .decl specificity_worse_somewhere(c0:symbol,c1:symbol,c2:symbol) .decl arg_is_function_value(c0:symbol,c1:symbol) @@ -374,6 +375,7 @@ .decl binding_pattern_type(c0:symbol,c1:symbol,c2:symbol) .decl param_binding_method(c0:symbol,c1:symbol) .decl param_binding(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol) +.decl param_keyof_object(c0:symbol,c1:symbol,c2:symbol) .decl param_kind_is_positional(c0:symbol) .decl param_decl(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol) .decl param_is_string_literal(c0:symbol,c1:symbol) From 6dd53f0a1b9b7964e6b3bc18f1a767d77c54d020 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:49:15 -0700 Subject: [PATCH 15/31] impact / path: a file:line written ./path, absolute under the repository, or file:line:col is the declaration at that line (#1600) A file:line is taken the way editors, stack traces and test runners print it: a leading ./ is dropped, an absolute path under the repository root is read relative to it, and a trailing :col is accepted. An absolute path outside the repository is named as outside instead of 'no callable spans'. A root-relative /src/... still answers as before. Closes #1600 Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> Co-Authored-By: Claude --- .../skills/axiomcode/scripts/axiomcode-path | 19 +++++++++++-- .../fileline-dotted-basename/case.json | 27 ++++++++++++++++++- .../fileline-dotted-basename/case.json | 10 ++++++- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 88bf73d7e..5d0277dad 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -231,11 +231,25 @@ class G: if not hasattr(self, '_mkind'): self._mkind = {r[0]: r[1] for r in self.q("SELECT id, kind FROM methods")} if self.has('methods') else {} return self._mkind.get((self.sym.get(i) or {}).get('method_id') or i) + def repo_file(self, f): + """-> (the file as the index stores it, whether it lies outside the repository). A file:line is written the way + editors, stack traces and test runners print it: `./src/a.js`, or absolute under the repository root. The index + stores it relative to that root, so both are read as the relative path; an absolute path elsewhere is kept, with + its leading `/` dropped as before, so a root-relative `/src/a.js` still matches — and is named as outside when not.""" + while f.startswith('./'): f = f[2:] + if not os.path.isabs(f): return f, False + real = os.path.realpath(f) + for root in {self.repo, os.path.abspath(self.repo)}: + for p in (real, os.path.abspath(f)): + if p == root or p.startswith(root.rstrip('/') + '/'): + return os.path.relpath(p, root).replace(os.sep, '/'), False + return f.lstrip('/'), True def _resolve(self, sel, fragment=False): s = sel.strip().strip('`"\'') - m = re.match(r'^(.+?):(\d+)$', s) + # file:line, or file:line:col as a stack trace prints it — the column never changes which callable spans the line + m = re.match(r'^(.+?):(\d+)(?::\d+)?$', s) if m and ('/' in m.group(1) or '.' in m.group(1)): # file:line → the method containing it - f, ln = m.group(1).lstrip('/'), int(m.group(2)) + (f, outside), ln = self.repo_file(m.group(1)), int(m.group(2)) # the smallest callable(s) spanning the line — several when they share it (`class Item { run(){} tag(){} }`); # top-level code belongs to the file's module node, which is a caller like any other r = self.q("SELECT id, end_line - line AS span FROM symbols WHERE (file = ? OR file LIKE ?) AND line <= ? AND end_line >= ? AND method_id IS NOT NULL AND kind <> 'module' ORDER BY span", f, f"%/{f}", ln, ln) @@ -251,6 +265,7 @@ class G: return (f"{self.disp(ids[0])} (at {s})" if len(ids) == 1 else f"{len(ids)} callables at {s}"), ids r = self.q("SELECT id FROM symbols WHERE (file = ? OR file LIKE ?) AND kind = 'module' AND method_id IS NOT NULL LIMIT 1", f, f"%/{f}") if r: return f"{self.disp(r[0]['id'])} (top-level code at {s})", [r[0]['id']] + if outside: die(f"{m.group(1)} is outside the indexed repository {self.repo}: give the file relative to that root, or ask the graph of the repository it belongs to") die(f"no callable spans {s}") if s.startswith('@') and self.has('decorations'): # @GetMapping / @*Mapping / @Test → every method carrying it pat = s[1:].replace('*', '%') diff --git a/tests/cases/javascript/fileline-dotted-basename/case.json b/tests/cases/javascript/fileline-dotted-basename/case.json index 4bcedc31f..051783940 100644 --- a/tests/cases/javascript/fileline-dotted-basename/case.json +++ b/tests/cases/javascript/fileline-dotted-basename/case.json @@ -15,4 +15,29 @@ {"why": "control: a dotted basename under a directory the index does not have is still refused", "run": ["impact", "zzz/order.service.js:3"], "expect_error": true, - "want": ["no callable spans zzz/order.service.js:3"]}]} + "want": ["no callable spans zzz/order.service.js:3"]}, + {"why": "a file:line with a leading ./ (as an editor prints it) is the same declaration", + "run": ["impact", "./src/services/userService.js:2"], + "want": ["change: findUser (at ./src/services/userService.js:2)", "placeOrder"], + "avoid": ["no callable spans"]}, + {"why": "an absolute path under the repository root is read relative to that root", + "run": ["impact", "{repo}/src/services/order.service.js:3"], + "want": ["change: placeOrder", "createOrder"], + "avoid": ["no callable spans", "outside the indexed repository"]}, + {"why": "file:line:col, the Node stack-trace form: the column does not change the declaration", + "run": ["impact", "{repo}/src/services/userService.js:2:5"], + "want": ["change: findUser", "placeOrder"], + "avoid": ["no callable spans"]}, + {"why": "path takes the same normalised file:line", + "run": ["path", "*", "./src/services/userService.js:2:5"], + "want": ["findUser", "placeOrder"], + "avoid": ["no callable spans"]}, + {"why": "control: a root-relative /src/... still answers, as before", + "run": ["impact", "/src/services/userService.js:2"], + "want": ["change: findUser"], + "avoid": ["no callable spans", "outside the indexed repository"]}, + {"why": "an absolute path outside the repository is named as outside, not as a line nothing spans", + "run": ["impact", "/nowhere-axiomcode/src/services/userService.js:2"], + "expect_error": true, + "want": ["is outside the indexed repository"], + "avoid": ["no callable spans"]}]} diff --git a/tests/cases/typescript/fileline-dotted-basename/case.json b/tests/cases/typescript/fileline-dotted-basename/case.json index d282220f8..701c6d748 100644 --- a/tests/cases/typescript/fileline-dotted-basename/case.json +++ b/tests/cases/typescript/fileline-dotted-basename/case.json @@ -15,4 +15,12 @@ {"why": "control: a dotted NAME with a fabricated prefix is still refused by the prefix check", "run": ["impact", "zzz.qqq.UserService.find"], "expect_error": true, - "want": ["'zzz.qqq' matches no package"]}]} + "want": ["'zzz.qqq' matches no package"]}, + {"why": "a file:line written ./ or absolute under the repository, with a :col, is the same declaration", + "run": ["impact", "{repo}/src/user.service.ts:3:10"], + "want": ["change: UserService.find", "UserController.get"], + "avoid": ["no callable spans"]}, + {"why": "path takes a ./ file:line", + "run": ["path", "*", "./src/user.service.ts:3"], + "want": ["UserController.get"], + "avoid": ["no callable spans"]}]} From a7dac9691c0fa99f401ad807b501ec4fdfb7ee5f Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:12:08 -0700 Subject: [PATCH 16/31] python: 1-based field lines, and a per-language schema guide for the skill Answering a question from the graph meant first finding out, with raw sqlite3, which table holds each fact in each language. reference/schema.md now says it for Python, Java and C#: decorations, resolved and library base types, entry points by reason, fields and properties and who writes them, call-edge tiers, strings and non-source text, tests and rungs, and where each language's graph lives. Each entry names the verb or the query, and what is not recorded. Every query was run on a fresh index of one project per language. SKILL.md links it in one line. Three schema notes the graph carries were stale and now agree: Python entry_points is not empty, C# fills fields, and type_use is empty for C#. The Python field extractor wrote tree-sitter's 0-based row as the field line, while every other Python relation is 1-based. Every field sat one line above its write, and a nested class's first field landed on its `class Meta:` line. Each site now adds 1. A new gate checks six write forms with class and method lines as controls; the golden had frozen the old values and moves by exactly one line on each of its 49 field rows. --- graph/bundle/SCHEMA.md | 6 +- graph/bundle/schema.ts | 6 +- .../extractors/python-field-extractor.ts | 21 ++-- .../python/verified/_golden/fields.golden | 98 +++++++-------- parser/src/test/python-gates/field-lines.ts | 117 ++++++++++++++++++ parser/src/test/python-tests.ts | 3 + plugins/axiomcode/skills/axiomcode/SKILL.md | 2 + .../skills/axiomcode/reference/schema.md | 102 +++++++++++++++ skills/axiomcode/SKILL.md | 2 + skills/axiomcode/reference/schema.md | 102 +++++++++++++++ 10 files changed, 396 insertions(+), 63 deletions(-) create mode 100644 parser/src/test/python-gates/field-lines.ts create mode 100644 plugins/axiomcode/skills/axiomcode/reference/schema.md create mode 100644 skills/axiomcode/reference/schema.md diff --git a/graph/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md index 914b3c9b8..6766d5259 100644 --- a/graph/bundle/SCHEMA.md +++ b/graph/bundle/SCHEMA.md @@ -672,7 +672,7 @@ Methods the runtime invokes without a client call site — process roots, test m **Notes** -- **python** — EMPTY. The Python rule set does not derive entry points; entry_reachable is therefore empty too. +- **python** — Framework entry points only: url, http, orm_hook, task, signal_receiver, fixture, di_provider and grpc_service. There is no test and no main reason: a pytest test is recognised by the query layer from its file and name, not here. ### `entry_reachable` @@ -729,7 +729,7 @@ Every field-like storage location the graph refers to: all client fields and enu **Notes** -- **all** — JAVA AND TYPESCRIPT, for the same reason as field_access: declared everywhere, populated by those two front ends. +- **all** — JAVA, TYPESCRIPT AND C#. EMPTY for Python and JavaScript: a Python attribute is a symbols row of kind field. In C# it holds true fields only; a property is a symbols row of kind field with a property id, and its accessors are methods rows (PROPERTY_GET, PROPERTY_SET, PROPERTY_INIT). - **typescript** — An enum member is absent: the parser gives it its own table with no declared type, and the property-access relation resolves through the field table. `Colour.Red` is therefore an unresolved field access, unlike Java where an enum constant is a fields row. - **java** — A library field is listed when some field_access edge reaches it, exactly as methods lists only the library methods an edge reaches, OR when a config_binding row names it as the field a configuration key binds to. The second was added in #890: a @Value field on a library type that nothing reads has no access edge, so config_binding named a field the table did not list and the join lost the row silently. @@ -888,7 +888,7 @@ THE OTHER HALF OF CHANGE IMPACT: one row per place a type is NAMED, with the con **Notes** -- **all** — JAVA AND TYPESCRIPT. Declared in every bundle and EMPTY for Python and JavaScript, so the schema does not churn as the remaining front ends land (#663). +- **all** — JAVA AND TYPESCRIPT. Declared in every bundle and EMPTY for Python, JavaScript and C#, so the schema does not churn as the remaining front ends land (#663). - **typescript** — The context set is TypeScript's own and is wider than Java's: AS_TARGET, SATISFIES_TARGET, TYPE_ALIAS_RHS, the CONDITIONAL_* family, MAPPED_*, INDEX_SIGNATURE_* and TEMPLATE_SPAN have no Java counterpart. A use inside a conditional type IS a use of that type and is recorded as one. - **typescript** — Only a reference whose KIND can name a declaration is a row: TYPE_REFERENCE and IMPORT_TYPE. ARRAY, UNION, TUPLE and PARENTHESIZED are structure whose CHILDREN are the named references; PRIMITIVE, LITERAL, TYPE_VARIABLE, MAPPED, CONDITIONAL, INDEXED_ACCESS and INTRINSIC name nothing declared. - **java** — EVERY DEPTH is here, unlike the receiver-typing relations the engine uses internally, which filter to depth 0. A field of type `Map` produces three rows. Filter on `depth = 0` when you want the type an expression has rather than every type its declaration mentions. diff --git a/graph/bundle/schema.ts b/graph/bundle/schema.ts index 9548c716d..cd7b1e189 100644 --- a/graph/bundle/schema.ts +++ b/graph/bundle/schema.ts @@ -747,21 +747,21 @@ export const NOTES: readonly NoteSpec[] = [ { 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.' }, { language: 'python', table: 'call_edges', note: 'The reason a site is ambiguous_unknown is exported per site in ext_call_site_unresolved (site, caller, reason, detail).' }, - { language: 'python', table: 'entry_points', note: 'EMPTY. The Python rule set does not derive entry points; entry_reachable is therefore empty too.' }, + { language: 'python', table: 'entry_points', note: 'Framework entry points only: url, http, orm_hook, task, signal_receiver, fixture, di_provider and grpc_service. There is no test and no main reason: a pytest test is recognised by the query layer from its file and name, not here.' }, { language: 'python', table: 'overrides', note: 'EMPTY — this table is Java-shaped. The Python dispatch envelope is in dispatch_candidates with basis `mro`, and `value` for a function assigned onto an instance\'s member; the raw linearisation is in ext_mro_position.' }, { language: 'python', table: 'type_instantiated', note: 'Every row has how = `new`: the rule set records that some client call constructs the class, not which form.' }, { language: 'all', table: 'call_edges', note: 'THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript; event_dispatch in C# too, where a mediator Send or Publish runs the handler for the request type, beside the row for the site itself. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list.' }, { language: 'java', table: 'call_edges', note: 'A multi_inferred fan is CHA-wide: it is every override the hierarchy admits, bounded only by the dispatch cap. type_instantiated is computed and exported but NOT read by any rule, so the fan is not narrowed to types the program constructs. Narrow it yourself by joining dispatch_candidates to type_instantiated — see the dispatch_envelope_of query. A receiver is ALSO typed by what flows into it (a local\'s initializer, the arguments callers pass to a parameter, the receivers callers invoke a method on for its `this`), and each flow-in type resolves its member directly, outside the fan: that is why a `fan_capped` site still carries edges, and why they are the types the program was seen to hand over, not the whole hierarchy.' }, { language: 'typescript', table: 'call_edges', note: 'A multi_inferred fan is CHA-wide, as in Java: type_instantiated is computed and exported but NOT read by any rule. The fan also has sources that are not virtual dispatch at all — an overload set or a union-typed receiver produces one too.' }, { language: 'python', table: 'call_edges', note: 'A multi_inferred fan IS narrowed by the instantiation set: type_instantiated_reachable (the constructed classes and their bases) bounds dispatch in resolution/dispatch.dl. Python is the only front end where that narrowing is applied, so a fan here is tighter than the same shape would be in Java or TypeScript.' }, - { language: 'all', table: 'type_use', note: 'JAVA AND TYPESCRIPT. Declared in every bundle and EMPTY for Python and JavaScript, so the schema does not churn as the remaining front ends land (#663).' }, + { language: 'all', table: 'type_use', note: 'JAVA AND TYPESCRIPT. Declared in every bundle and EMPTY for Python, JavaScript and C#, so the schema does not churn as the remaining front ends land (#663).' }, { language: 'typescript', table: 'type_use', note: 'The context set is TypeScript\'s own and is wider than Java\'s: AS_TARGET, SATISFIES_TARGET, TYPE_ALIAS_RHS, the CONDITIONAL_* family, MAPPED_*, INDEX_SIGNATURE_* and TEMPLATE_SPAN have no Java counterpart. A use inside a conditional type IS a use of that type and is recorded as one.' }, { language: 'typescript', table: 'type_use', note: 'Only a reference whose KIND can name a declaration is a row: TYPE_REFERENCE and IMPORT_TYPE. ARRAY, UNION, TUPLE and PARENTHESIZED are structure whose CHILDREN are the named references; PRIMITIVE, LITERAL, TYPE_VARIABLE, MAPPED, CONDITIONAL, INDEXED_ACCESS and INTRINSIC name nothing declared.' }, { language: 'java', table: 'type_use', note: 'EVERY DEPTH is here, unlike the receiver-typing relations the engine uses internally, which filter to depth 0. A field of type `Map` produces three rows. Filter on `depth = 0` when you want the type an expression has rather than every type its declaration mentions.' }, { language: 'java', table: 'type_use', note: 'A TYPE_VARIABLE reference (`T`, `E`) is not a row: it names the declaration\'s own parameter, not a type. Where the parameter has a written bound the USE resolves to that bound and IS a row, so ` void f(T t)` records a use of Node.' }, { language: 'java', table: 'type_use', note: 'The reference rows carry no line in the Java IR (every all-type-references row has an empty startLine), so this table has no position columns. Use owner_method_id, or owner_type_id plus the types row, to locate a use.' }, { language: 'all', table: 'field_access', note: 'JAVA AND TYPESCRIPT. The table is declared in every bundle and is EMPTY for Python, JavaScript and C# (C# fills `fields`, not `field_access`), so the schema does not churn as the remaining front ends land (#663). Check `SELECT count(*) FROM field_access` before reading an empty result as "nothing reads this field".' }, - { language: 'all', table: 'fields', note: 'JAVA AND TYPESCRIPT, for the same reason as field_access: declared everywhere, populated by those two front ends.' }, + { language: 'all', table: 'fields', note: 'JAVA, TYPESCRIPT AND C#. EMPTY for Python and JavaScript: a Python attribute is a symbols row of kind field. In C# it holds true fields only; a property is a symbols row of kind field with a property id, and its accessors are methods rows (PROPERTY_GET, PROPERTY_SET, PROPERTY_INIT).' }, { language: 'typescript', table: 'field_access', note: 'AN ACCESSOR IS NOT HERE. `get url()` read as `c.url` is a CALL, and call_edges already carries it with kind PROPERTY_READ or PROPERTY_WRITE (#703). The two tables are disjoint by construction: this one holds properties, call_edges holds accessors. Ask both when you want every read of a member.' }, { language: 'typescript', table: 'field_access', note: 'AN ELEMENT ACCESS IS NOT HERE either: `obj["x"]` with a literal key is a different node kind and is not yet a site. A known gap, not a silent one.' }, { language: 'typescript', table: 'field_access', note: 'A METHOD IS NOT A SITE. The callee of `obj.m()` is a PROPERTY_ACCESS node (37% of them, measured on one TypeScript library), and `const f = obj.m` reads a method as a value; neither is a data edge, and admitting them would fill the ambiguous_unknown tier with sites the engine HAS resolved elsewhere. Both are excluded and counted in ext_field_site_excluded with reasons method_callee and method_value.' }, diff --git a/parser/src/parsers/python/extractors/python-field-extractor.ts b/parser/src/parsers/python/extractors/python-field-extractor.ts index ef4374779..08b87a908 100644 --- a/parser/src/parsers/python/extractors/python-field-extractor.ts +++ b/parser/src/parsers/python/extractors/python-field-extractor.ts @@ -83,6 +83,11 @@ interface WriteObservation { */ writtenBuiltinType: string; origin: PythonFieldOrigin; + /** + * 1-based, like every other Python relation. tree-sitter rows are 0-based, so + * each site adds 1; without it every field sat one line above its write, and a + * nested class's first field landed on the `class Meta:` line itself. + */ line: number; endLine: number; methodHash: string; @@ -416,8 +421,8 @@ export class PythonFieldExtractor { collection.observations.push({ name, origin, - line: target.startPosition.row, - endLine: assignment.endPosition.row, + line: target.startPosition.row + 1, + endLine: assignment.endPosition.row + 1, methodHash: '', methodName: '', receiverName: '', @@ -448,8 +453,8 @@ export class PythonFieldExtractor { collection.observations.push({ name: this.mangle(collection.typeName, literal), origin: PythonFieldOrigin.SLOTS_ENTRY, - line: node.startPosition.row, - endLine: node.endPosition.row, + line: node.startPosition.row + 1, + endLine: node.endPosition.row + 1, methodHash: '', methodName: '', receiverName: '', @@ -728,8 +733,8 @@ export class PythonFieldExtractor { collection.observations.push({ name: this.mangle(collection.typeName, literal), origin: PythonFieldOrigin.SETATTR_DYNAMIC, - line: call.startPosition.row, - endLine: call.endPosition.row, + line: call.startPosition.row + 1, + endLine: call.endPosition.row + 1, methodHash, methodName, receiverName, @@ -764,8 +769,8 @@ export class PythonFieldExtractor { collection.observations.push({ name: this.mangle(collection.typeName, nameOverride || attributeName!.text), origin, - line: target.startPosition.row, - endLine: statement.endPosition.row, + line: target.startPosition.row + 1, + endLine: statement.endPosition.row + 1, methodHash, methodName, receiverName, diff --git a/parser/src/test-data/python/verified/_golden/fields.golden b/parser/src/test-data/python/verified/_golden/fields.golden index 6f1d24d56..d5cb2e89c 100644 --- a/parser/src/test-data/python/verified/_golden/fields.golden +++ b/parser/src/test-data/python/verified/_golden/fields.golden @@ -1,49 +1,49 @@ -name=GREEN fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=30 endLine=30 pyTypeLinkHash=PY_TYPE_9e523515e44d6cfd9bc7abbd870a39fc ownerTypeName=Colour ownerQualifiedName=class_forms.Colour fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,ENUM_MEMBER,READ_ONLY fieldOrigin=ENUM_MEMBER declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=30 hasAnnotation=false annotationIsString=false initializerText=2 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_512fa5aa67cc77834aa18c596c931e59 bindingLinkHash=PY_BINDING_57ddbcd9c75cb175fbf6a58f0413624c pyExpressionLinkHash=PY_EXPRESSION_6b100833fc3d59ad93d115d8db3d7889 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_354cf9941bff4d01e1a412286829e26f -name=KIND fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=5 endLine=5 pyTypeLinkHash=PY_TYPE_bf6354f36ebb9ff7d338d07707f4d6c9 ownerTypeName=Builder ownerQualifiedName=core.factory.Builder fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=5 hasAnnotation=false annotationIsString=false initializerText="""builder""" initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_7908beb43f8c739558eabfcf93ebca84 bindingLinkHash=PY_BINDING_8f17cce2244c83d9f0b22c927181e57c pyExpressionLinkHash=PY_EXPRESSION_6b2dc1f944007de6dc6ad962a10c4d68 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_da9d8e5a0a6fb23461a6d67eebf0b406 -name=RED fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=29 endLine=29 pyTypeLinkHash=PY_TYPE_9e523515e44d6cfd9bc7abbd870a39fc ownerTypeName=Colour ownerQualifiedName=class_forms.Colour fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,ENUM_MEMBER,READ_ONLY fieldOrigin=ENUM_MEMBER declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=29 hasAnnotation=false annotationIsString=false initializerText=1 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_512fa5aa67cc77834aa18c596c931e59 bindingLinkHash=PY_BINDING_788efc874ee5c9fe8de3cf8d313ccb08 pyExpressionLinkHash=PY_EXPRESSION_08330318b58d2f5435334c19e44e1bad serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_b63b80d192db3ec3d30504b33ab5a7d5 -name=_Inner__secret fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=26 endLine=26 pyTypeLinkHash=PY_TYPE_e0b00a82da98016f61db58a6de64ca3a ownerTypeName=Inner ownerQualifiedName=name_mangling_edge_cases.Outer.Inner fieldAccess=PRIVATE_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=26 hasAnnotation=false annotationIsString=false initializerText="""inner secret""" initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_75dd800d24b53dd3ca1d826ca1525876 bindingLinkHash=PY_BINDING_5f168c8d5c239011cb96ae52de48a359 pyExpressionLinkHash=PY_EXPRESSION_8d9a61772e886da7597f48f8d04adb1a serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_2ee87edc1495cf4099a701d55e5e6c46 -name=_Outer__secret fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=23 endLine=23 pyTypeLinkHash=PY_TYPE_fd26c3cd94bd9663ee01dd2cfe94159d ownerTypeName=Outer ownerQualifiedName=name_mangling_edge_cases.Outer fieldAccess=PRIVATE_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=23 hasAnnotation=false annotationIsString=false initializerText="""outer secret""" initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_75dd800d24b53dd3ca1d826ca1525876 bindingLinkHash=PY_BINDING_49c59ee9461329034a85a0296cef5067 pyExpressionLinkHash=PY_EXPRESSION_37ac9136831ba123510a02a1f4e8a855 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_f2cd30b8381aef7fb269f0afaa8e87df -name=_v fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=23 endLine=23 pyTypeLinkHash=PY_TYPE_a1bd5124fede6d4f3504a0dd00092899 ownerTypeName=Kinds ownerQualifiedName=method_kinds.Kinds fieldAccess=PROTECTED_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_43ebe179bc6f5e46ee12ceb6ef8f8371 receiverName=self isDeclaredInInit=false writeCount=1 writtenInMethodCount=1 firstWriteLine=23 hasAnnotation=false annotationIsString=false initializerText=value initializerKind=NAME pyModuleLinkHash=PY_MODULE_7f80b0a69e6ccd5fc2e3932cc9e5dc10 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_6c7b177707416257783b9ec57e0e0db7 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_dcf0d30e2575c32c0165dde58ab421cd -name=a fieldTypeName=int fieldBaseType=int potentialQualifiedName=field_origins.Named.a isAmbiguous=false startLine=26 endLine=26 pyTypeLinkHash=PY_TYPE_57e2471ba226031232617e9a2c039483 ownerTypeName=Named ownerQualifiedName=field_origins.Named fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=NAMEDTUPLE_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=26 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_9e2b1a44d2cdbd153e83899e6455543a pyExpressionLinkHash=PY_EXPRESSION_655c42272094ed05fdc74a8ceca41e9a serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_f5cf13ceb805ace0621794c47b0edd1c -name=amount fieldTypeName=int fieldBaseType=int potentialQualifiedName=service_layer.Record.amount isAmbiguous=false startLine=8 endLine=8 pyTypeLinkHash=PY_TYPE_8ffa51c20183b9069bc165293685e3fe ownerTypeName=Record ownerQualifiedName=service_layer.Record fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=8 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_87ff823ca7cda9220c2fe03210807591 bindingLinkHash=PY_BINDING_57bc7c03bfbb607dd13cde9fc0c4fa82 pyExpressionLinkHash=PY_EXPRESSION_0e91a6686acdabf7ba17b29df0ed45ef serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_1b8b80b6ac20cbea90df5020685db27e -name=args fieldTypeName=tuple fieldBaseType=tuple potentialQualifiedName=match_nested_patterns_and_guards.Command.args isAmbiguous=false startLine=17 endLine=17 pyTypeLinkHash=PY_TYPE_0d090a4533bb97a6729a4b05d91e8042 ownerTypeName=Command ownerQualifiedName=match_nested_patterns_and_guards.Command fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=17 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_fcb43aba22913355ac115ae9c4556240 bindingLinkHash=PY_BINDING_9a435ed4c1ece9c11135dbd0160b4df5 pyExpressionLinkHash=PY_EXPRESSION_0ee6d1d4f1d073dd98461650dcad5106 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_3957058ce75e0648a430fbe98178267e -name=attribute fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=13 endLine=13 pyTypeLinkHash=PY_TYPE_ba2fbe8e09ca96c2ac317281255eb0d6 ownerTypeName=Documented ownerQualifiedName=comment_forms.Documented fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=13 hasAnnotation=false annotationIsString=false initializerText=1 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_898ba18a31075a2ba7818f72c287b8d1 bindingLinkHash=PY_BINDING_2424a859b65550cd9888982deae03d22 pyExpressionLinkHash=PY_EXPRESSION_ffabf59979fb14780a4b3608e603c0d1 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_385e7b366a08ca3716f29841e22cc22f -name=b fieldTypeName=str fieldBaseType=str potentialQualifiedName=field_origins.Named.b isAmbiguous=false startLine=27 endLine=27 pyTypeLinkHash=PY_TYPE_57e2471ba226031232617e9a2c039483 ownerTypeName=Named ownerQualifiedName=field_origins.Named fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=NAMEDTUPLE_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=27 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_9557301ac4f28d860f8ea85d7d5f12c3 pyExpressionLinkHash=PY_EXPRESSION_3ef8970040c01d72c72ad800129733ee serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_310894d9c629bedb0d3af75ed37d2926 -name=children fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=6 endLine=6 pyTypeLinkHash=PY_TYPE_114cb72d42012abcb4ffcbce3bc6a345 ownerTypeName=Node ownerQualifiedName=core.base.Node fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_1800af667e7076b626bd654b5e3dbebd receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=6 hasAnnotation=false annotationIsString=false initializerText=[] initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_0af7a9df251376bafe876afe4a32f46e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_2b5c5d4e2ede82425c6cc08afb31af7e serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_379b43102358abde65f30ca1de0bcd84 -name=class_body_annotation fieldTypeName=int fieldBaseType=int potentialQualifiedName=field_origins.Origins.class_body_annotation isAmbiguous=false startLine=7 endLine=7 pyTypeLinkHash=PY_TYPE_8e5e7ae02d7e897e2dae13f4d1fc41bc ownerTypeName=Origins ownerQualifiedName=field_origins.Origins fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ANNOTATION_ONLY declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=7 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_b06aa81bc5c9cd99d90ba02007b150fe pyExpressionLinkHash=PY_EXPRESSION_545963f8035cf0bdb65e61c5d4e613c6 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_95475ba063df3c93c21f1c308805edd9 -name=class_body_assign fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=6 endLine=6 pyTypeLinkHash=PY_TYPE_8e5e7ae02d7e897e2dae13f4d1fc41bc ownerTypeName=Origins ownerQualifiedName=field_origins.Origins fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=6 hasAnnotation=false annotationIsString=false initializerText=1 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_1d3d16a7ecf67dc4f185bb6856b55ce1 pyExpressionLinkHash=PY_EXPRESSION_4c58afc15bc350db5af24aafaf24281c serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_56d654562f909d6501f78a7efbec6215 -name=class_var fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=52 endLine=52 pyTypeLinkHash=PY_TYPE_e61b2b74f7302f1be447bd28221d2310 ownerTypeName=ClassScope ownerQualifiedName=scope_forms.ClassScope fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=52 hasAnnotation=false annotationIsString=false initializerText=1 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_f56326f63a4c5bef9294b28a4acb9abc bindingLinkHash=PY_BINDING_4bcd55d471aae0bad8d158832c4e4dca pyExpressionLinkHash=PY_EXPRESSION_6068c5654402f0188994f976ddff8750 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_395310756cb1d1d83c7b698fea57e4dc -name=count fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=66 endLine=66 pyTypeLinkHash=PY_TYPE_35883ba9ad396e2203b42abef9e1d7c9 ownerTypeName=Chained ownerQualifiedName=resolution_edges.Chained fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_994dce81c215c9114b56d78e93cdf732 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=66 hasAnnotation=false annotationIsString=false initializerText=0 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_daef721b73c8bfbcc0348299f2f7bf9a bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_7b0cae40fc8c704752083b3ee0fceb16 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_21ead56058d3d069ccf562ee8f4901d4 -name=count fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=69 endLine=69 pyTypeLinkHash=PY_TYPE_35883ba9ad396e2203b42abef9e1d7c9 ownerTypeName=Chained ownerQualifiedName=resolution_edges.Chained fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR fieldOrigin=SELF_AUGASSIGN declaringMethodLinkHash=PY_METHOD_b561ec322633cc1dd8bcac47c5b8c82b receiverName=self isDeclaredInInit=false writeCount=1 writtenInMethodCount=1 firstWriteLine=69 hasAnnotation=false annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_daef721b73c8bfbcc0348299f2f7bf9a bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_76e084392d2dd97ca35228a147447c5e serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_cbc349dfa7dba6a7f967589d4623516d -name=data fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=22 endLine=22 pyTypeLinkHash=PY_TYPE_b4aa287abde8dbb60cac1736e458094f ownerTypeName=ClassScopeIsInvisibleToComprehensions ownerQualifiedName=scoping_edge_cases.ClassScopeIsInvisibleToComprehensions fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=22 hasAnnotation=false annotationIsString=false initializerText=[1, 2, 3] initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_7c5670e72548f0c21d5e8710e5a91903 bindingLinkHash=PY_BINDING_2979d1fb44aef0644d73caeef4e16875 pyExpressionLinkHash=PY_EXPRESSION_9d502d9e8053c97698b1fd82a5f1ef02 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_bebc562b5a7574ce8ac9e36ada3ef18c -name=doubled fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=27 endLine=27 pyTypeLinkHash=PY_TYPE_b4aa287abde8dbb60cac1736e458094f ownerTypeName=ClassScopeIsInvisibleToComprehensions ownerQualifiedName=scoping_edge_cases.ClassScopeIsInvisibleToComprehensions fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=27 hasAnnotation=false annotationIsString=false initializerText=[item * 2 for item in data] initializerKind=COMPREHENSION pyModuleLinkHash=PY_MODULE_7c5670e72548f0c21d5e8710e5a91903 bindingLinkHash=PY_BINDING_463a0938fe7b9c7d0b98f8a91a5fa9b1 pyExpressionLinkHash=PY_EXPRESSION_7a1c506cbc933dcebc9cff34cfeb9098 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_f84a1f38e2ec605e0f7371d09882a819 -name=key fieldTypeName=int fieldBaseType=int potentialQualifiedName=field_origins.Typed.key isAmbiguous=false startLine=31 endLine=31 pyTypeLinkHash=PY_TYPE_ab0f66955e01d63bfcdb185cba8220c9 ownerTypeName=Typed ownerQualifiedName=field_origins.Typed fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=TYPEDDICT_KEY declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=31 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_e2bb803db7c6d1eb22f6564f99c728a6 pyExpressionLinkHash=PY_EXPRESSION_cf8c36787653df27e29b2e8d273a91a4 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_cfe099bdbc0b4a62a3d6135ca67cf7c0 -name=key fieldTypeName=str fieldBaseType=str potentialQualifiedName=service_layer.Record.key isAmbiguous=false startLine=7 endLine=7 pyTypeLinkHash=PY_TYPE_8ffa51c20183b9069bc165293685e3fe ownerTypeName=Record ownerQualifiedName=service_layer.Record fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=7 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_87ff823ca7cda9220c2fe03210807591 bindingLinkHash=PY_BINDING_8c140f9ae98b25bc5c0a4168c96dc0dc pyExpressionLinkHash=PY_EXPRESSION_02ca24b3af6950ca1b62257fad48588b serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_27261db33e56b96f29132dfaa71d3f4e -name=kind fieldTypeName=str fieldBaseType=str potentialQualifiedName=match_nested_patterns_and_guards.Event.kind isAmbiguous=false startLine=10 endLine=10 pyTypeLinkHash=PY_TYPE_2ce58f5e8c9675c1e0fd5c50ffba85b9 ownerTypeName=Event ownerQualifiedName=match_nested_patterns_and_guards.Event fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=10 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_fcb43aba22913355ac115ae9c4556240 bindingLinkHash=PY_BINDING_a71edea3725a72a19e6afe34df82df70 pyExpressionLinkHash=PY_EXPRESSION_24ae5eca16a253b79944f8cf60d1f4cc serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_adf2e9134c1d3b66aedefcfe1ecbe569 -name=label fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=40 endLine=40 pyTypeLinkHash=PY_TYPE_cb9da9964bc3c43761a4e1269506effe ownerTypeName=ClassScopeIsInvisibleToMethods ownerQualifiedName=scoping_edge_cases.ClassScopeIsInvisibleToMethods fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=40 hasAnnotation=false annotationIsString=false initializerText="""class attribute""" initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_7c5670e72548f0c21d5e8710e5a91903 bindingLinkHash=PY_BINDING_98ec45593e2cb658d620508b81299faa pyExpressionLinkHash=PY_EXPRESSION_42fe92a294647ed36376fb8eb05bcab7 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_f1fd14ac02e06017fd967c9e6fed6fb3 -name=n fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=5 endLine=5 pyTypeLinkHash=PY_TYPE_b2f97d5a7e48f40b008fc644ae603fdf ownerTypeName=Holder ownerQualifiedName=expression_kinds.Holder fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_9949f650301345b4e4deaca03bd84aa2 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=5 hasAnnotation=false annotationIsString=false initializerText=n initializerKind=NAME pyModuleLinkHash=PY_MODULE_02d561b0acde0974e99e75b89b4bdab0 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_8d749d70dcd6b628deac2048e53d55e0 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_9df925d8945d2bf31f49db8111a0dc69 -name=name fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=17 endLine=17 pyTypeLinkHash=PY_TYPE_bf6354f36ebb9ff7d338d07707f4d6c9 ownerTypeName=Builder ownerQualifiedName=core.factory.Builder fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_bd7c847866b0674467ec65fee212e98d receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=17 hasAnnotation=false annotationIsString=false initializerText=name initializerKind=NAME pyModuleLinkHash=PY_MODULE_7908beb43f8c739558eabfcf93ebca84 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_585104a79ffa32b57b756846339f5739 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_e099ac70f91cfbdc5ff7c13e73233075 -name=name fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=5 endLine=5 pyTypeLinkHash=PY_TYPE_114cb72d42012abcb4ffcbce3bc6a345 ownerTypeName=Node ownerQualifiedName=core.base.Node fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_1800af667e7076b626bd654b5e3dbebd receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=5 hasAnnotation=false annotationIsString=false initializerText=name initializerKind=NAME pyModuleLinkHash=PY_MODULE_0af7a9df251376bafe876afe4a32f46e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_105c90e8c0edac7578788345181e8b7f serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_4892cbb3eae7d00e22619a4bee9e4477 -name=name fieldTypeName=str fieldBaseType=str potentialQualifiedName=match_nested_patterns_and_guards.Command.name isAmbiguous=false startLine=16 endLine=16 pyTypeLinkHash=PY_TYPE_0d090a4533bb97a6729a4b05d91e8042 ownerTypeName=Command ownerQualifiedName=match_nested_patterns_and_guards.Command fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=16 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_fcb43aba22913355ac115ae9c4556240 bindingLinkHash=PY_BINDING_cd0d311bf325ef8c8ae6238a63dcad6f pyExpressionLinkHash=PY_EXPRESSION_eac151d5d6927868317023b3e1775a9d serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_bdf77c8fc8ad7a8203a0dbfdd9def840 -name=node fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=18 endLine=18 pyTypeLinkHash=PY_TYPE_bf6354f36ebb9ff7d338d07707f4d6c9 ownerTypeName=Builder ownerQualifiedName=core.factory.Builder fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_bd7c847866b0674467ec65fee212e98d receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=18 hasAnnotation=false annotationIsString=false initializerText=Node(name) initializerKind=CALL pyModuleLinkHash=PY_MODULE_7908beb43f8c739558eabfcf93ebca84 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_e901fe2384634e118a14e9746dc8d29a serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_49ef51dcaf6157efde16ddf34306974e -name=nodes fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=27 endLine=27 pyTypeLinkHash=PY_TYPE_f511a7eca38906948dce8170bb4e5495 ownerTypeName=Registry ownerQualifiedName=core.base.Registry fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_d7a26baec95db1ac47ae906b256b56b6 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=27 hasAnnotation=false annotationIsString=false initializerText=[] initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_0af7a9df251376bafe876afe4a32f46e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_37663bc3f580f7f4b5e952c3b83bbb53 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_2a7e11b6b84f1f30e344016dbc9c423d -name=payload fieldTypeName=dict fieldBaseType=dict potentialQualifiedName=match_nested_patterns_and_guards.Event.payload isAmbiguous=false startLine=11 endLine=11 pyTypeLinkHash=PY_TYPE_2ce58f5e8c9675c1e0fd5c50ffba85b9 ownerTypeName=Event ownerQualifiedName=match_nested_patterns_and_guards.Event fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=11 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_fcb43aba22913355ac115ae9c4556240 bindingLinkHash=PY_BINDING_13c4c5fdb58a9e4c8b2de72989a22932 pyExpressionLinkHash=PY_EXPRESSION_1586dc4c0c9ec70cb3d13c84f1727e77 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_01ef6b233822f48581ac56724f516c07 -name=registered fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=35 endLine=35 pyTypeLinkHash=PY_TYPE_a1bd5124fede6d4f3504a0dd00092899 ownerTypeName=Kinds ownerQualifiedName=method_kinds.Kinds fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_5c50c5642d36a230fa6b6e9f7ed1d793 receiverName=cls isDeclaredInInit=false writeCount=1 writtenInMethodCount=1 firstWriteLine=35 hasAnnotation=false annotationIsString=false initializerText=True initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_7f80b0a69e6ccd5fc2e3932cc9e5dc10 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_770096a696c823a0acb4d74638579069 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_5eee83bd12ab74a7e50ee0ca4f615196 -name=repository fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=43 endLine=43 pyTypeLinkHash=PY_TYPE_c4ea4c823c48edb338a6cd78787cae9b ownerTypeName=Service ownerQualifiedName=service_layer.Service fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_c2679ef7c7ff76fd0d905dfb8219e815 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=43 hasAnnotation=false annotationIsString=false initializerText=repository initializerKind=NAME pyModuleLinkHash=PY_MODULE_87ff823ca7cda9220c2fe03210807591 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_39117b39cc41afe8b691c23f84675db1 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_ba06c6ab6360c63d00ccbdfb193ee986 -name=rows fieldTypeName=typing.List[Record] fieldBaseType=List potentialQualifiedName=service_layer.Store.rows isAmbiguous=false startLine=16 endLine=16 pyTypeLinkHash=PY_TYPE_2cf22ea62c0a0c4b7621a04b81853658 ownerTypeName=Store ownerQualifiedName=service_layer.Store fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_1b1abae80728c42e4cc6b0dbc18b5ec2 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=16 hasAnnotation=true annotationIsString=false initializerText=[] initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_87ff823ca7cda9220c2fe03210807591 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_ef6124b63d25a3882170fe90d7e7aa10 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_982e6f2f24bd41d8e0dcef33a86a3a6a -name=scaled fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=36 endLine=36 pyTypeLinkHash=PY_TYPE_b4aa287abde8dbb60cac1736e458094f ownerTypeName=ClassScopeIsInvisibleToComprehensions ownerQualifiedName=scoping_edge_cases.ClassScopeIsInvisibleToComprehensions fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=36 hasAnnotation=false annotationIsString=false initializerText=[item * data[0] for item in range(3)] initializerKind=COMPREHENSION pyModuleLinkHash=PY_MODULE_7c5670e72548f0c21d5e8710e5a91903 bindingLinkHash=PY_BINDING_b6ba5ccaaf2812fdfe59e082e4220ac8 pyExpressionLinkHash=PY_EXPRESSION_96eea804d6ac4509c9232efc3d58a9bf serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_38c6f01897875da284ae4ed20953ccf5 -name=self_assign fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=11 endLine=11 pyTypeLinkHash=PY_TYPE_8e5e7ae02d7e897e2dae13f4d1fc41bc ownerTypeName=Origins ownerQualifiedName=field_origins.Origins fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_0392ee9e7a4bc56bb004a6e7afba21d3 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=11 hasAnnotation=false annotationIsString=false initializerText=2 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_50aab41b2a79282580adb17ecd509bf4 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_9dd57248346cd4c94d18e3ae6a54e092 -name=self_augassign fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=12 endLine=12 pyTypeLinkHash=PY_TYPE_8e5e7ae02d7e897e2dae13f4d1fc41bc ownerTypeName=Origins ownerQualifiedName=field_origins.Origins fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_0392ee9e7a4bc56bb004a6e7afba21d3 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=12 hasAnnotation=false annotationIsString=false initializerText=0 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_c848ec2b334a0c0c95ecf82516738045 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_a08b36eb3272a0f66469bbe7d307dd42 -name=self_augassign fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=13 endLine=13 pyTypeLinkHash=PY_TYPE_8e5e7ae02d7e897e2dae13f4d1fc41bc ownerTypeName=Origins ownerQualifiedName=field_origins.Origins fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR fieldOrigin=SELF_AUGASSIGN declaringMethodLinkHash=PY_METHOD_0392ee9e7a4bc56bb004a6e7afba21d3 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=13 hasAnnotation=false annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_6e8a8a980cbdfe31ca1f70cf5768aa54 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_e63bf9a63c8f71ab184065a04e212c41 -name=slot_entry fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=8 endLine=8 pyTypeLinkHash=PY_TYPE_8e5e7ae02d7e897e2dae13f4d1fc41bc ownerTypeName=Origins ownerQualifiedName=field_origins.Origins fieldAccess=PUBLIC_ACCESS fieldModifier=SLOT,INSTANCE_VAR,READ_ONLY fieldOrigin=SLOTS_ENTRY declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=8 hasAnnotation=false annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_c3dd5c9baa37a65645ba641deaf75370 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_3b691937578677fc25973ac19b74dd84 -name=store fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=27 endLine=27 pyTypeLinkHash=PY_TYPE_2dfd4389681449004ff326855f81c683 ownerTypeName=Repository ownerQualifiedName=service_layer.Repository fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_e9a9fd209ad674fca757b55f0d59046a receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=27 hasAnnotation=false annotationIsString=false initializerText=store initializerKind=NAME pyModuleLinkHash=PY_MODULE_87ff823ca7cda9220c2fe03210807591 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_761a7f788c9fcafe8fe54d441cd94d55 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_6de6365a260cb8ca2a2f41c13e057e22 -name=tag fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=53 endLine=53 pyTypeLinkHash=PY_TYPE_bd7d395e13e5c973a6e8538aced32fab ownerTypeName=ClassMethodFactory ownerQualifiedName=resolution_edges.ClassMethodFactory fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_eb5681c8ddcf04929815457a1e598d59 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=53 hasAnnotation=false annotationIsString=false initializerText=tag initializerKind=NAME pyModuleLinkHash=PY_MODULE_daef721b73c8bfbcc0348299f2f7bf9a bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_0f7fa5219483899dc1b1880a6f9d494a serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_77575b7f5a7219c8590bc9a37754d3d0 -name=values fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=36 endLine=36 pyTypeLinkHash=PY_TYPE_1e732b162f5deaead4b2e1751fdc8b5f ownerTypeName=InClassBody ownerQualifiedName=comprehension_forms.InClassBody fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=36 hasAnnotation=false annotationIsString=false initializerText=[i for i in range(3)] initializerKind=COMPREHENSION pyModuleLinkHash=PY_MODULE_69c6710ffe12b2fc51e03f770319af1b bindingLinkHash=PY_BINDING_086207b12b3601fd4624743e01cd845a pyExpressionLinkHash=PY_EXPRESSION_5d743a1c689a266929bda307ac93ebce serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_0098386942688fc6a884e12e9f6911e3 -name=x fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=21 endLine=21 pyTypeLinkHash=PY_TYPE_834b419437b855738443676b9b3cada0 ownerTypeName=WithSlots ownerQualifiedName=class_forms.WithSlots fieldAccess=PUBLIC_ACCESS fieldModifier=SLOT,INSTANCE_VAR,READ_ONLY fieldOrigin=SLOTS_ENTRY declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=21 hasAnnotation=false annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_512fa5aa67cc77834aa18c596c931e59 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_bf49d94b1b5b1d615224bbcede193fd6 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_410e9424d9f85c7f01069a6333206741 -name=x fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=24 endLine=24 pyTypeLinkHash=PY_TYPE_834b419437b855738443676b9b3cada0 ownerTypeName=WithSlots ownerQualifiedName=class_forms.WithSlots fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,SLOT,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_1c60534bccbeaabf71d83fbb629164fb receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=24 hasAnnotation=false annotationIsString=false initializerText=1 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_512fa5aa67cc77834aa18c596c931e59 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_0862eff6ff0a1adb1f8aedc4c19d268f serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_23dc9f7e711905b8433e9f2ce8b4811e -name=x fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=9 endLine=9 pyTypeLinkHash=PY_TYPE_6f4e90e4697b76a388566c9bd84e0d4d ownerTypeName=Point ownerQualifiedName=match_class_and_sequence_patterns.Point fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_ed898c7ba31b1d5a88a7ed47da6a477b receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=9 hasAnnotation=false annotationIsString=false initializerText=x initializerKind=NAME pyModuleLinkHash=PY_MODULE_e8c22c801b9d7d33638ace57665fdd49 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_d8d7077f45710e9120c3e6398cc0986e serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_92a0068d85f354f19ad6d582e045d7df -name=x fieldTypeName=int fieldBaseType=int potentialQualifiedName=field_origins.Point.x isAmbiguous=false startLine=18 endLine=18 pyTypeLinkHash=PY_TYPE_6b71a387bb09f3559bd7785e8741463e ownerTypeName=Point ownerQualifiedName=field_origins.Point fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=18 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_faff6556d980286b5caffa3120d0a328 pyExpressionLinkHash=PY_EXPRESSION_d3a3294efad0cccf2cebd3a749b421ca serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_7d18be1a9fe29d0bed13a216fb06f410 -name=y fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=10 endLine=10 pyTypeLinkHash=PY_TYPE_6f4e90e4697b76a388566c9bd84e0d4d ownerTypeName=Point ownerQualifiedName=match_class_and_sequence_patterns.Point fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_ed898c7ba31b1d5a88a7ed47da6a477b receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=10 hasAnnotation=false annotationIsString=false initializerText=y initializerKind=NAME pyModuleLinkHash=PY_MODULE_e8c22c801b9d7d33638ace57665fdd49 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_0b2011f5a35e76274b3622f5fcb378c2 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_39c1e4363707f3951248f99912d45ae9 -name=y fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=21 endLine=21 pyTypeLinkHash=PY_TYPE_834b419437b855738443676b9b3cada0 ownerTypeName=WithSlots ownerQualifiedName=class_forms.WithSlots fieldAccess=PUBLIC_ACCESS fieldModifier=SLOT,INSTANCE_VAR,READ_ONLY fieldOrigin=SLOTS_ENTRY declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=21 hasAnnotation=false annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_512fa5aa67cc77834aa18c596c931e59 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_259ffe8cc6332d9477a5ec7ca24bb921 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_11cbc9360f3ced40836b98405f95cda3 -name=y fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=25 endLine=25 pyTypeLinkHash=PY_TYPE_834b419437b855738443676b9b3cada0 ownerTypeName=WithSlots ownerQualifiedName=class_forms.WithSlots fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,SLOT,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_1c60534bccbeaabf71d83fbb629164fb receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=25 hasAnnotation=false annotationIsString=false initializerText=2 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_512fa5aa67cc77834aa18c596c931e59 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_341ada2d54c00d80488099254b019ebe serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_0a33242ff4f96e4f7da2aaf37f7390af -name=y fieldTypeName=int fieldBaseType=int potentialQualifiedName=field_origins.Point.y isAmbiguous=false startLine=19 endLine=19 pyTypeLinkHash=PY_TYPE_6b71a387bb09f3559bd7785e8741463e ownerTypeName=Point ownerQualifiedName=field_origins.Point fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=19 hasAnnotation=true annotationIsString=false initializerText=0 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_16132b28e5f39ba60433d84427b51e50 pyExpressionLinkHash=PY_EXPRESSION_4c779c40db426accfc5948670ab22620 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_54dec3f2ca5dacd7edb5e5868dfee299 +name=GREEN fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=31 endLine=31 pyTypeLinkHash=PY_TYPE_9e523515e44d6cfd9bc7abbd870a39fc ownerTypeName=Colour ownerQualifiedName=class_forms.Colour fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,ENUM_MEMBER,READ_ONLY fieldOrigin=ENUM_MEMBER declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=31 hasAnnotation=false annotationIsString=false initializerText=2 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_512fa5aa67cc77834aa18c596c931e59 bindingLinkHash=PY_BINDING_57ddbcd9c75cb175fbf6a58f0413624c pyExpressionLinkHash=PY_EXPRESSION_6b100833fc3d59ad93d115d8db3d7889 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_354cf9941bff4d01e1a412286829e26f +name=KIND fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=6 endLine=6 pyTypeLinkHash=PY_TYPE_bf6354f36ebb9ff7d338d07707f4d6c9 ownerTypeName=Builder ownerQualifiedName=core.factory.Builder fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=6 hasAnnotation=false annotationIsString=false initializerText="""builder""" initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_7908beb43f8c739558eabfcf93ebca84 bindingLinkHash=PY_BINDING_8f17cce2244c83d9f0b22c927181e57c pyExpressionLinkHash=PY_EXPRESSION_6b2dc1f944007de6dc6ad962a10c4d68 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_da9d8e5a0a6fb23461a6d67eebf0b406 +name=RED fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=30 endLine=30 pyTypeLinkHash=PY_TYPE_9e523515e44d6cfd9bc7abbd870a39fc ownerTypeName=Colour ownerQualifiedName=class_forms.Colour fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,ENUM_MEMBER,READ_ONLY fieldOrigin=ENUM_MEMBER declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=30 hasAnnotation=false annotationIsString=false initializerText=1 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_512fa5aa67cc77834aa18c596c931e59 bindingLinkHash=PY_BINDING_788efc874ee5c9fe8de3cf8d313ccb08 pyExpressionLinkHash=PY_EXPRESSION_08330318b58d2f5435334c19e44e1bad serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_b63b80d192db3ec3d30504b33ab5a7d5 +name=_Inner__secret fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=27 endLine=27 pyTypeLinkHash=PY_TYPE_e0b00a82da98016f61db58a6de64ca3a ownerTypeName=Inner ownerQualifiedName=name_mangling_edge_cases.Outer.Inner fieldAccess=PRIVATE_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=27 hasAnnotation=false annotationIsString=false initializerText="""inner secret""" initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_75dd800d24b53dd3ca1d826ca1525876 bindingLinkHash=PY_BINDING_5f168c8d5c239011cb96ae52de48a359 pyExpressionLinkHash=PY_EXPRESSION_8d9a61772e886da7597f48f8d04adb1a serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_2ee87edc1495cf4099a701d55e5e6c46 +name=_Outer__secret fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=24 endLine=24 pyTypeLinkHash=PY_TYPE_fd26c3cd94bd9663ee01dd2cfe94159d ownerTypeName=Outer ownerQualifiedName=name_mangling_edge_cases.Outer fieldAccess=PRIVATE_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=24 hasAnnotation=false annotationIsString=false initializerText="""outer secret""" initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_75dd800d24b53dd3ca1d826ca1525876 bindingLinkHash=PY_BINDING_49c59ee9461329034a85a0296cef5067 pyExpressionLinkHash=PY_EXPRESSION_37ac9136831ba123510a02a1f4e8a855 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_f2cd30b8381aef7fb269f0afaa8e87df +name=_v fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=24 endLine=24 pyTypeLinkHash=PY_TYPE_a1bd5124fede6d4f3504a0dd00092899 ownerTypeName=Kinds ownerQualifiedName=method_kinds.Kinds fieldAccess=PROTECTED_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_43ebe179bc6f5e46ee12ceb6ef8f8371 receiverName=self isDeclaredInInit=false writeCount=1 writtenInMethodCount=1 firstWriteLine=24 hasAnnotation=false annotationIsString=false initializerText=value initializerKind=NAME pyModuleLinkHash=PY_MODULE_7f80b0a69e6ccd5fc2e3932cc9e5dc10 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_6c7b177707416257783b9ec57e0e0db7 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_dcf0d30e2575c32c0165dde58ab421cd +name=a fieldTypeName=int fieldBaseType=int potentialQualifiedName=field_origins.Named.a isAmbiguous=false startLine=27 endLine=27 pyTypeLinkHash=PY_TYPE_57e2471ba226031232617e9a2c039483 ownerTypeName=Named ownerQualifiedName=field_origins.Named fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=NAMEDTUPLE_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=27 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_9e2b1a44d2cdbd153e83899e6455543a pyExpressionLinkHash=PY_EXPRESSION_655c42272094ed05fdc74a8ceca41e9a serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_f5cf13ceb805ace0621794c47b0edd1c +name=amount fieldTypeName=int fieldBaseType=int potentialQualifiedName=service_layer.Record.amount isAmbiguous=false startLine=9 endLine=9 pyTypeLinkHash=PY_TYPE_8ffa51c20183b9069bc165293685e3fe ownerTypeName=Record ownerQualifiedName=service_layer.Record fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=9 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_87ff823ca7cda9220c2fe03210807591 bindingLinkHash=PY_BINDING_57bc7c03bfbb607dd13cde9fc0c4fa82 pyExpressionLinkHash=PY_EXPRESSION_0e91a6686acdabf7ba17b29df0ed45ef serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_1b8b80b6ac20cbea90df5020685db27e +name=args fieldTypeName=tuple fieldBaseType=tuple potentialQualifiedName=match_nested_patterns_and_guards.Command.args isAmbiguous=false startLine=18 endLine=18 pyTypeLinkHash=PY_TYPE_0d090a4533bb97a6729a4b05d91e8042 ownerTypeName=Command ownerQualifiedName=match_nested_patterns_and_guards.Command fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=18 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_fcb43aba22913355ac115ae9c4556240 bindingLinkHash=PY_BINDING_9a435ed4c1ece9c11135dbd0160b4df5 pyExpressionLinkHash=PY_EXPRESSION_0ee6d1d4f1d073dd98461650dcad5106 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_3957058ce75e0648a430fbe98178267e +name=attribute fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=14 endLine=14 pyTypeLinkHash=PY_TYPE_ba2fbe8e09ca96c2ac317281255eb0d6 ownerTypeName=Documented ownerQualifiedName=comment_forms.Documented fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=14 hasAnnotation=false annotationIsString=false initializerText=1 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_898ba18a31075a2ba7818f72c287b8d1 bindingLinkHash=PY_BINDING_2424a859b65550cd9888982deae03d22 pyExpressionLinkHash=PY_EXPRESSION_ffabf59979fb14780a4b3608e603c0d1 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_385e7b366a08ca3716f29841e22cc22f +name=b fieldTypeName=str fieldBaseType=str potentialQualifiedName=field_origins.Named.b isAmbiguous=false startLine=28 endLine=28 pyTypeLinkHash=PY_TYPE_57e2471ba226031232617e9a2c039483 ownerTypeName=Named ownerQualifiedName=field_origins.Named fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=NAMEDTUPLE_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=28 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_9557301ac4f28d860f8ea85d7d5f12c3 pyExpressionLinkHash=PY_EXPRESSION_3ef8970040c01d72c72ad800129733ee serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_310894d9c629bedb0d3af75ed37d2926 +name=children fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=7 endLine=7 pyTypeLinkHash=PY_TYPE_114cb72d42012abcb4ffcbce3bc6a345 ownerTypeName=Node ownerQualifiedName=core.base.Node fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_1800af667e7076b626bd654b5e3dbebd receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=7 hasAnnotation=false annotationIsString=false initializerText=[] initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_0af7a9df251376bafe876afe4a32f46e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_2b5c5d4e2ede82425c6cc08afb31af7e serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_379b43102358abde65f30ca1de0bcd84 +name=class_body_annotation fieldTypeName=int fieldBaseType=int potentialQualifiedName=field_origins.Origins.class_body_annotation isAmbiguous=false startLine=8 endLine=8 pyTypeLinkHash=PY_TYPE_8e5e7ae02d7e897e2dae13f4d1fc41bc ownerTypeName=Origins ownerQualifiedName=field_origins.Origins fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ANNOTATION_ONLY declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=8 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_b06aa81bc5c9cd99d90ba02007b150fe pyExpressionLinkHash=PY_EXPRESSION_545963f8035cf0bdb65e61c5d4e613c6 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_95475ba063df3c93c21f1c308805edd9 +name=class_body_assign fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=7 endLine=7 pyTypeLinkHash=PY_TYPE_8e5e7ae02d7e897e2dae13f4d1fc41bc ownerTypeName=Origins ownerQualifiedName=field_origins.Origins fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=7 hasAnnotation=false annotationIsString=false initializerText=1 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_1d3d16a7ecf67dc4f185bb6856b55ce1 pyExpressionLinkHash=PY_EXPRESSION_4c58afc15bc350db5af24aafaf24281c serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_56d654562f909d6501f78a7efbec6215 +name=class_var fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=53 endLine=53 pyTypeLinkHash=PY_TYPE_e61b2b74f7302f1be447bd28221d2310 ownerTypeName=ClassScope ownerQualifiedName=scope_forms.ClassScope fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=53 hasAnnotation=false annotationIsString=false initializerText=1 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_f56326f63a4c5bef9294b28a4acb9abc bindingLinkHash=PY_BINDING_4bcd55d471aae0bad8d158832c4e4dca pyExpressionLinkHash=PY_EXPRESSION_6068c5654402f0188994f976ddff8750 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_395310756cb1d1d83c7b698fea57e4dc +name=count fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=67 endLine=67 pyTypeLinkHash=PY_TYPE_35883ba9ad396e2203b42abef9e1d7c9 ownerTypeName=Chained ownerQualifiedName=resolution_edges.Chained fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_994dce81c215c9114b56d78e93cdf732 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=67 hasAnnotation=false annotationIsString=false initializerText=0 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_daef721b73c8bfbcc0348299f2f7bf9a bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_7b0cae40fc8c704752083b3ee0fceb16 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_21ead56058d3d069ccf562ee8f4901d4 +name=count fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=70 endLine=70 pyTypeLinkHash=PY_TYPE_35883ba9ad396e2203b42abef9e1d7c9 ownerTypeName=Chained ownerQualifiedName=resolution_edges.Chained fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR fieldOrigin=SELF_AUGASSIGN declaringMethodLinkHash=PY_METHOD_b561ec322633cc1dd8bcac47c5b8c82b receiverName=self isDeclaredInInit=false writeCount=1 writtenInMethodCount=1 firstWriteLine=70 hasAnnotation=false annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_daef721b73c8bfbcc0348299f2f7bf9a bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_76e084392d2dd97ca35228a147447c5e serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_cbc349dfa7dba6a7f967589d4623516d +name=data fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=23 endLine=23 pyTypeLinkHash=PY_TYPE_b4aa287abde8dbb60cac1736e458094f ownerTypeName=ClassScopeIsInvisibleToComprehensions ownerQualifiedName=scoping_edge_cases.ClassScopeIsInvisibleToComprehensions fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=23 hasAnnotation=false annotationIsString=false initializerText=[1, 2, 3] initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_7c5670e72548f0c21d5e8710e5a91903 bindingLinkHash=PY_BINDING_2979d1fb44aef0644d73caeef4e16875 pyExpressionLinkHash=PY_EXPRESSION_9d502d9e8053c97698b1fd82a5f1ef02 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_bebc562b5a7574ce8ac9e36ada3ef18c +name=doubled fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=28 endLine=28 pyTypeLinkHash=PY_TYPE_b4aa287abde8dbb60cac1736e458094f ownerTypeName=ClassScopeIsInvisibleToComprehensions ownerQualifiedName=scoping_edge_cases.ClassScopeIsInvisibleToComprehensions fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=28 hasAnnotation=false annotationIsString=false initializerText=[item * 2 for item in data] initializerKind=COMPREHENSION pyModuleLinkHash=PY_MODULE_7c5670e72548f0c21d5e8710e5a91903 bindingLinkHash=PY_BINDING_463a0938fe7b9c7d0b98f8a91a5fa9b1 pyExpressionLinkHash=PY_EXPRESSION_7a1c506cbc933dcebc9cff34cfeb9098 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_f84a1f38e2ec605e0f7371d09882a819 +name=key fieldTypeName=int fieldBaseType=int potentialQualifiedName=field_origins.Typed.key isAmbiguous=false startLine=32 endLine=32 pyTypeLinkHash=PY_TYPE_ab0f66955e01d63bfcdb185cba8220c9 ownerTypeName=Typed ownerQualifiedName=field_origins.Typed fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=TYPEDDICT_KEY declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=32 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_e2bb803db7c6d1eb22f6564f99c728a6 pyExpressionLinkHash=PY_EXPRESSION_cf8c36787653df27e29b2e8d273a91a4 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_cfe099bdbc0b4a62a3d6135ca67cf7c0 +name=key fieldTypeName=str fieldBaseType=str potentialQualifiedName=service_layer.Record.key isAmbiguous=false startLine=8 endLine=8 pyTypeLinkHash=PY_TYPE_8ffa51c20183b9069bc165293685e3fe ownerTypeName=Record ownerQualifiedName=service_layer.Record fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=8 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_87ff823ca7cda9220c2fe03210807591 bindingLinkHash=PY_BINDING_8c140f9ae98b25bc5c0a4168c96dc0dc pyExpressionLinkHash=PY_EXPRESSION_02ca24b3af6950ca1b62257fad48588b serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_27261db33e56b96f29132dfaa71d3f4e +name=kind fieldTypeName=str fieldBaseType=str potentialQualifiedName=match_nested_patterns_and_guards.Event.kind isAmbiguous=false startLine=11 endLine=11 pyTypeLinkHash=PY_TYPE_2ce58f5e8c9675c1e0fd5c50ffba85b9 ownerTypeName=Event ownerQualifiedName=match_nested_patterns_and_guards.Event fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=11 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_fcb43aba22913355ac115ae9c4556240 bindingLinkHash=PY_BINDING_a71edea3725a72a19e6afe34df82df70 pyExpressionLinkHash=PY_EXPRESSION_24ae5eca16a253b79944f8cf60d1f4cc serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_adf2e9134c1d3b66aedefcfe1ecbe569 +name=label fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=41 endLine=41 pyTypeLinkHash=PY_TYPE_cb9da9964bc3c43761a4e1269506effe ownerTypeName=ClassScopeIsInvisibleToMethods ownerQualifiedName=scoping_edge_cases.ClassScopeIsInvisibleToMethods fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=41 hasAnnotation=false annotationIsString=false initializerText="""class attribute""" initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_7c5670e72548f0c21d5e8710e5a91903 bindingLinkHash=PY_BINDING_98ec45593e2cb658d620508b81299faa pyExpressionLinkHash=PY_EXPRESSION_42fe92a294647ed36376fb8eb05bcab7 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_f1fd14ac02e06017fd967c9e6fed6fb3 +name=n fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=6 endLine=6 pyTypeLinkHash=PY_TYPE_b2f97d5a7e48f40b008fc644ae603fdf ownerTypeName=Holder ownerQualifiedName=expression_kinds.Holder fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_9949f650301345b4e4deaca03bd84aa2 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=6 hasAnnotation=false annotationIsString=false initializerText=n initializerKind=NAME pyModuleLinkHash=PY_MODULE_02d561b0acde0974e99e75b89b4bdab0 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_8d749d70dcd6b628deac2048e53d55e0 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_9df925d8945d2bf31f49db8111a0dc69 +name=name fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=18 endLine=18 pyTypeLinkHash=PY_TYPE_bf6354f36ebb9ff7d338d07707f4d6c9 ownerTypeName=Builder ownerQualifiedName=core.factory.Builder fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_bd7c847866b0674467ec65fee212e98d receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=18 hasAnnotation=false annotationIsString=false initializerText=name initializerKind=NAME pyModuleLinkHash=PY_MODULE_7908beb43f8c739558eabfcf93ebca84 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_585104a79ffa32b57b756846339f5739 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_e099ac70f91cfbdc5ff7c13e73233075 +name=name fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=6 endLine=6 pyTypeLinkHash=PY_TYPE_114cb72d42012abcb4ffcbce3bc6a345 ownerTypeName=Node ownerQualifiedName=core.base.Node fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_1800af667e7076b626bd654b5e3dbebd receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=6 hasAnnotation=false annotationIsString=false initializerText=name initializerKind=NAME pyModuleLinkHash=PY_MODULE_0af7a9df251376bafe876afe4a32f46e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_105c90e8c0edac7578788345181e8b7f serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_4892cbb3eae7d00e22619a4bee9e4477 +name=name fieldTypeName=str fieldBaseType=str potentialQualifiedName=match_nested_patterns_and_guards.Command.name isAmbiguous=false startLine=17 endLine=17 pyTypeLinkHash=PY_TYPE_0d090a4533bb97a6729a4b05d91e8042 ownerTypeName=Command ownerQualifiedName=match_nested_patterns_and_guards.Command fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=17 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_fcb43aba22913355ac115ae9c4556240 bindingLinkHash=PY_BINDING_cd0d311bf325ef8c8ae6238a63dcad6f pyExpressionLinkHash=PY_EXPRESSION_eac151d5d6927868317023b3e1775a9d serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_bdf77c8fc8ad7a8203a0dbfdd9def840 +name=node fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=19 endLine=19 pyTypeLinkHash=PY_TYPE_bf6354f36ebb9ff7d338d07707f4d6c9 ownerTypeName=Builder ownerQualifiedName=core.factory.Builder fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_bd7c847866b0674467ec65fee212e98d receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=19 hasAnnotation=false annotationIsString=false initializerText=Node(name) initializerKind=CALL pyModuleLinkHash=PY_MODULE_7908beb43f8c739558eabfcf93ebca84 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_e901fe2384634e118a14e9746dc8d29a serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_49ef51dcaf6157efde16ddf34306974e +name=nodes fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=28 endLine=28 pyTypeLinkHash=PY_TYPE_f511a7eca38906948dce8170bb4e5495 ownerTypeName=Registry ownerQualifiedName=core.base.Registry fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_d7a26baec95db1ac47ae906b256b56b6 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=28 hasAnnotation=false annotationIsString=false initializerText=[] initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_0af7a9df251376bafe876afe4a32f46e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_37663bc3f580f7f4b5e952c3b83bbb53 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_2a7e11b6b84f1f30e344016dbc9c423d +name=payload fieldTypeName=dict fieldBaseType=dict potentialQualifiedName=match_nested_patterns_and_guards.Event.payload isAmbiguous=false startLine=12 endLine=12 pyTypeLinkHash=PY_TYPE_2ce58f5e8c9675c1e0fd5c50ffba85b9 ownerTypeName=Event ownerQualifiedName=match_nested_patterns_and_guards.Event fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=12 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_fcb43aba22913355ac115ae9c4556240 bindingLinkHash=PY_BINDING_13c4c5fdb58a9e4c8b2de72989a22932 pyExpressionLinkHash=PY_EXPRESSION_1586dc4c0c9ec70cb3d13c84f1727e77 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_01ef6b233822f48581ac56724f516c07 +name=registered fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=36 endLine=36 pyTypeLinkHash=PY_TYPE_a1bd5124fede6d4f3504a0dd00092899 ownerTypeName=Kinds ownerQualifiedName=method_kinds.Kinds fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_5c50c5642d36a230fa6b6e9f7ed1d793 receiverName=cls isDeclaredInInit=false writeCount=1 writtenInMethodCount=1 firstWriteLine=36 hasAnnotation=false annotationIsString=false initializerText=True initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_7f80b0a69e6ccd5fc2e3932cc9e5dc10 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_770096a696c823a0acb4d74638579069 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_5eee83bd12ab74a7e50ee0ca4f615196 +name=repository fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=44 endLine=44 pyTypeLinkHash=PY_TYPE_c4ea4c823c48edb338a6cd78787cae9b ownerTypeName=Service ownerQualifiedName=service_layer.Service fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_c2679ef7c7ff76fd0d905dfb8219e815 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=44 hasAnnotation=false annotationIsString=false initializerText=repository initializerKind=NAME pyModuleLinkHash=PY_MODULE_87ff823ca7cda9220c2fe03210807591 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_39117b39cc41afe8b691c23f84675db1 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_ba06c6ab6360c63d00ccbdfb193ee986 +name=rows fieldTypeName=typing.List[Record] fieldBaseType=List potentialQualifiedName=service_layer.Store.rows isAmbiguous=false startLine=17 endLine=17 pyTypeLinkHash=PY_TYPE_2cf22ea62c0a0c4b7621a04b81853658 ownerTypeName=Store ownerQualifiedName=service_layer.Store fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_1b1abae80728c42e4cc6b0dbc18b5ec2 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=17 hasAnnotation=true annotationIsString=false initializerText=[] initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_87ff823ca7cda9220c2fe03210807591 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_ef6124b63d25a3882170fe90d7e7aa10 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_982e6f2f24bd41d8e0dcef33a86a3a6a +name=scaled fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=37 endLine=37 pyTypeLinkHash=PY_TYPE_b4aa287abde8dbb60cac1736e458094f ownerTypeName=ClassScopeIsInvisibleToComprehensions ownerQualifiedName=scoping_edge_cases.ClassScopeIsInvisibleToComprehensions fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=37 hasAnnotation=false annotationIsString=false initializerText=[item * data[0] for item in range(3)] initializerKind=COMPREHENSION pyModuleLinkHash=PY_MODULE_7c5670e72548f0c21d5e8710e5a91903 bindingLinkHash=PY_BINDING_b6ba5ccaaf2812fdfe59e082e4220ac8 pyExpressionLinkHash=PY_EXPRESSION_96eea804d6ac4509c9232efc3d58a9bf serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_38c6f01897875da284ae4ed20953ccf5 +name=self_assign fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=12 endLine=12 pyTypeLinkHash=PY_TYPE_8e5e7ae02d7e897e2dae13f4d1fc41bc ownerTypeName=Origins ownerQualifiedName=field_origins.Origins fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_0392ee9e7a4bc56bb004a6e7afba21d3 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=12 hasAnnotation=false annotationIsString=false initializerText=2 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_50aab41b2a79282580adb17ecd509bf4 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_9dd57248346cd4c94d18e3ae6a54e092 +name=self_augassign fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=13 endLine=13 pyTypeLinkHash=PY_TYPE_8e5e7ae02d7e897e2dae13f4d1fc41bc ownerTypeName=Origins ownerQualifiedName=field_origins.Origins fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_0392ee9e7a4bc56bb004a6e7afba21d3 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=13 hasAnnotation=false annotationIsString=false initializerText=0 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_c848ec2b334a0c0c95ecf82516738045 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_a08b36eb3272a0f66469bbe7d307dd42 +name=self_augassign fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=14 endLine=14 pyTypeLinkHash=PY_TYPE_8e5e7ae02d7e897e2dae13f4d1fc41bc ownerTypeName=Origins ownerQualifiedName=field_origins.Origins fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR fieldOrigin=SELF_AUGASSIGN declaringMethodLinkHash=PY_METHOD_0392ee9e7a4bc56bb004a6e7afba21d3 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=14 hasAnnotation=false annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_6e8a8a980cbdfe31ca1f70cf5768aa54 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_e63bf9a63c8f71ab184065a04e212c41 +name=slot_entry fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=9 endLine=9 pyTypeLinkHash=PY_TYPE_8e5e7ae02d7e897e2dae13f4d1fc41bc ownerTypeName=Origins ownerQualifiedName=field_origins.Origins fieldAccess=PUBLIC_ACCESS fieldModifier=SLOT,INSTANCE_VAR,READ_ONLY fieldOrigin=SLOTS_ENTRY declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=9 hasAnnotation=false annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_c3dd5c9baa37a65645ba641deaf75370 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_3b691937578677fc25973ac19b74dd84 +name=store fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=28 endLine=28 pyTypeLinkHash=PY_TYPE_2dfd4389681449004ff326855f81c683 ownerTypeName=Repository ownerQualifiedName=service_layer.Repository fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_e9a9fd209ad674fca757b55f0d59046a receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=28 hasAnnotation=false annotationIsString=false initializerText=store initializerKind=NAME pyModuleLinkHash=PY_MODULE_87ff823ca7cda9220c2fe03210807591 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_761a7f788c9fcafe8fe54d441cd94d55 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_6de6365a260cb8ca2a2f41c13e057e22 +name=tag fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=54 endLine=54 pyTypeLinkHash=PY_TYPE_bd7d395e13e5c973a6e8538aced32fab ownerTypeName=ClassMethodFactory ownerQualifiedName=resolution_edges.ClassMethodFactory fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_eb5681c8ddcf04929815457a1e598d59 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=54 hasAnnotation=false annotationIsString=false initializerText=tag initializerKind=NAME pyModuleLinkHash=PY_MODULE_daef721b73c8bfbcc0348299f2f7bf9a bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_0f7fa5219483899dc1b1880a6f9d494a serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_77575b7f5a7219c8590bc9a37754d3d0 +name=values fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=37 endLine=37 pyTypeLinkHash=PY_TYPE_1e732b162f5deaead4b2e1751fdc8b5f ownerTypeName=InClassBody ownerQualifiedName=comprehension_forms.InClassBody fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=37 hasAnnotation=false annotationIsString=false initializerText=[i for i in range(3)] initializerKind=COMPREHENSION pyModuleLinkHash=PY_MODULE_69c6710ffe12b2fc51e03f770319af1b bindingLinkHash=PY_BINDING_086207b12b3601fd4624743e01cd845a pyExpressionLinkHash=PY_EXPRESSION_5d743a1c689a266929bda307ac93ebce serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_0098386942688fc6a884e12e9f6911e3 +name=x fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=22 endLine=22 pyTypeLinkHash=PY_TYPE_834b419437b855738443676b9b3cada0 ownerTypeName=WithSlots ownerQualifiedName=class_forms.WithSlots fieldAccess=PUBLIC_ACCESS fieldModifier=SLOT,INSTANCE_VAR,READ_ONLY fieldOrigin=SLOTS_ENTRY declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=22 hasAnnotation=false annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_512fa5aa67cc77834aa18c596c931e59 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_bf49d94b1b5b1d615224bbcede193fd6 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_410e9424d9f85c7f01069a6333206741 +name=x fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=25 endLine=25 pyTypeLinkHash=PY_TYPE_834b419437b855738443676b9b3cada0 ownerTypeName=WithSlots ownerQualifiedName=class_forms.WithSlots fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,SLOT,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_1c60534bccbeaabf71d83fbb629164fb receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=25 hasAnnotation=false annotationIsString=false initializerText=1 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_512fa5aa67cc77834aa18c596c931e59 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_0862eff6ff0a1adb1f8aedc4c19d268f serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_23dc9f7e711905b8433e9f2ce8b4811e +name=x fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=10 endLine=10 pyTypeLinkHash=PY_TYPE_6f4e90e4697b76a388566c9bd84e0d4d ownerTypeName=Point ownerQualifiedName=match_class_and_sequence_patterns.Point fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_ed898c7ba31b1d5a88a7ed47da6a477b receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=10 hasAnnotation=false annotationIsString=false initializerText=x initializerKind=NAME pyModuleLinkHash=PY_MODULE_e8c22c801b9d7d33638ace57665fdd49 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_d8d7077f45710e9120c3e6398cc0986e serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_92a0068d85f354f19ad6d582e045d7df +name=x fieldTypeName=int fieldBaseType=int potentialQualifiedName=field_origins.Point.x isAmbiguous=false startLine=19 endLine=19 pyTypeLinkHash=PY_TYPE_6b71a387bb09f3559bd7785e8741463e ownerTypeName=Point ownerQualifiedName=field_origins.Point fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=19 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_faff6556d980286b5caffa3120d0a328 pyExpressionLinkHash=PY_EXPRESSION_d3a3294efad0cccf2cebd3a749b421ca serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_7d18be1a9fe29d0bed13a216fb06f410 +name=y fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=11 endLine=11 pyTypeLinkHash=PY_TYPE_6f4e90e4697b76a388566c9bd84e0d4d ownerTypeName=Point ownerQualifiedName=match_class_and_sequence_patterns.Point fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_ed898c7ba31b1d5a88a7ed47da6a477b receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=11 hasAnnotation=false annotationIsString=false initializerText=y initializerKind=NAME pyModuleLinkHash=PY_MODULE_e8c22c801b9d7d33638ace57665fdd49 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_0b2011f5a35e76274b3622f5fcb378c2 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_39c1e4363707f3951248f99912d45ae9 +name=y fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=22 endLine=22 pyTypeLinkHash=PY_TYPE_834b419437b855738443676b9b3cada0 ownerTypeName=WithSlots ownerQualifiedName=class_forms.WithSlots fieldAccess=PUBLIC_ACCESS fieldModifier=SLOT,INSTANCE_VAR,READ_ONLY fieldOrigin=SLOTS_ENTRY declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=22 hasAnnotation=false annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_512fa5aa67cc77834aa18c596c931e59 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_259ffe8cc6332d9477a5ec7ca24bb921 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_11cbc9360f3ced40836b98405f95cda3 +name=y fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=26 endLine=26 pyTypeLinkHash=PY_TYPE_834b419437b855738443676b9b3cada0 ownerTypeName=WithSlots ownerQualifiedName=class_forms.WithSlots fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,SLOT,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_1c60534bccbeaabf71d83fbb629164fb receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=26 hasAnnotation=false annotationIsString=false initializerText=2 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_512fa5aa67cc77834aa18c596c931e59 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_341ada2d54c00d80488099254b019ebe serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_0a33242ff4f96e4f7da2aaf37f7390af +name=y fieldTypeName=int fieldBaseType=int potentialQualifiedName=field_origins.Point.y isAmbiguous=false startLine=20 endLine=20 pyTypeLinkHash=PY_TYPE_6b71a387bb09f3559bd7785e8741463e ownerTypeName=Point ownerQualifiedName=field_origins.Point fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=20 hasAnnotation=true annotationIsString=false initializerText=0 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_16132b28e5f39ba60433d84427b51e50 pyExpressionLinkHash=PY_EXPRESSION_4c779c40db426accfc5948670ab22620 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_54dec3f2ca5dacd7edb5e5868dfee299 diff --git a/parser/src/test/python-gates/field-lines.ts b/parser/src/test/python-gates/field-lines.ts new file mode 100644 index 000000000..3b543f851 --- /dev/null +++ b/parser/src/test/python-gates/field-lines.ts @@ -0,0 +1,117 @@ +/** + * A field row carries the line its write is ON, 1-based like every other Python relation. + * + * The field extractor copied tree-sitter's 0-based `row` straight into `startLine`, + * `endLine` and `firstWriteLine`, while methods, types, comments and blocks all add 1. So + * every field sat one line above its write: the first field of a nested `class Meta:` was + * recorded AT the `class Meta:` line, and a query by `file:line` or a diff hunk on the + * write landed on the field above it, or on nothing. + * + * The frozen golden could not see it: it had frozen the 0-based values. This gate names + * the expected line of each write form, so a wrong value is red even when it is stable. + * + * Near-miss controls: the declaration lines of the classes and of `__init__` must not move + * (they were already 1-based), and no field may sit on a `class` line. + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { PythonProjectAnalyzer } from '@/workflows/python/python-project-analyzer'; + +const SOURCE = `class Form: + name = "form" + + class Meta: + csrf = True + fields = ( + "a", + "b", + ) + + def __init__(self): + self.count = 0 + setattr(self, "dynamic", 1) + + +class Slotted: + __slots__ = ("x",) +`; + +/** field name -> [startLine, endLine] of its (first) write, 1-based. */ +const FIELDS: ReadonlyArray = [ + ['name', 2, 2, 'a top-level class-body assignment'], + ['csrf', 5, 5, 'the first field of a nested class: NOT the `class Meta:` line (4)'], + ['fields', 6, 9, 'a multi-line class-body assignment spans its value'], + ['count', 12, 12, 'self.x = ... in __init__'], + ['dynamic', 13, 13, 'setattr(self, "name", v)'], + ['x', 17, 17, 'a __slots__ entry'], +]; + +/** control: declarations that were already 1-based keep their line. */ +const DECLS: ReadonlyArray = [ + ['all-python-types.csv', 'Form', 1], + ['all-python-types.csv', 'Meta', 4], + ['all-python-types.csv', 'Slotted', 16], + ['all-python-methods.csv', '__init__', 11], +]; + +export async function fieldLines(): Promise { + const problems: string[] = []; + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'py-fieldlines-')); + const source = path.join(root, 'src'); + fs.mkdirSync(source, { recursive: true }); + fs.writeFileSync(path.join(source, 'forms.py'), SOURCE); + + const outputDir = path.join(root, 'out'); + await new PythonProjectAnalyzer().analyze({ + rootDir: source, + outputDir, + baseMservPath: '/repo', + serviceVersionLinkHash: 'SERVICE_VERSION_' + '0'.repeat(32), + }); + + const tsv = (file: string): Record[] => { + const lines = fs.readFileSync(path.join(outputDir, file), 'utf-8').split('\n').filter(Boolean); + const header = lines[0]!.split('\t'); + return lines.slice(1).map((l) => { + const cells = l.split('\t'); + return Object.fromEntries(header.map((h, i) => [h, cells[i] ?? ''])); + }); + }; + + const fields = tsv('all-python-fields.csv'); + for (const [name, start, end, why] of FIELDS) { + const row = fields.find((f) => f.name === name); + if (!row) { + problems.push(`${name} (${why}): no field row`); + continue; + } + const got = [Number(row.startLine), Number(row.endLine), Number(row.firstWriteLine)]; + if (got[0] !== start || got[1] !== end || got[2] !== start) { + problems.push(`${name} (${why}): expected start=${start} end=${end} firstWrite=${start}, ` + + `got start=${got[0]} end=${got[1]} firstWrite=${got[2]}`); + } + } + + const classLines = new Set(); + for (const [file, name, line] of DECLS) { + const row = tsv(file).find((r) => r.name === name); + const got = row ? Number(row.startLine) : NaN; + if (got !== line) problems.push(`control: ${name} in ${file} expected line ${line}, got ${got}`); + if (file === 'all-python-types.csv') classLines.add(line); + } + for (const f of fields) { + if (classLines.has(Number(f.startLine))) { + problems.push(`control: field ${f.name} sits on a class line (${f.startLine})`); + } + } + + fs.rmSync(root, { recursive: true, force: true }); + console.log(` ${FIELDS.length} write forms checked for 1-based lines; ` + + `${DECLS.length} declaration lines and the no-field-on-a-class-line rule as controls`); + for (const problem of problems) { + console.log(` FAIL ${problem}`); + } + return problems.length === 0 ? 0 : 1; +} diff --git a/parser/src/test/python-tests.ts b/parser/src/test/python-tests.ts index db6bc3961..feaf252ad 100644 --- a/parser/src/test/python-tests.ts +++ b/parser/src/test/python-tests.ts @@ -36,6 +36,7 @@ import { softKeywordTypeCall } from './python-gates/soft-keyword-type-call'; import { asyncIteration } from './python-gates/async-iteration'; import { splatCallee } from './python-gates/splat-callee'; import { cachedProperty } from './python-gates/cached-property'; +import { fieldLines } from './python-gates/field-lines'; import { pep604Union } from './python-gates/pep604-union'; const VERIFIED = 'src/test-data/python/verified'; @@ -587,6 +588,8 @@ const CHECKS: Check[] = [ proves: 'a call whose result is splatted is a named static call, not a dynamic one' }, { name: 'cached property', run: cachedProperty, proves: 'a @cached_property is a property getter — a read that runs a body — not a method nobody calls' }, + { name: 'field lines', run: fieldLines, + proves: 'a field row sits on the line of its write, 1-based; never on the class line above it' }, { name: 'PEP 604 unions', run: pep604Union, proves: 'a union is decomposed whatever its operands look like; a subscripted operand does not collapse it' }, { name: 'async iteration', run: asyncIteration, diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 18ea9df5e..2cc15b7d2 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -95,6 +95,8 @@ and service loaders are invisible. Detail: `reference/changed-and-tests.md`. (with the unresolved sites that might connect them). Endpoints as written: `Owner.method`, `Type`, `file.ts:123`, `'new File'`, `'@GetMapping'`, `'*'`, or a bare word. A misspelt name stops with the close ones. Detail: `reference/path.md`. +A fact no verb prints (decorations, bases, entry points by reason, field writers): `reference/schema.md` names the table per language. + ## What it cannot see — say so instead of guessing Reflection, string dispatch, event buses; receivers the engine could not type; callbacks invoked by a library; diff --git a/plugins/axiomcode/skills/axiomcode/reference/schema.md b/plugins/axiomcode/skills/axiomcode/reference/schema.md new file mode 100644 index 000000000..6fd14df6f --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/reference/schema.md @@ -0,0 +1,102 @@ +# schema — which table holds X, per language + +Ask a verb first; open the graph only for a fact no verb prints. Every graph holds ONE language, and the same fact +lives in a different table per language. This page says where, for Python, Java and C#, and what is not recorded +at all, so you stop looking. Measured on a Django app, a Spring Boot app and an ASP.NET app, one fresh index each. + +## Which graph + +| | | +|---|---| +| the main language (most files) | `.axiomcode/out/graph.sqlite`, a symlink to `.axiomcode/out//graph.sqlite` | +| every other language | `.axiomcode/lang//out/graph.sqlite` | +| which one you opened | `sqlite3 -readonly "SELECT value FROM run WHERE key='language'"` | +| tested SQL, caveats, value meanings | the `schema_queries`, `schema_notes`, `schema_vocab` tables in the same file | + +Ids are opaque (`PY_METHOD_…`, `METHOD_REGISTRY_…`, `CS_PROPERTY_…`): join on them, never parse them. `symbols` +holds every declaration of every kind with `file`, `line`, `owner`, `is_test`; `symbols.id` is the id the other +tables use, and `symbols.method_id` / `type_id` join it to `methods` / `types`. + +## Fact by language + +| fact | Python | Java | C# | +|---|---|---|---| +| decoration / annotation / attribute | `decorations`; owner is a method or type | `decorations`; owner is a method, type, field or a **parameter** (`METHOD_PARAMETER_…`, joins nothing) | `decorations`; owner is a method, type or property (`CS_PROPERTY_…`) | +| its name and text | `name` = last dotted segment (`@admin.register(X)` → `register`); `text` = as written, args included | `name` as written after `@`; `text` with args, string quotes tripled (`"""/articles"""`) | `name` as written; `text` = `@Name` **only**, even for `[Endpoint(Name = "x")]`: the arguments are in `literals` at the same file:line | +| base types, resolved | `type_ancestors` (transitive) | `type_ancestors`, library bases included as `types.provenance='external'` | `type_ancestors` (transitive) | +| base types, library / unresolved | **not** in `type_ancestors`: `type_refs` `context='BASE_CLASS'` (last segment only, `Model`) and `ext_type_base_unresolved` (c1 = type id, c3 = text, `models.Model`) | as above; also `type_use` `context='SUPER_TYPE'` with `owner_type_id` | **not** in `type_ancestors`: `type_refs` `context='BASE_LIST'` (name without type args); `ext_type_base_unresolved` c3 = name, but c1 is a declaration group, not a `types.id` | +| entry points | `entry_points(method_id, reason)`: `url`, `orm_hook` seen; rules also emit `http`, `task`, `signal_receiver`, `fixture`, `di_provider`, `grpc_service`. **No** `test` or `main` reason | `test`, `http`, `bean_ctor`, `factory`, `main` seen; also `cli`, `queue`, `scheduled`, `lifecycle`, `spring_factories`; config keys in `ext_config_entry_point` | `test`, `http`, `orm_hook`, `framework_hook`, `main` seen; also `queue`, `grpc_service` | +| field declarations | `symbols` `kind='field'` (`PY_FIELD_…`, `owner` `Form` or `Form.Meta`); `fields` is **empty** | `fields` | `fields` = true fields only; a property is `symbols` `kind='field'` with a `CS_PROPERTY_…` id, and its accessors are `methods` `kind` `PROPERTY_GET` / `PROPERTY_SET` / `PROPERTY_INIT` (`get_X`, `set_X`) | +| who writes / reads a field | **not recorded**: `field_access` is empty; `refs` `ATTRIBUTE_ACCESS` / `FIELD` is every mention by name and line, read and write alike, with no field id | `field_access` (`access` read / write, `tier`, `caller_id`) | property: `call_edges` `kind` `property_write` / `property_read` to the accessor. Field: **not recorded** (`field_access` empty; `refs` `NAME_REFERENCE` / `FIELD` by name) | +| call edges | `call_edges`; tiers `known_edge`, `multi_inferred`, `boundary_lib`, `ambiguous_unknown`; kinds `METHOD_CALL`, `SELF_CALL`, `DECORATOR_*`, `PROPERTY_READ`, … | tiers add `ambiguous_anon`; kinds `method`, `new`, `anon_new`, `ctor_delegate`, `ref` | tiers add `known_builtin_operator`, `known_implicit_ctor`; kinds add `property_read`/`_write`, `operator`, `conversion`, `indexer`, `delegate` | +| why a call is unresolved | `ext_call_site_unresolved` (c0 site, c1 caller, c2 reason, c3 call kind) | `unresolved_sites` only, no reason | `ext_site_unresolved_named` (c0 site, c1 receiver type or ``, c2 name) | +| strings in source | `literals(value, file, line)` | `literals`; config keys: `ext_config_binding` (key, mechanism, target kind, target id, owner), `ext_config_class_ref` | `literals` | +| text outside the source (XML, YAML, SQL, …) | **not in the graph**: scanned per query, cached in `.axiomcode/out/dl/nonsource.sqlite` (`files(id, rel)`, `tok(tok, fid)`) | same | same | +| tests | `symbols.is_test` (by file path); no test entry point | `is_test` + `entry_points` `reason='test'` | `is_test` + `entry_points` `reason='test'` | +| test rungs (`[sound]`, `[fixture]`, `[at import]`, …) | **not stored**: computed per query | same | same | + +`field_access`, `type_use` and `type_instantiated` are empty in some languages (`type_use` in Python and C#, +`type_instantiated` in C#): run `SELECT count(*)` before reading an empty answer as "nothing". `overrides` is empty +in Python: its dispatch set is `dispatch_candidates` (basis `mro`). + +## The verb for each fact + +| fact | verb | +|---|---| +| methods carrying a decoration | `path '@login_required' '*'` (or `'@GetMapping'`): the decorated methods and what they reach | +| subtypes of a type | `impact `: "must change with it" | +| who writes a Java field | `impact .`: "produces or writes it" | +| who writes a C# property | `impact :`: answers through its accessors. `impact Type.Prop` and `Type._field` find nothing in C# on this build (the symbols carry no owner): fall back to the SQL below | +| a Python field | `impact .` lists readers `[in scope]` / `[by name]` only; there is no writer section, because no writer relation exists | +| text files naming a declaration | `impact X`: "bound from outside the source"; `context ""`: "text files that name these" | +| a config key or a quoted string | `impact app.cache.ttl` · `impact '"some-string"'` | +| test rungs and routes | `impact X --tests-only --why` · `test-impact --why` | +| entry points by reason | no verb: SQL below | + +## Queries + +```sh +G=.axiomcode/out/graph.sqlite +# [all] decorations, with the owner whatever its kind (a Java parameter's owner comes back NULL) +sqlite3 -readonly $G "SELECT d.text, s.kind, s.qualified_name, d.file, d.line FROM decorations d + LEFT JOIN symbols s ON s.id = d.owner_id WHERE d.name = 'GetMapping'" +# [all] entry points by reason, then one reason's methods +sqlite3 -readonly $G "SELECT reason, count(*) FROM entry_points GROUP BY 1" +sqlite3 -readonly $G "SELECT m.qualified_name, m.file_path, m.start_line FROM entry_points e + JOIN methods m ON m.id = e.method_id WHERE e.reason = 'http'" +# [all] resolved ancestors (Java: library ones too, provenance 'external') +sqlite3 -readonly $G "SELECT a.qualified_name, a.provenance FROM type_ancestors x JOIN types t ON t.id = x.type_id + JOIN types a ON a.id = x.ancestor_type_id WHERE t.name = ''" +# [python] library bases, full text as written +sqlite3 -readonly $G "SELECT t.qualified_name, u.c3 FROM ext_type_base_unresolved u JOIN types t ON t.id = u.c1" +# [csharp] library bases: the owner is the innermost type whose span holds the base-list line +sqlite3 -readonly $G "SELECT r.name, (SELECT t.qualified_name FROM types t WHERE t.file_path = r.file + AND r.line BETWEEN t.start_line AND t.end_line ORDER BY t.start_line DESC LIMIT 1) AS owner + FROM type_refs r WHERE r.context = 'BASE_LIST'" +# [java] field writers +sqlite3 -readonly $G "SELECT m.qualified_name, a.file_path, a.start_line, a.tier FROM field_access a + JOIN fields f ON f.id = a.field_id JOIN methods m ON m.id = a.caller_id + WHERE f.owner_qualified_name LIKE '%.' AND f.name = '' AND a.access = 'write'" +# [csharp] property writers (property_read for readers) +sqlite3 -readonly $G "SELECT c.qualified_name, s.file_path, s.start_line FROM call_edges e + JOIN methods t ON t.id = e.callee_method_id JOIN methods c ON c.id = e.caller_id + JOIN call_sites s ON s.id = e.call_site_id WHERE e.kind = 'property_write' AND t.name = 'set_'" +# [all] tiers in this graph; [python] what the unresolved sites are waiting on +sqlite3 -readonly $G "SELECT tier, count(*) FROM call_edges GROUP BY 1" +sqlite3 -readonly $G "SELECT c2, count(*) FROM ext_call_site_unresolved GROUP BY 1 ORDER BY 2 DESC" +``` + +## Traps + +- **Paths.** Java `methods`, `types`, `fields`, `call_sites` and `field_access` hold ABSOLUTE paths; its `symbols`, + `decorations` and `type_refs` hold repo-relative ones, as every Python and C# table does. Match Java with + `LIKE '%/rel/path.java'`. +- **Lines.** Java `type_refs` rows carry `line = 0` in every context but the two annotation ones: locate a Java + base through `type_use` or the type. A C# `BASE_LIST` line is where the base list is written, which is below + `types.start_line` when attributes or a line break come first: join by span, not by equal line. In a graph built + before the field-line fix, every Python field line is one early (0-based): a nested class's first field sits on + its `class Meta:` line. +- **Names.** Python `type_refs` and `decorations` keep only the last dotted segment; the full text is in + `ext_type_base_unresolved.c3` and `decorations.text`. String cells are CSV-escaped: match with `LIKE '%x%'`. +- **`ext_*` tables** have positional columns `c0…cN`; `SELECT description FROM schema_tables WHERE name = ''` + names them. diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index 3e981cbeb..e7da7e45b 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -95,6 +95,8 @@ and service loaders are invisible. Detail: `reference/changed-and-tests.md`. (with the unresolved sites that might connect them). Endpoints as written: `Owner.method`, `Type`, `file.ts:123`, `'new File'`, `'@GetMapping'`, `'*'`, or a bare word. A misspelt name stops with the close ones. Detail: `reference/path.md`. +A fact no verb prints (decorations, bases, entry points by reason, field writers): `reference/schema.md` names the table per language. + ## What it cannot see — say so instead of guessing Reflection, string dispatch, event buses; receivers the engine could not type; callbacks invoked by a library; diff --git a/skills/axiomcode/reference/schema.md b/skills/axiomcode/reference/schema.md new file mode 100644 index 000000000..6fd14df6f --- /dev/null +++ b/skills/axiomcode/reference/schema.md @@ -0,0 +1,102 @@ +# schema — which table holds X, per language + +Ask a verb first; open the graph only for a fact no verb prints. Every graph holds ONE language, and the same fact +lives in a different table per language. This page says where, for Python, Java and C#, and what is not recorded +at all, so you stop looking. Measured on a Django app, a Spring Boot app and an ASP.NET app, one fresh index each. + +## Which graph + +| | | +|---|---| +| the main language (most files) | `.axiomcode/out/graph.sqlite`, a symlink to `.axiomcode/out//graph.sqlite` | +| every other language | `.axiomcode/lang//out/graph.sqlite` | +| which one you opened | `sqlite3 -readonly "SELECT value FROM run WHERE key='language'"` | +| tested SQL, caveats, value meanings | the `schema_queries`, `schema_notes`, `schema_vocab` tables in the same file | + +Ids are opaque (`PY_METHOD_…`, `METHOD_REGISTRY_…`, `CS_PROPERTY_…`): join on them, never parse them. `symbols` +holds every declaration of every kind with `file`, `line`, `owner`, `is_test`; `symbols.id` is the id the other +tables use, and `symbols.method_id` / `type_id` join it to `methods` / `types`. + +## Fact by language + +| fact | Python | Java | C# | +|---|---|---|---| +| decoration / annotation / attribute | `decorations`; owner is a method or type | `decorations`; owner is a method, type, field or a **parameter** (`METHOD_PARAMETER_…`, joins nothing) | `decorations`; owner is a method, type or property (`CS_PROPERTY_…`) | +| its name and text | `name` = last dotted segment (`@admin.register(X)` → `register`); `text` = as written, args included | `name` as written after `@`; `text` with args, string quotes tripled (`"""/articles"""`) | `name` as written; `text` = `@Name` **only**, even for `[Endpoint(Name = "x")]`: the arguments are in `literals` at the same file:line | +| base types, resolved | `type_ancestors` (transitive) | `type_ancestors`, library bases included as `types.provenance='external'` | `type_ancestors` (transitive) | +| base types, library / unresolved | **not** in `type_ancestors`: `type_refs` `context='BASE_CLASS'` (last segment only, `Model`) and `ext_type_base_unresolved` (c1 = type id, c3 = text, `models.Model`) | as above; also `type_use` `context='SUPER_TYPE'` with `owner_type_id` | **not** in `type_ancestors`: `type_refs` `context='BASE_LIST'` (name without type args); `ext_type_base_unresolved` c3 = name, but c1 is a declaration group, not a `types.id` | +| entry points | `entry_points(method_id, reason)`: `url`, `orm_hook` seen; rules also emit `http`, `task`, `signal_receiver`, `fixture`, `di_provider`, `grpc_service`. **No** `test` or `main` reason | `test`, `http`, `bean_ctor`, `factory`, `main` seen; also `cli`, `queue`, `scheduled`, `lifecycle`, `spring_factories`; config keys in `ext_config_entry_point` | `test`, `http`, `orm_hook`, `framework_hook`, `main` seen; also `queue`, `grpc_service` | +| field declarations | `symbols` `kind='field'` (`PY_FIELD_…`, `owner` `Form` or `Form.Meta`); `fields` is **empty** | `fields` | `fields` = true fields only; a property is `symbols` `kind='field'` with a `CS_PROPERTY_…` id, and its accessors are `methods` `kind` `PROPERTY_GET` / `PROPERTY_SET` / `PROPERTY_INIT` (`get_X`, `set_X`) | +| who writes / reads a field | **not recorded**: `field_access` is empty; `refs` `ATTRIBUTE_ACCESS` / `FIELD` is every mention by name and line, read and write alike, with no field id | `field_access` (`access` read / write, `tier`, `caller_id`) | property: `call_edges` `kind` `property_write` / `property_read` to the accessor. Field: **not recorded** (`field_access` empty; `refs` `NAME_REFERENCE` / `FIELD` by name) | +| call edges | `call_edges`; tiers `known_edge`, `multi_inferred`, `boundary_lib`, `ambiguous_unknown`; kinds `METHOD_CALL`, `SELF_CALL`, `DECORATOR_*`, `PROPERTY_READ`, … | tiers add `ambiguous_anon`; kinds `method`, `new`, `anon_new`, `ctor_delegate`, `ref` | tiers add `known_builtin_operator`, `known_implicit_ctor`; kinds add `property_read`/`_write`, `operator`, `conversion`, `indexer`, `delegate` | +| why a call is unresolved | `ext_call_site_unresolved` (c0 site, c1 caller, c2 reason, c3 call kind) | `unresolved_sites` only, no reason | `ext_site_unresolved_named` (c0 site, c1 receiver type or ``, c2 name) | +| strings in source | `literals(value, file, line)` | `literals`; config keys: `ext_config_binding` (key, mechanism, target kind, target id, owner), `ext_config_class_ref` | `literals` | +| text outside the source (XML, YAML, SQL, …) | **not in the graph**: scanned per query, cached in `.axiomcode/out/dl/nonsource.sqlite` (`files(id, rel)`, `tok(tok, fid)`) | same | same | +| tests | `symbols.is_test` (by file path); no test entry point | `is_test` + `entry_points` `reason='test'` | `is_test` + `entry_points` `reason='test'` | +| test rungs (`[sound]`, `[fixture]`, `[at import]`, …) | **not stored**: computed per query | same | same | + +`field_access`, `type_use` and `type_instantiated` are empty in some languages (`type_use` in Python and C#, +`type_instantiated` in C#): run `SELECT count(*)` before reading an empty answer as "nothing". `overrides` is empty +in Python: its dispatch set is `dispatch_candidates` (basis `mro`). + +## The verb for each fact + +| fact | verb | +|---|---| +| methods carrying a decoration | `path '@login_required' '*'` (or `'@GetMapping'`): the decorated methods and what they reach | +| subtypes of a type | `impact `: "must change with it" | +| who writes a Java field | `impact .`: "produces or writes it" | +| who writes a C# property | `impact :`: answers through its accessors. `impact Type.Prop` and `Type._field` find nothing in C# on this build (the symbols carry no owner): fall back to the SQL below | +| a Python field | `impact .` lists readers `[in scope]` / `[by name]` only; there is no writer section, because no writer relation exists | +| text files naming a declaration | `impact X`: "bound from outside the source"; `context ""`: "text files that name these" | +| a config key or a quoted string | `impact app.cache.ttl` · `impact '"some-string"'` | +| test rungs and routes | `impact X --tests-only --why` · `test-impact --why` | +| entry points by reason | no verb: SQL below | + +## Queries + +```sh +G=.axiomcode/out/graph.sqlite +# [all] decorations, with the owner whatever its kind (a Java parameter's owner comes back NULL) +sqlite3 -readonly $G "SELECT d.text, s.kind, s.qualified_name, d.file, d.line FROM decorations d + LEFT JOIN symbols s ON s.id = d.owner_id WHERE d.name = 'GetMapping'" +# [all] entry points by reason, then one reason's methods +sqlite3 -readonly $G "SELECT reason, count(*) FROM entry_points GROUP BY 1" +sqlite3 -readonly $G "SELECT m.qualified_name, m.file_path, m.start_line FROM entry_points e + JOIN methods m ON m.id = e.method_id WHERE e.reason = 'http'" +# [all] resolved ancestors (Java: library ones too, provenance 'external') +sqlite3 -readonly $G "SELECT a.qualified_name, a.provenance FROM type_ancestors x JOIN types t ON t.id = x.type_id + JOIN types a ON a.id = x.ancestor_type_id WHERE t.name = ''" +# [python] library bases, full text as written +sqlite3 -readonly $G "SELECT t.qualified_name, u.c3 FROM ext_type_base_unresolved u JOIN types t ON t.id = u.c1" +# [csharp] library bases: the owner is the innermost type whose span holds the base-list line +sqlite3 -readonly $G "SELECT r.name, (SELECT t.qualified_name FROM types t WHERE t.file_path = r.file + AND r.line BETWEEN t.start_line AND t.end_line ORDER BY t.start_line DESC LIMIT 1) AS owner + FROM type_refs r WHERE r.context = 'BASE_LIST'" +# [java] field writers +sqlite3 -readonly $G "SELECT m.qualified_name, a.file_path, a.start_line, a.tier FROM field_access a + JOIN fields f ON f.id = a.field_id JOIN methods m ON m.id = a.caller_id + WHERE f.owner_qualified_name LIKE '%.' AND f.name = '' AND a.access = 'write'" +# [csharp] property writers (property_read for readers) +sqlite3 -readonly $G "SELECT c.qualified_name, s.file_path, s.start_line FROM call_edges e + JOIN methods t ON t.id = e.callee_method_id JOIN methods c ON c.id = e.caller_id + JOIN call_sites s ON s.id = e.call_site_id WHERE e.kind = 'property_write' AND t.name = 'set_'" +# [all] tiers in this graph; [python] what the unresolved sites are waiting on +sqlite3 -readonly $G "SELECT tier, count(*) FROM call_edges GROUP BY 1" +sqlite3 -readonly $G "SELECT c2, count(*) FROM ext_call_site_unresolved GROUP BY 1 ORDER BY 2 DESC" +``` + +## Traps + +- **Paths.** Java `methods`, `types`, `fields`, `call_sites` and `field_access` hold ABSOLUTE paths; its `symbols`, + `decorations` and `type_refs` hold repo-relative ones, as every Python and C# table does. Match Java with + `LIKE '%/rel/path.java'`. +- **Lines.** Java `type_refs` rows carry `line = 0` in every context but the two annotation ones: locate a Java + base through `type_use` or the type. A C# `BASE_LIST` line is where the base list is written, which is below + `types.start_line` when attributes or a line break come first: join by span, not by equal line. In a graph built + before the field-line fix, every Python field line is one early (0-based): a nested class's first field sits on + its `class Meta:` line. +- **Names.** Python `type_refs` and `decorations` keep only the last dotted segment; the full text is in + `ext_type_base_unresolved.c3` and `decorations.text`. String cells are CSV-escaped: match with `LIKE '%x%'`. +- **`ext_*` tables** have positional columns `c0…cN`; `SELECT description FROM schema_tables WHERE name = ''` + names them. From 829a3ca95d23010de367a3f715b27f3f6e1fd5f0 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:04:10 -0700 Subject: [PATCH 17/31] javascript: a call through a memoize/once wrapper returns what that site's argument returns (#695) A wrapper that returns a closure whose result is the call of the wrapper's parameter (memoize, once, lazy) now gives each wrapping call site a value of its own. A call through it takes its value from that site's argument only, instead of the union of every caller's argument, or nothing once the wrapper is over the parameter fan cap. Case 64: two memoized getters of different modules each reach their own module's member (were ambient_terminal); once/lazy getters resolve to one target instead of two. Controls (a closure returning its parameter, a closure calling its parameter but returning something else) are unchanged. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/resolution/value-flow.dl | 52 ++++++++++- graph/javascript/souffle/decls_all.dl | 6 ++ .../64-memoize-wrapper-result/src/alpha.js | 5 ++ .../64-memoize-wrapper-result/src/app.js | 37 ++++++++ .../64-memoize-wrapper-result/src/beta.js | 5 ++ .../64-memoize-wrapper-result/src/many.js | 10 +++ .../64-memoize-wrapper-result/src/memoize.js | 13 +++ .../64-memoize-wrapper-result/src/once.js | 26 ++++++ .../expected/64-memoize-wrapper-result.diag | 3 + .../expected/64-memoize-wrapper-result.edges | 86 +++++++++++++++++++ .../expected/64-memoize-wrapper-result.oracle | 38 ++++++++ 11 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 graph/test/javascript/cases/64-memoize-wrapper-result/src/alpha.js create mode 100644 graph/test/javascript/cases/64-memoize-wrapper-result/src/app.js create mode 100644 graph/test/javascript/cases/64-memoize-wrapper-result/src/beta.js create mode 100644 graph/test/javascript/cases/64-memoize-wrapper-result/src/many.js create mode 100644 graph/test/javascript/cases/64-memoize-wrapper-result/src/memoize.js create mode 100644 graph/test/javascript/cases/64-memoize-wrapper-result/src/once.js create mode 100644 graph/test/javascript/expected/64-memoize-wrapper-result.diag create mode 100644 graph/test/javascript/expected/64-memoize-wrapper-result.edges create mode 100644 graph/test/javascript/expected/64-memoize-wrapper-result.oracle diff --git a/graph/javascript/engine/resolution/value-flow.dl b/graph/javascript/engine/resolution/value-flow.dl index 5f8da8a6b..f953761b9 100644 --- a/graph/javascript/engine/resolution/value-flow.dl +++ b/graph/javascript/engine/resolution/value-flow.dl @@ -364,8 +364,58 @@ installed_function(val, m) :- expr_kind(_, "ASSIGNMENT", _, val), expr_child(_, // parser roots both under RETURN). The owner is the function returned FROM — the // parser attributes a nested function's body to the nested function, so no // filtering by nesting is needed. Plus `@returns {T}`. -return_value(m, k, i) :- expr_root(_, "RETURN", e), expr_owner(_, m, _, e), !expr_kind(_, "THIS", _, e), expr_value(e, k, i). +return_value(m, k, i) :- expr_root(_, "RETURN", e), expr_owner(_, m, _, e), !expr_kind(_, "THIS", _, e), + !wrapped_result_return(e), expr_value(e, k, i). return_value(m, "inst", t) :- method_return_type(_, _, tr, m), type_ref_type(tr, t). + +// ── a WRAPPER's closure returns what its argument returns, per call site (#695) ── +// `memoize(fn)`, `once(f)`, `lazy(make)`: the wrapper returns a closure, and the +// closure returns the result of calling the wrapper's parameter, directly +// (`() => make()`) or through a variable (`result = fn(); return result`). Read +// context-insensitively, the parameter holds every function any caller passed, so +// `getAlpha()` returned the union of every memoized getter's result (a superset), or, +// once the wrapper was over the fan cap, nothing at all. The site knows better: at +// `const getAlpha = memoize(() => require('./alpha'))`, a call of what that site +// returned returns what ITS argument returns. So the site is a value of its own, +// `("wrap", site)`, carried beside the closure's `("func", c)` wherever the closure +// goes, and a call through it takes its value from that site's argument alone. The +// closure's carrying `return` feeds no caller-merged value: every call of the +// closure reaches it through a site. Nothing here reads param_value, so a wrapper +// over the fan cap is answered as exactly as one with two callers. +// +// Syntactic, so it stratifies below the flow: the closure is the function a `return` +// of the wrapper introduces (or names through a const), and the carry is the call of +// the parameter through `=`, `||`, `??`, `&&`, `?:`, `(a, b)` and a local binding. +wrapper_closure(h, c) :- expr_root(_, "RETURN", e), expr_owner(_, h, _, e), expr_introduces(_, c, e). +wrapper_closure(h, c) :- expr_root(_, "RETURN", e), expr_owner(_, h, _, e), expr_binding(_, v, e), + var_init(_, _, init, v), expr_introduces(_, c, init). +param_call_kind("FUNCTION_CALL"). +param_call_kind("FUNCTION_CALL_CALL"). +param_call_kind("FUNCTION_CALL_APPLY"). +carries_param_call(pc, p) :- expr_kind(_, "CALL", _, pc), call_site(_, ck, _, _, _, _, pc, _, _), param_call_kind(ck), + expr_child(_, pc, "CALLEE", _, c), expr_param(_, p, c). +carries_param_call(e, p) :- expr_kind(_, "CONDITIONAL", _, e), expr_child(_, e, r, _, c), r != "CONDITION", carries_param_call(c, p). +carries_param_call(e, p) :- expr_kind(_, "BINARY", _, e), expr_operator(_, op, e), binary_op_passes_value(op), + expr_child(_, e, _, _, c), carries_param_call(c, p). +carries_param_call(e, p) :- expr_kind(_, "ASSIGNMENT", _, e), expr_child(_, e, "ASSIGNMENT_VALUE", _, c), carries_param_call(c, p). +carries_param_call(e, p) :- expr_kind(_, "SEQUENCE", _, e), sequence_last(e, c), carries_param_call(c, p). +carries_param_call(e, p) :- expr_binding(_, v, e), var_carries_param_call(v, p). +var_carries_param_call(v, p) :- var_init(_, _, init, v), carries_param_call(init, p). +var_carries_param_call(v, p) :- expr_kind(_, "ASSIGNMENT", _, a), expr_operator(_, op, a), assignment_op_writes_value(op), + expr_child(_, a, "ASSIGNMENT_TARGET", _, tgt), expr_binding(_, v, tgt), + expr_child(_, a, "ASSIGNMENT_VALUE", _, val), carries_param_call(val, p). +// wrapper_forwards(Wrapper, Closure, ArgPosition): the closure's result is the call of +// the wrapper's parameter at that position. +wrapper_forwards(h, c, pos) :- wrapper_closure(h, c), param_decl(_, _, ppos, h, p), !param_is_rest(_, p), + pos = to_number(ppos), expr_root(_, "RETURN", e), expr_owner(_, c, _, e), carries_param_call(e, p). +wrapped_result_return(e) :- wrapper_closure(h, c), param_decl(_, _, _, h, p), + expr_root(_, "RETURN", e), expr_owner(_, c, _, e), carries_param_call(e, p). +// The site's own value, and a call through it. +expr_value(s, "wrap", s) :- expr_kind(_, "CALL", _, s), expr_resolves_to_method(s, h), wrapper_forwards(h, _, _). +expr_value(ce, k, i) :- expr_kind(_, "CALL", _, ce), callee_value(ce, "wrap", s), + !call_site(_, "FUNCTION_CALL_BIND", _, _, _, _, ce, _, _), + expr_resolves_to_method(s, h), wrapper_forwards(h, _, pos), call_arg(s, pos, arg), + expr_value(arg, "func", g), return_value(g, k, i). // `return this` is POLYMORPHIC: a fluent `add()` declared on Node and called on an // AbstractBlock returns the AbstractBlock. So a `this`-return is not a value of the // method — it is the RECEIVER of each call, substituted at the site (below). A diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index db5004f54..e50495f56 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -301,6 +301,12 @@ .decl this_value(c0:symbol, c1:symbol, c2:symbol) .decl installed_function(c0:symbol, c1:symbol) .decl return_value(c0:symbol, c1:symbol, c2:symbol) +.decl wrapper_closure(c0:symbol, c1:symbol) +.decl param_call_kind(c0:symbol) +.decl carries_param_call(c0:symbol, c1:symbol) +.decl var_carries_param_call(c0:symbol, c1:symbol) +.decl wrapper_forwards(c0:symbol, c1:symbol, c2:number) +.decl wrapped_result_return(c0:symbol) .decl prop_value(c0:symbol, c1:symbol, c2:symbol, c3:symbol, c4:symbol) .decl member_write(c0:symbol, c1:symbol, c2:symbol, c3:symbol, c4:symbol) .decl prototype_object(c0:symbol, c1:symbol) diff --git a/graph/test/javascript/cases/64-memoize-wrapper-result/src/alpha.js b/graph/test/javascript/cases/64-memoize-wrapper-result/src/alpha.js new file mode 100644 index 000000000..17e7413ab --- /dev/null +++ b/graph/test/javascript/cases/64-memoize-wrapper-result/src/alpha.js @@ -0,0 +1,5 @@ +class AlphaPlugin { + static getHooks(c) { return c; } + run() { return 1; } +} +module.exports = AlphaPlugin; diff --git a/graph/test/javascript/cases/64-memoize-wrapper-result/src/app.js b/graph/test/javascript/cases/64-memoize-wrapper-result/src/app.js new file mode 100644 index 000000000..d2ddb319e --- /dev/null +++ b/graph/test/javascript/cases/64-memoize-wrapper-result/src/app.js @@ -0,0 +1,37 @@ +const memoize = require("./memoize"); +const { once, lazy, constant, tap, Gamma } = require("./once"); +const AlphaPlugin = require("./alpha"); +const BetaPlugin = require("./beta"); + +// Two memoized getters of different modules: each call reaches its own module only. +const getAlpha = memoize(() => require("./alpha")); +const getBeta = memoize(() => require("./beta")); + +function hooks(c) { + getAlpha().getHooks(c); + getBeta().getHooks(c); +} + +// `once` and `lazy` over instances. +const firstAlpha = once(() => new AlphaPlugin()); +const makeBeta = lazy(() => new BetaPlugin()); +const firstBeta = once(() => new BetaPlugin()); +const makeAlpha = lazy(() => new AlphaPlugin()); + +function runAll() { + firstAlpha().run(); + makeBeta().run(); + firstBeta().run(); + makeAlpha().run(); +} + +// CONTROLS: a closure that returns its parameter; one that returns something else. +const keepBeta = constant(new BetaPlugin()); +const tapped = tap(() => new AlphaPlugin()); + +function controls() { + keepBeta().run(); + tapped().render(); +} + +module.exports = { hooks, runAll, controls, Gamma }; diff --git a/graph/test/javascript/cases/64-memoize-wrapper-result/src/beta.js b/graph/test/javascript/cases/64-memoize-wrapper-result/src/beta.js new file mode 100644 index 000000000..ce97020d5 --- /dev/null +++ b/graph/test/javascript/cases/64-memoize-wrapper-result/src/beta.js @@ -0,0 +1,5 @@ +class BetaPlugin { + static getHooks(c) { return c; } + run() { return 2; } +} +module.exports = BetaPlugin; diff --git a/graph/test/javascript/cases/64-memoize-wrapper-result/src/many.js b/graph/test/javascript/cases/64-memoize-wrapper-result/src/many.js new file mode 100644 index 000000000..59efb9828 --- /dev/null +++ b/graph/test/javascript/cases/64-memoize-wrapper-result/src/many.js @@ -0,0 +1,10 @@ +// Enough memoized getters to put `memoize` over the parameter fan cap. +const memoize = require("./memoize"); +const g01 = memoize(() => 1); const g02 = memoize(() => 2); const g03 = memoize(() => 3); +const g04 = memoize(() => 4); const g05 = memoize(() => 5); const g06 = memoize(() => 6); +const g07 = memoize(() => 7); const g08 = memoize(() => 8); const g09 = memoize(() => 9); +const g10 = memoize(() => 10); const g11 = memoize(() => 11); const g12 = memoize(() => 12); +const g13 = memoize(() => 13); const g14 = memoize(() => 14); const g15 = memoize(() => 15); +const g16 = memoize(() => 16); const g17 = memoize(() => 17); const g18 = memoize(() => 18); +const g19 = memoize(() => 19); const g20 = memoize(() => 20); const g21 = memoize(() => 21); +module.exports = [g01, g02, g03, g04, g05, g06, g07, g08, g09, g10, g11, g12, g13, g14, g15, g16, g17, g18, g19, g20, g21]; diff --git a/graph/test/javascript/cases/64-memoize-wrapper-result/src/memoize.js b/graph/test/javascript/cases/64-memoize-wrapper-result/src/memoize.js new file mode 100644 index 000000000..51d42e509 --- /dev/null +++ b/graph/test/javascript/cases/64-memoize-wrapper-result/src/memoize.js @@ -0,0 +1,13 @@ +// A memoizing wrapper: its closure returns what calling its parameter returned. +const memoize = fn => { + let cache = false; + let result = undefined; + return () => { + if (cache) return result; + result = fn(); + cache = true; + fn = undefined; + return result; + }; +}; +module.exports = memoize; diff --git a/graph/test/javascript/cases/64-memoize-wrapper-result/src/once.js b/graph/test/javascript/cases/64-memoize-wrapper-result/src/once.js new file mode 100644 index 000000000..274878547 --- /dev/null +++ b/graph/test/javascript/cases/64-memoize-wrapper-result/src/once.js @@ -0,0 +1,26 @@ +// `once`: a function expression returned, the result held in a variable. +function once(f) { + let done = false; + let value; + return function () { + if (!done) { + done = true; + value = f(); + } + return value; + }; +} +// `lazy`: the closure returns the parameter's call directly. +function lazy(make) { + return () => make(); +} +// CONTROL: a closure that returns its parameter itself, not a call of it. +function constant(v) { + return () => v; +} +// CONTROL: a closure that calls its parameter but returns something else. +function tap(g) { + return () => { g(); return new Gamma(); }; +} +class Gamma { render() { return 1; } } +module.exports = { once, lazy, constant, tap, Gamma }; diff --git a/graph/test/javascript/expected/64-memoize-wrapper-result.diag b/graph/test/javascript/expected/64-memoize-wrapper-result.diag new file mode 100644 index 000000000..73bd4eb57 --- /dev/null +++ b/graph/test/javascript/expected/64-memoize-wrapper-result.diag @@ -0,0 +1,3 @@ +package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - +unresolved memoize.js:7:14 FUNCTION_CALL fn callee_untyped +unresolved memoize.js:7:14 FUNCTION_CALL fn param_fan_capped diff --git a/graph/test/javascript/expected/64-memoize-wrapper-result.edges b/graph/test/javascript/expected/64-memoize-wrapper-result.edges new file mode 100644 index 000000000..68101987a --- /dev/null +++ b/graph/test/javascript/expected/64-memoize-wrapper-result.edges @@ -0,0 +1,86 @@ +app.js:11:3 FUNCTION_CALL getAlpha -> known_edge memoize.js:5:10 +app.js:11:3 METHOD_CALL getAlpha().getHooks -> known_edge alpha.js:2:3 getHooks +app.js:12:3 FUNCTION_CALL getBeta -> known_edge memoize.js:5:10 +app.js:12:3 METHOD_CALL getBeta().getHooks -> known_edge beta.js:2:3 getHooks +app.js:16:20 FUNCTION_CALL once -> callback_registered app.js:16:25 +app.js:16:20 FUNCTION_CALL once -> known_edge once.js:2:1 once +app.js:16:31 CONSTRUCTOR_CALL AlphaPlugin -> implicit_constructor - +app.js:17:18 FUNCTION_CALL lazy -> callback_registered app.js:17:23 +app.js:17:18 FUNCTION_CALL lazy -> known_edge once.js:14:1 lazy +app.js:17:29 CONSTRUCTOR_CALL BetaPlugin -> implicit_constructor - +app.js:18:19 FUNCTION_CALL once -> callback_registered app.js:18:24 +app.js:18:19 FUNCTION_CALL once -> known_edge once.js:2:1 once +app.js:18:30 CONSTRUCTOR_CALL BetaPlugin -> implicit_constructor - +app.js:19:19 FUNCTION_CALL lazy -> callback_registered app.js:19:24 +app.js:19:19 FUNCTION_CALL lazy -> known_edge once.js:14:1 lazy +app.js:19:30 CONSTRUCTOR_CALL AlphaPlugin -> implicit_constructor - +app.js:22:3 FUNCTION_CALL firstAlpha -> known_edge once.js:5:10 +app.js:22:3 METHOD_CALL firstAlpha().run -> known_edge alpha.js:3:3 run +app.js:23:3 FUNCTION_CALL makeBeta -> known_edge once.js:15:10 +app.js:23:3 METHOD_CALL makeBeta().run -> known_edge beta.js:3:3 run +app.js:24:3 FUNCTION_CALL firstBeta -> known_edge once.js:5:10 +app.js:24:3 METHOD_CALL firstBeta().run -> known_edge beta.js:3:3 run +app.js:25:3 FUNCTION_CALL makeAlpha -> known_edge once.js:15:10 +app.js:25:3 METHOD_CALL makeAlpha().run -> known_edge alpha.js:3:3 run +app.js:29:18 FUNCTION_CALL constant -> known_edge once.js:18:1 constant +app.js:29:27 CONSTRUCTOR_CALL BetaPlugin -> implicit_constructor - +app.js:30:16 FUNCTION_CALL tap -> callback_registered app.js:30:20 +app.js:30:16 FUNCTION_CALL tap -> known_edge once.js:22:1 tap +app.js:30:26 CONSTRUCTOR_CALL AlphaPlugin -> implicit_constructor - +app.js:33:3 FUNCTION_CALL keepBeta -> known_edge once.js:19:10 +app.js:33:3 METHOD_CALL keepBeta().run -> known_edge beta.js:3:3 run +app.js:34:3 FUNCTION_CALL tapped -> known_edge once.js:23:10 +app.js:34:3 METHOD_CALL tapped().render -> known_edge once.js:25:15 render +app.js:7:18 FUNCTION_CALL memoize -> callback_registered app.js:7:26 +app.js:7:18 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +app.js:8:17 FUNCTION_CALL memoize -> callback_registered app.js:8:25 +app.js:8:17 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:3:13 FUNCTION_CALL memoize -> callback_registered many.js:3:21 +many.js:3:13 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:3:43 FUNCTION_CALL memoize -> callback_registered many.js:3:51 +many.js:3:43 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:3:73 FUNCTION_CALL memoize -> callback_registered many.js:3:81 +many.js:3:73 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:4:13 FUNCTION_CALL memoize -> callback_registered many.js:4:21 +many.js:4:13 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:4:43 FUNCTION_CALL memoize -> callback_registered many.js:4:51 +many.js:4:43 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:4:73 FUNCTION_CALL memoize -> callback_registered many.js:4:81 +many.js:4:73 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:5:13 FUNCTION_CALL memoize -> callback_registered many.js:5:21 +many.js:5:13 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:5:43 FUNCTION_CALL memoize -> callback_registered many.js:5:51 +many.js:5:43 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:5:73 FUNCTION_CALL memoize -> callback_registered many.js:5:81 +many.js:5:73 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:6:13 FUNCTION_CALL memoize -> callback_registered many.js:6:21 +many.js:6:13 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:6:44 FUNCTION_CALL memoize -> callback_registered many.js:6:52 +many.js:6:44 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:6:75 FUNCTION_CALL memoize -> callback_registered many.js:6:83 +many.js:6:75 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:7:13 FUNCTION_CALL memoize -> callback_registered many.js:7:21 +many.js:7:13 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:7:44 FUNCTION_CALL memoize -> callback_registered many.js:7:52 +many.js:7:44 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:7:75 FUNCTION_CALL memoize -> callback_registered many.js:7:83 +many.js:7:75 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:8:13 FUNCTION_CALL memoize -> callback_registered many.js:8:21 +many.js:8:13 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:8:44 FUNCTION_CALL memoize -> callback_registered many.js:8:52 +many.js:8:44 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:8:75 FUNCTION_CALL memoize -> callback_registered many.js:8:83 +many.js:8:75 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:9:13 FUNCTION_CALL memoize -> callback_registered many.js:9:21 +many.js:9:13 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:9:44 FUNCTION_CALL memoize -> callback_registered many.js:9:52 +many.js:9:44 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:9:75 FUNCTION_CALL memoize -> callback_registered many.js:9:83 +many.js:9:75 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +memoize.js:7:14 FUNCTION_CALL fn -> ambiguous_unknown - +once.js:15:16 FUNCTION_CALL make -> multi_inferred app.js:17:23 +once.js:15:16 FUNCTION_CALL make -> multi_inferred app.js:19:24 +once.js:23:18 FUNCTION_CALL g -> known_edge app.js:30:20 +once.js:23:30 CONSTRUCTOR_CALL Gamma -> implicit_constructor - +once.js:8:15 FUNCTION_CALL f -> multi_inferred app.js:16:25 +once.js:8:15 FUNCTION_CALL f -> multi_inferred app.js:18:24 diff --git a/graph/test/javascript/expected/64-memoize-wrapper-result.oracle b/graph/test/javascript/expected/64-memoize-wrapper-result.oracle new file mode 100644 index 000000000..c64a7851d --- /dev/null +++ b/graph/test/javascript/expected/64-memoize-wrapper-result.oracle @@ -0,0 +1,38 @@ +app.js:16:20 FUNCTION_CALL once EXACT once.js:2:1 +app.js:16:31 CONSTRUCTOR_CALL AlphaPlugin SYNTHESIZED_OK +app.js:17:18 FUNCTION_CALL lazy EXACT once.js:14:1 +app.js:17:29 CONSTRUCTOR_CALL BetaPlugin SYNTHESIZED_OK +app.js:18:19 FUNCTION_CALL once EXACT once.js:2:1 +app.js:18:30 CONSTRUCTOR_CALL BetaPlugin SYNTHESIZED_OK +app.js:19:19 FUNCTION_CALL lazy EXACT once.js:14:1 +app.js:19:30 CONSTRUCTOR_CALL AlphaPlugin SYNTHESIZED_OK +app.js:29:18 FUNCTION_CALL constant EXACT once.js:18:1 +app.js:29:27 CONSTRUCTOR_CALL BetaPlugin SYNTHESIZED_OK +app.js:30:16 FUNCTION_CALL tap EXACT once.js:22:1 +app.js:30:26 CONSTRUCTOR_CALL AlphaPlugin SYNTHESIZED_OK +app.js:34:3 METHOD_CALL render EXACT once.js:25:15 +app.js:7:18 FUNCTION_CALL memoize EXACT memoize.js:2:17 +app.js:8:17 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:3:13 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:3:43 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:3:73 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:4:13 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:4:43 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:4:73 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:5:13 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:5:43 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:5:73 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:6:13 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:6:44 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:6:75 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:7:13 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:7:44 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:7:75 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:8:13 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:8:44 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:8:75 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:9:13 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:9:44 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:9:75 FUNCTION_CALL memoize EXACT memoize.js:2:17 +once.js:23:30 CONSTRUCTOR_CALL Gamma SYNTHESIZED_OK +# defects: 0 From ec894e41b4b2c9c2d130fbca34c416f61bc44080 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:42:47 -0700 Subject: [PATCH 18/31] build: a stopped query or index no longer loses the main graph (#1555) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-language repository could be left with no .axiomcode/out/graph.sqlite after its first build: the other languages' graphs sat unpublished under .axiomcode/out/, every query said "no graph yet — building one", rebuilt every language, and lost the main graph again. The build a query starts ran in the query's process group. A caller that stops the query (a timeout, Ctrl-C, a host that ends the command's process group) stopped the build with it: before the main graph was published, or while it was being indexed, which the build took for a failure and answered by deleting the solved graph. On a repository whose first build outlasts the caller's timeout, every query repeated it. - ax_contract.ensure_graph starts the build in a session of its own, as the background refresher already is, so it finishes whatever becomes of the query that started it; a later query finds it running and waits for it. - axiomcode-build: a signal that arrives once the main language is solved is held until graph.sqlite points at that graph (the indexer runs in its own process group); the languages still unsolved are recorded in out/partial, so the graph reads as stale and the next build builds them. A signal before the main language is solved stops the build at once, as before. - A query waiting on a refresh no longer stops waiting when the main graph is published while the language that holds the edited file is still being solved: it answered from that language's previous graph. Tests: tests/multi_language.py gains the stopped-query and stopped-index checks (single-language and stopped-before-solve controls); its refresh and upgrade checks pass again. tests/refresh_races.py waits for the repair build before holding the lock and accepts the wait message #1555 introduced. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/ax_contract.py | 24 +++++- .../skills/axiomcode/scripts/ax_fresh.py | 36 ++++++-- .../skills/axiomcode/scripts/axiomcode-build | 36 ++++++-- tests/README.md | 3 +- tests/multi_language.py | 82 ++++++++++++++++++- tests/refresh_races.py | 8 +- 6 files changed, 169 insertions(+), 20 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py index f51fba1e2..d7d18c2ac 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py @@ -452,8 +452,7 @@ def ensure_graph(repo, db): if nowait: os.makedirs(os.path.join(repo, '.axiomcode'), exist_ok=True) log = open(os.path.join(repo, '.axiomcode', 'first-build.log'), 'w') - kw = dict(start_new_session=True) if os.name != 'nt' else dict(creationflags=0x00000008 | 0x00000200) # DETACHED | NEW_GROUP, as ax_fresh.kick - p = subprocess.Popen([bash, build, repo], stdin=subprocess.DEVNULL, stdout=log, stderr=log, close_fds=True, env=env, **kw) + p = _start_build([bash, build, repo], log, env) # until the build holds its lock a second query would see no build and start another, so that much is always waited up = time.time() + 10 while p.poll() is None and not ax_fresh.building(repo) and time.time() < up: time.sleep(0.05) @@ -469,7 +468,7 @@ def ensure_graph(repo, db): os.makedirs(os.path.join(repo, '.axiomcode'), exist_ok=True) logp = os.path.join(repo, '.axiomcode', 'first-build.log') with open(logp, 'w') as log: - p = subprocess.Popen([bash, build, repo], stdin=subprocess.DEVNULL, stdout=log, stderr=log, close_fds=True, env=env) + p = _start_build([bash, build, repo], log, env) shown = [0] def relay(): try: @@ -482,6 +481,25 @@ def relay(): return p.returncode == 0 and os.path.exists(db) +def _start_build(argv, log, env): + """the build a query starts, in a session (a process group) of its own, as ax_fresh.kick starts the refresher. + + THE BUILD IS NOT THE QUERY'S (#1555). It goes on solving the other languages after the query has its answer, and a + query is stopped all the time: a caller's timeout, Ctrl-C, an agent host that ends the command's process group when + it returns or times out. Started in the query's group, the build was stopped with it -- on a repository in several + languages, whose first build outlasts a two-minute timeout, before the main graph was published, or while it was + being indexed, which deleted the solved graph as a failure. No graph.sqlite was left, the next query built again + from nothing, was stopped again, and every query rebuilt forever. In its own session the build finishes whatever + becomes of the query; a later query finds it running and waits for it rather than starting another.""" + if os.name != 'nt': + return subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=log, stderr=log, close_fds=True, env=env, start_new_session=True) + # DETACHED | NEW_GROUP, and out of the caller's job object where the job allows it (see ax_fresh.kick) + for flags in (0x00000008 | 0x00000200 | 0x01000000, 0x00000008 | 0x00000200): + try: return subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=log, stderr=log, close_fds=True, env=env, creationflags=flags) + except OSError: continue + return subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=log, stderr=log, close_fds=True, env=env) + + def _published(repo): """the main graph is out while the build goes on solving the repository's other languages: the answer is given now, and names on stderr (a `graph refresh:` line, which the MCP server carries into the answer) what it cannot see yet""" diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index 2c09172de..120044190 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -378,6 +378,15 @@ def pending(repo): old = [l for l in langs if os.path.isfile(os.path.join(repo, '.axiomcode', 'lang', l, 'out', 'graph.sqlite'))] return dict(pending=langs, pending_old=old, first=all(len(r) > 1 and r[1] == 'first' for r in rows)) +def unbuilt(repo): + """the languages a build that was stopped after publishing the main graph left unbuilt (axiomcode-build writes them + to out/partial), [] when none""" + try: return open(os.path.join(out_dir(repo), 'partial')).read().split() + except OSError: return [] + +def unbuilt_row(repo): + return f".axiomcode/out/partial (the {', '.join(unbuilt(repo))} graph was not built: its build was stopped)" + def status(repo): if not has_graph(repo): return dict(state='no graph') if graph_broken(repo): return dict(state='building' if building(repo) else 'stale', changed=['.axiomcode/out/graph.sqlite (the pointer to the graph is broken)'], added=[], removed=[]) @@ -389,7 +398,8 @@ def status(repo): if not (changed or added or removed): # a build that already published this tree's main graph and is still solving other languages: the graph a query # reads is current, and waiting would be waiting for other languages' compiles - return dict(state='building', **pending(repo)) if busy else dict(state='fresh') + if busy: return dict(state='building', **pending(repo)) + return dict(state='stale', changed=[unbuilt_row(repo)], added=[], removed=[]) if unbuilt(repo) else dict(state='fresh') d = dict(state='building' if busy else 'stale', changed=changed, added=added, removed=removed) if not busy and st.get('failed_table') == change_key(c): d['failed'] = st.get('failed_log', ''); d['failed_reason'] = st.get('failed_reason', '') return d @@ -451,6 +461,7 @@ def worker(repo): else: c = changes(repo, t) if graph_broken(repo) and not (c and any(c)): c = [['.axiomcode/out/graph.sqlite (broken pointer)'], [], []] + elif unbuilt(repo) and not (c and any(c)): c = [[unbuilt_row(repo)], [], []] if (c is None or not any(c)) and not base_moved(repo): write_state(repo, state='fresh', checked=time.time(), checked_by=os.environ.get('AXIOMCODE_REFRESH_TRIGGER', '')); return 0 c = c or [[], [], []] @@ -718,13 +729,26 @@ def expected_left(repo): if st.get('state') == 'building' and st.get('started'): return secs - (time.time() - st['started']) return secs + float(os.environ.get('AXIOMCODE_REFRESH_DEBOUNCE') or 2) -def wait_fresh(repo, seconds, say=False): +def _waits_on(s, files): + """the refresh has published the main graph and is still solving languages (#1555) of which one holds an edited file + the wait is for (`files`; True: any language at all): the graph that answers for that edit is not out yet""" + langs = set(s.get('pending') or ()) + if not langs or not files: return False + if files is True: return True + import ax_langs + return any(l in langs for f in files for l in ax_langs.BY_EXT.get(os.path.splitext(f)[1].lower(), ())) + +def wait_fresh(repo, seconds, say=False, files=()): """wait until the graph matches the files again (the refresh swapped in its graph), a rebuild fails, or `seconds` - pass. With `say`, a progress line on stderr every 10 s: a wait is never silent. True when the graph is current""" + pass. With `say`, a progress line on stderr every 10 s: a wait is never silent. True when the graph is current. + THE GRAPH OF THE EDIT, NOT THE MAIN ONE. A refresh publishes the main language's graph first and goes on with the + others (#1555); from then on the file table matches, and a wait that ended there answered a question about a Python + edit in a TypeScript repository from the previous Python graph: `nothing named` for the function just added. So a + wait for `files` goes on while a language that holds one of them is still being solved (files=True: any language).""" end = t0 = time.time(); end += seconds; last = t0 while True: s = status(repo) - if s['state'] in ('fresh', 'no graph', 'unknown') or not edited(s): return True + if (s['state'] in ('fresh', 'no graph', 'unknown') or not edited(s)) and not _waits_on(s, files): return True if s.get('failed'): return False if s['state'] == 'stale': kick(repo, 'a query') now = time.time() @@ -768,7 +792,7 @@ def with_note(n): # the answer as it is, then left = expected_left(repo) print(f"waiting for the graph to refresh (--fresh): {len(edited(s))} file(s) changed since the graph was built" + (f", the last build took {int(build_seconds(repo)[0])} s" if left is not None else '') + " …", file=sys.stderr, flush=True) - if wait_fresh(repo, float(os.environ.get('AXIOMCODE_FRESH_MAX') or 600), say=True): passthrough() + if wait_fresh(repo, float(os.environ.get('AXIOMCODE_FRESH_MAX') or 600), say=True, files=True): passthrough() s = status(repo) stale = Stale(edited(s)); r = run() out = r.stdout.decode('utf-8', 'replace') @@ -782,7 +806,7 @@ def with_note(n): # the answer as it is, then print(f"waiting for the graph to refresh: this answer touches {', '.join(stale.files[:3])}" + (' …' if len(stale.files) > 3 else '') + f", edited since the graph was built; up to {int(budget)} s" + (f" (the last build took {int(build_seconds(repo)[0])} s)" if left is not None else '') + " …", file=sys.stderr, flush=True) - if wait_fresh(repo, budget, say=True): passthrough() + if wait_fresh(repo, budget, say=True, files=stale.files): passthrough() s = status(repo); stale = Stale(edited(s)) if edited(s): r = run(); out = r.stdout.decode('utf-8', 'replace') diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build index a4ee69daa..9d15fe1c7 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build @@ -242,7 +242,7 @@ LANGDIR="$REPO/.axiomcode/lang" # one graph directory per language other th # file the parser reads with its hash; when none differs the graph is current whatever git says: a dirty tree whose # edits are already indexed is not rebuilt, and a tree edited and restored, which git calls clean against a graph built # from the edit, is. A commit that changed no file moves the stamp, not the graph. -if [ -f "$OUT/graph.sqlite" ] && [ -f "$OUT/stamp" ] && python3 "$H/ax_fresh.py" uptodate "$REPO" "$LANGS" "${AXIOMCODE_SRC:-}" "${STAMP#"$PREFIX"}"; then +if [ -f "$OUT/graph.sqlite" ] && [ -f "$OUT/stamp" ] && [ ! -f "$OUT/partial" ] && python3 "$H/ax_fresh.py" uptodate "$REPO" "$LANGS" "${AXIOMCODE_SRC:-}" "${STAMP#"$PREFIX"}"; then if [ "$(cat "$OUT/stamp")" != "$STAMP" ]; then echo "$STAMP" > "$OUT/stamp"; INDEXED_TREE="$OLD_INDEXED"; set_base "$OLD_INDEXED"; PREV=""; keep_base_graph; commit_base; fi has_symbols "$OUT/graph.sqlite" || python3 "$H/axiomcode-index" "$REPO" [ -f "$REPO/.axiomcode/engine" ] || printf '%s\n' "$ENGINE" > "$REPO/.axiomcode/engine" || true @@ -353,7 +353,22 @@ unpend(){ grep -v "^$1 " "$PENDING" > "$PENDING.new" 2>/dev/null || true; mv -f EPID="" stop_engine(){ if [ -n "$EPID" ] && kill -0 "$EPID" 2>/dev/null; then kill -TERM -- "-$EPID" 2>/dev/null || kill -TERM "$EPID" 2>/dev/null || true; fi; rm -f "$PROGRESS" "$PENDING" "$PENDING.new"; } trap stop_engine EXIT -trap 'exit 130' INT; trap 'exit 143' TERM HUP +# A BUILD STOPPED ONCE ITS MAIN LANGUAGE IS SOLVED PUBLISHES THAT GRAPH FIRST. The solved main graph is most of what the +# build cost; stopped while it was being indexed (a caller's timeout ends the whole process group, the indexer included), +# the build took the indexer's death for a failure, deleted the solved graph and left no graph.sqlite, so the next query +# built everything again, was stopped again, and a repository in several languages rebuilt forever. A signal that +# arrives after the engine names the main language solved, or while it is published, is held until graph.sqlite points +# at it (and the indexer runs in a process group of its own, which a signal to the build's does not reach); the build +# then stops, the other languages unpublished. One that arrives earlier stops the build at once, as it always did. +SIG=""; SIGS=0; PUBLISHING=""; MAIN_DONE="" +on_signal(){ SIGS=$((SIGS + 1)); SIG="$1" + if [ -z "$MAIN_DONE" ] && { [ -n "$PUBLISHING" ] || grep -q "^$LANG_ARG ok" "$PROGRESS" 2>/dev/null; }; then return 0; fi + exit "$1"; } +trap 'on_signal 130' INT; trap 'on_signal 143' TERM HUP +# run a command in a process group of its own and wait for it; a held signal interrupts `wait`, not the command +shielded(){ local p rc n; set -m; "$@" & p=$!; set +m + while :; do n=$SIGS; rc=0; wait "$p" || rc=$?; [ "$SIGS" != "$n" ] && [ "$rc" -gt 128 ] && kill -0 "$p" 2>/dev/null && continue; break; done + return "$rc"; } # an engine older than this script has no --progress and refuses an option it does not know: it gets none, and its # graphs are published together when it ends, as they always were PROG=(); grep -q -- '--progress' "$ENGINE/bin/axiomcode" 2>/dev/null && PROG=(--progress "$PROGRESS") @@ -361,10 +376,11 @@ set -m "$ENGINE/bin/axiomcode" all "$SRC" "$OUT" --language "$SOLVE" ${PROG[@]+"${PROG[@]}"} ${LIB[@]+"${LIB[@]}"} < /dev/null > "$REPO/.axiomcode/build.log" 2>&1 & EPID=$! set +m -MAIN_DONE=""; QUEUED=(); READY=(); FAILED=(); DONE=" "; SEEN=0 +QUEUED=(); READY=(); FAILED=(); DONE=" "; SEEN=0 # THE MAIN LANGUAGE'S GRAPH BECOMES THE GRAPH. Everything a finished build did for it happens here, as soon as it is # solved: index, stamp, file table, baseline, pointer. publish_main(){ + PUBLISHING=1 # the new database: a FILE under $OUT/$LANG_ARG. Matched as `*/$LANG_ARG/*` it was any path with the language's name # in it, the repository's own included: a repository at .../python/app matched the graph.sqlite SYMLINK, the link was # pointed at itself, the previous graph deleted, and every verb after it reported no graph. @@ -374,7 +390,7 @@ publish_main(){ # A QUERY NEVER READS A HALF-BUILT GRAPH. The new database gets its symbols, refs and views BEFORE graph.sqlite points # at it; until the pointer moves, every verb (and the hooks) keeps answering from the previous graph. Only then is the # old one removed: a reader that opened it keeps the file it opened. - AXIOMCODE_INDEX_DB="$DB" python3 "$H/axiomcode-index" "$REPO" || { restore; echo "indexing the new graph failed — the graph that was there is untouched"; exit 1; } + shielded env AXIOMCODE_INDEX_DB="$DB" python3 "$H/axiomcode-index" "$REPO" || { restore; echo "indexing the new graph failed — the graph that was there is untouched"; exit 1; } stamp_meta "$DB" echo "$STAMP" > "$OUT/stamp" # the engine that built this graph: the next build, the background refresh above all, starts its search there (see the top) @@ -396,7 +412,13 @@ publish_main(){ done commit_base rm -rf "$PREV" - MAIN_DONE=1 + MAIN_DONE=1; PUBLISHING="" + # the languages still unsolved are named in $OUT/partial: the graph is not up to date while it is there, so the next + # build (a refresh the next query starts, or an index) builds them rather than calling the file table current + if [ -n "$SIG" ]; then + [ -s "$PENDING" ] && cut -d' ' -f1 "$PENDING" > "$OUT/partial" + echo "$LANG_ARG graph published at $OUT/graph.sqlite; stopped before the other languages were built"; exit "$SIG" + fi python3 - "$OUT/graph.sqlite" <<'PY' import sqlite3, sys rows = sqlite3.connect(sys.argv[1]).execute("SELECT tier, count(*) FROM call_edges GROUP BY tier ORDER BY 2 DESC").fetchall() @@ -445,7 +467,7 @@ while :; do running=1; kill -0 "$EPID" 2>/dev/null || running=0 drain [ "$running" = 1 ] || break - sleep 1 + sleep 1 || true # a held signal ends the sleep, not the build done ENGINE_RC=0; wait "$EPID" || ENGINE_RC=$?; EPID="" drain @@ -459,7 +481,7 @@ fi for l in ${OTHER_LANGS//,/ }; do case "$DONE" in *" $l "*) ;; *) publish_other "$l" ok;; esac done -rm -f "$PROGRESS" "$PENDING" +rm -f "$PROGRESS" "$PENDING" "$OUT/partial" if [ ${#FAILED[@]} -gt 0 ]; then for l in "${FAILED[@]}"; do if [ -f "$LANGDIR/$l/out/graph.sqlite" ]; then echo "⚠️ the $l graph did not build: $l questions are answered from its PREVIOUS graph, which predates this tree; see $REPO/.axiomcode/build.log" diff --git a/tests/README.md b/tests/README.md index e681a117f..49fdb76fc 100644 --- a/tests/README.md +++ b/tests/README.md @@ -61,7 +61,8 @@ One check needs no graph and is its own script: unelevated Windows user (WinError 1314); indexes a case, so it needs the engine python3 tests/multi_language.py a repository in several languages is indexed in all of them and every query asks each graph: nothing dropped, a one-graph answer unchanged, an edit reported once, - refreshed, upgraded from a one-language graph (builds real graphs, needs the engine) + refreshed, upgraded from a one-language graph; a first query or an index stopped + mid-build still leaves the main graph published (builds real graphs, needs the engine) python3 tests/latency.py what a query repeats on every call is done once, with the same answer: the non-source scan cached per graph, a repeated impact solve read back, one tree walk for every language, the verb and the Python found without starting programs, a first build that reports its diff --git a/tests/multi_language.py b/tests/multi_language.py index 7d4b743e5..773e85e20 100644 --- a/tests/multi_language.py +++ b/tests/multi_language.py @@ -20,14 +20,21 @@ control a repository in one language gets no .axiomcode/lang and no fan-out build output a Maven repository's target/ and a generated javadoc are not a JavaScript project (#1545); the same repository with JavaScript of its own, one file in a directory named target, still gets that graph + stopped a first query builds the graph, and the caller stops it mid-build (a timeout, Ctrl-C, a host that ends the + process group): the build is not the query's, it goes on and publishes every graph, and the next query + answers from them without building again. `axiomcode index` stopped after its main language is solved + publishes that graph before it stops, and the graph stays stale until the others are built. Controls: + one stopped before anything is solved stops at once and leaves no graph; a repository in one language + behaves the same python3 tests/multi_language.py [-v] """ -import json, os, shutil, subprocess, sys, tempfile, time +import fcntl, json, os, shutil, signal, subprocess, sys, tempfile, time ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) AX = os.path.join(ROOT, 'bin', 'axiomcode') FRESH = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'ax_fresh.py') +BUILD = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode-build') FILES = { 'tsconfig.json': '{ "compilerOptions": { "strict": true }, "include": ["src"] }\n', @@ -88,6 +95,37 @@ def settle(repo, env): time.sleep(0.2) +def building(repo): + """a build holds the repository's build lock""" + try: fd = os.open(os.path.join(repo, '.axiomcode', 'build.lock'), os.O_RDWR) + except OSError: return False + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB); return False + except OSError: return True + finally: os.close(fd) + + +def until(cond, seconds): + end = time.time() + seconds + while time.time() < end: + if cond(): return True + time.sleep(0.05) + return False + + +def stopped_query(repo, env, verb_args): + """a first query in its own process group, stopped by a signal to that group once its build holds the lock, as a + caller's timeout stops it; then the build (if it survived) is waited for. Returns whether the build was running when + the query was stopped""" + p = subprocess.Popen([AX] + verb_args, cwd=repo, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) + started = until(lambda: building(repo) or p.poll() is not None, 300) and p.poll() is None + try: os.killpg(p.pid, signal.SIGTERM) + except OSError: pass + p.wait(); time.sleep(0.5) + until(lambda: not building(repo), 900) + return started + + def main(argv): verbose = '-v' in argv fails = [] @@ -234,6 +272,48 @@ def check(ok, why, detail=''): st = json.loads(sh(one, sys.executable, FRESH, 'status', '.', '--json', env=quiet).stdout or '{}') check(st.get('state') == 'fresh', 'control: and it is fresh', json.dumps(st)) + # ── stopped ─────────────────────────────────────────────────────────────────────────────────────────── + kq = os.path.join(work, 'stopped'); make(kq, FILES) + ran = stopped_query(kq, quiet, ['impact', 'add', '.']) + main_db = os.path.join(kq, '.axiomcode', 'out', 'graph.sqlite') + langs = {l: os.path.exists(os.path.join(kq, '.axiomcode', 'lang', l, 'out', 'graph.sqlite')) for l in ('python', 'javascript')} + check(ran and os.path.exists(main_db) and all(langs.values()), + 'stopped: a first query stopped mid-build leaves the build running, and it publishes the main graph and every other one', + json.dumps(dict(build_was_running=ran, main=os.path.exists(main_db), **langs)) + '\n' + ' '.join(sorted(os.listdir(os.path.join(kq, '.axiomcode', 'out'))))) + q = sh(kq, AX, 'impact', 'add', '.', env=quiet) + check(q.returncode == 0 and '══ python graph' in q.stdout and 'building one' not in q.stderr and 'graph build is' not in q.stderr, + 'stopped: and the next query answers from those graphs, without building again', q.stdout[-600:] + q.stderr[-600:]) + # `axiomcode index` stopped once its main language is solved publishes that graph first + ki = os.path.join(work, 'stopped-index'); make(ki, FILES) + prog = os.path.join(ki, '.axiomcode', 'out', '.progress') + b = subprocess.Popen(['bash', BUILD, ki], cwd=ki, env=quiet, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, start_new_session=True) + solved = until(lambda: 'typescript ok' in (open(prog).read() if os.path.exists(prog) else ''), 600) + os.killpg(b.pid, signal.SIGTERM); out, _ = b.communicate() + ptr = os.path.join(ki, '.axiomcode', 'out', 'graph.sqlite') + check(solved and b.returncode != 0 and os.path.exists(ptr) and os.path.exists(os.path.join(ki, '.axiomcode', 'out', 'stamp')), + 'stopped: an index stopped after its main language is solved publishes that graph before it stops', f'rc={b.returncode} solved={solved}\n{out}') + q = sh(ki, AX, 'impact', 'square', '.', env=quiet) + check(q.returncode == 0 and 'area' in q.stdout and 'building one' not in q.stderr, + 'stopped: and a query answers from it without building', q.stdout[-600:] + q.stderr[-600:]) + st = json.loads(sh(ki, sys.executable, FRESH, 'status', '.', '--json', env=quiet).stdout or '{}') + r = sh(ki, AX, 'index', '.', env=quiet) + check(st.get('state') == 'stale' and 'up to date' not in r.stdout + and all(os.path.exists(os.path.join(ki, '.axiomcode', 'lang', l, 'out', 'graph.sqlite')) for l in ('python', 'javascript')), + 'stopped: the languages it did not build leave the graph stale, and the next build builds them', json.dumps(st) + '\n' + r.stdout[-600:]) + # control: stopped before anything is solved, it stops (no graph to keep) and the next query builds one + kc = os.path.join(work, 'stopped-early'); make(kc, FILES) + b = subprocess.Popen(['bash', BUILD, kc], cwd=kc, env=quiet, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, start_new_session=True) + until(lambda: building(kc), 60) + t0 = time.time(); os.killpg(b.pid, signal.SIGTERM); out, _ = b.communicate(); took = time.time() - t0 + check(b.returncode != 0 and took < 15 and not os.path.lexists(os.path.join(kc, '.axiomcode', 'out', 'graph.sqlite')), + f'control: an index stopped before its main language is solved stops at once ({took:.1f}s) and leaves no graph', f'rc={b.returncode}\n{out}') + # control: a repository in one language, its first query stopped mid-build, gets its graph the same way + k1 = os.path.join(work, 'stopped-one'); make(k1, {k: v for k, v in FILES.items() if k.startswith(('src/', 'tsconfig'))}) + ran = stopped_query(k1, quiet, ['impact', 'square', '.']) + q = sh(k1, AX, 'impact', 'square', '.', env=quiet) + check(ran and not os.path.exists(os.path.join(k1, '.axiomcode', 'lang')) and q.returncode == 0 and 'area' in q.stdout and 'building one' not in q.stderr, + 'stopped: in a repository of one language too, the stopped first query\'s build completes and the next query answers', q.stdout[-400:] + q.stderr[-400:]) + # ── build output ────────────────────────────────────────────────────────────────────────────────────── mvn = os.path.join(work, 'maven'); make(mvn, MAVEN) b = sh(mvn, AX, 'index', '.', env=quiet) diff --git a/tests/refresh_races.py b/tests/refresh_races.py index ea013f44e..38bc752e9 100644 --- a/tests/refresh_races.py +++ b/tests/refresh_races.py @@ -99,17 +99,21 @@ def check(ok, why, detail=''): # ── query mid-build ─────────────────────────────────────────────────────────────────────────────────── lock = os.path.join(repo, '.axiomcode', 'build.lock') + # the repair above answered as soon as its graph was published (#1555) and may still be solving: it ends first, + # or it re-points graph.sqlite under the lock this check holds + fd = os.open(lock, os.O_RDWR | os.O_CREAT, 0o644); fcntl.flock(fd, fcntl.LOCK_EX); fcntl.flock(fd, fcntl.LOCK_UN); os.close(fd) good = os.path.realpath(ptr); os.remove(ptr) held = threading.Event() def hold(): fd = os.open(lock, os.O_RDWR | os.O_CREAT, 0o644); fcntl.flock(fd, fcntl.LOCK_EX); held.set() - time.sleep(3); os.symlink(good, ptr); fcntl.flock(fd, fcntl.LOCK_UN); os.close(fd) + try: time.sleep(3); os.symlink(good, ptr) + finally: fcntl.flock(fd, fcntl.LOCK_UN); os.close(fd) # never left held: every later build would wait on it t = threading.Thread(target=hold); t.start(); held.wait() logm = os.path.getmtime(os.path.join(repo, '.axiomcode', 'build.log')) t0 = time.time(); r = sh(repo, AX, 'impact', 'helper', '.', env=dict(env, AXIOMCODE_NO_REFRESH='1')); took = time.time() - t0 t.join() - check(r.returncode == 0 and 'caller' in r.stdout and 'a graph build is running' in r.stderr, + check(r.returncode == 0 and 'caller' in r.stdout and ('a graph build is running' in r.stderr or 'a graph build is already running' in r.stderr), f'query mid-build: a query that finds no graph while a build runs waits for it, says so, and answers ({took:.1f}s)', r.stdout[-600:] + r.stderr[-600:]) check(os.path.getmtime(os.path.join(repo, '.axiomcode', 'build.log')) == logm, 'query mid-build: and starts no build of its own') From 366468de0e7afadb9c3ea25a0b52b10f35b7b47c Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:19:13 -0700 Subject: [PATCH 19/31] javascript: stop manufacturing wide value sets through string and typedef identities A property write through a `str` value or a JSDoc typedef `shape` landed on every other expression sharing that identity: a string is one value per text, a typedef one value per typedef. On a real bundler's source these writes were ~73K member_write rows and the source of its widest value sets (hundreds of parameters holding the same 552 values). - shared_identity_kind (ambient, str) guards every member-write rule (plain, computed key, keyed and enumerated copy, Object.assign-likes, defineProperty, defineProperties, setPrototypeOf); dropped writes are declared as member_write_refused(a, "shared_identity"). - A write through a shape keeps only func/ctor values (a handler stored on an untracked object is still callable through its typedef) and drops data stores. - keys_helper atoms reordered so each is bound by the previous one (it was a cross product of first params and loop heads, re-run every iteration). - path_value's two path-text regex tests hoisted into non-recursive helpers. New golden case 64-shared-identity-writes with controls: object literal and class-instance writes, typedef-declared members, and a handler written through a typedef all keep their edges. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/resolution/frameworks.dl | 15 +++-- .../engine/resolution/value-flow.dl | 67 +++++++++++++++---- graph/javascript/souffle/decls_all.dl | 4 ++ .../64-shared-identity-writes/src/main.js | 61 +++++++++++++++++ .../src/package.json | 1 + .../expected/64-shared-identity-writes.diag | 3 + .../expected/64-shared-identity-writes.edges | 14 ++++ .../expected/64-shared-identity-writes.oracle | 11 +++ 8 files changed, 155 insertions(+), 21 deletions(-) create mode 100644 graph/test/javascript/cases/64-shared-identity-writes/src/main.js create mode 100644 graph/test/javascript/cases/64-shared-identity-writes/src/package.json create mode 100644 graph/test/javascript/expected/64-shared-identity-writes.diag create mode 100644 graph/test/javascript/expected/64-shared-identity-writes.edges create mode 100644 graph/test/javascript/expected/64-shared-identity-writes.oracle diff --git a/graph/javascript/engine/resolution/frameworks.dl b/graph/javascript/engine/resolution/frameworks.dl index 4612145ac..dc932573e 100644 --- a/graph/javascript/engine/resolution/frameworks.dl +++ b/graph/javascript/engine/resolution/frameworks.dl @@ -29,8 +29,8 @@ assign_like_site(ce, tgt, src) :- expr_kind(_, "CALL", _, ce), call_site(_, "MET assign_like_site(ce, tgt, src) :- expr_kind(_, "CALL", _, ce), callee_value(ce, "func", m), model_assign_like_function(m), call_arg(ce, 0, tgt), call_arg(ce, 1, src). model_assign_like_function(m) :- module_export_value(mod, "default", "func", m), module_package("lib", "merge-descriptors", mod). -member_write(k, i, n, k2, i2) :- assign_like_site(_, tgt, src), expr_value(tgt, k, i), k != "ambient", - expr_value(src, k0, i0), prop_value(k0, i0, n, k2, i2). +member_write(k, i, n, k2, i2) :- assign_like_site(_, tgt, src), expr_value(tgt, k, i), !shared_identity_kind(k), + expr_value(src, k0, i0), prop_value(k0, i0, n, k2, i2), (k != "shape" ; shape_stores_kind(k2)). // The call evaluates to its target. expr_value(ce, k, i) :- assign_like_site(ce, tgt, _), expr_value(tgt, k, i). @@ -49,7 +49,7 @@ object_create_site(ce, src) :- expr_kind(_, "CALL", _, ce), call_site(_, "METHOD expr_child(_, ce, "RECEIVER", _, recv), expr_value(recv, "ambient", "Object"), call_arg(ce, 0, src). expr_value(ce, "obj", ce) :- object_create_site(ce, _). -inherits_from(k, i, k0, i0) :- inherit_like_site(_, tgt, src), expr_value(tgt, k, i), k != "ambient", expr_value(src, k0, i0). +inherits_from(k, i, k0, i0) :- inherit_like_site(_, tgt, src), expr_value(tgt, k, i), !shared_identity_kind(k), expr_value(src, k0, i0). inherits_from("obj", ce, k0, i0) :- object_create_site(ce, src), expr_value(src, k0, i0). prop_value(k, i, n, k2, i2) :- inherits_from(k, i, k0, i0), prop_value(k0, i0, n, k2, i2). // `Object.setPrototypeOf(Child.prototype, Parent.prototype)` — the spelling of @@ -164,8 +164,8 @@ descriptor_value(d, k, i) :- literal_owns_method(d, g), method_decl(_, "get", _, define_property_site(ce, tgt, key, desc) :- expr_kind(_, "CALL", _, ce), call_site(_, "METHOD_CALL", "defineProperty", _, _, _, ce, _, _), expr_child(_, ce, "RECEIVER", _, recv), expr_value(recv, "ambient", "Object"), call_arg(ce, 0, tgt), call_arg(ce, 1, key), call_arg(ce, 2, desc). -member_write(k, i, n, k2, i2) :- define_property_site(_, tgt, key, desc), expr_value(tgt, k, i), k != "ambient", - expr_value(key, "str", n), descriptor_value(desc, k2, i2). +member_write(k, i, n, k2, i2) :- define_property_site(_, tgt, key, desc), expr_value(tgt, k, i), !shared_identity_kind(k), + expr_value(key, "str", n), descriptor_value(desc, k2, i2), (k != "shape" ; shape_stores_kind(k2)). // `Object.defineProperties(o, { n: desc, … })` and `Object.create(proto, { n: desc })`. descriptors_literal_entry(lit, n, desc) :- expr_child(_, lit, "PROPERTY_KEY", idx, key), expr_name(_, n, key), expr_child(_, lit, "PROPERTY_VALUE", idx, desc). @@ -173,8 +173,9 @@ descriptors_literal_entry(lit, n, desc) :- expr_child(_, lit, "COMPUTED_KEY", id expr_child(_, lit, "PROPERTY_VALUE", idx, desc). member_write(k, i, n, k2, i2) :- expr_kind(_, "CALL", _, ce), call_site(_, "METHOD_CALL", "defineProperties", _, _, _, ce, _, _), expr_child(_, ce, "RECEIVER", _, recv), expr_value(recv, "ambient", "Object"), - call_arg(ce, 0, tgt), expr_value(tgt, k, i), k != "ambient", - call_arg(ce, 1, lit), descriptors_literal_entry(lit, n, desc), descriptor_value(desc, k2, i2). + call_arg(ce, 0, tgt), expr_value(tgt, k, i), !shared_identity_kind(k), + call_arg(ce, 1, lit), descriptors_literal_entry(lit, n, desc), descriptor_value(desc, k2, i2), + (k != "shape" ; shape_stores_kind(k2)). member_write("obj", ce, n, k2, i2) :- object_create_site(ce, _), call_arg(ce, 1, lit), descriptors_literal_entry(lit, n, desc), descriptor_value(desc, k2, i2). // The call evaluates to its target (`Object.defineProperty(o, …)` returns o). diff --git a/graph/javascript/engine/resolution/value-flow.dl b/graph/javascript/engine/resolution/value-flow.dl index 5f8da8a6b..ba31f8a86 100644 --- a/graph/javascript/engine/resolution/value-flow.dl +++ b/graph/javascript/engine/resolution/value-flow.dl @@ -232,9 +232,14 @@ path_source(k, i) :- var_binding_path(_, _, v), var_init(_, _, e, v), expr_value path_source(k, i) :- expr_param(_, p, e), expr_binding_path(_, _, e), param_value(p, k, i). path_source("module", mod) :- import_decl(_, _, _, "DESTRUCTURED", n, _, _, _, imp), contains(".", n), import_module(imp, mod). path_source(k, i) :- path_step(_, _, _, k, i). -path_value(k, i, p, k2, i2) :- path_rest_base(p, b), path_parent(b, par), match("[0-9]+", substr(b, strlen(par) + 1, strlen(b) - strlen(par) - 1)), - path_value(k, i, par, k2, i2). -path_value(k, i, p, k2, i2) :- path_rest_base(p, b), b != "", !match("([^.]*[.])*[0-9]+", b), path_value(k, i, b, k2, i2). +path_value(k, i, p, k2, i2) :- path_rest_index_parent(p, par), path_value(k, i, par, k2, i2). +path_value(k, i, p, k2, i2) :- path_rest_named_base(p, b), path_value(k, i, b, k2, i2). +// The two tests above are on the path TEXT only, so they are decided once here, below +// the flow, instead of by a regex on every pass of the recursion (#732: the second +// clause was 29 s of a 200 s solve on a real bundler's source when it matched inline). +path_rest_index_parent(p, par) :- path_rest_base(p, b), path_parent(b, par), + match("[0-9]+", substr(b, strlen(par) + 1, strlen(b) - strlen(par) - 1)). +path_rest_named_base(p, b) :- path_rest_base(p, b), b != "", !match("([^.]*[.])*[0-9]+", b). path_parent(b, substr(b, 0, n)) :- path_rest_base(_, b), contains(".", b), n = range(1, strlen(b) - 1), substr(b, n, 1) = ".", !contains(".", substr(b, n + 1, strlen(b) - n - 1)). binding_path(par) :- path_parent(_, par). @@ -499,14 +504,36 @@ expr_value(e, "free", cat(mod, ":", n)) :- expr_kind(_, "IDENTIFIER", _, e), // every `@param {Object} opts` in the program, so a write through any of them would // be a read through all of them — a global merge point that made one held-out // solve 35x slower. The platform's properties are not tracked. +// +// The same holds for `str` (#732): a string value is identified by its TEXT, so +// `"request"` is one value wherever the literal is written, and a write through a +// binding that may hold it landed on every other binding that may hold the same text. +// At runtime a property write onto a primitive is dropped (or throws in strict code), +// so nothing real is lost. On a real bundler's own source these were 37K member_write +// rows, one of the points where its widest value sets were manufactured. +// +// A `shape` is a JSDoc `@typedef {Object}` REFERENCE, also one identity per typedef: +// `@param {StatsModule} m; m.moduleName = x` wrote x into every StatsModule in the +// program. On that bundler these were 36K member_write rows onto 66 typedefs, and +// they are where its widest value sets were made: 513 parameters of 620 unrelated +// functions all held the same 552 values. But where the object behind a typedef is +// not tracked, the shape is the only carrier of a HANDLER stored on it: +// `context.cachedGetErrors = c => …`, later `cachedGetErrors(compilation)`, was 9 +// real edges on that bundler. So a write through a shape keeps what a call needs, a +// function or a class (shape_stores_kind), and drops the data stores that made the +// width. Both tests are on kinds, so the rules stay monotone. +shared_identity_kind("ambient"). +shared_identity_kind("str"). +shape_stores_kind("func"). +shape_stores_kind("ctor"). member_write(k, i, n, k2, i2) :- expr_kind(_, "ASSIGNMENT", _, a), expr_operator(_, op, a), assignment_op_writes_value(op), expr_child(_, a, "ASSIGNMENT_TARGET", _, tgt), expr_kind(_, "PROPERTY_ACCESS", _, tgt), expr_name(_, n, tgt), - expr_child(_, tgt, "ACCESS_TARGET", _, r), expr_value(r, k, i), k != "ambient", - expr_child(_, a, "ASSIGNMENT_VALUE", _, val), expr_value(val, k2, i2). + expr_child(_, tgt, "ACCESS_TARGET", _, r), expr_value(r, k, i), !shared_identity_kind(k), + expr_child(_, a, "ASSIGNMENT_VALUE", _, val), expr_value(val, k2, i2), (k != "shape" ; shape_stores_kind(k2)). member_write(k, i, n, k2, i2) :- computed_key_write(a, tgt, key, val), !keyed_copy_write(a, _, _), !generic_setter_write(a), key_name(key, n), - expr_child(_, tgt, "ACCESS_TARGET", _, r), expr_value(r, k, i), k != "ambient", k != "arr", k != "coll", - expr_value(val, k2, i2). + expr_child(_, tgt, "ACCESS_TARGET", _, r), expr_value(r, k, i), !shared_identity_kind(k), k != "arr", k != "coll", + expr_value(val, k2, i2), (k != "shape" ; shape_stores_kind(k2)). computed_key_write(a, tgt, key, val) :- expr_kind(_, "ASSIGNMENT", _, a), expr_operator(_, op, a), assignment_op_writes_value(op), expr_child(_, a, "ASSIGNMENT_TARGET", _, tgt), expr_kind(_, "ELEMENT_ACCESS", _, tgt), expr_child(_, tgt, "COMPUTED_KEY", _, key), expr_child(_, a, "ASSIGNMENT_VALUE", _, val). @@ -539,10 +566,15 @@ same_key_binding(k1, k2) :- expr_binding(_, v, k1), expr_binding(_, v, k2). generic_setter_write(a) :- computed_key_write(a, tgt, key, _), expr_child(_, tgt, "ACCESS_TARGET", _, r), expr_param(_, _, r), expr_param(_, _, key), !keyed_copy_write(a, _, _). member_write_refused(a, "generic_setter") :- generic_setter_write(a). +// A write whose target may hold a string (above): declared, so the +// dropped store is countable. `ambient` targets were never tracked and are not listed. +member_write_refused(a, "shared_identity") :- expr_kind(_, "ASSIGNMENT", _, a), expr_operator(_, op, a), assignment_op_writes_value(op), + expr_child(_, a, "ASSIGNMENT_TARGET", _, tgt), expr_child(_, tgt, "ACCESS_TARGET", _, r), + expr_value(r, k, _), shared_identity_kind(k), k != "ambient". member_write(k, i, n, k2, i2) :- keyed_copy_write(a, key, src), computed_key_write(a, tgt, key, _), key_name(key, n), - expr_child(_, tgt, "ACCESS_TARGET", _, r), expr_value(r, k, i), k != "ambient", k != "arr", k != "coll", - expr_value(src, k0, i0), prop_value(k0, i0, n, k2, i2). + expr_child(_, tgt, "ACCESS_TARGET", _, r), expr_value(r, k, i), !shared_identity_kind(k), k != "arr", k != "coll", + expr_value(src, k0, i0), prop_value(k0, i0, n, k2, i2), (k != "shape" ; shape_stores_kind(k2)). // A copy whose keys are ENUMERATED FROM THE SOURCE (#707): `for (const k in src) // dst[k] = src[k]`, `for (const k of Object.keys(src))`, `Object.keys(src).forEach(k // => { dst[k] = src[k]; })`, the same through `Object.getOwnPropertyNames`, an alias @@ -558,8 +590,9 @@ enumerated_copy(a, src) :- keyed_copy_write(a, key, src), key_enumerates(key2, o same_subject(e1, e2) :- same_key_binding(e1, e2). same_subject(e1, e2) :- expr_kind(_, "THIS", _, e1), expr_kind(_, "THIS", _, e2), expr_owner(_, m, _, e1), expr_owner(_, m, _, e2). member_write(k, i, n, k2, i2) :- enumerated_copy(a, src), computed_key_write(a, tgt, _, _), - expr_child(_, tgt, "ACCESS_TARGET", _, r), expr_value(r, k, i), k != "ambient", k != "arr", k != "coll", - expr_value(src, k0, i0), prop_value(k0, i0, n, k2, i2), n != "constructor", n != "prototype". + expr_child(_, tgt, "ACCESS_TARGET", _, r), expr_value(r, k, i), !shared_identity_kind(k), k != "arr", k != "coll", + expr_value(src, k0, i0), prop_value(k0, i0, n, k2, i2), n != "constructor", n != "prototype", + (k != "shape" ; shape_stores_kind(k2)). // key_enumerates(KeyExpr, Source): a reference to a binding that enumerates Source's // own property names. key_enumerates(key, src) :- loop_head(v, src), for_in_iterable(src), expr_binding(_, v, key). @@ -590,9 +623,15 @@ object_keys_call(ce, o) :- expr_kind(_, "CALL", _, ce), expr_resolves_to_method( // keys_helper(Method): `function keys(o) { const a = []; for (const k in o) a.push(k); // return a; }`, or `function keys(o) { return Object.keys(o); }`: returns an array of // its first parameter's property names. -keys_helper(m) :- param_decl(_, _, "0", m, p), loop_head(v, e), for_in_iterable(e), expr_param(_, p, e), - expr_binding(_, v, kref), array_method_call(pce, "push", a), call_arg(pce, _, kref), return_value(m, "arr", a). -keys_helper(m) :- param_decl(_, _, "0", m, p), object_keys_call(ce, e), expr_param(_, p, e), return_value(m, "arr", ce). +// ATOM ORDER MATTERS (#732): Souffle joins in the order written, and these rules sit in +// the value-flow recursion, so they are re-run every iteration. Written with +// `param_decl` first they began with the product of every first parameter and every +// loop head (no shared variable), and on a real bundler's source that product was the +// most expensive clause of the solve: 76 s of 200, deriving nothing. Each atom below +// is bound by the one before it. +keys_helper(m) :- loop_head(v, e), for_in_iterable(e), expr_param(_, p, e), param_decl(_, _, "0", m, p), + expr_binding(_, v, kref), call_arg(pce, _, kref), array_method_call(pce, "push", a), return_value(m, "arr", a). +keys_helper(m) :- object_keys_call(ce, e), expr_param(_, p, e), param_decl(_, _, "0", m, p), return_value(m, "arr", ce). // `Object.assign` and its library cousins: resolution/frameworks.dl. // ── literal_owns_method(LiteralExpr, MethodHash) — PD-JS-1 workaround ─────── diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index db5004f54..c763533b1 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -314,6 +314,10 @@ .decl keys_array_expr(c0:symbol, c1:symbol) .decl object_keys_call(c0:symbol, c1:symbol) .decl keys_helper(c0:symbol) +.decl shared_identity_kind(c0:symbol) +.decl shape_stores_kind(c0:symbol) +.decl path_rest_index_parent(c0:symbol, c1:symbol) +.decl path_rest_named_base(c0:symbol, c1:symbol) .decl generic_setter_write(c0:symbol) .decl member_write_refused(c0:symbol, c1:symbol) .decl literal_contains_method(c0:symbol, c1:symbol) diff --git a/graph/test/javascript/cases/64-shared-identity-writes/src/main.js b/graph/test/javascript/cases/64-shared-identity-writes/src/main.js new file mode 100644 index 000000000..bcbf2d910 --- /dev/null +++ b/graph/test/javascript/cases/64-shared-identity-writes/src/main.js @@ -0,0 +1,61 @@ +// A write through a string is not a store (#732). A string is one identity per TEXT, +// so a write through one binding holding 'k' landed on every other expression holding +// 'k'; at runtime a property written onto a primitive is dropped. +function alpha() { return 'a'; } +function beta() { return 'b'; } + +const key = 'k'; +key.fn = beta; +function viaString() { return 'k'.fn(); } // nothing: a string has no such member +/** @param {string} s */ +function tagString(s) { s.fn = alpha; } +tagString('k'); +function viaStringParam() { return 'k'.fn(); } // nothing either + +// A JSDoc typedef is one identity for every value documented with it, so a DATA store +// through it wrote into every other value of that typedef. The objects that really +// flow in keep the store; the typedef does not. +class Left { go() { return 'l'; } } +class Right { go() { return 'r'; } } +/** + * @typedef {Object} Entry + * @property {string} name + */ +/** @param {Entry} e */ +function putLeft(e) { e.side = new Left(); } +/** @param {Entry} e */ +function putRight(e) { e.side = new Right(); } +/** @param {Entry} e */ +function goEntry(e) { return e.side.go(); } // Left.go only: what THIS caller's object holds +const first = { name: 'first' }; +putLeft(first); +function viaTypedef() { return goEntry(first); } +const second = { name: 'second' }; +putRight(second); + +// CONTROL: where the object behind a typedef is not tracked, the typedef is the only +// carrier of a handler stored on it, so a FUNCTION written through it still reads back. +/** + * @typedef {Object} Hooks + * @property {string} name + */ +/** @param {Hooks} hooks */ +function install(hooks) { hooks.onError = alpha; } +/** @param {Hooks} hooks */ +function report(hooks) { return hooks.onError(); } // alpha, through the typedef + +// The controls. An object literal and a class instance keep their writes, and a +// typedef's DECLARED members still read. +class Widget { render() { return 'w'; } } +/** + * @typedef {Object} Ctx + * @property {Widget} widget + */ +/** @param {Ctx} c */ +function draw(c) { return c.widget.render(); } // Widget.render, from the declaration +const holder = {}; +holder.run = alpha; +function viaLiteral() { return holder.run(); } // alpha +class Box { constructor() { this.run = beta; } } +function viaInstance() { return new Box().run(); } // beta +module.exports = { viaString, viaStringParam, viaTypedef, install, report, draw, viaLiteral, viaInstance }; diff --git a/graph/test/javascript/cases/64-shared-identity-writes/src/package.json b/graph/test/javascript/cases/64-shared-identity-writes/src/package.json new file mode 100644 index 000000000..4bd47bd99 --- /dev/null +++ b/graph/test/javascript/cases/64-shared-identity-writes/src/package.json @@ -0,0 +1 @@ +{ "name": "64-shared-identity-writes" } diff --git a/graph/test/javascript/expected/64-shared-identity-writes.diag b/graph/test/javascript/expected/64-shared-identity-writes.diag new file mode 100644 index 000000000..d62615a70 --- /dev/null +++ b/graph/test/javascript/expected/64-shared-identity-writes.diag @@ -0,0 +1,3 @@ +package_entry 64-shared-identity-writes . [] DEFAULT_INDEX index.js MISSING_FILE -> - +unresolved main.js:13:36 METHOD_CALL fn no_target +unresolved main.js:9:31 METHOD_CALL fn no_target diff --git a/graph/test/javascript/expected/64-shared-identity-writes.edges b/graph/test/javascript/expected/64-shared-identity-writes.edges new file mode 100644 index 000000000..602892c4e --- /dev/null +++ b/graph/test/javascript/expected/64-shared-identity-writes.edges @@ -0,0 +1,14 @@ +main.js:12:1 FUNCTION_CALL tagString -> known_edge main.js:11:1 tagString +main.js:13:36 METHOD_CALL 'k'.fn -> ambient_terminal - +main.js:25:32 CONSTRUCTOR_CALL Left -> implicit_constructor - +main.js:27:33 CONSTRUCTOR_CALL Right -> implicit_constructor - +main.js:29:30 METHOD_CALL e.side.go -> known_edge main.js:18:14 go +main.js:31:1 FUNCTION_CALL putLeft -> known_edge main.js:25:1 putLeft +main.js:32:32 FUNCTION_CALL goEntry -> known_edge main.js:29:1 goEntry +main.js:34:1 FUNCTION_CALL putRight -> known_edge main.js:27:1 putRight +main.js:45:33 METHOD_CALL hooks.onError -> known_edge main.js:4:1 alpha +main.js:55:27 METHOD_CALL c.widget.render -> known_edge main.js:49:16 render +main.js:58:32 METHOD_CALL holder.run -> known_edge main.js:4:1 alpha +main.js:60:33 CONSTRUCTOR_CALL Box -> known_edge main.js:59:13 +main.js:60:33 METHOD_CALL new Box().run -> known_edge main.js:5:1 beta +main.js:9:31 METHOD_CALL 'k'.fn -> ambient_terminal - diff --git a/graph/test/javascript/expected/64-shared-identity-writes.oracle b/graph/test/javascript/expected/64-shared-identity-writes.oracle new file mode 100644 index 000000000..7d7d51e48 --- /dev/null +++ b/graph/test/javascript/expected/64-shared-identity-writes.oracle @@ -0,0 +1,11 @@ +main.js:12:1 FUNCTION_CALL tagString EXACT main.js:11:1 +main.js:25:32 CONSTRUCTOR_CALL Left SYNTHESIZED_OK +main.js:27:33 CONSTRUCTOR_CALL Right SYNTHESIZED_OK +main.js:31:1 FUNCTION_CALL putLeft EXACT main.js:25:1 +main.js:32:32 FUNCTION_CALL goEntry EXACT main.js:29:1 +main.js:34:1 FUNCTION_CALL putRight EXACT main.js:27:1 +main.js:55:27 METHOD_CALL render EXACT main.js:49:16 +main.js:58:32 METHOD_CALL run EXACT main.js:4:1 +main.js:60:33 CONSTRUCTOR_CALL Box EXACT main.js:59:13 +main.js:60:33 METHOD_CALL run EXACT main.js:5:1 +# defects: 0 From b9653a997bd0fe168bb05d9d0f03ca1a45d1573c Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:38:48 -0700 Subject: [PATCH 20/31] javascript: a write through a typedef keeps what is later called or iterated A store through a JSDoc typedef dropped every value that was not a function or a class, so `ctx.logger = new Logger()`, `ctx.api = { fetch() {} }`, `ctx.util = require('./util')` and `ctx.list = [a, b]` no longer reached the calls made through the same typedef elsewhere (`ctx.logger.log()`, `for (const f of ctx.list) f()`), which had resolved before. Instances, object literals, module namespaces, arrays and collections are kept now; string, symbol and other scalar stores stay dropped, which is where the width came from. Golden case 64 gains the module and iterated-array shapes and a string-store control that must stay unresolved. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/resolution/value-flow.dl | 16 ++++++++-- .../64-shared-identity-writes/src/main.js | 22 ++++++++++++-- .../64-shared-identity-writes/src/util.js | 3 ++ .../expected/64-shared-identity-writes.diag | 1 + .../expected/64-shared-identity-writes.edges | 30 ++++++++++++------- .../expected/64-shared-identity-writes.oracle | 19 ++++++------ 6 files changed, 65 insertions(+), 26 deletions(-) create mode 100644 graph/test/javascript/cases/64-shared-identity-writes/src/util.js diff --git a/graph/javascript/engine/resolution/value-flow.dl b/graph/javascript/engine/resolution/value-flow.dl index ba31f8a86..4047dc8eb 100644 --- a/graph/javascript/engine/resolution/value-flow.dl +++ b/graph/javascript/engine/resolution/value-flow.dl @@ -519,13 +519,23 @@ expr_value(e, "free", cat(mod, ":", n)) :- expr_kind(_, "IDENTIFIER", _, e), // functions all held the same 552 values. But where the object behind a typedef is // not tracked, the shape is the only carrier of a HANDLER stored on it: // `context.cachedGetErrors = c => …`, later `cachedGetErrors(compilation)`, was 9 -// real edges on that bundler. So a write through a shape keeps what a call needs, a -// function or a class (shape_stores_kind), and drops the data stores that made the -// width. Both tests are on kinds, so the rules stay monotone. +// real edges on that bundler. So a write through a shape keeps every value a call +// can later be made on or through (shape_stores_kind): a function or a class; an +// instance, object literal or module namespace whose members are called +// (`ctx.logger = new Logger()`, then `ctx.logger.log()`, where nothing but the +// typedef carries ctx); and an array or collection of handlers that is iterated +// (`ctx.list = [a, b]; for (const f of ctx.list) f()`). It drops the string, +// symbol and other scalar stores, which were the width. Both tests are on kinds, +// so the rules stay monotone. shared_identity_kind("ambient"). shared_identity_kind("str"). shape_stores_kind("func"). shape_stores_kind("ctor"). +shape_stores_kind("inst"). +shape_stores_kind("obj"). +shape_stores_kind("module"). +shape_stores_kind("arr"). +shape_stores_kind("coll"). member_write(k, i, n, k2, i2) :- expr_kind(_, "ASSIGNMENT", _, a), expr_operator(_, op, a), assignment_op_writes_value(op), expr_child(_, a, "ASSIGNMENT_TARGET", _, tgt), expr_kind(_, "PROPERTY_ACCESS", _, tgt), expr_name(_, n, tgt), expr_child(_, tgt, "ACCESS_TARGET", _, r), expr_value(r, k, i), !shared_identity_kind(k), diff --git a/graph/test/javascript/cases/64-shared-identity-writes/src/main.js b/graph/test/javascript/cases/64-shared-identity-writes/src/main.js index bcbf2d910..dda2eda64 100644 --- a/graph/test/javascript/cases/64-shared-identity-writes/src/main.js +++ b/graph/test/javascript/cases/64-shared-identity-writes/src/main.js @@ -14,7 +14,8 @@ function viaStringParam() { return 'k'.fn(); } // nothing either // A JSDoc typedef is one identity for every value documented with it, so a DATA store // through it wrote into every other value of that typedef. The objects that really -// flow in keep the store; the typedef does not. +// flow in keep the store; the typedef keeps only what a call can be made on (a +// function, a class, an instance, an object literal), so both sides reach goEntry. class Left { go() { return 'l'; } } class Right { go() { return 'r'; } } /** @@ -26,7 +27,7 @@ function putLeft(e) { e.side = new Left(); } /** @param {Entry} e */ function putRight(e) { e.side = new Right(); } /** @param {Entry} e */ -function goEntry(e) { return e.side.go(); } // Left.go only: what THIS caller's object holds +function goEntry(e) { return e.side.go(); } // Left.go, and Right.go through the typedef const first = { name: 'first' }; putLeft(first); function viaTypedef() { return goEntry(first); } @@ -43,6 +44,20 @@ putRight(second); function install(hooks) { hooks.onError = alpha; } /** @param {Hooks} hooks */ function report(hooks) { return hooks.onError(); } // alpha, through the typedef +// The same holds for an instance or an object literal whose methods are called later, +// a module namespace, and an array of handlers that is iterated. +class Logger { log() { return 'g'; } } +const util = require('./util'); +/** @param {Hooks} hooks */ +function installLogger(hooks) { hooks.logger = new Logger(); hooks.api = { fetch() { return 'f'; } }; } +/** @param {Hooks} hooks */ +function useLogger(hooks) { hooks.logger.log(); return hooks.api.fetch(); } // Logger.log, fetch +/** @param {Hooks} hooks */ +function installMore(hooks) { hooks.util = util; hooks.list = [alpha, beta]; hooks.label = 'x'; } +/** @param {Hooks} hooks */ +function useMore(hooks) { hooks.util.helper(); for (const f of hooks.list) f(); } // helper, alpha, beta +/** @param {Hooks} hooks */ +function useLabel(hooks) { return hooks.label.toUpperCase(); } // nothing: a string store through a typedef is dropped // The controls. An object literal and a class instance keep their writes, and a // typedef's DECLARED members still read. @@ -58,4 +73,5 @@ holder.run = alpha; function viaLiteral() { return holder.run(); } // alpha class Box { constructor() { this.run = beta; } } function viaInstance() { return new Box().run(); } // beta -module.exports = { viaString, viaStringParam, viaTypedef, install, report, draw, viaLiteral, viaInstance }; +module.exports = { viaString, viaStringParam, viaTypedef, install, report, installLogger, useLogger, installMore, useMore, + useLabel, draw, viaLiteral, viaInstance }; diff --git a/graph/test/javascript/cases/64-shared-identity-writes/src/util.js b/graph/test/javascript/cases/64-shared-identity-writes/src/util.js new file mode 100644 index 000000000..b216b5fd0 --- /dev/null +++ b/graph/test/javascript/cases/64-shared-identity-writes/src/util.js @@ -0,0 +1,3 @@ +'use strict'; +function helper() { return 'h'; } +module.exports = { helper }; diff --git a/graph/test/javascript/expected/64-shared-identity-writes.diag b/graph/test/javascript/expected/64-shared-identity-writes.diag index d62615a70..52cc811b1 100644 --- a/graph/test/javascript/expected/64-shared-identity-writes.diag +++ b/graph/test/javascript/expected/64-shared-identity-writes.diag @@ -1,3 +1,4 @@ package_entry 64-shared-identity-writes . [] DEFAULT_INDEX index.js MISSING_FILE -> - unresolved main.js:13:36 METHOD_CALL fn no_target +unresolved main.js:60:35 METHOD_CALL toUpperCase receiver_untyped unresolved main.js:9:31 METHOD_CALL fn no_target diff --git a/graph/test/javascript/expected/64-shared-identity-writes.edges b/graph/test/javascript/expected/64-shared-identity-writes.edges index 602892c4e..88d6f96a1 100644 --- a/graph/test/javascript/expected/64-shared-identity-writes.edges +++ b/graph/test/javascript/expected/64-shared-identity-writes.edges @@ -1,14 +1,22 @@ main.js:12:1 FUNCTION_CALL tagString -> known_edge main.js:11:1 tagString main.js:13:36 METHOD_CALL 'k'.fn -> ambient_terminal - -main.js:25:32 CONSTRUCTOR_CALL Left -> implicit_constructor - -main.js:27:33 CONSTRUCTOR_CALL Right -> implicit_constructor - -main.js:29:30 METHOD_CALL e.side.go -> known_edge main.js:18:14 go -main.js:31:1 FUNCTION_CALL putLeft -> known_edge main.js:25:1 putLeft -main.js:32:32 FUNCTION_CALL goEntry -> known_edge main.js:29:1 goEntry -main.js:34:1 FUNCTION_CALL putRight -> known_edge main.js:27:1 putRight -main.js:45:33 METHOD_CALL hooks.onError -> known_edge main.js:4:1 alpha -main.js:55:27 METHOD_CALL c.widget.render -> known_edge main.js:49:16 render -main.js:58:32 METHOD_CALL holder.run -> known_edge main.js:4:1 alpha -main.js:60:33 CONSTRUCTOR_CALL Box -> known_edge main.js:59:13 -main.js:60:33 METHOD_CALL new Box().run -> known_edge main.js:5:1 beta +main.js:26:32 CONSTRUCTOR_CALL Left -> implicit_constructor - +main.js:28:33 CONSTRUCTOR_CALL Right -> implicit_constructor - +main.js:30:30 METHOD_CALL e.side.go -> multi_inferred main.js:19:14 go +main.js:30:30 METHOD_CALL e.side.go -> multi_inferred main.js:20:15 go +main.js:32:1 FUNCTION_CALL putLeft -> known_edge main.js:26:1 putLeft +main.js:33:32 FUNCTION_CALL goEntry -> known_edge main.js:30:1 goEntry +main.js:35:1 FUNCTION_CALL putRight -> known_edge main.js:28:1 putRight +main.js:46:33 METHOD_CALL hooks.onError -> known_edge main.js:4:1 alpha +main.js:52:48 CONSTRUCTOR_CALL Logger -> implicit_constructor - +main.js:54:29 METHOD_CALL hooks.logger.log -> known_edge main.js:49:16 log +main.js:54:56 METHOD_CALL hooks.api.fetch -> known_edge main.js:52:76 fetch +main.js:58:27 METHOD_CALL hooks.util.helper -> known_edge util.js:2:1 helper +main.js:58:76 FUNCTION_CALL f -> multi_inferred main.js:4:1 alpha +main.js:58:76 FUNCTION_CALL f -> multi_inferred main.js:5:1 beta +main.js:60:35 METHOD_CALL hooks.label.toUpperCase -> ambiguous_unknown - +main.js:70:27 METHOD_CALL c.widget.render -> known_edge main.js:64:16 render +main.js:73:32 METHOD_CALL holder.run -> known_edge main.js:4:1 alpha +main.js:75:33 CONSTRUCTOR_CALL Box -> known_edge main.js:74:13 +main.js:75:33 METHOD_CALL new Box().run -> known_edge main.js:5:1 beta main.js:9:31 METHOD_CALL 'k'.fn -> ambient_terminal - diff --git a/graph/test/javascript/expected/64-shared-identity-writes.oracle b/graph/test/javascript/expected/64-shared-identity-writes.oracle index 7d7d51e48..fa1e22836 100644 --- a/graph/test/javascript/expected/64-shared-identity-writes.oracle +++ b/graph/test/javascript/expected/64-shared-identity-writes.oracle @@ -1,11 +1,12 @@ main.js:12:1 FUNCTION_CALL tagString EXACT main.js:11:1 -main.js:25:32 CONSTRUCTOR_CALL Left SYNTHESIZED_OK -main.js:27:33 CONSTRUCTOR_CALL Right SYNTHESIZED_OK -main.js:31:1 FUNCTION_CALL putLeft EXACT main.js:25:1 -main.js:32:32 FUNCTION_CALL goEntry EXACT main.js:29:1 -main.js:34:1 FUNCTION_CALL putRight EXACT main.js:27:1 -main.js:55:27 METHOD_CALL render EXACT main.js:49:16 -main.js:58:32 METHOD_CALL run EXACT main.js:4:1 -main.js:60:33 CONSTRUCTOR_CALL Box EXACT main.js:59:13 -main.js:60:33 METHOD_CALL run EXACT main.js:5:1 +main.js:26:32 CONSTRUCTOR_CALL Left SYNTHESIZED_OK +main.js:28:33 CONSTRUCTOR_CALL Right SYNTHESIZED_OK +main.js:32:1 FUNCTION_CALL putLeft EXACT main.js:26:1 +main.js:33:32 FUNCTION_CALL goEntry EXACT main.js:30:1 +main.js:35:1 FUNCTION_CALL putRight EXACT main.js:28:1 +main.js:52:48 CONSTRUCTOR_CALL Logger SYNTHESIZED_OK +main.js:70:27 METHOD_CALL render EXACT main.js:64:16 +main.js:73:32 METHOD_CALL run EXACT main.js:4:1 +main.js:75:33 CONSTRUCTOR_CALL Box EXACT main.js:74:13 +main.js:75:33 METHOD_CALL run EXACT main.js:5:1 # defects: 0 From 125e2293080dc8149bcaaf696162cf561119430d Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:40:53 -0700 Subject: [PATCH 21/31] ts: a call through an annotation the parser cannot read stays unresolved (#536) tsc resolves a call through a variable's ANNOTATION. Where that annotation needs the checker -- an indexed access through a generic alias such as `StoreApi['setState']`, a named interface, an alias to an alias -- the parser fell through to the arrow initialiser and claimed it with `overloadCandidateCount = 1`, where tsc names one arm of an overload pair. The row is now left unresolved (count 0) instead. Unannotated arrows and annotations the parser can read (a function type, a type literal, one alias hop) resolve exactly as before, including the all-optional signature set that keeps the initialiser with the full candidate count. On a real state-management library: 9 call sites move from arrow/1 to unresolved, exactly the 9 that disagreed with getResolvedSignature; engine edges 4492 -> 4492. Goldens unchanged. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> Co-Authored-By: Claude --- .../extractors/ts-resolution-linker.ts | 8 ++ parser/src/test/typescript-tests.ts | 78 +++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/parser/src/parsers/typescript/extractors/ts-resolution-linker.ts b/parser/src/parsers/typescript/extractors/ts-resolution-linker.ts index f08089ccd..e7bd514dc 100644 --- a/parser/src/parsers/typescript/extractors/ts-resolution-linker.ts +++ b/parser/src/parsers/typescript/extractors/ts-resolution-linker.ts @@ -544,6 +544,14 @@ export class TsLocalResolver { if (binding.kind !== TsBoundKind.VariableDeclaration) { return; } + // An annotation this pass cannot read -- an indexed access such as + // `StoreApi['setState']`, a named interface, an alias to an alias -- + // still decides the call for tsc, and it may declare an overload set. The + // initialiser is not what `getResolvedSignature` names, and `1` would be a + // count nobody measured (#536). Left unresolved for the engine. + if (declared.length === 0 && (binding.node as { type?: ts.TypeNode }).type) { + return; + } const variableHash = this.input.variableHashByNode.get(nodeId(binding.node, this.sf)); const variable = variableHash ? this.variableByHash.get(variableHash) : undefined; // THE arrow-function path, for an UNANNOTATED binding: `const f = () => {}; diff --git a/parser/src/test/typescript-tests.ts b/parser/src/test/typescript-tests.ts index 2e7ca86aa..1cb810987 100644 --- a/parser/src/test/typescript-tests.ts +++ b/parser/src/test/typescript-tests.ts @@ -2944,6 +2944,83 @@ async function annotationCanDeclareASignatureSet(): Promise { } } +/** + * An annotation the parser cannot read is not a licence to name the initialiser (#536). + * + * tsc resolves a call through a variable's ANNOTATION. Where the annotation needs + * the checker -- an indexed access through a generic alias, a named interface -- + * the parser fell through to the arrow initialiser and claimed it with + * `overloadCandidateCount = 1`, where tsc sees an overload pair. The row now stays + * unresolved, which the engine measures instead of trusting. + */ +async function uncheckableAnnotationLeavesCallUnresolved(): Promise { + if (!parserPresent()) { + return pendingCheck('an uncheckable annotation leaves the call unresolved', + 'no extractor yet. An annotation may need the checker'); + } + const { outputDir, cleanup } = await analyseInline('ts-uncheckable-', { + 'store.ts': [ + 'type SetStateInternal = {', // 1 + ' _(partial: T | Partial, replace?: false): void;', // 2 + ' _(state: T, replace: true): void;', // 3 + "}['_'];", // 4 + 'export interface StoreApi { setState: SetStateInternal }', // 5 + ].join('\n'), + 'a.ts': [ + "import type { StoreApi } from './store';", // 1 + 'interface Twice { (a: string): void; (a: number): void }', // 2 + 'export function wire(state: S): void {', // 3 + " const fromTools: StoreApi['setState'] = (..._a: unknown[]): void => {};", // 4 + ' fromTools(state);', // 5 -> needs the checker + ' const twice: Twice = (_a: string | number): void => {};', // 6 + " twice('x');", // 7 -> needs the checker + ' const bare = (_a: string): void => {};', // 8 + " bare('x');", // 9 -> control: the arrow + ' const typed: (a: string) => void = (_a: string): void => {};', // 10 + " typed('x');", // 11 -> control: the annotation + '}', + ].join('\n'), + }); + try { + const methodLine = new Map(relation(outputDir, 'all-typescript-methods.csv') + .map((r) => [r['tsMethodUniqueHash'] ?? '', r['startLine'] ?? ''])); + const calls = relation(outputDir, 'all-typescript-call-sites.csv'); + const failures: string[] = []; + const expect = (line: string, targetLine: string, count: string, kind: string): void => { + const row = calls.find((r) => r['startLine'] === line); + if (row === undefined) { + failures.push(`no call site at line ${line}`); + return; + } + const got = methodLine.get(row['resolvedSignatureLinkHash'] ?? '') ?? ''; + if (got !== targetLine) { + failures.push(`call at line ${line} resolved to a method at line ${got}, ` + + `expected ${targetLine}`); + } + if ((row['overloadCandidateCount'] ?? '') !== count) { + failures.push(`call at line ${line} reports overloadCandidateCount ` + + `${row['overloadCandidateCount']}, expected ${count}`); + } + if ((row['resolvedTargetKind'] ?? '') !== kind) { + failures.push(`call at line ${line} reports resolvedTargetKind ` + + `${row['resolvedTargetKind']}, expected ${kind}`); + } + }; + // tsc names an arm of the annotation; the parser cannot, so it names nothing. + expect('5', '', '0', 'UNRESOLVED'); + expect('7', '', '0', 'UNRESOLVED'); + // Controls: an unannotated arrow and a function-type annotation still resolve. + expect('9', '8', '1', 'PROJECT_IMPLEMENTATION'); + expect('11', '10', '1', 'PROJECT_SIGNATURE'); + console.log(' 4 call(s): two through an annotation that needs the checker left unresolved, ' + + 'an unannotated arrow and a function-type annotation unchanged'); + for (const f of failures.slice(0, 8)) { console.log(` ${f}`); } + return failures.length ? 1 : 0; + } finally { + cleanup(); + } +} + /** * An object literal's property KEY reaches the IR (#111). @@ -4586,6 +4663,7 @@ const CHECKS: Check[] = [ { name: 'a decorator is descended once', proves: 'a callable inside a decorator argument is one ts_method, not two colliding on one key as a false overload set', run: decoratorDescendedOnce }, { name: 'declaration extensions are whole extensions', proves: '`.d.cts` and `.d.mts` are single extensions, so no stem keeps a stray `.d`', run: declarationExtensionsAreWholeExtensions }, { name: 'an annotation can declare a signature set', proves: 'a type literal holding several call signatures is resolved as the set it is, and arity picks the arm tsc picks', run: annotationCanDeclareASignatureSet }, + { name: 'an uncheckable annotation leaves the call unresolved', proves: 'a call through a variable whose annotation needs the checker names neither the initialiser nor a single candidate', run: uncheckableAnnotationLeavesCallUnresolved }, { name: 'object-literal keys reach the IR', proves: 'a property key is emitted as its own row, joinable to its value, never bound as a scope reference, and absent when computed', run: objectLiteralKeysReachTheIr }, { name: 'empty imports record their module edge', proves: 'an import that binds nothing still emits a resolved row, so a package imported only for its ambient declarations can be staged', run: emptyImportsRecordTheirModuleEdge }, { name: 'reopened type members share a group key', proves: 'a member of a declaration-merged type has one identity across files, so two signatures of it are overload siblings rather than a wrong answer', run: reopenedTypeMembersShareAGroupKey }, From 820749a43a09b7d7b3de4ced99320255f95ade14 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:10:57 -0700 Subject: [PATCH 22/31] hooks: one source table for every hook, the directive once per session and specific, test-impact runs concrete test classes - _where.py holds the one table of source extensions and test paths (SOURCE_EXT, SOURCE_ALT, TEST, is_source, is_test); changes.py, enrich.py, direct.py, orient.py and _graphline.py read it instead of their own regexes. The edit hooks' lists had no .cs, so a C# edit got no graph line. An extensionless script with a Python shebang counts as source, as the index reads it. - direct.py: the once-per-session stamp is keyed on the session (temp directory), not on (session, repository). The block is 5 lines instead of 11, and when the Grep, shell grep or Read names something the graph declares, the first line names the verb and target that answer it. - test-impact: command_for replaces an abstract Java or C# test class with the concrete classes that extend it (type_ancestors; abstract read from the declaration). mvn -Dtest= runs 0 tests. The classes that inherit a reached test are named in the command, as the ownerless note already claimed. The body-edit hook line uses the same function. --- plugins/axiomcode/hooks/_graphline.py | 27 ++-- plugins/axiomcode/hooks/_where.py | 28 +++++ plugins/axiomcode/hooks/changes.py | 6 +- plugins/axiomcode/hooks/direct.py | 115 +++++++++++++++--- plugins/axiomcode/hooks/enrich.py | 4 +- plugins/axiomcode/hooks/orient.py | 4 +- .../axiomcode/scripts/axiomcode-test-impact | 78 +++++++++++- tests/README.md | 6 +- .../java/abstract-test-base-command/case.json | 10 ++ .../abstract-test-base-command/load-new.java | 11 ++ .../abstract-test-base-command/load-old.java | 11 ++ .../abstract-test-base-command/save-new.java | 11 ++ .../abstract-test-base-command/save-old.java | 11 ++ .../src/pkg/AbstractRepoContractTest.java | 12 ++ .../src/pkg/CachedLoadTest.java | 4 + .../src/pkg/JdbcRepoTest.java | 8 ++ .../src/pkg/LoadTest.java | 10 ++ .../src/pkg/MemoryRepoTest.java | 8 ++ .../src/pkg/Repo.java | 11 ++ tests/directive.py | 43 ++++++- tests/hook_languages.py | 112 +++++++++++++++++ tests/hooks_from_path.py | 3 + tests/hosts.py | 3 + tests/mcp_first.py | 3 + 24 files changed, 501 insertions(+), 38 deletions(-) create mode 100644 tests/cases/java/abstract-test-base-command/case.json create mode 100644 tests/cases/java/abstract-test-base-command/load-new.java create mode 100644 tests/cases/java/abstract-test-base-command/load-old.java create mode 100644 tests/cases/java/abstract-test-base-command/save-new.java create mode 100644 tests/cases/java/abstract-test-base-command/save-old.java create mode 100644 tests/cases/java/abstract-test-base-command/src/pkg/AbstractRepoContractTest.java create mode 100644 tests/cases/java/abstract-test-base-command/src/pkg/CachedLoadTest.java create mode 100644 tests/cases/java/abstract-test-base-command/src/pkg/JdbcRepoTest.java create mode 100644 tests/cases/java/abstract-test-base-command/src/pkg/LoadTest.java create mode 100644 tests/cases/java/abstract-test-base-command/src/pkg/MemoryRepoTest.java create mode 100644 tests/cases/java/abstract-test-base-command/src/pkg/Repo.java create mode 100644 tests/hook_languages.py diff --git a/plugins/axiomcode/hooks/_graphline.py b/plugins/axiomcode/hooks/_graphline.py index f5b58f072..ea164afe2 100644 --- a/plugins/axiomcode/hooks/_graphline.py +++ b/plugins/axiomcode/hooks/_graphline.py @@ -88,23 +88,34 @@ def distinct_paths(files): _TI = None -def _command_for(lang, files, classes): - """the runnable command `axiomcode test-impact` prints, from the same function""" +def _ti(): + """axiomcode-test-impact as a module, loaded once; None when it cannot be""" global _TI if _TI is None: p = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts', 'axiomcode-test-impact') try: ld = importlib.machinery.SourceFileLoader('ax_test_impact', p) m = importlib.util.module_from_spec(importlib.util.spec_from_loader('ax_test_impact', ld)); ld.exec_module(m) - _TI = m.command_for + _TI = m except Exception: - _TI = lambda *a: None - try: return _TI(lang, files, classes) + _TI = False + return _TI or None + + +def _command_for(lang, files, classes, db=None): + """the runnable command `axiomcode test-impact` prints, from the same function""" + try: return _ti().command_for(lang, files, classes, db) except Exception: return None -LANG = {'.java': 'java', '.py': 'python', '.ts': 'typescript', '.tsx': 'typescript', '.js': 'javascript', - '.mjs': 'javascript', '.cjs': 'javascript', '.jsx': 'javascript', '.cs': 'csharp'} +def _concrete(db, classes): + """the classes a runner can run: each abstract test class replaced by the classes that extend it (test-impact)""" + try: return _ti().concrete_test_classes(db, classes)[0] + except Exception: return classes + + +import _where +LANG = {e: ls[0] for e, ls in _where.BY_EXT.items()} # one table for every hook (_where.py) SHOWN = 6 @@ -136,6 +147,8 @@ def body_line(db, results): return f"graph: body edit of {what}: no test reaches it through the graph (a lower bound)" lang = LANG.get(os.path.splitext(results[0][0].get('file', ''))[1], '') fl, cl = sorted(files), sorted(owners) + if lang in ('java', 'csharp') and cl: + cl = sorted(_concrete(db, cl)) # before the cut, so the command and the "+N more" count the same classes cmd = _command_for(lang, fl[:SHOWN], cl[:SHOWN]) more = (len({c.split('.')[-1] for c in cl}) if lang in ('java', 'csharp') and cl else len(fl)) - SHOWN tail = (f"; run: {cmd}" + (f" (+{more} more: axiomcode test-impact)" if more > 0 else '')) if cmd else "; axiomcode test-impact gives the command" diff --git a/plugins/axiomcode/hooks/_where.py b/plugins/axiomcode/hooks/_where.py index dc71e291c..e83b7da30 100644 --- a/plugins/axiomcode/hooks/_where.py +++ b/plugins/axiomcode/hooks/_where.py @@ -30,6 +30,34 @@ '.ts': ('typescript',), '.tsx': ('typescript',), '.mts': ('typescript',), '.cts': ('typescript',), '.js': ('javascript', 'typescript'), '.jsx': ('javascript', 'typescript'), '.mjs': ('javascript', 'typescript'), '.cjs': ('javascript', 'typescript')} +# THE ONE TABLE OF WHAT A HOOK CALLS SOURCE. Each hook kept its own regex or tuple of extensions, and each drifted: +# the edit hooks had no `.cs`, so a C# edit got no graph line while a C# read did. Every hook asks these instead. +SOURCE_EXT = tuple(BY_EXT) +SOURCE_ALT = '|'.join(sorted((e[1:] for e in BY_EXT), key=len, reverse=True)) # for a regex: (?:py|java|cs|...) +# test code, in each language's convention: a tests/ or src/test/ directory, a *Test / *Tests class file, a C# test +# project (App.Tests/), a test_*.py module, a *.spec.ts / *.test.js file +TEST = re.compile(r'(^|/)(tests?|__tests__)/|/src/test/|(^|/)[\w.]+\.Tests?/|Tests?\.(java|cs)$|\.(spec|test)\.[jt]sx?$|(^|/)test_') + + +def is_source(path): + """a file some language's graph is built from: a known extension, or an extensionless script with a Python + shebang (the index reads those as modules)""" + path = str(path or '') + ext = os.path.splitext(path)[1].lower() + if ext: return ext in BY_EXT + try: + with open(path, 'rb') as f: first = f.readline(200) + return first.startswith(b'#!') and b'python' in first + except OSError: return False + + +def is_test(rel): + return bool(TEST.search(str(rel or '').replace(os.sep, '/'))) + + +def lang_of(path): + """the language a file's tests are run in, '' when none""" + return (BY_EXT.get(os.path.splitext(str(path or ''))[1].lower()) or ('',))[0] _session = 'x' _cache = None diff --git a/plugins/axiomcode/hooks/changes.py b/plugins/axiomcode/hooks/changes.py index 3bfa63e8a..f8858f388 100644 --- a/plugins/axiomcode/hooks/changes.py +++ b/plugins/axiomcode/hooks/changes.py @@ -28,7 +28,7 @@ if tool in ('Edit', 'Write', 'MultiEdit') and inp.get('file_path'): os.environ.update(_where.lang_env(cwd, inp['file_path'])) SCR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts') -SRC = re.compile(r'\.(java|ts|tsx|js|mjs|cjs|py)$'); TEST = re.compile(r'(^|/)(tests?|__tests__)/|/src/test/|Tests?\.java$|\.(spec|test)\.[jt]sx?$|(^|/)test_') +TEST = _where.TEST # what counts as source and as test: one table, _where.py STATE = os.path.join(cwd, '.axiomcode', f"hooks-state-{ev.get('session_id', 'x')}.json") def load_state(): try: return json.load(open(STATE)) @@ -126,7 +126,7 @@ def key(d): return f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')} lines = [] if event == 'PreToolUse' and tool in ('Edit', 'Write', 'MultiEdit'): fp = _where._abs(inp.get('file_path', ''), scwd); rel = rel_of(fp) - if not SRC.search(rel) or TEST.search(rel) or not os.path.exists(fp): sys.exit(0) + if not _where.is_source(fp) or TEST.search(rel) or not os.path.exists(fp): sys.exit(0) cur = open(fp, errors='replace').read(); new = cur if tool == 'Write': new = str(inp.get('content', '')) else: @@ -153,7 +153,7 @@ def key(d): return f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')} if bg: os.environ['AXIOMCODE_GRAPH'] = bg if event == 'PostToolUse' and tool == 'Bash': c = str(inp.get('command', '')) - if not re.search(r'\bsed\s+-i|\bpatch\b|\bgit\s+(apply|checkout|switch|pull|merge|rebase|revert|cherry-pick|stash\s+pop|reset\s+--hard|restore)\b|>>?\s*\S+\.(java|ts|tsx|js|py)\b|\b(python3?|node|bash|sh)\s+\S+|\bmv\b|\bcp\b|\brm\b', c): sys.exit(0) + if not re.search(r'\bsed\s+-i|\bpatch\b|\bgit\s+(apply|checkout|switch|pull|merge|rebase|revert|cherry-pick|stash\s+pop|reset\s+--hard|restore)\b|>>?\s*\S+\.(' + _where.SOURCE_ALT + r')\b|\b(python3?|node|bash|sh)\s+\S+|\bmv\b|\bcp\b|\brm\b', c): sys.exit(0) j = changed([], timeout=18) st = load_state(); seen = set(st.get('reported', [])) new = [d for d in j.get('changed', []) if d.get('target') and key(d) not in seen and not TEST.search(d['file'])] diff --git a/plugins/axiomcode/hooks/direct.py b/plugins/axiomcode/hooks/direct.py index c9aea74a3..5b8da8c71 100755 --- a/plugins/axiomcode/hooks/direct.py +++ b/plugins/axiomcode/hooks/direct.py @@ -22,30 +22,105 @@ · ONLY WHERE THE CHOICE IS. A Bash call that searches or reads source competes with the graph; `git`, `ls`, a build or a test run does not, and a directive in front of one is noise. """ -import json, os, re, sys +import json, os, re, shlex, sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import _host, _where -MARK = '.axiomcode/.directed-{}' # once per session: keyed by session id, not once per repo forever -SRC = ('.java', '.ts', '.tsx', '.py', '.js', '.jsx', '.mjs', '.cjs', '.cs') +SRC = _where.SOURCE_EXT # one table for every hook (_where.py) # the shell commands that are the search or read this directive competes with SEARCH = re.compile(r'(^|[;&|(]\s*|\s)(grep|egrep|rg|ag|ack|git\s+grep|find|fd|cat|head|tail|sed|awk|less)\b') +# words a search pattern is made of that name no declaration (a keyword written into the pattern to anchor it) +KEYWORDS = {'def', 'class', 'public', 'private', 'protected', 'static', 'final', 'void', 'function', 'import', 'from', + 'return', 'new', 'async', 'await', 'self', 'this', 'interface', 'struct', 'record', 'override', 'virtual', + 'const', 'let', 'var', 'string', 'int', 'bool', 'true', 'false', 'null', 'None', 'True', 'False', 'package', + 'namespace', 'using', 'extends', 'implements'} +CALLABLE = ('method', 'function', 'constructor') # The MCP tools are named first and the shell verbs second (#1425): `axiomcode install` pre-approves only the tools, # so an agent that takes the first spelling it reads should be taking the one it is already allowed to call. +# Short on purpose: it is read once, and 13 generic lines were re-read on every later turn. FULL = ( - "graph: this repository has a resolved call graph, and it answers the question this search is asking.\n" - " the axiomcode_impact tool (targets=[]) — everything a change to that declaration reaches, each entry\n" - " labelled with how certain it is; finds callers that never spell the name (an interface, an override, a\n" - " callback, DI, a config key), which is what a grep for the name cannot do.\n" - " axiomcode_path — how one thing reaches another. axiomcode_context — where a task's words land.\n" - " axiomcode_changed / axiomcode_test_impact — what an edit moved and which tests reach it.\n" - " Without those tools, the same verbs from the shell: `axiomcode impact `, `axiomcode path `,\n" - " `axiomcode context \"\"`, `axiomcode changed`, `axiomcode test-impact`.\n" - " Search raw files after that, to read or to change specific lines. If you hand this to a subagent,\n" - " carry this line into its prompt — exploration done in a child is exploration done without the graph." + "graph: this repository has a resolved call graph; ask it before a raw search. axiomcode_impact targets=[]:\n" + " who uses a declaration and what a change breaks, with callers that never spell the name (an interface, an override,\n" + " a callback, DI). axiomcode_path: how A reaches B. axiomcode_context: where a task lands. axiomcode_changed /\n" + " axiomcode_test_impact: what an edit moved, which tests reach it. Shell: `axiomcode impact `, `axiomcode path\n" + " `, `axiomcode context \"\"`, `axiomcode changed`, `axiomcode test-impact`. Pass this to a subagent." ) +# after a line that already names the verb for THIS search, the rest of the menu in one line +REST = (" Also: axiomcode_path (how A reaches B), axiomcode_context (a task in words), axiomcode_test_impact (the tests an" + " edit reaches); in a shell `axiomcode `. Pass this to a subagent.") + + +def _stamp(cwd, sid): + """ONCE PER SESSION, not once per (session, repository): keyed on the session in the temp directory, so an agent + that moves between repositories (a dev copy, a second service) hears it the first time only. A session with no id + keeps the old per-repository stamp, since every such session would otherwise share one.""" + if not sid: + return os.path.join(cwd, '.axiomcode', '.directed-x') + tmp = os.environ.get('TMPDIR') or os.environ.get('TEMP') or os.environ.get('TMP') or ('/tmp' if os.name != 'nt' else os.path.expanduser('~')) + return os.path.join(tmp, 'axiomcode-hooks', 'directed-' + (re.sub(r'[^\w.-]', '_', str(sid))[:80] or 'x')) + + +def _pattern(tool, inp): + """the text a search looks for: Grep's pattern, or the first argument after grep / rg / ag / ack in a shell command""" + if tool == 'Grep': + return str(inp.get('pattern') or '') + if tool == 'Bash': + try: toks = shlex.split(str(inp.get('command') or '')) + except ValueError: toks = str(inp.get('command') or '').split() + for i, t in enumerate(toks): + if t in ('grep', 'egrep', 'rg', 'ag', 'ack'): + j = i + 1 + while j < len(toks) and toks[j].startswith('-'): + if toks[j] in ('-e', '--regexp'): j += 1; break + j += 2 if toks[j] in ('-f', '-A', '-B', '-C', '-m', '--include', '--exclude', '-g', '-t', '--type') else 1 + if j < len(toks): return toks[j] + return '' + + +def specific(cwd, tool, inp, fp): + """ONE line naming the verb that answers THIS search, when the graph has what it names: a Grep for a declared name + gets `impact` on that declaration, a Read of an indexed file gets `context` scoped to it. '' when the graph has + nothing to say about it (the generic block then stands alone).""" + import sqlite3 + db = _where.graph_db(cwd, fp or None) + if not os.path.isfile(db): + return '' + try: + con = sqlite3.connect(f'file:{db}?mode=ro', uri=True) + except sqlite3.Error: + return '' + try: + pat = _pattern(tool, inp) + if pat: + for w in re.findall(r'[A-Za-z_]\w{2,}', pat): + if w in KEYWORDS: + continue + rows = con.execute("SELECT display, kind, file, line FROM symbols WHERE name = ? AND kind != 'module' " + "ORDER BY is_test, (kind IN ('method','function','constructor')) DESC, file, line LIMIT 4", (w,)).fetchall() + if not rows: + continue + disp, kind, f, l = rows[0] + target = disp if len(rows) == 1 else w + what = 'Who calls it and what a change breaks' if kind in CALLABLE else \ + ('Who reads and writes it' if kind in ('field', 'const', 'variable', 'property') else 'Who uses it, its subtypes, what a change breaks') + where = f"{disp}, {f}:{l}" if len(rows) == 1 else f"{len(rows) if len(rows) < 4 else '4+'} declarations, first {disp} {f}:{l}" + return (f"graph: `{w}` is declared here ({where}). {what}: axiomcode_impact targets=[\"{target}\"]" + f" (`axiomcode impact {target}`); a grep misses the callers that never spell it.") + if tool in ('Read', 'Bash') and fp: + rel = os.path.relpath(os.path.realpath(fp), os.path.realpath(cwd)).replace(os.sep, '/') + n = con.execute("SELECT count(*) FROM symbols WHERE file = ? AND kind IN ('method','function','constructor')", (rel,)).fetchone()[0] + if n: + return (f"graph: {rel} has {n} callable(s) in the graph. How it works: axiomcode_context task=\"\"" + f" in_path=\"{rel}\" source=True; one of them: axiomcode_impact targets=[\"{rel}:\"].") + except sqlite3.Error: + pass + finally: + con.close() + return '' + + def main(): ev = _host.read() tool = ev.get('tool_name') or '' @@ -56,7 +131,7 @@ def main(): if not cwd: sys.exit(0) - stamp = os.path.join(cwd, MARK.format(ev.get('session_id') or 'x')) + stamp = _stamp(cwd, ev.get('session_id')) # the agent is already reaching for the graph -- saying it again is the nag this is trying not to be, # and once it has, the directive has nothing left to say this session @@ -71,7 +146,7 @@ def main(): # a Read of something that is not source is not the decision this is about (a log, a lockfile, a README) if tool == 'Read': fp = inp.get('file_path') or '' - if not fp.endswith(SRC): + if not _where.is_source(_where._abs(fp, ev.get('cwd') or os.getcwd())): sys.exit(0) if tool == 'Bash' and not SEARCH.search(inp.get('command') or ''): @@ -82,7 +157,7 @@ def main(): if tool == 'Bash': _, paths = _where.bash_where(inp.get('command'), ev.get('cwd') or os.getcwd()) files = [p for p in paths if os.path.isfile(p)] - if files and not any(p.endswith(SRC) for p in files): + if files and not any(_where.is_source(p) for p in files): sys.exit(0) if os.path.exists(stamp): @@ -93,7 +168,13 @@ def main(): except Exception: pass - text = FULL + fp = _where._abs(inp['file_path'], ev.get('cwd') or os.getcwd()) if tool == 'Read' and inp.get('file_path') else \ + (next((p for p in files if _where.is_source(p)), '') if tool == 'Bash' else '') + try: + one = specific(cwd, tool, inp, fp) + except Exception: + one = '' + text = (one + '\n' + REST) if one else FULL # stream-json does not carry additionalContext, so a run cannot show from its transcript that this # fired or what it said -- and #1100 is a question about exactly that. enrich.py already logs itself # next to the graph for the same reason; this writes the same file, so one reader sees both halves. diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index 75edab875..b0c747d14 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -113,7 +113,7 @@ def outside(p): if files and all(os.path.splitext(a)[1] and os.path.splitext(a)[1].lower() not in _graphline.LANG for a in files): sys.exit(0) if m: tool = 'Grep'; inp = {'pattern': m.group(3)}; _from_shell = True else: - m = re.search(r"sed -n '?(\d+),(\d+)p'? (\S+)", c) or re.search(r'\bcat\s+(\S+\.(?:java|ts|tsx|js|py|cs))\b', c) + m = re.search(r"sed -n '?(\d+),(\d+)p'? (\S+)", c) or re.search(r'\bcat\s+(\S+\.(?:' + _where.SOURCE_ALT + r'))\b', c) # a relative file is relative to where the shell is when it runs: the session's directory, or a `cd` before it if m: base, _ = _where.bash_where(c[:m.start()], scwd) if m and m.re.groups == 3: tool = 'Read'; inp = {'file_path': _where._abs(m.group(3), base), 'offset': int(m.group(1)), 'limit': int(m.group(2)) - int(m.group(1)) + 1} @@ -160,7 +160,7 @@ def context_ids(): # with it, who produces or writes it, who reads it, what reaches those, the tests. The moment this is useful is now. import concurrent.futures fp = _where._abs(inp.get('file_path', ''), scwd); rel = rel_of(fp) - if not re.search(r'\.(java|ts|tsx|js|mjs|cjs|py)$', rel) or re.search(r'(^|/)(tests?|__tests__)/|/src/test/|Tests?\.java$|\.(spec|test)\.[jt]sx?$|(^|/)test_', rel): sys.exit(0) + if not _where.is_source(fp) or _where.is_test(rel): sys.exit(0) SCR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts') try: ch = json.loads(subprocess.run([sys.executable, os.path.join(SCR, 'axiomcode-changed'), cwd, rel, '--json'], capture_output=True, text=True, timeout=10).stdout or '{}') except Exception: ch = {} diff --git a/plugins/axiomcode/hooks/orient.py b/plugins/axiomcode/hooks/orient.py index e78d27ec0..2755a551f 100755 --- a/plugins/axiomcode/hooks/orient.py +++ b/plugins/axiomcode/hooks/orient.py @@ -88,7 +88,7 @@ def prompt_root(prompt): # It is the only moment where saying nothing guarantees the skill is never used, so it says one thing and # takes the same once-per-repo stamp. Still bounded, still never repeated, and still silent where it would # be noise: a tree with no source in a supported language has nothing to offer and says nothing. - EXT = ('.java', '.ts', '.tsx', '.py', '.js', '.jsx', '.mjs', '.cjs', '.cs') + EXT = _where.SOURCE_EXT SKIP = {'node_modules', '.git', 'dist', 'build', 'target', 'venv', '.venv', '__pycache__', 'obj'} found = 0 for root, dirs, files in os.walk(cwd): @@ -135,7 +135,7 @@ def _below(d, depth): # Plain English words are not enough however rare: a large codebase declares functions named `when`, `used`, `safe`, # `main`, `loop` and `parser`, so any sentence about anything matched two of them. IDENT = re.compile(r'`([^`]+)`|\b([A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)+)\b|\b([a-z]+[A-Z]\w*|[A-Za-z]+_\w+)\b|\b(\w+)\(\)|(? ".join(r.get('chain') or []) or "reaches it directly" print(f" {r.get('display')} [{r.get('certainty')}] {chain}") + if replaced: + # one line however many: an abstract class runs nothing by name, so the command names what extends it + each = [f"{a} -> " + (', '.join(subs[:3]) + (f" +{len(subs) - 3}" if len(subs) > 3 else '') if subs else 'none in the graph') + for a, subs in sorted(replaced.items())] + print(f"\n {len(each)} abstract test class(es) run nothing by name, so the command runs the classes that extend them: " + + '; '.join(each[:4]) + (f"; +{len(each) - 4} more (--json: abstract_replaced)" if len(each) > 4 else '')) if cmd_all: print(f"\n {cmd_all}") elif edited and cmd_all: @@ -501,7 +571,7 @@ def main(argv): for f, (n, ts) in sorted(loaded.items()): print(f" {f} named as '{n}' by: " + ", ".join(ts[:6]) + (f" … +{len(ts) - 6}" if len(ts) > 6 else "")) both = sorted(set(run_files) | set(loaded_files)) - cmd = command_for(_guess_lang(both) or lang, both[:limit], classes + [os.path.splitext(os.path.basename(f))[0] for f in loaded_files] if classes else []) + cmd = command_for(_guess_lang(both) or lang, both[:limit], run_classes + [os.path.splitext(os.path.basename(f))[0] for f in loaded_files] if classes else [], db) if cmd: print(f"\n with the tests above: {cmd}") missed = [f for f in outside if f not in loaded] if missed: diff --git a/tests/README.md b/tests/README.md index e681a117f..a1f76bf8d 100644 --- a/tests/README.md +++ b/tests/README.md @@ -14,7 +14,11 @@ One check needs no graph and is its own script: python3 tests/surfaces.py every dispatched verb is documented on --help, SKILL.md and MCP python3 tests/fastpath.py the hooks' SQL fast path agrees with the rules, shape by shape python3 tests/directive.py the PreToolUse directive hook keeps its promises (never blocks, - never raises, silent without a graph) + never raises, silent without a graph, once per session across + repositories, and names the verb for a declared name it is searched for) + python3 tests/hook_languages.py the edit hooks speak for C# as for Java and Python, from one extension table, + and a body edit's command runs the classes that extend an abstract test base + (indexes a small C# project, so it needs the engine) python3 tests/refresh.py the graph refreshes itself after an edit in every language: a query sees the edit, `changed` answers the same before and after, a burst costs one rebuild and queries during it answer (#1305; builds real graphs, needs the engine) diff --git a/tests/cases/java/abstract-test-base-command/case.json b/tests/cases/java/abstract-test-base-command/case.json new file mode 100644 index 000000000..c724c9901 --- /dev/null +++ b/tests/cases/java/abstract-test-base-command/case.json @@ -0,0 +1,10 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a test declared in an abstract base runs only as part of the classes that extend it, so the command names those; mvn -Dtest= runs nothing", + "run": ["test-impact", "{repo}", "--old", "{repo}/save-old.java", "--new", "{repo}/save-new.java", "--file", "src/pkg/Repo.java"], + "want": ["mvn test -Dtest=JdbcRepoTest,MemoryRepoTest", "AbstractRepoContractTest -> JdbcRepoTest, MemoryRepoTest"], + "avoid": ["-Dtest=AbstractRepoContractTest"]}, + {"why": "control: a CONCRETE base keeps its place in the command (its tests run in it), and the class inheriting the reached test is named too, as the ownerless note says", + "run": ["test-impact", "{repo}", "--old", "{repo}/load-old.java", "--new", "{repo}/load-new.java", "--file", "src/pkg/Repo.java"], + "want": ["mvn test -Dtest=CachedLoadTest,LoadTest"], + "avoid": ["abstract test class"]}]} diff --git a/tests/cases/java/abstract-test-base-command/load-new.java b/tests/cases/java/abstract-test-base-command/load-new.java new file mode 100644 index 000000000..8dc3fd69d --- /dev/null +++ b/tests/cases/java/abstract-test-base-command/load-new.java @@ -0,0 +1,11 @@ +package pkg; + +public class Repo { + public int save(int x) { + return x + 1; + } + + public int load(int x) { + return x - 2; + } +} diff --git a/tests/cases/java/abstract-test-base-command/load-old.java b/tests/cases/java/abstract-test-base-command/load-old.java new file mode 100644 index 000000000..b18efe2e5 --- /dev/null +++ b/tests/cases/java/abstract-test-base-command/load-old.java @@ -0,0 +1,11 @@ +package pkg; + +public class Repo { + public int save(int x) { + return x + 1; + } + + public int load(int x) { + return x - 1; + } +} diff --git a/tests/cases/java/abstract-test-base-command/save-new.java b/tests/cases/java/abstract-test-base-command/save-new.java new file mode 100644 index 000000000..0d0b000b4 --- /dev/null +++ b/tests/cases/java/abstract-test-base-command/save-new.java @@ -0,0 +1,11 @@ +package pkg; + +public class Repo { + public int save(int x) { + return x + 2; + } + + public int load(int x) { + return x - 1; + } +} diff --git a/tests/cases/java/abstract-test-base-command/save-old.java b/tests/cases/java/abstract-test-base-command/save-old.java new file mode 100644 index 000000000..b18efe2e5 --- /dev/null +++ b/tests/cases/java/abstract-test-base-command/save-old.java @@ -0,0 +1,11 @@ +package pkg; + +public class Repo { + public int save(int x) { + return x + 1; + } + + public int load(int x) { + return x - 1; + } +} diff --git a/tests/cases/java/abstract-test-base-command/src/pkg/AbstractRepoContractTest.java b/tests/cases/java/abstract-test-base-command/src/pkg/AbstractRepoContractTest.java new file mode 100644 index 000000000..bc06ab873 --- /dev/null +++ b/tests/cases/java/abstract-test-base-command/src/pkg/AbstractRepoContractTest.java @@ -0,0 +1,12 @@ +package pkg; + +import org.junit.jupiter.api.Test; + +public abstract class AbstractRepoContractTest { + protected abstract Repo repo(); + + @Test + void savesOne() { + repo().save(1); + } +} diff --git a/tests/cases/java/abstract-test-base-command/src/pkg/CachedLoadTest.java b/tests/cases/java/abstract-test-base-command/src/pkg/CachedLoadTest.java new file mode 100644 index 000000000..a2b970655 --- /dev/null +++ b/tests/cases/java/abstract-test-base-command/src/pkg/CachedLoadTest.java @@ -0,0 +1,4 @@ +package pkg; + +public class CachedLoadTest extends LoadTest { +} diff --git a/tests/cases/java/abstract-test-base-command/src/pkg/JdbcRepoTest.java b/tests/cases/java/abstract-test-base-command/src/pkg/JdbcRepoTest.java new file mode 100644 index 000000000..330a54c2a --- /dev/null +++ b/tests/cases/java/abstract-test-base-command/src/pkg/JdbcRepoTest.java @@ -0,0 +1,8 @@ +package pkg; + +public class JdbcRepoTest extends AbstractRepoContractTest { + @Override + protected Repo repo() { + return new Repo(); + } +} diff --git a/tests/cases/java/abstract-test-base-command/src/pkg/LoadTest.java b/tests/cases/java/abstract-test-base-command/src/pkg/LoadTest.java new file mode 100644 index 000000000..92103fa1b --- /dev/null +++ b/tests/cases/java/abstract-test-base-command/src/pkg/LoadTest.java @@ -0,0 +1,10 @@ +package pkg; + +import org.junit.jupiter.api.Test; + +public class LoadTest { + @Test + void loadsOne() { + new Repo().load(1); + } +} diff --git a/tests/cases/java/abstract-test-base-command/src/pkg/MemoryRepoTest.java b/tests/cases/java/abstract-test-base-command/src/pkg/MemoryRepoTest.java new file mode 100644 index 000000000..837c4d5c2 --- /dev/null +++ b/tests/cases/java/abstract-test-base-command/src/pkg/MemoryRepoTest.java @@ -0,0 +1,8 @@ +package pkg; + +public class MemoryRepoTest extends AbstractRepoContractTest { + @Override + protected Repo repo() { + return new Repo(); + } +} diff --git a/tests/cases/java/abstract-test-base-command/src/pkg/Repo.java b/tests/cases/java/abstract-test-base-command/src/pkg/Repo.java new file mode 100644 index 000000000..b18efe2e5 --- /dev/null +++ b/tests/cases/java/abstract-test-base-command/src/pkg/Repo.java @@ -0,0 +1,11 @@ +package pkg; + +public class Repo { + public int save(int x) { + return x + 1; + } + + public int load(int x) { + return x - 1; + } +} diff --git a/tests/directive.py b/tests/directive.py index 3fa22bc1c..b31ab8af5 100644 --- a/tests/directive.py +++ b/tests/directive.py @@ -7,7 +7,10 @@ Each check names the promise rather than the code path, so a failure here says which promise broke. """ -import json, os, subprocess, sys, tempfile +import json, os, sqlite3, subprocess, sys, tempfile +# the directive's once-per-session stamp lives in the temp directory, keyed on the session: a run of its own, or a +# second run of this script reuses the first run's session ids and hears nothing +os.environ['TMPDIR'] = tempfile.mkdtemp(prefix='ax-hooks-') HOOK = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'plugins', 'axiomcode', 'hooks', 'direct.py') @@ -91,12 +94,48 @@ def check(why, cond, detail=''): open(os.path.join(ro, '.axiomcode', 'out', 'graph.sqlite'), 'w').close() os.chmod(os.path.join(ro, '.axiomcode'), 0o500) # stamp cannot be written try: - rc, out, _ = fire(ro, 'Grep', {'pattern': 'f'}) + rc, out, _ = fire(ro, 'Grep', {'pattern': 'f'}, session='s5') check('a repository it cannot write to still gets its directive, not an error', rc == 0 and ctx(out), f'rc={rc} out={out[:120]}') finally: os.chmod(os.path.join(ro, '.axiomcode'), 0o700) +# ── once per SESSION, not per (session, repository); and the line names the verb for what the search names ── +def graph(repo, rows): + os.makedirs(os.path.join(repo, '.axiomcode', 'out'), exist_ok=True) + con = sqlite3.connect(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')) + con.execute("CREATE TABLE symbols(id TEXT, name TEXT, display TEXT, kind TEXT, qualified_name TEXT, signature TEXT," + " file TEXT, line INT, end_line INT, owner TEXT, is_test INT, method_id TEXT, type_id TEXT)") + con.executemany("INSERT INTO symbols(id, name, display, kind, file, line, is_test) VALUES (?,?,?,?,?,?,0)", rows) + con.commit(); con.close() + +with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b: + for r in (a, b): + os.makedirs(os.path.join(r, 'src')) + open(os.path.join(r, 'src', 'OrderStore.cs'), 'w').write('class OrderStore { public string FindById(int id) => ""; }\n') + graph(r, [('m1', 'FindById', 'OrderStore.FindById', 'method', 'src/OrderStore.cs', 1), + ('t1', 'OrderStore', 'OrderStore', 'class', 'src/OrderStore.cs', 1)]) + rc, out, _ = fire(a, 'Grep', {'pattern': 'FindById'}, session='k1') + said = ctx(out) or '' + check('a Grep for a declared name is told which verb answers it, on that declaration', + 'axiomcode_impact targets=["OrderStore.FindById"]' in said and 'src/OrderStore.cs:1' in said, said[:300]) + check('and the whole directive is short: at most 6 lines', 0 < len(said.splitlines()) <= 6, said) + rc, out, _ = fire(b, 'Grep', {'pattern': 'FindById'}, session='k1') + check('the same session in a second repository hears nothing more: the stamp is per session, not per repository', + rc == 0 and out == '', f'out={out[:160]}') + rc, out, _ = fire(b, 'Grep', {'pattern': 'FindById'}, session='k2') + check('control: a new session in that repository is told', bool(ctx(out)), f'out={out[:160]}') + rc, out, _ = fire(a, 'Grep', {'pattern': 'def fetch_all'}, session='k3') + said = ctx(out) or '' + check('control: a search for a name the graph does not declare gets the general directive only', + said and 'is declared here' not in said and 'axiomcode_impact' in said, said[:300]) + rc, out, _ = fire(a, 'Bash', {'command': "grep -rn 'OrderStore' src"}, session='k4') + said = ctx(out) or '' + check('a shell grep for a declared type is told which verb answers it', 'targets=["OrderStore"]' in said, said[:300]) + rc, out, _ = fire(a, 'Read', {'file_path': os.path.join(a, 'src', 'OrderStore.cs')}, session='k5') + said = ctx(out) or '' + check('a Read of an indexed C# file is told to ask context scoped to it', 'in_path="src/OrderStore.cs"' in said, said[:300]) + print() print(f"{len(checked) - len(fails)} of {len(checked)} promise(s) held" if not fails else f"{len(fails)} FAILED: " + '; '.join(fails)) sys.exit(1 if fails else 0) diff --git a/tests/hook_languages.py b/tests/hook_languages.py new file mode 100644 index 000000000..522d45234 --- /dev/null +++ b/tests/hook_languages.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""tests/hook_languages.py: the edit hooks speak for every language the index reads, from one table. + +Each hook kept its own list of source extensions, and the edit hooks' lists had no `.cs`: a C# edit got no graph line +while a C# Read and Grep did. The lists now come from one table (_where.BY_EXT), so this checks the hooks on a C# +project, where the old lists were silent: + + · a body edit to a C# method gets the one line of reaching tests and the command that runs them (enrich.py); + · a signature edit to a C# method gets its blast radius BEFORE it lands (changes.py, PreToolUse); + · a test declared in an abstract C# base runs as the class that extends it, so the command filters on that class, + not on the base, which `dotnet test --filter FullyQualifiedName~` would never match; + · control: an edit to a file no graph is built from (the .csproj) says nothing; + · control: the one table still carries every extension the old lists had. + +It indexes a small project, so it needs the engine, as run.py does. + + python3 tests/hook_languages.py +""" +import json, os, subprocess, sys, tempfile + +# the directive's once-per-session stamp lives in the temp directory, keyed on the session +os.environ['TMPDIR'] = tempfile.mkdtemp(prefix='ax-hooks-') +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +HOOKS = os.path.join(ROOT, 'plugins', 'axiomcode', 'hooks') +AX = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') + +SRC = { + 'src/App/App.csproj': '\n net8.0\n\n', + 'src/App/Repo.cs': ('namespace App;\n\npublic class Repo\n{\n public int Save(int x)\n {\n return x + 1;\n }\n\n' + ' public int Load(int x)\n {\n return x - 1;\n }\n}\n'), + 'src/App/Service.cs': ('namespace App;\n\npublic class Service\n{\n public int Run(Repo r)\n {\n return r.Load(2);\n }\n}\n'), + 'src/App.Tests/App.Tests.csproj': ('\n net8.0\n' + ' \n \n' + ' \n \n\n'), + 'src/App.Tests/RepoContractTests.cs': ('using App;\nusing Xunit;\n\nnamespace App.Tests;\n\npublic abstract class RepoContractTests\n{\n' + ' protected abstract Repo Make();\n\n [Fact]\n public void SavesOne()\n {\n' + ' Make().Save(1);\n }\n}\n'), + 'src/App.Tests/SqlRepoTests.cs': ('using App;\n\nnamespace App.Tests;\n\npublic class SqlRepoTests : RepoContractTests\n{\n' + ' protected override Repo Make() => new Repo();\n}\n'), +} + +fails, checked = [], [] +def check(why, cond, detail=''): + checked.append(why) + print(('ok ' if cond else 'FAIL ') + why + (f'\n {detail}' if not cond and detail else '')) + if not cond: + fails.append(why) + + +def fire(repo, session, tool, inp, hook, event): + ev = {'hook_event_name': event, 'tool_name': tool, 'tool_input': inp, 'cwd': repo, 'session_id': session} + r = subprocess.run([sys.executable, os.path.join(HOOKS, hook)], input=json.dumps(ev), capture_output=True, text=True, timeout=120) + out = r.stdout.strip() + if not out: + return '' + try: + return json.loads(out)['hookSpecificOutput']['additionalContext'] + except (ValueError, KeyError, TypeError): + return out + + +def git(repo, *a): + subprocess.run(['git', '-c', 'user.email=t@t', '-c', 'user.name=t', *a], cwd=repo, capture_output=True, check=True) + + +with tempfile.TemporaryDirectory() as repo: + for n, t in SRC.items(): + os.makedirs(os.path.dirname(os.path.join(repo, n)), exist_ok=True) + open(os.path.join(repo, n), 'w').write(t) + git(repo, 'init', '-q'); git(repo, 'add', '-A'); git(repo, 'commit', '-qm', 'init') + built = subprocess.run(['bash', AX, 'index', repo, '--lang', 'csharp'], capture_output=True, text=True, timeout=1800) + if not os.path.exists(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')): + print('FAIL could not index the project; the engine is needed\n ' + built.stderr.strip()[-300:]) + sys.exit(1) + + f = os.path.join(repo, 'src/App/Repo.cs') + orig = open(f).read() + + # PreToolUse: the edit is applied to a copy, and a signature change is reported before it lands + out = fire(repo, 'c1', 'Edit', {'file_path': f, 'old_string': 'public int Load(int x)', 'new_string': 'public int Load(long x)'}, + 'changes.py', 'PreToolUse') + check('a signature edit to a C# method gets its blast radius before it lands', + 'about to change' in out and 'Repo.Load' in out and 'Service.Run' in out, out) + + # PostToolUse: a body-only edit gets one line, the tests and the command + open(f, 'w').write(orig.replace('x + 1', 'x + 2')) + out = fire(repo, 'c2', 'Edit', {'file_path': f}, 'enrich.py', 'PostToolUse') + check('a body edit to a C# method gets the line of tests that reach it', 'body edit of Repo.Save' in out, out) + check('the command runs the class that extends the abstract base that declares the test', + 'FullyQualifiedName~SqlRepoTests' in out, out) + check('and never the abstract base, which no inherited test is named after', 'FullyQualifiedName~RepoContractTests' not in out, out) + open(f, 'w').write(orig) + + # control: a file no graph is built from + p = os.path.join(repo, 'src/App/App.csproj') + t = open(p).read() + open(p, 'w').write(t.replace('net8.0', 'net9.0')) + out = fire(repo, 'c3', 'Edit', {'file_path': p}, 'enrich.py', 'PostToolUse') + out2 = fire(repo, 'c3', 'Edit', {'file_path': p, 'old_string': 'net9.0', 'new_string': 'net8.0'}, 'changes.py', 'PreToolUse') + check('control: an edit to a .csproj says nothing', out == '' and out2 == '', out + out2) + open(p, 'w').write(t) + +sys.path.insert(0, HOOKS) +import _where +check('control: the one table still carries every extension the hooks listed before', + all(e in getattr(_where, 'SOURCE_EXT', ()) for e in ('.java', '.ts', '.tsx', '.py', '.js', '.jsx', '.mjs', '.cjs', '.cs')), getattr(_where, 'SOURCE_EXT', None)) +check('a C# test project and a *Tests.cs file are test code; a C# source file is not', + getattr(_where, 'is_test', lambda r: None)('src/App.Tests/Anything.cs') and getattr(_where, 'is_test', lambda r: None)('src/Foo/RepoTests.cs') and not getattr(_where, 'is_test', lambda r: None)('src/App/Repo.cs')) + +print() +print(f"{len(checked) - len(fails)} of {len(checked)} check(s) held" if not fails else f"{len(fails)} FAILED: " + '; '.join(fails)) +sys.exit(1 if fails else 0) diff --git a/tests/hooks_from_path.py b/tests/hooks_from_path.py index 6d961600f..fd80ef883 100644 --- a/tests/hooks_from_path.py +++ b/tests/hooks_from_path.py @@ -24,6 +24,9 @@ python3 tests/hooks_from_path.py """ import json, os, shutil, subprocess, sys, tempfile, time +# the directive's once-per-session stamp lives in the temp directory, keyed on the session: a run of its own, or a +# second run of this script reuses the first run's session ids and hears nothing +os.environ['TMPDIR'] = tempfile.mkdtemp(prefix='ax-hooks-') ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) HOOKS = os.path.join(ROOT, 'plugins', 'axiomcode', 'hooks') diff --git a/tests/hosts.py b/tests/hosts.py index 955cb874b..e1f235d84 100644 --- a/tests/hosts.py +++ b/tests/hosts.py @@ -17,6 +17,9 @@ python3 tests/hosts.py """ import json, os, shutil, subprocess, sys, tempfile +# the directive's once-per-session stamp lives in the temp directory, keyed on the session: a run of its own, or a +# second run of this script reuses the first run's session ids and hears nothing +os.environ['TMPDIR'] = tempfile.mkdtemp(prefix='ax-hooks-') ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) HOOKS = os.path.join(ROOT, 'plugins', 'axiomcode', 'hooks') diff --git a/tests/mcp_first.py b/tests/mcp_first.py index 39499ab56..1c4b6068a 100644 --- a/tests/mcp_first.py +++ b/tests/mcp_first.py @@ -13,6 +13,9 @@ python3 tests/mcp_first.py indexes one case, so it needs the engine, as hosts.py does """ import json, os, re, shutil, subprocess, sys, tempfile +# the directive's once-per-session stamp lives in the temp directory, keyed on the session: a run of its own, or a +# second run of this script reuses the first run's session ids and hears nothing +os.environ['TMPDIR'] = tempfile.mkdtemp(prefix='ax-hooks-') ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PLUG = os.path.join(ROOT, 'plugins', 'axiomcode') From decee859cc6c226e0a5a6ef2439b5f5fa11fdf08 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:34:42 -0700 Subject: [PATCH 23/31] java: a var in an enhanced for, a try-with-resources resource and a lambda parameter takes its type (#1401) A `var` local whose type does not come from its initializer expression was left a declared unknown in three shapes, recorded as known-missing in case 61: - the element variable of `for (var x : xs)`: now the component type of an array, the written type argument of a platform collection (or an `? extends` bound), or the Iterable argument of a client type, read from what the client wrote when the platform is not staged; - a try-with-resources resource: its implicit close() is now a synthesised `resource_close` edge to the resource type's no-argument close(), declared or `var`; - a lambda parameter, `var` or implicit, whose target is a platform functional interface: a table maps each parameter to the interface's type argument. The bundle positions a resource_close site at the resource local's declaration. Case 61 gains the shapes with controls (raw list, `? super`, an unlisted interface, a second type argument, a var resource of unknown type that gets no close()). Its three known-missing edges now resolve, so the file is removed; the bytecode oracle agrees 37/37. --- graph/bundle/SCHEMA.md | 2 + graph/bundle/build.ts | 14 ++ graph/bundle/languages.ts | 14 ++ graph/bundle/schema.ts | 2 + .../engine/call-edge-generation/call_chain.dl | 42 ++++++ .../engine/expression-resolution/lambda.dl | 72 +++++++++++ .../java/engine/resolution/reference-types.dl | 120 ++++++++++++++++++ graph/java/souffle/decls_all.dl | 20 +++ .../61-var-local-type/src/probe/Bag.java | 11 ++ .../61-var-local-type/src/probe/Gadget.java | 6 + .../src/probe/VarLocals.java | 107 +++++++++++++++- .../61-var-local-type/src/probe/Widget.java | 5 + .../java/expected/61-var-local-type.edges | 27 +++- .../expected/61-var-local-type.field-oracle | 7 + .../java/expected/61-var-local-type.fields | 2 + .../expected/61-var-local-type.known-missing | 13 -- .../java/expected/61-var-local-type.oracle | 5 +- .../java/expected/61-var-local-type.type-use | 27 ++++ .../61-var-local-type.type-use-oracle | 9 ++ 19 files changed, 485 insertions(+), 20 deletions(-) create mode 100644 graph/test/java/cases/61-var-local-type/src/probe/Bag.java create mode 100644 graph/test/java/cases/61-var-local-type/src/probe/Gadget.java create mode 100644 graph/test/java/expected/61-var-local-type.field-oracle create mode 100644 graph/test/java/expected/61-var-local-type.fields delete mode 100644 graph/test/java/expected/61-var-local-type.known-missing create mode 100644 graph/test/java/expected/61-var-local-type.type-use-oracle diff --git a/graph/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md index 6766d5259..7a17d7bf9 100644 --- a/graph/bundle/SCHEMA.md +++ b/graph/bundle/SCHEMA.md @@ -436,6 +436,7 @@ One row per place a call is written (or, for a synthesised edge, the construct t - **typescript** — caller_id is normally the parser's caller method, or the module initializer for top-level code; when neither exists it is the TS_MODULE_ hash itself, kept as a greppable marker rather than a blank. - **java** — callee_name for `new X()` and for `new X() { … }` (anon_new) is the class name written at the site; NULL for ctor_delegate (`this(…)`/`super(…)`) and record_accessor, which write no name. A by-name lookup must therefore exclude kind IN (new, anon_new) to avoid counting a construction as a call to a same-named method. - **java** — A record_accessor site is the RECORD_PATTERN expression, positioned where the pattern is written. +- **java** — A resource_close site is the resource LOCAL of a try-with-resources, positioned at its declaration (line only, no column); callee_name is NULL. - **python** — A DECORATOR_APPLICATION edge targets the callable the decorator factory RETURNS, not the name written at the `@` — `@deco(X)` applies the inner callable that `deco` returned. The written name is carried by the separate DECORATOR_CALL row, so a by-name lookup must exclude DECORATOR_APPLICATION or it will read the wrapper as a mismatch. - **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. @@ -483,6 +484,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h | `anon_new` | java | `new X() { … }` — an anonymous class creation. | | `event` | java | Synthesised: the publishEvent call site to a listener it runs (tier event_dispatch). Not a written call to that method. | | `record_accessor` | java | Synthesised: a record pattern `case Pair(var l, var r)` calls each accessor. Not a written call; the site is the pattern expression. | +| `resource_close` | java | Synthesised: a try-with-resources resource `try (var w = open())` is closed when the block exits, so its close() is called. Not a written call; the site is the resource local. | | `FUNCTION_CALL` | typescript | `f(…)` — a bare callee. | | `METHOD_CALL` | typescript | `obj.m(…)`. | | `CONSTRUCTOR_CALL` | typescript | `new X(…)`. | diff --git a/graph/bundle/build.ts b/graph/bundle/build.ts index 0723c4d46..c2a515f67 100644 --- a/graph/bundle/build.ts +++ b/graph/bundle/build.ts @@ -515,6 +515,20 @@ export async function buildCore(inp: BuildInputs): Promise { } } } + if (A.ir.localSites) { + const L = A.ir.localSites; + const src = await clientSource(inp.clientIrDir, L.file); + if (src) { + const h = src.header; + const ci = h.col(L.id), cl = h.col(L.startLine), cel = h.col(L.endLine); + for await (const r of rowsOf(src)) { + const row = sites.get(r[ci] ?? ''); + if (!row) continue; + fill(row, 4, fileOf(L.fileVia, h, r)); + fill(row, 5, int(r[cl])); fill(row, 7, int(r[cel])); + } + } + } // a site keyed on a type (Python METACLASS_CREATION) is positioned at the class declaration for (const row of sites.values()) { if (row[5] !== null) continue; diff --git a/graph/bundle/languages.ts b/graph/bundle/languages.ts index cfb657d2b..baee29540 100644 --- a/graph/bundle/languages.ts +++ b/graph/bundle/languages.ts @@ -94,6 +94,14 @@ export interface CallSitesIR { file: string; expressionId: string; calleeName: string; startLine: string; startColumn: string; endLine?: string; fileVia: { column: string; through: 'modules' }; } +/** + * Java try-with-resources: the implicit close() edge is keyed on the resource LOCAL, which has no + * expression row, so the site is positioned at the local's declaration instead. + */ +export interface LocalSitesIR { + file: string; id: string; startLine: string; endLine: string; + fileVia: { column: string; through: 'modules' | 'types' }; +} /** * Python decorators — a site id may be the decorator's own hash (a bare decorator with no * expression row) or the decorator's EXPRESSION hash; the row links both, so it is matched @@ -150,6 +158,7 @@ export interface LanguageAdapter { expressions: ExpressionsIR; callSites?: CallSitesIR; decorators?: DecoratorsIR; + localSites?: LocalSitesIR; /** absent where the front end writes no skipped-files report */ skipped?: SkippedIR; }; @@ -207,6 +216,11 @@ const JAVA: LanguageAdapter = { // the parser puts the invoked name / created class name in literalValue for these kinds calleeName: { column: 'literalValue', kinds: ['METHOD_INVOCATION', 'OBJECT_CREATION', 'METHOD_REFERENCE', 'ANONYMOUS_CLASS_CREATION'] }, }, + // a resource_close site is the try-with-resources resource local, positioned at its declaration + localSites: { + file: 'all-local-variables.csv', id: 'localVariableRegistryUniqueHash', startLine: 'startLine', endLine: 'endLine', + fileVia: { column: 'typeRegistryLinkHash', through: 'types' }, + }, // The Java report carries no construct and no position: the three reasons it emits // (EMPTY_CONTENT, FILE_TOO_LARGE, READ_ERROR) are properties of the whole file. skipped: { file: 'skipped-java-files.csv', filePath: 'filePath', reason: 'reason' }, diff --git a/graph/bundle/schema.ts b/graph/bundle/schema.ts index cd7b1e189..c4bfee466 100644 --- a/graph/bundle/schema.ts +++ b/graph/bundle/schema.ts @@ -616,6 +616,7 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'call_edges', column: 'kind', value: 'anon_new', languages: J, meaning: '`new X() { … }` — an anonymous class creation.' }, { table: 'call_edges', column: 'kind', value: 'event', languages: J, meaning: 'Synthesised: the publishEvent call site to a listener it runs (tier event_dispatch). Not a written call to that method.' }, { table: 'call_edges', column: 'kind', value: 'record_accessor', languages: J, meaning: 'Synthesised: a record pattern `case Pair(var l, var r)` calls each accessor. Not a written call; the site is the pattern expression.' }, + { table: 'call_edges', column: 'kind', value: 'resource_close', languages: J, meaning: 'Synthesised: a try-with-resources resource `try (var w = open())` is closed when the block exits, so its close() is called. Not a written call; the site is the resource local.' }, // — TypeScript (the parser's callKind) { table: 'call_edges', column: 'kind', value: 'FUNCTION_CALL', languages: T, meaning: '`f(…)` — a bare callee.' }, { table: 'call_edges', column: 'kind', value: 'METHOD_CALL', languages: T, meaning: '`obj.m(…)`.' }, @@ -733,6 +734,7 @@ export const NOTES: readonly NoteSpec[] = [ { language: 'typescript', table: 'call_sites', note: 'caller_id is normally the parser\'s caller method, or the module initializer for top-level code; when neither exists it is the TS_MODULE_ hash itself, kept as a greppable marker rather than a blank.' }, { language: 'java', table: 'call_sites', note: 'callee_name for `new X()` and for `new X() { … }` (anon_new) is the class name written at the site; NULL for ctor_delegate (`this(…)`/`super(…)`) and record_accessor, which write no name. A by-name lookup must therefore exclude kind IN (new, anon_new) to avoid counting a construction as a call to a same-named method.' }, { language: 'java', table: 'call_sites', note: 'A record_accessor site is the RECORD_PATTERN expression, positioned where the pattern is written.' }, + { language: 'java', table: 'call_sites', note: 'A resource_close site is the resource LOCAL of a try-with-resources, positioned at its declaration (line only, no column); callee_name is NULL.' }, { language: 'python', table: 'call_sites', note: 'A DECORATOR_APPLICATION edge targets the callable the decorator factory RETURNS, not the name written at the `@` — `@deco(X)` applies the inner callable that `deco` returned. The written name is carried by the separate DECORATOR_CALL row, so a by-name lookup must exclude DECORATOR_APPLICATION or it will read the wrapper as a mismatch.' }, { language: 'typescript', table: 'call_sites', note: 'end_line / end_column come from the expression row; the call-site row itself records only the start.' }, { language: 'javascript', table: 'methods', note: 'signature is empty and owner_qualified_name is NULL: JavaScript declares neither. owner_type_id is set for class members, including members declared by assignment.' }, diff --git a/graph/java/engine/call-edge-generation/call_chain.dl b/graph/java/engine/call-edge-generation/call_chain.dl index b509e6ed2..2e904be31 100644 --- a/graph/java/engine/call-edge-generation/call_chain.dl +++ b/graph/java/engine/call-edge-generation/call_chain.dl @@ -221,6 +221,48 @@ call_chain_edge(patternExpr, caller, "-", accessor, "lib", "boundary_lib", "reco record_pattern_accessor(patternExpr, accessor, "lib"), expr_ultimate_method("client", patternExpr, caller). +// ── TRY-WITH-RESOURCES: the implicit close() ───────────────────────────────────────────────── +// `try (var w = Widget.create()) { … }` closes w when the block exits, and javac writes that call +// (`invokevirtual Widget.close()`) into the method. No source line spells it, so there was no site +// and no edge, and a close() that only ever runs this way had no caller at all. A real edge, not +// lowering: close() is the resource's own method, often client code with a body of its own. The +// suppressed-exception plumbing beside it (`addSuppressed`) IS lowering, and both oracles drop it. +// +// Exactly one close() per resource, declared or `var`: the resource's type is its declared type, +// or for `var` its initializer's (JLS 14.4.1). The target is the close() with no parameter that +// lookup finds on that type, plus, as for a written call, the overrides below an abstract or +// interface declaration unless the dispatch cap refuses that fan. +// +// The site is the resource LOCAL: it has no expression row, and the initializer's expression is a +// written call site of its own. The `kind` column says resource_close, so a synthesised edge is +// never mistaken for a written call; like record_accessor it is not an invocation_site. +// Java 9's `try (existing)` names a variable declared elsewhere and has no resource local, so it +// is not covered here. +// java_local_variable (19): 8 scopeKind 11 isVarInferred 13 method 18 hash +twr_resource(local, m) :- java_local_variable(_, _, _, _, _, _, _, _, "TRY_WITH_RESOURCES", _, _, _, _, m, _, _, _, _, local), + m != "". +twr_resource_type(local, t) :- twr_resource(local, _), local_var_type_resolves(_, local, _, t). +twr_resource_type(local, t) :- twr_resource(local, _), + java_local_variable(_, _, _, _, _, _, _, _, _, _, _, "true", _, _, _, _, _, _, local), + local_init_expr(local, e), + expr_type(p, e, t), p != "external". +// java_method / lib_method (22): 0 name 17 parameterCount 21 hash +close_no_arg(m) :- java_method("close", _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, "0", _, _, _, m). +close_no_arg(m) :- lib_method("close", _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, "0", _, _, _, m). +twr_close_decl(local, d) :- twr_resource_type(local, t), method_name_in_type("close", t, d), close_no_arg(d). +twr_close(local, d) :- twr_close_decl(local, d). +twr_close(local, c) :- twr_close_decl(local, d), method_override(c, d), !cha_wide_base(d). +twr_close_count(local, n) :- twr_close(local, _), n = count : { twr_close(local, _) }. + +call_chain_edge(local, caller, "-", c, "client", "known_edge", "resource_close") :- + twr_close(local, c), method_owner("client", _, c), + twr_close_count(local, 1), twr_resource(local, caller). +call_chain_edge(local, caller, "-", c, "client", "multi_inferred", "resource_close") :- + twr_close(local, c), method_owner("client", _, c), + twr_close_count(local, n), n >= 2, twr_resource(local, caller). +call_chain_edge(local, caller, "-", c, "lib", "boundary_lib", "resource_close") :- + twr_close(local, c), method_owner("lib", _, c), twr_resource(local, caller). + call_chain_edge(call, caller, "-", "-", "-", "ambiguous_anon", kind) :- call_class(call, "ambiguous_anon"), call_from(call, caller), invocation_site(call, kind). diff --git a/graph/java/engine/expression-resolution/lambda.dl b/graph/java/engine/expression-resolution/lambda.dl index 0a98fcdf5..8ccff8cd0 100644 --- a/graph/java/engine/expression-resolution/lambda.dl +++ b/graph/java/engine/expression-resolution/lambda.dl @@ -266,6 +266,78 @@ lambda_param_binding(lam, name, argT) :- java_expression("LAMBDA_EXPRESSION", "ARGUMENT", _, _, _, _, call, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, lam), recv_tvar_type(call, classTv, argT). +// ── (3d) A PLATFORM functional interface that is NOT staged ────────────────── +// Every clause above needs the target interface's SAM, so with the JDK not staged a lambda whose +// target is `BiConsumer` typed neither parameter: `(a, b) -> a.paint()` and +// `(var a, var b) -> a.paint()` both left `a` a declared unknown. Which type argument a platform +// interface's SAM takes at each parameter is fixed by the JDK, so it is a table: platform_fi_param +// (interface, lambda parameter position, type argument position). Supplier, Runnable and Callable +// take no parameter and so have no row. An interface not listed stays untyped. #1401. +// +// Keyed on the target's WRITTEN reference and on ref_external_name, which is empty when the +// reference resolves (the JDK staged, or a client type of the same name), so a resolved target is +// left to the SAM clauses above and the two never both answer. A client-declared interface is +// typed there from its own single abstract method. +platform_fi_param("java.util.function", "Function", "0", "0"). +platform_fi_param("java.util.function", "BiFunction", "0", "0"). +platform_fi_param("java.util.function", "BiFunction", "1", "1"). +platform_fi_param("java.util.function", "Consumer", "0", "0"). +platform_fi_param("java.util.function", "BiConsumer", "0", "0"). +platform_fi_param("java.util.function", "BiConsumer", "1", "1"). +platform_fi_param("java.util.function", "Predicate", "0", "0"). +platform_fi_param("java.util.function", "BiPredicate", "0", "0"). +platform_fi_param("java.util.function", "BiPredicate", "1", "1"). +platform_fi_param("java.util.function", "UnaryOperator", "0", "0"). +platform_fi_param("java.util.function", "BinaryOperator", "0", "0"). +platform_fi_param("java.util.function", "BinaryOperator", "1", "0"). +platform_fi_param("java.util", "Comparator", "0", "0"). +platform_fi_param("java.util", "Comparator", "1", "0"). + +platform_fi_ref(ref, n) :- ref_external_name(ref, q), + platform_fi_param(pkg, n, _, _), + q = cat(pkg, ".", n). +platform_fi_ref(ref, n) :- ref_external_name(ref, n), + platform_fi_param(pkg, n, _, _), + ref_file(ref, n, fp), + import_wildcard("client", pkg, fp, _). + +// The written target reference, resolved or not: the declared type of the local, field, method +// return or assigned local the lambda initializes, or the declared type of the client method +// parameter it is passed to. Same five shapes as lambda_target_fi_ref above. +lambda_target_written_ref(lam, ref) :- + java_expression("LAMBDA_EXPRESSION", "ROOT", "LOCAL_VAR_INITIALIZER", "LOCAL_VARIABLE", _, local, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, lam), + java_type_reference(_, "LOCAL_VARIABLE", _, _, _, _, _, "0", _, _, _, _, _, _, _, local, "LOCAL_VARIABLE", ref). +lambda_target_written_ref(lam, ref) :- + java_expression("LAMBDA_EXPRESSION", "ROOT", "FIELD_INITIALIZER", "FIELD", _, field, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, lam), + java_type_reference(_, "FIELD_TYPE", _, _, _, _, _, "0", _, _, _, _, _, _, _, field, "FIELD", ref). +lambda_target_written_ref(lam, ref) :- + java_expression("LAMBDA_EXPRESSION", "ROOT", "RETURN_VALUE", "RETURN_STATEMENT", _, m, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, lam), + java_type_reference(_, "METHOD_RETURN", _, _, _, _, _, "0", _, _, _, _, _, _, _, m, "METHOD", ref). +lambda_target_written_ref(lam, ref) :- + java_expression("LAMBDA_EXPRESSION", "ASSIGNMENT_VALUE", _, _, _, _, asgn, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, lam), + java_expression(_, "ASSIGNMENT_TARGET", _, _, _, _, asgn, _, _, _, name, _, _, _, _, _, _, _, _, _, _, _, _, _, tgt), + expr_ultimate_method("client", tgt, m), + java_local_variable(name, _, _, _, _, _, _, _, _, _, _, _, _, m, _, _, _, _, local), + java_type_reference(_, "LOCAL_VARIABLE", _, _, _, _, _, "0", _, _, _, _, _, _, _, local, "LOCAL_VARIABLE", ref). +lambda_target_written_ref(lam, ref) :- + java_expression("LAMBDA_EXPRESSION", "ARGUMENT", _, _, _, _, call, argpos, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, lam), + applicable_candidate(call, callee), + param_decl("client", _, argpos, _, callee, ph), + java_type_reference(_, "METHOD_PARAM", _, _, _, _, _, "0", _, _, _, _, _, _, _, ph, "METHOD_PARAM", ref). + +// The parameter type is the type argument as written, or a wildcard's bound: the function type of +// `Consumer` takes a W (JLS 9.9), and so does that of `Function`. +fi_written_arg(ref, pos, argRef) :- written_type_arg(ref, pos, argRef). +fi_written_arg(ref, pos, argRef) :- wildcard_type_arg(ref, pos, _, argRef). + +lambda_param_binding(lam, name, t) :- + java_expression("IDENTIFIER_REFERENCE", "LAMBDA_PARAMETER", _, _, _, _, lam, k, _, _, name, _, _, _, "LAMBDA_PARAMETER", _, _, _, _, _, _, _, _, _, _), + lambda_target_written_ref(lam, ref), + platform_fi_ref(ref, n), + platform_fi_param(_, n, k, j), + fi_written_arg(ref, j, argRef), + type_ref_resolves(_, argRef, t). + // Type every LAMBDA_PARAMETER use (receiver/argument) to its bound type, scoped to the same // enclosing method as the lambda and matched by name. Feeds the normal typed-receiver dispatch, // so `s.open(x)` resolves on the bound type. (Name+method scope; nested same-name lambda params diff --git a/graph/java/engine/resolution/reference-types.dl b/graph/java/engine/resolution/reference-types.dl index 702199541..a5e7e60bc 100644 --- a/graph/java/engine/resolution/reference-types.dl +++ b/graph/java/engine/resolution/reference-types.dl @@ -90,6 +90,126 @@ local_var_type_resolves(prov, local, ref, elem) :- foreach_iterable(local, iterE lib_type(_, "java.lang.Iterable", _, _, _, _, _, _, _, _, _, _, _, ity), type_prov(elem, prov). +// ── `for (var x : xs)` with the platform NOT staged ───────────────────────────────────────── +// Both clauses above climb to java.lang.Iterable's own type variable, which exists only when the +// JDK IR is staged. Without it `for (var x : ws)` over a `List ws` typed nothing, while +// `for (Widget x : ws)` resolved because the element type is written there. Three shapes are read +// from what the client wrote instead, each keyed on the iterable's WRITTEN declaration reference +// (resolved or not): an array, a platform collection with its one type argument, and a client type +// that implements Iterable. #1401. +// +// The written reference of the iterable expression: a parameter, a local, a field, or the declared +// return of a client method called in place. Unlike recv_decl_ref this does not ask the reference +// to resolve, since `List` does not resolve when the platform is not staged. +// java_method_parameter (13): 0 name 2 method 12 hash. java_field (14): 0 name 8 owner 13 hash. +iter_written_ref(iterExpr, ref) :- foreach_iterable(_, iterExpr), + java_expression(_, _, _, _, _, _, _, _, _, _, name, _, _, _, "PARAMETER", _, _, _, _, _, _, _, _, _, iterExpr), + expr_ultimate_method("client", iterExpr, m), + java_method_parameter(name, _, m, _, _, _, _, _, _, _, _, _, param), + java_type_reference(_, "METHOD_PARAM", _, _, _, _, _, "0", _, _, _, _, _, _, _, param, "METHOD_PARAM", ref). +iter_written_ref(iterExpr, ref) :- foreach_iterable(_, iterExpr), + java_expression(_, _, _, _, _, _, _, _, _, _, name, _, _, _, "LOCAL_VARIABLE", _, _, _, _, _, _, _, _, _, iterExpr), + expr_ultimate_method("client", iterExpr, m), + java_local_variable(name, _, _, _, _, _, _, _, _, _, _, _, _, m, _, _, _, _, local), + java_type_reference(_, "LOCAL_VARIABLE", _, _, _, _, _, "0", _, _, _, _, _, _, _, local, "LOCAL_VARIABLE", ref). +iter_written_ref(iterExpr, ref) :- foreach_iterable(_, iterExpr), + java_expression(_, _, _, _, _, _, _, _, _, _, name, _, _, _, "FIELD", _, _, _, _, _, _, _, _, _, iterExpr), + expr_ultimate_type("client", iterExpr, encl), + java_field(name, _, _, _, _, _, _, _, encl, _, _, _, _, field), + java_type_reference(_, "FIELD_TYPE", _, _, _, _, _, "0", _, _, _, _, _, _, _, field, "FIELD", ref). +iter_written_ref(iterExpr, ref) :- foreach_iterable(_, iterExpr), + applicable_candidate(iterExpr, callee), + java_type_reference(_, "METHOD_RETURN", _, _, _, _, _, "0", _, _, _, _, _, _, _, callee, "METHOD", ref). + +// (1) An ARRAY iterable: the element is the component type. One dimension only; the element of a +// `Widget[][]` is a `Widget[]`, on which no method of Widget can be called. +// java_type_reference (18): 0 kind 11 arrayDimensions +local_var_type_resolves(prov, local, ref, elem) :- foreach_iterable(local, iterExpr), + iter_written_ref(iterExpr, ref), + java_type_reference("ARRAY", _, _, _, _, _, _, _, _, _, _, "1", _, _, _, _, _, ref), + type_ref_resolves(prov, ref, elem). + +// (2) A PLATFORM COLLECTION with its one type argument written: `List`, `List`. Only the names in foreach_platform_iterable, each Iterable over its only type +// parameter, and only when the reference names that platform type: the qualified name from a +// single-type import or written out, or the simple name under an on-demand import of its package. +// ref_external_name is empty once the platform is staged, so the clauses above then answer alone. +// A raw `List` writes no argument and stays untyped. +foreach_platform_iterable("java.lang", "Iterable"). +foreach_platform_iterable("java.util", "Collection"). +foreach_platform_iterable("java.util", "List"). +foreach_platform_iterable("java.util", "Set"). +foreach_platform_iterable("java.util", "SortedSet"). +foreach_platform_iterable("java.util", "NavigableSet"). +foreach_platform_iterable("java.util", "SequencedCollection"). +foreach_platform_iterable("java.util", "SequencedSet"). +foreach_platform_iterable("java.util", "Queue"). +foreach_platform_iterable("java.util", "Deque"). +foreach_platform_iterable("java.util", "ArrayList"). +foreach_platform_iterable("java.util", "LinkedList"). +foreach_platform_iterable("java.util", "HashSet"). +foreach_platform_iterable("java.util", "LinkedHashSet"). +foreach_platform_iterable("java.util", "TreeSet"). +foreach_platform_iterable("java.util", "ArrayDeque"). +foreach_platform_iterable("java.util", "PriorityQueue"). + +platform_iterable_ref(ref) :- ref_external_name(ref, q), + foreach_platform_iterable(pkg, n), + q = cat(pkg, ".", n). +// java.lang is imported implicitly, so a bare `Iterable` is the platform's. +platform_iterable_ref(ref) :- ref_external_name(ref, "Iterable"). +platform_iterable_ref(ref) :- ref_external_name(ref, n), + foreach_platform_iterable(pkg, n), + ref_file(ref, n, fp), + import_wildcard("client", pkg, fp, _). + +// The type argument at `pos` of a written reference, as written or as a wildcard's bound. +// `? extends W` reads as a W, which is all a loop variable does with it; `? super W` reads as an +// Object, so for an element the variance is checked (for_elem_type_arg). +// java_type_reference (18): 0 kind 5 parentReferenceHash 6 position 7 depth 12 wildcardVariance +written_type_arg(ref, pos, argRef) :- java_type_reference(k, _, _, _, _, ref, pos, "1", _, _, _, _, _, _, _, _, _, argRef), + k != "WILDCARD", k != "TYPE_VARIABLE". +wildcard_type_arg(ref, pos, variance, argRef) :- java_type_reference("WILDCARD", _, _, _, _, ref, pos, "1", _, _, _, _, variance, _, _, _, _, wc), + java_type_reference(k, _, _, _, _, wc, _, "2", _, _, _, _, _, _, _, _, _, argRef), + k != "WILDCARD", k != "TYPE_VARIABLE". +for_elem_type_arg(ref, argRef) :- written_type_arg(ref, "0", argRef). +for_elem_type_arg(ref, argRef) :- wildcard_type_arg(ref, "0", "EXTENDS", argRef). + +local_var_type_resolves(prov, local, argRef, elem) :- foreach_iterable(local, iterExpr), + iter_written_ref(iterExpr, ref), + platform_iterable_ref(ref), + for_elem_type_arg(ref, argRef), + type_ref_resolves(prov, argRef, elem). + +// (3) A CLIENT type that implements Iterable: `class Bag implements Iterable`, iterated as +// `Bag`. Its implements clause names Iterable, unresolved for the same reason, and the +// argument there is a type variable of the declaring type, bound through the iterable's written +// reference by type_arg_binding (which climbs the client hierarchy), or a concrete type. +// java_type_reference (18): 2 typeRegistryLinkHash (the declaring type) 10 typeVariableName +iterable_decl_arg(a, argRef) :- java_type_reference(_, ctx, a, _, _, _, _, "0", _, _, _, _, _, _, _, _, _, iref), + iterable_decl_context(ctx), + platform_iterable_ref(iref), + java_type_reference(_, _, _, _, _, iref, "0", "1", _, _, _, _, _, _, _, _, _, argRef). +iterable_decl_context("IMPLEMENTS_INTERFACE"). +iterable_decl_context("SUPER_TYPE"). +self_or_ancestor(c, c) :- java_type(_, _, _, _, _, _, _, _, _, _, _, _, _, c). +self_or_ancestor(c, a) :- java_type(_, _, _, _, _, _, _, _, _, _, _, _, _, c), type_ancestor(c, a). + +local_var_type_resolves(prov, local, ref, elem) :- foreach_iterable(local, iterExpr), + iter_written_ref(iterExpr, ref), + type_ref_resolves("client", ref, c), + self_or_ancestor(c, a), + iterable_decl_arg(a, argRef), + java_type_reference("TYPE_VARIABLE", _, _, _, _, _, _, _, _, _, tv, _, _, _, _, _, _, argRef), + type_arg_binding(ref, a, tv, elem), + type_prov(elem, prov). +local_var_type_resolves(prov, local, argRef, elem) :- foreach_iterable(local, iterExpr), + iter_written_ref(iterExpr, ref), + type_ref_resolves("client", ref, c), + self_or_ancestor(c, a), + iterable_decl_arg(a, argRef), + type_ref_resolves(prov, argRef, elem). + // A RECORD DECONSTRUCTION component — `if (n instanceof Pair(Leaf a, Node ignored)) a.render()`. // The component binds a variable exactly as `instanceof Leaf l` does, but the two are shaped // differently in the IR: a bare pattern's type reference is owned by the LOCAL, so the declared-type diff --git a/graph/java/souffle/decls_all.dl b/graph/java/souffle/decls_all.dl index 2d975faec..c27f23b31 100644 --- a/graph/java/souffle/decls_all.dl +++ b/graph/java/souffle/decls_all.dl @@ -932,3 +932,23 @@ .decl event_listener_class(c0:symbol,c1:symbol) .decl event_listener_method(c0:symbol) .decl event_dispatch_edge(c0:symbol,c1:symbol,c2:symbol) +// #1401: `var` in an enhanced for, a try-with-resources resource and a lambda parameter +.decl iter_written_ref(c0:symbol,c1:symbol) // (iterable expr, its written declaration ref) +.decl foreach_platform_iterable(c0:symbol,c1:symbol) // (package, simple name) knob +.decl platform_iterable_ref(c0:symbol) // ref names an unstaged platform Iterable +.decl written_type_arg(c0:symbol,c1:symbol,c2:symbol) // (ref, position, type-argument ref) +.decl wildcard_type_arg(c0:symbol,c1:symbol,c2:symbol,c3:symbol) // (ref, position, variance, bound ref) +.decl for_elem_type_arg(c0:symbol,c1:symbol) // (ref, element type-argument ref) +.decl iterable_decl_arg(c0:symbol,c1:symbol) // (client type, its Iterable argument ref) +.decl iterable_decl_context(c0:symbol) +.decl self_or_ancestor(c0:symbol,c1:symbol) +.decl platform_fi_param(c0:symbol,c1:symbol,c2:symbol,c3:symbol) // (package, interface, lambda param pos, type-arg pos) knob +.decl platform_fi_ref(c0:symbol,c1:symbol) // (ref, platform interface simple name) +.decl lambda_target_written_ref(c0:symbol,c1:symbol) // (lambda, written target ref) +.decl fi_written_arg(c0:symbol,c1:symbol,c2:symbol) // (ref, position, type-argument ref) +.decl twr_resource(c0:symbol,c1:symbol) // (resource local, enclosing method) +.decl twr_resource_type(c0:symbol,c1:symbol) +.decl close_no_arg(c0:symbol) +.decl twr_close_decl(c0:symbol,c1:symbol) +.decl twr_close(c0:symbol,c1:symbol) +.decl twr_close_count(c0:symbol,c1:number) diff --git a/graph/test/java/cases/61-var-local-type/src/probe/Bag.java b/graph/test/java/cases/61-var-local-type/src/probe/Bag.java new file mode 100644 index 000000000..dfcac1ec5 --- /dev/null +++ b/graph/test/java/cases/61-var-local-type/src/probe/Bag.java @@ -0,0 +1,11 @@ +package probe; + +import java.util.Iterator; + +/** A client generic that is iterable over its own type argument. */ +public class Bag implements Iterable { + @Override + public Iterator iterator() { + return null; + } +} diff --git a/graph/test/java/cases/61-var-local-type/src/probe/Gadget.java b/graph/test/java/cases/61-var-local-type/src/probe/Gadget.java new file mode 100644 index 000000000..6dc748e9c --- /dev/null +++ b/graph/test/java/cases/61-var-local-type/src/probe/Gadget.java @@ -0,0 +1,6 @@ +package probe; + +public class Gadget { + public void paint() { + } +} diff --git a/graph/test/java/cases/61-var-local-type/src/probe/VarLocals.java b/graph/test/java/cases/61-var-local-type/src/probe/VarLocals.java index bf5c79e01..195b9a1d3 100644 --- a/graph/test/java/cases/61-var-local-type/src/probe/VarLocals.java +++ b/graph/test/java/cases/61-var-local-type/src/probe/VarLocals.java @@ -4,6 +4,10 @@ import java.util.Collections; import java.util.List; import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.ToIntFunction; /** * `var` is a reserved type name (JLS 14.4), so a local declared with it never has a type called @@ -74,13 +78,71 @@ void fromEnhancedForDeclared(List ws) { } } - /** var resource in try-with-resources. */ + /** var over an array: the component type. */ + void fromArray(Widget[] ws) { + for (var x : ws) { + x.paint(); + } + } + + /** var over a client generic that implements Iterable: T as written on the declaration. */ + void fromClientIterable(Bag bag) { + for (var x : bag) { + x.paint(); + } + } + + /** var over a list whose type argument is a wildcard: its bound. */ + void fromWildcardList(List ws) { + for (var x : ws) { + x.paint(); + } + } + + /** + * control: a raw List has no element type. x is an Object, and Widget's hashCode must not be + * picked for it. + */ + @SuppressWarnings("rawtypes") + int fromRawList(List raw) { + int n = 0; + for (var x : raw) { + n += x.hashCode(); + } + return n; + } + + /** var resource in try-with-resources: the implicit close() at the end is a call. */ void fromResource() { try (var w = Widget.create()) { w.paint(); } } + /** control: the declared resource closes the same way. */ + void fromResourceDeclared() { + try (Widget w = Widget.create()) { + w.paint(); + } + } + + /** two resources, closed in reverse order: one close() each. */ + void twoResources() { + try (var a = new Widget(); var b = Widget.create()) { + a.paint(); + } + } + + /** + * control: a var resource whose initializer is an unstaged platform call. Its type is unknown, + * so there is no close() edge at all, and in particular not Widget's, the only client close(). + */ + long fromResourceUnknown() { + try (var s = java.util.stream.Stream.of(1)) { + return s.count(); + } + } + /** var from an unstaged platform call: the type is unknown, so the call stays unresolved. */ int fromUnknown() { var xs = Collections.emptyList(); @@ -107,4 +169,47 @@ Format source() { BiConsumer lambdaParams() { return (var a, var b) -> a.paint(); } + + /** the same with implicit parameters. */ + BiConsumer lambdaImplicit() { + return (a, b) -> b.paint(); + } + + /** a lambda assigned to a declared local. */ + void lambdaLocal(Widget w) { + Consumer c = x -> x.paint(); + c.accept(w); + } + + /** a lambda passed to a client method whose parameter is `Consumer`. */ + void lambdaArgument() { + each(x -> x.paint()); + } + + void each(Consumer c) { + } + + /** control: the second type argument feeds the second parameter, not the first. */ + BiFunction lambdaSecondArgument() { + return (a, b) -> { + b.paint(); + return a; + }; + } + + /** control: Function's second type argument is its result, never its parameter. */ + Function lambdaResultArgument() { + return g -> { + g.paint(); + return Widget.create(); + }; + } + + /** + * control: a functional interface the table does not list. Its parameter stays untyped, so + * the call on it is a declared unknown rather than a guess. + */ + ToIntFunction lambdaUnknownInterface() { + return x -> x.toString().length(); + } } diff --git a/graph/test/java/cases/61-var-local-type/src/probe/Widget.java b/graph/test/java/cases/61-var-local-type/src/probe/Widget.java index 11ded4520..3f908eb01 100644 --- a/graph/test/java/cases/61-var-local-type/src/probe/Widget.java +++ b/graph/test/java/cases/61-var-local-type/src/probe/Widget.java @@ -11,4 +11,9 @@ public void paint() { @Override public void close() { } + + @Override + public int hashCode() { + return 1; + } } diff --git a/graph/test/java/expected/61-var-local-type.edges b/graph/test/java/expected/61-var-local-type.edges index dcbfc0084..dd0eb4c06 100644 --- a/graph/test/java/expected/61-var-local-type.edges +++ b/graph/test/java/expected/61-var-local-type.edges @@ -1,16 +1,22 @@ -ambiguous_unknown method probe.VarLocals#fromEnhancedFor(List) -> - ambiguous_unknown method probe.VarLocals#fromLiteral() -> - +ambiguous_unknown method probe.VarLocals#fromRawList(List) -> - +ambiguous_unknown method probe.VarLocals#fromResourceUnknown() -> - ambiguous_unknown method probe.VarLocals#fromUnknown() -> - -ambiguous_unknown method probe.VarLocals#lambdaParams() -> - +ambiguous_unknown method probe.VarLocals#lambdaUnknownInterface() -> - ambiguous_unknown method probe.VarLocals#literalInline() -> - boundary_lib method probe.VarLocals#declaredExternal(Object) -> external:java.text.Format.format boundary_lib method probe.VarLocals#fromLiteralDeclared() -> external:String.length +boundary_lib method probe.VarLocals#lambdaLocal(Widget) -> external:java.util.function.Consumer.accept boundary_lib method probe.VarLocals#varExternal(Object) -> external:java.text.Format.format +known_edge lambda_body probe.VarLocals#lambdaLocal(Widget) -> probe.Widget#paint() known_edge method probe.VarLocals#declaredExternal(Object) -> probe.VarLocals#source() +known_edge method probe.VarLocals#fromArray(Widget[]) -> probe.Widget#paint() known_edge method probe.VarLocals#fromCall(Registry) -> probe.Handler#handle() known_edge method probe.VarLocals#fromCall(Registry) -> probe.Registry#lookup(String) known_edge method probe.VarLocals#fromCallDeclared(Registry) -> probe.Handler#handle() known_edge method probe.VarLocals#fromCallDeclared(Registry) -> probe.Registry#lookup(String) +known_edge method probe.VarLocals#fromClientIterable(Bag) -> probe.Widget#paint() +known_edge method probe.VarLocals#fromEnhancedFor(List) -> probe.Widget#paint() known_edge method probe.VarLocals#fromEnhancedForDeclared(List) -> probe.Widget#paint() known_edge method probe.VarLocals#fromFactory() -> probe.Widget#create() known_edge method probe.VarLocals#fromFactory() -> probe.Widget#paint() @@ -19,7 +25,24 @@ known_edge method probe.VarLocals#fromNewReassigned() -> probe.Widget#create() known_edge method probe.VarLocals#fromNewReassigned() -> probe.Widget#paint() known_edge method probe.VarLocals#fromResource() -> probe.Widget#create() known_edge method probe.VarLocals#fromResource() -> probe.Widget#paint() +known_edge method probe.VarLocals#fromResourceDeclared() -> probe.Widget#create() +known_edge method probe.VarLocals#fromResourceDeclared() -> probe.Widget#paint() +known_edge method probe.VarLocals#fromWildcardList(List) -> probe.Widget#paint() +known_edge method probe.VarLocals#lambdaArgument() -> probe.VarLocals#each(Consumer) +known_edge method probe.VarLocals#lambdaArgument() -> probe.Widget#paint() +known_edge method probe.VarLocals#lambdaImplicit() -> probe.Widget#paint() +known_edge method probe.VarLocals#lambdaLocal(Widget) -> probe.Widget#paint() +known_edge method probe.VarLocals#lambdaParams() -> probe.Widget#paint() +known_edge method probe.VarLocals#lambdaResultArgument() -> probe.Gadget#paint() +known_edge method probe.VarLocals#lambdaResultArgument() -> probe.Widget#create() +known_edge method probe.VarLocals#lambdaSecondArgument() -> probe.Gadget#paint() +known_edge method probe.VarLocals#twoResources() -> probe.Widget#create() +known_edge method probe.VarLocals#twoResources() -> probe.Widget#paint() known_edge method probe.VarLocals#varExternal(Object) -> probe.VarLocals#source() known_edge new probe.VarLocals#fromNew() -> probe.Widget#() known_edge new probe.VarLocals#fromNewReassigned() -> probe.Widget#() +known_edge new probe.VarLocals#twoResources() -> probe.Widget#() known_edge new probe.Widget#create() -> probe.Widget#() +known_edge resource_close probe.VarLocals#fromResource() -> probe.Widget#close() +known_edge resource_close probe.VarLocals#fromResourceDeclared() -> probe.Widget#close() +known_edge resource_close probe.VarLocals#twoResources() -> probe.Widget#close() diff --git a/graph/test/java/expected/61-var-local-type.field-oracle b/graph/test/java/expected/61-var-local-type.field-oracle new file mode 100644 index 000000000..ee4c4947d --- /dev/null +++ b/graph/test/java/expected/61-var-local-type.field-oracle @@ -0,0 +1,7 @@ +61-var-local-type + precision 0.0000 (0 correct, 0 wrong) + recall 0.0000 (0 of 0 in the bytecode) + sites 4 resolved 0 (0.0%) + tiers ambiguous_unknown=4 + access read=4 + not scored: 0 initializer-owned, 0 in a class the build did not compile, 0 oracle rows in a static initializer, 0 reads of an inlined constant diff --git a/graph/test/java/expected/61-var-local-type.fields b/graph/test/java/expected/61-var-local-type.fields new file mode 100644 index 000000000..198374c0d --- /dev/null +++ b/graph/test/java/expected/61-var-local-type.fields @@ -0,0 +1,2 @@ +ambiguous_unknown read probe.VarLocals#fromResourceUnknown() -> - +ambiguous_unknown read probe.VarLocals#lambdaSecondArgument() -> - diff --git a/graph/test/java/expected/61-var-local-type.known-missing b/graph/test/java/expected/61-var-local-type.known-missing deleted file mode 100644 index 996b5ec5e..000000000 --- a/graph/test/java/expected/61-var-local-type.known-missing +++ /dev/null @@ -1,13 +0,0 @@ -# Accepted gap, measured by the bytecode oracle in CI: a `var` local whose type comes from something -# other than its initializer expression is left a declared unknown. Before #1401 each of these was a -# wrong `external:var.` edge; now the receiver is honestly untyped. Three shapes: -# - the element variable of an enhanced for loop (`for (var w : widgets)`): its type is the -# iterable's element type; -# - a try-with-resources variable (`try (var w = open())` where open() is not typed client-side); -# - `var` lambda parameters (`(var w) -> w.paint()`): the type comes from the target functional -# interface. -# Follow-up: type `var` in these three shapes. It cannot rot: when one starts resolving, this line -# reports NOW-FIXED and the case fails until it is removed. -probe.VarLocals#fromEnhancedFor -> probe.Widget#paint() -probe.VarLocals#fromResource -> probe.Widget#close() -probe.VarLocals#lambdaParams -> probe.Widget#paint() diff --git a/graph/test/java/expected/61-var-local-type.oracle b/graph/test/java/expected/61-var-local-type.oracle index b29608363..1ea4ecbcc 100644 --- a/graph/test/java/expected/61-var-local-type.oracle +++ b/graph/test/java/expected/61-var-local-type.oracle @@ -1,4 +1 @@ -oracle=20 engine=17 agree=17 missing=3 (known 3, NEW 0) extra=0 - known-missing probe.VarLocals#fromEnhancedFor -> probe.Widget#paint() - known-missing probe.VarLocals#fromResource -> probe.Widget#close() - known-missing probe.VarLocals#lambdaParams -> probe.Widget#paint() +oracle=37 engine=37 agree=37 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/java/expected/61-var-local-type.type-use b/graph/test/java/expected/61-var-local-type.type-use index e1b611ace..92e8e6d01 100644 --- a/graph/test/java/expected/61-var-local-type.type-use +++ b/graph/test/java/expected/61-var-local-type.type-use @@ -1,25 +1,52 @@ +ambiguous_unknown ANNOTATION_TYPE 0 probe.Bag [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.VarLocals [ANNOTATION] -> - ambiguous_unknown ANNOTATION_TYPE 0 probe.Widget [ANNOTATION] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 0 probe.Bag [TYPE] -> - ambiguous_unknown IMPLEMENTS_INTERFACE 0 probe.Widget [TYPE] -> - ambiguous_unknown LOCAL_VARIABLE 0 probe.VarLocals#declaredExternal(Object) [LOCAL_VARIABLE] -> - ambiguous_unknown LOCAL_VARIABLE 0 probe.VarLocals#fromLiteralDeclared() [LOCAL_VARIABLE] -> - +ambiguous_unknown LOCAL_VARIABLE 0 probe.VarLocals#lambdaLocal(Widget) [LOCAL_VARIABLE] -> - ambiguous_unknown METHOD_PARAM 0 probe.Registry#lookup(String) [METHOD_PARAM] -> - ambiguous_unknown METHOD_PARAM 0 probe.VarLocals#declaredExternal(Object) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.VarLocals#each(Consumer) [METHOD_PARAM] -> - ambiguous_unknown METHOD_PARAM 0 probe.VarLocals#fromEnhancedFor(List) [METHOD_PARAM] -> - ambiguous_unknown METHOD_PARAM 0 probe.VarLocals#fromEnhancedForDeclared(List) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.VarLocals#fromRawList(List) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.VarLocals#fromWildcardList(List) [METHOD_PARAM] -> - ambiguous_unknown METHOD_PARAM 0 probe.VarLocals#varExternal(Object) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_RETURN 0 probe.Bag#iterator() [METHOD] -> - ambiguous_unknown METHOD_RETURN 0 probe.VarLocals#declaredExternal(Object) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 probe.VarLocals#lambdaImplicit() [METHOD] -> - ambiguous_unknown METHOD_RETURN 0 probe.VarLocals#lambdaParams() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 probe.VarLocals#lambdaResultArgument() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 probe.VarLocals#lambdaSecondArgument() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 probe.VarLocals#lambdaUnknownInterface() [METHOD] -> - ambiguous_unknown METHOD_RETURN 0 probe.VarLocals#source() [METHOD] -> - ambiguous_unknown METHOD_RETURN 0 probe.VarLocals#varExternal(Object) [METHOD] -> - known_edge LOCAL_VARIABLE 0 probe.VarLocals#fromCallDeclared(Registry) [LOCAL_VARIABLE] -> probe.Handler known_edge LOCAL_VARIABLE 0 probe.VarLocals#fromEnhancedForDeclared(List) [LOCAL_VARIABLE] -> probe.Widget +known_edge LOCAL_VARIABLE 0 probe.VarLocals#fromResourceDeclared() [LOCAL_VARIABLE] -> probe.Widget +known_edge LOCAL_VARIABLE 1 probe.VarLocals#lambdaLocal(Widget) [LOCAL_VARIABLE] -> probe.Widget +known_edge METHOD_PARAM 0 probe.VarLocals#fromArray(Widget[]) [METHOD_PARAM] -> probe.Widget known_edge METHOD_PARAM 0 probe.VarLocals#fromCall(Registry) [METHOD_PARAM] -> probe.Registry known_edge METHOD_PARAM 0 probe.VarLocals#fromCallDeclared(Registry) [METHOD_PARAM] -> probe.Registry +known_edge METHOD_PARAM 0 probe.VarLocals#fromClientIterable(Bag) [METHOD_PARAM] -> probe.Bag +known_edge METHOD_PARAM 0 probe.VarLocals#lambdaLocal(Widget) [METHOD_PARAM] -> probe.Widget +known_edge METHOD_PARAM 1 probe.VarLocals#fromClientIterable(Bag) [METHOD_PARAM] -> probe.Widget known_edge METHOD_PARAM 1 probe.VarLocals#fromEnhancedFor(List) [METHOD_PARAM] -> probe.Widget known_edge METHOD_PARAM 1 probe.VarLocals#fromEnhancedForDeclared(List) [METHOD_PARAM] -> probe.Widget +known_edge METHOD_PARAM 2 probe.VarLocals#each(Consumer) [METHOD_PARAM] -> probe.Widget +known_edge METHOD_PARAM 2 probe.VarLocals#fromWildcardList(List) [METHOD_PARAM] -> probe.Widget known_edge METHOD_RETURN 0 probe.Registry#lookup(String) [METHOD] -> probe.Handler known_edge METHOD_RETURN 0 probe.Widget#create() [METHOD] -> probe.Widget +known_edge METHOD_RETURN 1 probe.VarLocals#lambdaImplicit() [METHOD] -> probe.Widget known_edge METHOD_RETURN 1 probe.VarLocals#lambdaParams() [METHOD] -> probe.Widget +known_edge METHOD_RETURN 1 probe.VarLocals#lambdaResultArgument() [METHOD] -> probe.Gadget +known_edge METHOD_RETURN 1 probe.VarLocals#lambdaResultArgument() [METHOD] -> probe.Widget +known_edge METHOD_RETURN 1 probe.VarLocals#lambdaSecondArgument() [METHOD] -> probe.Gadget +known_edge METHOD_RETURN 1 probe.VarLocals#lambdaSecondArgument() [METHOD] -> probe.Widget +known_edge METHOD_RETURN 1 probe.VarLocals#lambdaUnknownInterface() [METHOD] -> probe.Widget known_edge OBJECT_CREATION_TYPE 0 probe.VarLocals#fromNew() [EXPRESSION] -> probe.Widget known_edge OBJECT_CREATION_TYPE 0 probe.VarLocals#fromNewReassigned() [EXPRESSION] -> probe.Widget +known_edge OBJECT_CREATION_TYPE 0 probe.VarLocals#twoResources() [EXPRESSION] -> probe.Widget known_edge OBJECT_CREATION_TYPE 0 probe.Widget#create() [EXPRESSION] -> probe.Widget diff --git a/graph/test/java/expected/61-var-local-type.type-use-oracle b/graph/test/java/expected/61-var-local-type.type-use-oracle new file mode 100644 index 000000000..1e8ab296f --- /dev/null +++ b/graph/test/java/expected/61-var-local-type.type-use-oracle @@ -0,0 +1,9 @@ +61-var-local-type + precision 1.0000 (11 correct, 0 wrong) + recall 0.8462 (11 of 13 in the bytecode) + references 56 resolved 30 (53.6%) + tiers ambiguous_unknown=26 known_edge=30 + not scored: 0 in a context the bytecode does not carry, 0 in a class the build did not compile, 0 local in a class compiled without -g + contexts ANNOTATION_TYPE=4 IMPLEMENTS_INTERFACE=2 LOCAL_VARIABLE=7 METHOD_PARAM=18 METHOD_RETURN=21 OBJECT_CREATION_TYPE=4 + MISSING probe.VarLocals CAST_EXPRESSION probe.Widget + MISSING probe.VarLocals METHOD_PARAM probe.Gadget From a2276bd7414ded75a23999c8a9226c2265750c54 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:59:02 -0700 Subject: [PATCH 24/31] ci: key the compiled engine cache on the CPU's feature flags, and drop a cached engine that dies with SIGILL The engine is compiled with -march=native, and the CI cache key named the CPU by its model name only. Hosted runners that report the same model name do not all expose the same instruction set, so a binary compiled on one was restored onto another and died with an illegal instruction on every TypeScript case. The key now hashes the model name and the flags line of /proc/cpuinfo. The driver also removes a locally cached binary that exits with SIGILL, so a bad entry in a shared cache kills one run, not every run after it. --- .github/workflows/ci.yml | 7 +++++-- graph/pipeline/run-souffle.sh | 13 ++++++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e09200258..a59ce1fd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -397,12 +397,15 @@ jobs: # Two things decide the binary that ENGINE_ID does not cover, so they are in the # key too: the driver that compiles it (run-souffle.sh holds the compiler flags), # and the runner's CPU, because the driver compiles with -march=native and a - # binary built on one CPU model can die with an illegal instruction on another. + # binary built on one CPU can die with an illegal instruction on another. + # The CPU part hashes the FEATURE FLAGS, not only the model name: hosted runners + # that report the same model name do not all expose the same instruction set, so + # a model-name key restored a binary that died with SIGILL on every case. - name: this language's engine id id: eid run: | echo "id=$(bash graph/pipeline/run-souffle.sh --language ${{ matrix.lang }} --print-engine-id)" >> "$GITHUB_OUTPUT" - echo "cpu=$(grep -m1 'model name' /proc/cpuinfo | sha256sum | cut -c1-12)" >> "$GITHUB_OUTPUT" + echo "cpu=$(grep -E '^(model name|flags)[[:space:]]*:' /proc/cpuinfo | sort -u | sha256sum | cut -c1-12)" >> "$GITHUB_OUTPUT" - name: cache the compiled Soufflé engine if: ${{ !inputs.fresh }} diff --git a/graph/pipeline/run-souffle.sh b/graph/pipeline/run-souffle.sh index 5cf84aacf..2b0cb5424 100755 --- a/graph/pipeline/run-souffle.sh +++ b/graph/pipeline/run-souffle.sh @@ -699,7 +699,18 @@ while [ "$iter" -lt 50 ]; do echo "▶ solving with profiling -> $AXIOM_SOUFFLE_PROFILE" "$PBIN" -F "$FACTS" -D "$RAW" -p "$AXIOM_SOUFFLE_PROFILE" else - "$BIN" -F "$FACTS" -D "$RAW" + # A cached binary is compiled with -march=native. Restored onto a CPU without one of + # the instructions it uses (a shared cache, a CI cache keyed too coarsely), it dies + # with SIGILL (exit 132) before solving anything. Never leave it there to kill every + # later run the same way: drop the cache entry, so the next run recompiles, and say so. + rc=0; "$BIN" -F "$FACTS" -D "$RAW" || rc=$? + if [ "$rc" -ne 0 ]; then + if [ "$rc" -eq 132 ] && [ -z "$PACKAGED" ]; then + rm -f "$BIN" + echo "❌ the cached engine $BIN died with an illegal instruction: it was compiled for a different CPU. Removed it; the next run recompiles." >&2 + fi + exit "$rc" + fi fi # No frontier declared for this language: the solve above is the whole answer. Break # BEFORE the count, because the count is what misreported it. See issue #475. From b071da4e9ad1d7349eb7b8ded0995cfd97af3d9c Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:14:13 -0700 Subject: [PATCH 25/31] typescript: a function held in an array or table is run by the call that iterates or indexes it (#967) `const steps = [a, b]; for (const s of steps) s(node)` left `a` and `b` with no caller, and everything they reach lost its tests: impact answered "0 tests" for a helper three hops below a pipeline whose tests were indexed. The value-flow holder rules now follow a function one step further, into the elements of an array or object literal a variable, field or parameter holds (through `as const`/`satisfies`, `push`/`unshift` and a spread). A read of an element runs what the elements hold: a for-of binding (also over `Object.values(h)`), `h[i]` / `h[key]`, the element parameter of a forEach/map/filter/reduce callback, and `h.name` over a literal, which reaches only the property of that name. Each such call is a `fn_value_call`, the dispatch rung that impact, path and test selection walk. No call-site answer moves: for several elements the compiler names one arbitrary member of the union, so the call-site sites stay pinned as known-missing. Case 78 covers every shape, with controls that must not change: an array nothing iterates, a for-in over the table, a direct call, and a written key that must not reach the other entries. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../78-callable-collections/src/pipeline.ts | 83 +++++++++++ .../78-callable-collections/src/steps.ts | 18 +++ .../expected/64-array-dispatch.envelope | 3 + .../expected/64-array-dispatch.known-missing | 7 + ...ct-literal-and-expression-callees.envelope | 1 + .../expected/78-callable-collections.edges | 25 ++++ .../expected/78-callable-collections.entries | 15 ++ .../expected/78-callable-collections.envelope | 19 +++ .../expected/78-callable-collections.fields | 2 + .../78-callable-collections.fields-oracle | 10 ++ .../78-callable-collections.known-missing | 20 +++ .../expected/78-callable-collections.oracle | 11 ++ .../expected/78-callable-collections.type-use | 8 ++ .../78-callable-collections.types-oracle | 7 + .../engine/resolution/value-flow.dl | 135 ++++++++++++++++++ graph/typescript/souffle/decls_all.dl | 8 ++ 16 files changed, 372 insertions(+) create mode 100644 graph/test/typescript/cases/78-callable-collections/src/pipeline.ts create mode 100644 graph/test/typescript/cases/78-callable-collections/src/steps.ts create mode 100644 graph/test/typescript/expected/64-array-dispatch.envelope create mode 100644 graph/test/typescript/expected/78-callable-collections.edges create mode 100644 graph/test/typescript/expected/78-callable-collections.entries create mode 100644 graph/test/typescript/expected/78-callable-collections.envelope create mode 100644 graph/test/typescript/expected/78-callable-collections.fields create mode 100644 graph/test/typescript/expected/78-callable-collections.fields-oracle create mode 100644 graph/test/typescript/expected/78-callable-collections.known-missing create mode 100644 graph/test/typescript/expected/78-callable-collections.oracle create mode 100644 graph/test/typescript/expected/78-callable-collections.type-use create mode 100644 graph/test/typescript/expected/78-callable-collections.types-oracle diff --git a/graph/test/typescript/cases/78-callable-collections/src/pipeline.ts b/graph/test/typescript/cases/78-callable-collections/src/pipeline.ts new file mode 100644 index 000000000..729b63fef --- /dev/null +++ b/graph/test/typescript/cases/78-callable-collections/src/pipeline.ts @@ -0,0 +1,83 @@ +import { widen, narrow, square, halve, pushed, spreadIn, neverRun, exportedSteps, type Node } from './steps' + +// Callables registered as DATA and dispatched by iteration, index or key (#967). None of +// these call sites names a declaration, so each is a value call through the collection: +// a dispatch-rung route in the envelope, not a resolved call-site edge. + +const steps = [widen, narrow] +const frozen = [square] as const +const table = { grow: widen, shrink: narrow } +const registry: Array<(n: Node) => Node> = [] +registry.push(pushed) +const extended = [...steps, spreadIn] +const idle = [neverRun] + +// iterated +export function runIterated(node: Node): Node { + for (const step of steps) node = step(node) + return node +} + +// iterated through `as const` +export function runFrozen(node: Node): Node { + for (const step of frozen) node = step(node) + return node +} + +// indexed, and indexed into a local first +export function runIndexed(node: Node, i: number): Node { + const pick = steps[i] + return pick(steps[0](node)) +} + +// a table read under a computed key reaches every entry; under a written key, only that entry +export function runByKey(node: Node, k: 'grow' | 'shrink'): Node { + return table[k](node) +} +export function runByName(node: Node): Node { + return table.shrink(node) +} + +// the element parameter of an array method's callback +export function runForEach(node: Node): void { + steps.forEach((step) => step(node)) +} +export function runReduce(node: Node): Node { + return frozen.reduce((acc, step) => step(acc), node) +} + +// filled by push, and by a spread of another registry +export function runRegistry(node: Node): Node { + for (const step of registry) node = step(node) + for (const step of extended) node = step(node) + return node +} + +// imported from the module that declares it +export function runImported(node: Node): Node { + for (const step of exportedSteps) node = step(node) + return node +} + +// the values of a table +export function runValues(node: Node): Node { + for (const step of Object.values(table)) node = step(node) + return node +} + +// CONTROL: the idle array is only measured, never run, so neverRun keeps no caller. +export function countIdle(): number { + return idle.length +} + +// CONTROL: a for-in binds KEYS, never elements, so nothing here is a call through the table. +export function keysOnly(): string[] { + const out: string[] = [] + for (const k in table) out.push(k) + return out +} + +// CONTROL: called by name, the shape that never needed a rule. +export function runDirect(node: Node): Node { + return halve(node) +} diff --git a/graph/test/typescript/cases/78-callable-collections/src/steps.ts b/graph/test/typescript/cases/78-callable-collections/src/steps.ts new file mode 100644 index 000000000..4967d656c --- /dev/null +++ b/graph/test/typescript/cases/78-callable-collections/src/steps.ts @@ -0,0 +1,18 @@ +export type Node = { width: number } + +// Each step is named at NO call site: it is reached only through the collection holding it. +export function widen(node: Node): Node { return { width: measure(node) + 1 } } +export function narrow(node: Node): Node { return { width: measure(node) - 1 } } +export function square(node: Node): Node { return { width: measure(node) * 2 } } +export function halve(node: Node): Node { return { width: measure(node) / 2 } } +export function pushed(node: Node): Node { return { width: measure(node) + 3 } } +export function spreadIn(node: Node): Node { return { width: measure(node) + 4 } } + +// CONTROL: held in an array nothing ever iterates, indexes or calls through. +export function neverRun(node: Node): Node { return { width: measure(node) + 5 } } + +// The far end of every chain. +export function measure(node: Node): number { return node.width } + +// An exported registry, iterated from another module. +export const exportedSteps = [halve] diff --git a/graph/test/typescript/expected/64-array-dispatch.envelope b/graph/test/typescript/expected/64-array-dispatch.envelope new file mode 100644 index 000000000..9f8a80ae1 --- /dev/null +++ b/graph/test/typescript/expected/64-array-dispatch.envelope @@ -0,0 +1,3 @@ +value_call pipeline#runByName -> steps#squareRange +value_call pipeline#runIndexed -> steps#widenRange +value_call pipeline#runIterated -> steps#widenRange diff --git a/graph/test/typescript/expected/64-array-dispatch.known-missing b/graph/test/typescript/expected/64-array-dispatch.known-missing index e15505412..282e63097 100644 --- a/graph/test/typescript/expected/64-array-dispatch.known-missing +++ b/graph/test/typescript/expected/64-array-dispatch.known-missing @@ -11,6 +11,13 @@ # Measured against the TypeScript compiler's own answer (checker.getResolvedSignature, # via tools/tsc_oracle_case.mjs): oracle=7 engine=4 agree=4 extra=0. runDirect, and # each step's own call to measure, resolve and are the controls. +# +# They are no longer SILENT (#967): each is a `value_call` in +# expected/64-array-dispatch.envelope, a dispatch-rung route that impact, path and +# test selection walk. The call-site answer stays absent on purpose. For a collection +# of several functions the compiler names one arbitrary member of the element union, +# and a call-site answer could only match it by being wrong at every other element +# (case 78 measures that). pipeline#runIterated(Node) -> steps#widenRange(Node) pipeline#runIndexed(Node) -> steps#widenRange(Node) pipeline#runByName(Node) -> steps#squareRange(Node) diff --git a/graph/test/typescript/expected/72-object-literal-and-expression-callees.envelope b/graph/test/typescript/expected/72-object-literal-and-expression-callees.envelope index 15989edb1..baaffa735 100644 --- a/graph/test/typescript/expected/72-object-literal-and-expression-callees.envelope +++ b/graph/test/typescript/expected/72-object-literal-and-expression-callees.envelope @@ -10,4 +10,5 @@ value pool#Pool.size -> pool#size value registry#@3:22 -> registry#money value_call fetcher#get -> fetcher# value_call fetcher#get -> fetcher#defaultFetch +value_call pool#usePool -> pool#@25 value_call registry#price -> registry#money diff --git a/graph/test/typescript/expected/78-callable-collections.edges b/graph/test/typescript/expected/78-callable-collections.edges new file mode 100644 index 000000000..ee4407318 --- /dev/null +++ b/graph/test/typescript/expected/78-callable-collections.edges @@ -0,0 +1,25 @@ +ambiguous_unknown FUNCTION_CALL pipeline#(?) @L43 -> - +ambiguous_unknown FUNCTION_CALL pipeline#(?,?) @L46 -> - +ambiguous_unknown FUNCTION_CALL pipeline#runFrozen(Node) @L23 -> - +ambiguous_unknown FUNCTION_CALL pipeline#runImported(Node) @L58 -> - +ambiguous_unknown FUNCTION_CALL pipeline#runIndexed(Node,number) @L30 -> - +ambiguous_unknown FUNCTION_CALL pipeline#runIterated(Node) @L17 -> - +ambiguous_unknown FUNCTION_CALL pipeline#runRegistry(Node) @L52 -> - +ambiguous_unknown FUNCTION_CALL pipeline#runValues(Node) @L64 -> - +ambiguous_unknown METHOD_CALL pipeline#() @L11 -> - +ambiguous_unknown METHOD_CALL pipeline#keysOnly() @L76 -> - +ambiguous_unknown METHOD_CALL pipeline#runByKey(Node,'grow' | 'shrink') @L35 -> - +ambiguous_unknown METHOD_CALL pipeline#runByName(Node) @L38 -> - +ambiguous_unknown METHOD_CALL pipeline#runForEach(Node) @L43 -> - +ambiguous_unknown METHOD_CALL pipeline#runIndexed(Node,number) @L30 -> - +ambiguous_unknown METHOD_CALL pipeline#runReduce(Node) @L46 -> - +ambiguous_unknown METHOD_CALL pipeline#runValues(Node) @L64 -> - +known_edge FUNCTION_CALL pipeline#runDirect(Node) @L82 -> steps#halve(Node) +known_edge FUNCTION_CALL pipeline#runRegistry(Node) @L51 -> pipeline#(Node) +known_edge FUNCTION_CALL steps#halve(Node) @L7 -> steps#measure(Node) +known_edge FUNCTION_CALL steps#narrow(Node) @L5 -> steps#measure(Node) +known_edge FUNCTION_CALL steps#neverRun(Node) @L12 -> steps#measure(Node) +known_edge FUNCTION_CALL steps#pushed(Node) @L8 -> steps#measure(Node) +known_edge FUNCTION_CALL steps#spreadIn(Node) @L9 -> steps#measure(Node) +known_edge FUNCTION_CALL steps#square(Node) @L6 -> steps#measure(Node) +known_edge FUNCTION_CALL steps#widen(Node) @L4 -> steps#measure(Node) diff --git a/graph/test/typescript/expected/78-callable-collections.entries b/graph/test/typescript/expected/78-callable-collections.entries new file mode 100644 index 000000000..9622ff48f --- /dev/null +++ b/graph/test/typescript/expected/78-callable-collections.entries @@ -0,0 +1,15 @@ +── entry_point (14) ── + exported_from_entry_module pipeline#countIdle pipeline.ts:69 + exported_from_entry_module pipeline#keysOnly pipeline.ts:74 + exported_from_entry_module pipeline#runByKey pipeline.ts:34 + exported_from_entry_module pipeline#runByName pipeline.ts:37 + exported_from_entry_module pipeline#runDirect pipeline.ts:81 + exported_from_entry_module pipeline#runForEach pipeline.ts:42 + exported_from_entry_module pipeline#runFrozen pipeline.ts:22 + exported_from_entry_module pipeline#runImported pipeline.ts:57 + exported_from_entry_module pipeline#runIndexed pipeline.ts:28 + exported_from_entry_module pipeline#runIterated pipeline.ts:16 + exported_from_entry_module pipeline#runReduce pipeline.ts:45 + exported_from_entry_module pipeline#runRegistry pipeline.ts:50 + exported_from_entry_module pipeline#runValues pipeline.ts:63 + unimported_module pipeline# pipeline.ts:1 diff --git a/graph/test/typescript/expected/78-callable-collections.envelope b/graph/test/typescript/expected/78-callable-collections.envelope new file mode 100644 index 000000000..5a1721884 --- /dev/null +++ b/graph/test/typescript/expected/78-callable-collections.envelope @@ -0,0 +1,19 @@ +value pipeline#@10:23 -> steps#pushed +value_call pipeline#@43 -> steps#narrow +value_call pipeline#@43 -> steps#widen +value_call pipeline#@46 -> steps#square +value_call pipeline#runByKey -> steps#narrow +value_call pipeline#runByKey -> steps#widen +value_call pipeline#runByName -> steps#narrow +value_call pipeline#runFrozen -> steps#square +value_call pipeline#runImported -> steps#halve +value_call pipeline#runIndexed -> steps#narrow +value_call pipeline#runIndexed -> steps#widen +value_call pipeline#runIterated -> steps#narrow +value_call pipeline#runIterated -> steps#widen +value_call pipeline#runRegistry -> steps#narrow +value_call pipeline#runRegistry -> steps#pushed +value_call pipeline#runRegistry -> steps#spreadIn +value_call pipeline#runRegistry -> steps#widen +value_call pipeline#runValues -> steps#narrow +value_call pipeline#runValues -> steps#widen diff --git a/graph/test/typescript/expected/78-callable-collections.fields b/graph/test/typescript/expected/78-callable-collections.fields new file mode 100644 index 000000000..b5e742ff3 --- /dev/null +++ b/graph/test/typescript/expected/78-callable-collections.fields @@ -0,0 +1,2 @@ +ambiguous_unknown read pipeline#countIdle() -> - +known_edge read steps#measure(Node) -> { width: number }#width diff --git a/graph/test/typescript/expected/78-callable-collections.fields-oracle b/graph/test/typescript/expected/78-callable-collections.fields-oracle new file mode 100644 index 000000000..8fc5cf9aa --- /dev/null +++ b/graph/test/typescript/expected/78-callable-collections.fields-oracle @@ -0,0 +1,10 @@ +78-callable-collections [fields] + precision 0.0000 (0 correct, 1 wrong) + recall 0.0000 (0 of 2 the compiler resolved) + sites 2 resolved 1 (50.0%) + tiers ambiguous_unknown=1 known_edge=1 + access read=2 + not scored: 1 rows whose target is not a client declaration + WRONG steps#measure(Node) READ { width: number }#width + MISSING pipeline#runByName(Node) READ pipeline#shrink + MISSING steps#measure(Node) READ steps#width diff --git a/graph/test/typescript/expected/78-callable-collections.known-missing b/graph/test/typescript/expected/78-callable-collections.known-missing new file mode 100644 index 000000000..92411e10e --- /dev/null +++ b/graph/test/typescript/expected/78-callable-collections.known-missing @@ -0,0 +1,20 @@ +# Accepted call-site gaps for 78-callable-collections. Each line is an edge the TypeScript +# compiler resolves and this engine does not. A NEW missing edge fails the suite; +# a line here that STARTS working also fails, so the debt cannot rot. +# +# Every site here calls a function held as an ELEMENT of a collection (#967). The compiler +# answers each with ONE declaration: the element type of `[widen, narrow]` is the union of +# their types, reduced to one arbitrary member, so it names widen and never narrow. A +# call-site answer cannot match that without being wrong at every other element. These +# sites are answered in the ENVELOPE instead (expected/78-callable-collections.envelope, +# `value_call`): every element the collection holds, at the dispatch rung. +pipeline#(?) -> steps#widen(Node) +pipeline#(?,?) -> steps#square(Node) +pipeline#runByKey(Node,'grow' | 'shrink') -> steps#widen(Node) +pipeline#runByName(Node) -> steps#narrow(Node) +pipeline#runFrozen(Node) -> steps#square(Node) +pipeline#runImported(Node) -> steps#halve(Node) +pipeline#runIndexed(Node,number) -> steps#widen(Node) +pipeline#runIterated(Node) -> steps#widen(Node) +pipeline#runRegistry(Node) -> steps#widen(Node) +pipeline#runValues(Node) -> steps#widen(Node) diff --git a/graph/test/typescript/expected/78-callable-collections.oracle b/graph/test/typescript/expected/78-callable-collections.oracle new file mode 100644 index 000000000..82d64eaae --- /dev/null +++ b/graph/test/typescript/expected/78-callable-collections.oracle @@ -0,0 +1,11 @@ +oracle=19 engine=9 agree=9 missing=10 (known 10, NEW 0) extra=0 + known pipeline#(?) -> steps#widen(Node) + known pipeline#(?,?) -> steps#square(Node) + known pipeline#runByKey(Node,'grow' | 'shrink') -> steps#widen(Node) + known pipeline#runByName(Node) -> steps#narrow(Node) + known pipeline#runFrozen(Node) -> steps#square(Node) + known pipeline#runImported(Node) -> steps#halve(Node) + known pipeline#runIndexed(Node,number) -> steps#widen(Node) + known pipeline#runIterated(Node) -> steps#widen(Node) + known pipeline#runRegistry(Node) -> steps#widen(Node) + known pipeline#runValues(Node) -> steps#widen(Node) diff --git a/graph/test/typescript/expected/78-callable-collections.type-use b/graph/test/typescript/expected/78-callable-collections.type-use new file mode 100644 index 000000000..4b9e4a950 --- /dev/null +++ b/graph/test/typescript/expected/78-callable-collections.type-use @@ -0,0 +1,8 @@ +ambiguous_unknown AS_TARGET 0 pipeline [EXPRESSION] -> - +ambiguous_unknown VARIABLE_TYPE 0 pipeline [VARIABLE] -> - +known_edge METHOD_PARAM 0 pipeline [METHOD_PARAM] -> Node +known_edge METHOD_PARAM 0 steps [METHOD_PARAM] -> Node +known_edge METHOD_PARAM 2 pipeline [VARIABLE] -> Node +known_edge METHOD_RETURN 0 pipeline [METHOD] -> Node +known_edge METHOD_RETURN 0 steps [METHOD] -> Node +known_edge METHOD_RETURN 2 pipeline [VARIABLE] -> Node diff --git a/graph/test/typescript/expected/78-callable-collections.types-oracle b/graph/test/typescript/expected/78-callable-collections.types-oracle new file mode 100644 index 000000000..91626944f --- /dev/null +++ b/graph/test/typescript/expected/78-callable-collections.types-oracle @@ -0,0 +1,7 @@ +78-callable-collections [types] + precision 1.0000 (2 correct, 0 wrong) + recall 1.0000 (2 of 2 the compiler resolved) + sites 40 resolved 38 (95.0%) + tiers ambiguous_unknown=2 known_edge=38 + contexts AS_TARGET=1 METHOD_PARAM=20 METHOD_RETURN=18 VARIABLE_TYPE=1 + not scored: 2 rows whose target is not a client declaration diff --git a/graph/typescript/engine/resolution/value-flow.dl b/graph/typescript/engine/resolution/value-flow.dl index d6c16d6a6..b64ea09d4 100644 --- a/graph/typescript/engine/resolution/value-flow.dl +++ b/graph/typescript/engine/resolution/value-flow.dl @@ -438,3 +438,138 @@ called_through(ce, sig) :- call_callee_expr(_, ce, ne), member_sig_access(ne, si method_dispatch_candidate(sig, m, "value") :- member_sig_access(_, sig), holder_holds_function(sig, m), m != sig. + +// ============================================================================ +// A FUNCTION HELD AS AN ELEMENT OF A COLLECTION, AND THE CALL THAT RUNS IT (#967) +// +// `const steps = [widen, narrow]; for (const s of steps) s(node)`. A pipeline registers +// its steps as DATA and calls them by position: the registration is a reference, not a +// call, and the invocation names an element, not a declaration. Neither line was an edge, +// so `widen` had no caller and every declaration it reaches inherited that silence: impact +// on a helper three hops down answered "0 tests" while the tests were all indexed. +// +// The holder rules above follow a function into a variable, field or parameter. This adds +// the one step they lacked: a holder whose value is an ARRAY or OBJECT LITERAL holds each +// callable ELEMENT of it, and a read of an element runs what the holder's elements hold: +// +// * `for (const s of h)`, `for (const s of Object.values(h))` the loop binding holds them; +// * `h[i](x)`, `h[kind](x)`, `const s = h[i]` the element access is one; +// * `h.forEach((s) => s(x))`, `h.reduce((acc, s) => s(acc), x)` the callback's element parameter holds them; +// * `h.run(x)` over `{ run: runImpl }` only the property of that name. +// +// A literal reaches a holder by every route holder_value already has (an initializer, an +// argument a resolved call passes, a field an options literal fills), through `as const` +// and `satisfies`, and `push`/`unshift` and a spread `[...base, f]` add to it. +// +// Envelope only, like every rule in this file: the call-site answer does not move, and a +// call run this way is a `fn_value_call` — a dispatch-rung route, not a resolved edge, +// which is the certainty it has: `steps[0]` runs ONE of the elements. +// ============================================================================ + +// ── holder_elem_value(Holder, Expr): Expr is an element of a collection the holder holds ── +holder_elem_value(h, x) :- holder_value(h, e), + value_branch(e, lit), + expr_kind("client", k, _, lit), + collection_literal_child(k, r), + expr_child("client", lit, r, _, x). +collection_literal_child("ARRAY_LITERAL", "ARRAY_ELEMENT"). +collection_literal_child("OBJECT_LITERAL", "OBJECT_PROPERTY_VALUE"). +// `h.push(f)`, `h.unshift(f)` — a registry filled after it is declared +holder_elem_value(h, a) :- call_site("client", "METHOD_CALL", n, _, recv, ce, _), + array_method_adds(n), + expr_holder(recv, h), + expr_child("client", ce, "ARGUMENT", _, a). +array_method_adds("push"). +array_method_adds("unshift"). + +// ── expr_holder(Expr, Holder): the expression names the holder ── +expr_holder(x, h) :- expr_referenced("client", k, h, x), holder_ref_kind(k). +expr_holder(x, f) :- ts_field_access_target(x, f). +// an imported `const` is the exporting module's variable +expr_holder(x, v) :- expr_referenced("client", "IMPORT_BINDING", ih, x), + import_binds(ih, _, "VARIABLE", v). + +// ── holder_elem_fn(Holder, Fn): an element of the holder's collection is, or holds, Fn ── +holder_elem_fn(h, m) :- holder_elem_value(h, x), + !expr_kind("client", "CALL_EXPRESSION", _, x), + expr_callable(x, m). +holder_elem_fn(h, m) :- holder_elem_value(h, x), + expr_holder(x, h2), + holder_holds_function(h2, m). +// an object literal's own method: `{ run() {…} }` +holder_elem_fn(h, m) :- holder_value(h, e), + value_branch(e, l), + objlit_member_fn(l, _, m). +// `[...base, f]` — the spread array's elements are this one's +holder_elem_fn(h, m) :- holder_elem_value(h, x), + expr_kind("client", "SPREAD_ELEMENT", _, x), + expr_child("client", x, "SPREAD_OPERAND", _, y), + expr_holder(y, h2), + holder_elem_fn(h2, m). +// `const all = steps` — a holder handed another holder's collection holds its elements +holder_elem_fn(h, m) :- holder_value(h, e), + value_branch(e, x), + expr_holder(x, h2), + h2 != h, + holder_elem_fn(h2, m). + +// ── the reads ─────────────────────────────────────────────────────────────── +// `h[i]`, `h[kind]`: any element (an index or a computed key picks one we cannot) +elem_read_fn(x, m) :- expr_kind("client", "ELEMENT_ACCESS", _, x), + expr_child("client", x, "RECEIVER", _, r), + expr_holder(r, h), + holder_elem_fn(h, m). +// `h.run`: the property of that name, when the holder holds an object literal +elem_read_fn(x, m) :- property_access_name(x, n), + expr_child("client", x, "RECEIVER", _, r), + expr_holder(r, h), + holder_value(h, e), + value_branch(e, l), + objlit_prop(l, n, val), + expr_callable(val, m). +elem_read_fn(x, m) :- property_access_name(x, n), + expr_child("client", x, "RECEIVER", _, r), + expr_holder(r, h), + holder_value(h, e), + value_branch(e, l), + objlit_prop(l, n, val), + expr_holder(val, h2), + holder_holds_function(h2, m). + +// the collection an expression iterates: a holder, or `Object.values(holder)` +iterated_elem_fn(e, m) :- expr_holder(e, h), holder_elem_fn(h, m). +iterated_elem_fn(ce, m) :- call_site("client", "METHOD_CALL", "values", _, recv, ce, _), + expr_literal_value("client", "Object", recv), + expr_referenced("client", "AMBIENT_GLOBAL", _, recv), + expr_child("client", ce, "ARGUMENT", "0", a), + expr_holder(a, h), + holder_elem_fn(h, m). + +// `for (const s of h)` — a plain binding; a destructuring pattern names a PART of the element +holder_holds_function(v, m) :- for_binding_iterable(v, e), + !var_binding("client", _, _, _, _, v), + iterated_elem_fn(e, m). +// `const s = h[i]` +holder_holds_function(h, m) :- holder_value(h, e), + value_branch(e, x), + elem_read_fn(x, m). +// `h.forEach((s) => …)`: the callback parameter that receives an element +holder_holds_function(p, m) :- call_site("client", "METHOD_CALL", n, _, recv, ce, _), + array_method_element_param(n, pos), + iterated_elem_fn(recv, m), + expr_child("client", ce, "ARGUMENT", "0", a), + expr_anon_decl("client", cb, a), + param_decl("client", _, pos, _, cb, p). +array_method_element_param("forEach", "0"). +array_method_element_param("map", "0"). +array_method_element_param("flatMap", "0"). +array_method_element_param("filter", "0"). +array_method_element_param("find", "0"). +array_method_element_param("findLast", "0"). +array_method_element_param("some", "0"). +array_method_element_param("every", "0"). +array_method_element_param("reduce", "1"). +array_method_element_param("reduceRight", "1"). + +// `h[i](x)`, `h.run(x)` +call_runs(ce, m) :- call_callee_expr(_, ce, ne), elem_read_fn(ne, m). diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 294be602f..0210d202f 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -202,6 +202,14 @@ .decl call_runs(c0:symbol,c1:symbol) .decl fn_value_call(c0:symbol,c1:symbol) // (caller, fn): caller makes a call that runs fn, a function a holder holds or a call returned .decl holder_ref_kind(c0:symbol) +.decl holder_elem_value(c0:symbol,c1:symbol) // (holder, expr): expr is an element of a collection literal the holder holds (#967) +.decl collection_literal_child(c0:symbol,c1:symbol) +.decl array_method_adds(c0:symbol) +.decl expr_holder(c0:symbol,c1:symbol) +.decl holder_elem_fn(c0:symbol,c1:symbol) // (holder, fn): an element of the holder's collection is, or holds, fn +.decl elem_read_fn(c0:symbol,c1:symbol) +.decl iterated_elem_fn(c0:symbol,c1:symbol) +.decl array_method_element_param(c0:symbol,c1:symbol) .decl field_in_scope(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl field_initializer(c0:symbol,c1:symbol,c2:symbol) .decl objlit_prop_arrow(c0:symbol,c1:symbol,c2:symbol) From 2ac30b0c5a7ae42fce61ca78512938c2b0b83a29 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:05:02 -0700 Subject: [PATCH 26/31] impact, path and hooks: each dependent once, prose is not a binding, labels worded from the reason (#1389, #1423, #1544) One row per dependent: impact keyed its must-change and direct rows on (callable, reason), so a callable with two reasons printed twice and the section header counted it once per rung. Rows are now keyed on the callable within each section; the surest reason names the row and the others follow as "also:". --json keeps every reason. path next: names each caller once, and the Grep preview prints overloads in one file as one line. Non-source text: a name matched inside a .sh or .dl file (or an extensionless shell script) is out of scope and dropped. A plain-word name (note, export, validate) written with no call, quote or qualifier around it is prose: counted with the grep that lists it, listed under --page all or --limit, flagged "prose" in --json, and never counted as a binding. Labels: entry points are worded from one table in ax_edges.py. A route, main, RPC, CLI or message surface keeps today's sentence; a framework callback (an ORM hook, a bean constructor, a lifecycle method) says the framework calls it back and a change breaks that contract. An event or request hop says it sends what the handler handles. The tests line names test-code callables the answer lists but the count does not credit. --- plugins/axiomcode/hooks/enrich.py | 8 +- .../skills/axiomcode/scripts/ax_edges.py | 47 +++++- .../skills/axiomcode/scripts/ax_nonsource.py | 50 ++++++- .../skills/axiomcode/scripts/ax_pages.py | 9 +- .../axiomcode/scripts/axiomcode-context | 2 + .../skills/axiomcode/scripts/axiomcode-impact | 138 +++++++++++++++--- .../skills/axiomcode/scripts/axiomcode-path | 37 +++-- .../csharp/entry-worded-by-reason/case.json | 16 ++ .../src/ItemsController.cs | 11 ++ .../entry-worded-by-reason/src/Shop.csproj | 3 + .../src/ShopDbContext.cs | 11 ++ .../unmodelled-entry-not-local/case.json | 2 +- .../java/entry-worded-by-reason/case.json | 16 ++ .../src/com/example/Initializer.java | 5 + .../src/com/example/Mid.java | 2 + .../src/com/example/Store.java | 16 ++ .../src/com/example/StoreApi.java | 12 ++ .../src/com/example/Top.java | 2 + .../src/com/example/Wire.java | 5 + .../.github/workflows/ci.yml | 5 + .../common-word-text-is-prose/case.json | 26 ++++ .../common-word-text-is-prose/config.yml | 2 + .../python/common-word-text-is-prose/notes.py | 19 +++ .../common-word-text-is-prose/routes.cfg | 2 + .../python/common-word-text-is-prose/rules.dl | 2 + .../python/common-word-text-is-prose/run.sh | 3 + .../templates/page.html | 4 + .../python/common-word-text-is-prose/wrapper | 2 + .../one-row-per-dependent/a/__init__.py | 0 .../python/one-row-per-dependent/a/views.py | 5 + .../one-row-per-dependent/b/__init__.py | 0 .../python/one-row-per-dependent/b/views.py | 5 + .../python/one-row-per-dependent/case.json | 18 +++ .../one-row-per-dependent/formatters.py | 22 +++ tests/cases/python/shebang-script/case.json | 6 +- tests/enrich_lines.py | 8 +- 36 files changed, 477 insertions(+), 44 deletions(-) create mode 100644 tests/cases/csharp/entry-worded-by-reason/case.json create mode 100644 tests/cases/csharp/entry-worded-by-reason/src/ItemsController.cs create mode 100644 tests/cases/csharp/entry-worded-by-reason/src/Shop.csproj create mode 100644 tests/cases/csharp/entry-worded-by-reason/src/ShopDbContext.cs create mode 100644 tests/cases/java/entry-worded-by-reason/case.json create mode 100644 tests/cases/java/entry-worded-by-reason/src/com/example/Initializer.java create mode 100644 tests/cases/java/entry-worded-by-reason/src/com/example/Mid.java create mode 100644 tests/cases/java/entry-worded-by-reason/src/com/example/Store.java create mode 100644 tests/cases/java/entry-worded-by-reason/src/com/example/StoreApi.java create mode 100644 tests/cases/java/entry-worded-by-reason/src/com/example/Top.java create mode 100644 tests/cases/java/entry-worded-by-reason/src/com/example/Wire.java create mode 100644 tests/cases/python/common-word-text-is-prose/.github/workflows/ci.yml create mode 100644 tests/cases/python/common-word-text-is-prose/case.json create mode 100644 tests/cases/python/common-word-text-is-prose/config.yml create mode 100644 tests/cases/python/common-word-text-is-prose/notes.py create mode 100644 tests/cases/python/common-word-text-is-prose/routes.cfg create mode 100644 tests/cases/python/common-word-text-is-prose/rules.dl create mode 100644 tests/cases/python/common-word-text-is-prose/run.sh create mode 100644 tests/cases/python/common-word-text-is-prose/templates/page.html create mode 100644 tests/cases/python/common-word-text-is-prose/wrapper create mode 100644 tests/cases/python/one-row-per-dependent/a/__init__.py create mode 100644 tests/cases/python/one-row-per-dependent/a/views.py create mode 100644 tests/cases/python/one-row-per-dependent/b/__init__.py create mode 100644 tests/cases/python/one-row-per-dependent/b/views.py create mode 100644 tests/cases/python/one-row-per-dependent/case.json create mode 100644 tests/cases/python/one-row-per-dependent/formatters.py diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index b0c747d14..6d1dbc198 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -387,6 +387,11 @@ def lookup(n): if e: rel_[r['id']] = (e['how'], ctx_names.get(e['who'], '?')) rows = sorted(rows, key=lambda r: r['id'] not in rel_) # stable: the order above within each group if plain and not rel_ and not unres: return n, [], [] + # OVERLOADS IN ONE FILE ARE ONE NAME TO THE READER: `ISender.Send` declared twice in one interface printed as the + # same line twice. Each (display, file) is one line, saying how many declarations it stands for. + grp = {} + for r in rows: grp.setdefault((r['display'], r['file']), []).append(r) + rows = [v[0] for v in grp.values()]; n_ol = {v[0]['id']: len(v) for v in grp.values()} total = len(rows); rows = rows[:2] out = [] paths = _graphline.distinct_paths([r['file'] or '' for r in rows]) @@ -404,7 +409,8 @@ def lookup(n): + (f"; {nt} in tests" if nt and 2 < len(up) < 40 and nt < len(up) else '') + ")") if up \ else _graphline.zero_label(c, r['method_id'], r['display'].rsplit('.', 1)[-1], r['is_test']) out.append(f" {r['display']} {path}:{r['line']} ← {callers} → {dn}" + (f" ? {un}" if un else '') - + (f" ⇣ {nover[r['id']]} override(s)" if nover.get(r['id']) else '') + tag) + + (f" ⇣ {nover[r['id']]} override(s)" if nover.get(r['id']) else '') + + (f" ({n_ol[r['id']]} overloads)" if n_ol.get(r['id'], 1) > 1 else '') + tag) if total > len(rows) and out: out[-1] += f" (+{total - len(rows)} more declaration(s){'' if total < 40 else ' or more'})" if unres: out.append(f" ({ctx_names.get(unres[0]['caller_id'], '?')}, which you just read, calls a `{n}` at L{unres[0]['start_line']} whose receiver is not typed — it may be any of the above)") return n, rows, out diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index a5fd5008e..17323fd82 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -168,10 +168,10 @@ def legend(tiers): 'registered': 'handed over as a value — the engine recorded the hand-off, not a call site', 'capped set': 'calls it, as one of a candidate set too large to enumerate — this is a sample of that set', } -# …and where the TIER says something more specific than its certainty. An event is not handed over as a -# value: the caller publishes or emits it, and the event system runs every handler whose event it is. +# …and where the TIER says something more specific than its certainty. A request or event is not handed over as a +# value (a JavaScript callback's wording): the dependent sends it, and a framework runs the handler for what is sent. TIER_WHY = { - 'event_dispatch': 'publishes an event this handles: the event system runs it, no call site names it', + 'event_dispatch': 'sends the request or event this handles — a framework runs it for what is sent here, no call site names it', } @@ -179,6 +179,47 @@ def direct_why(tier): """what a DIRECT dependent row says for an edge of this tier.""" return TIER_WHY.get(tier) or DIRECT_WHY.get(direct_cert(tier), 'calls it') + +# ── entry points: what the reason token means, said in words ─────────────────────────────────────────────── +# `entry_points.reason` is an engine token (`orm_hook`, `bean_ctor`, `framework_hook`). Printed raw inside a fixed +# sentence it read "is a orm_hook entry point … Changing it changes what the outside world can call" for a model +# configuration callback the ORM runs at startup, which no outside caller ever reaches. Two kinds, one table: +# OUTSIDE a request, a process start, a remote client, a command line or a message reaches it: changing it +# changes what the outside world can call, and the sentence says so (unchanged). +# CALLBACK the framework calls it back (a hook, a lifecycle method, a constructor it runs, a factory, a fixture, a +# provider): a change breaks that framework contract, not an outside caller. +# Every surface that words an entry point (impact's entry line and `next:`, path) reads this table. A token it has not +# seen is worded "framework-called ()", never printed bare, and is a callback: the weaker claim. +ENTRY = { + 'http': ('an HTTP route handler', 'outside'), 'url': ('a URL route handler', 'outside'), + 'main': ('a program entry point', 'outside'), 'grpc_service': ('a gRPC service method', 'outside'), + 'hub': ('a real-time hub method', 'outside'), 'cli': ('a command-line command', 'outside'), + 'queue': ('a message consumer', 'outside'), 'task': ('a background task a queue runs', 'outside'), + 'web_filter': ('a web request filter', 'outside'), + 'package_export': ('an export of the package', 'outside'), + 'exported_from_entry_module': ('an export of the entry module', 'outside'), + 'unimported_module': ('a module run directly, which nothing imports', 'outside'), + 'framework_hook': ('a framework hook', 'callback'), 'orm_hook': ('a model hook the ORM or validation library runs', 'callback'), + 'lifecycle': ('a lifecycle callback', 'callback'), 'bean_ctor': ('a constructor the container runs to build a bean', 'callback'), + 'factory': ('a factory method the container calls', 'callback'), 'fixture': ('a test fixture', 'callback'), + 'service_loader': ('a provider a service loader instantiates', 'callback'), + 'spring_factories': ('an auto-configuration class the container loads', 'callback'), + 'di_provider': ('a dependency-injection provider', 'callback'), 'signal_receiver': ('a signal receiver', 'callback'), + 'web_listener': ('a web container listener', 'callback'), 'scheduled': ('a scheduled job', 'callback'), + 'event_listener': ('an event listener', 'callback'), + 'test': ('a test', 'test'), +} + + +def entry_phrase(reason): + """'an ORM model hook' for `orm_hook`; an unknown token is 'a framework-called () method'""" + return ENTRY[reason][0] if reason in ENTRY else f"a framework-called ({reason}) method" + + +def entry_outside(reason): + """does the outside world (a request, a process start, a client, a message) call an entry point of this reason?""" + return ENTRY.get(reason, ('', 'callback'))[1] == 'outside' + # certainties that are backed by an edge the ENGINE asserted, as opposed to a name or a text match. # Consumers that used to test `cert == 'resolved'` to mean "this row claims an edge" test this # instead: the membership is exactly what it was before this table existed, so no row leaves any diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py index 8e6bffa5a..aef1c5b37 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py @@ -50,10 +50,52 @@ def _classify(fp): return 1 if b'\0' in head else 0 +# ── which name matches are noise ───────────────────────────────────────────────────────────────────────── +# Two rules, applied to every hit before anyone sees it (impact's "bound from outside the source", context's text +# bindings): +# +# OUT OF SCOPE. A shell script or a Datalog file is never analysed, so a name matched inside one is noise by rule: +# `hits` for a method `hits` was 21 rows, most of them CI scripts and rule comments. An extensionless script is +# one by its first line (`mvnw`, `gradlew`: `#!/bin/sh`). +# +# PROSE. A name that is a plain English word (`note`, `export`, `build`, `validate`: lowercase letters only, no `_` +# and no case boundary) is written as a word in templates, YAML, CI files and comments far more often than as a +# binding: 261 rows for one Django view, 71 for `note`, a Maven `validate` for a Java `validate`. +# Such a match is kept as a binding only where the line writes it the way code or configuration refers to a +# callable: called (`note(`), quoted as a value (`"note"`, `'note'`), or qualified with `#`, `::` or `->` +# (`Owner#note`; a CSS `#note` selector is not one). A dotted `Owner.note` is matched as the qualified name itself and never reaches this rule. The +# rest are PROSE: counted and grep-able, not listed as places a rename breaks. +OUT_OF_SCOPE_EXT = {'.sh', '.bash', '.zsh', '.ksh', '.dl'} +SHELL_SHEBANG = re.compile(r'#!\s*\S*(?:/|\s)(?:env\s+)?(?:ba|z|k|da)?sh\b') +COMMON = re.compile(r'[a-z]+') +_QUOTES = '"\'`' +QUALIFIER = re.compile(r'[\w$)\]>](?:#|::|->)$') # `Owner#note`, `Owner::note`, `$obj->note`; not a CSS `#note` selector + + +def out_of_scope(rel, text): + """a shell script or a Datalog file: a name matched there is never a binding (owner's rule)""" + if os.path.splitext(rel)[1].lower() in OUT_OF_SCOPE_EXT: return True + return not os.path.splitext(rel)[1] and bool(SHELL_SHEBANG.match(text[:120])) + + +def is_common(name): + """a plain word: the names whose bare mentions are mostly prose""" + return bool(COMMON.fullmatch(name or '')) + + +def code_shaped(line, start, end): + """the match line[start:end] is written as a reference: called, quoted as a whole value, or #/::/-> qualified""" + before, after = line[:start], line[end:] + if after.lstrip().startswith('('): return True + if before[-1:] and before[-1] in _QUOTES and after[:1] == before[-1]: return True + return bool(QUALIFIER.search(before)) + + class NonSource: def __init__(self, repo, cache_dir, indexed, skip_dir, skip_ext): self.repo, self.cache_dir, self.indexed, self.skip_dir, self.skip_ext = repo, cache_dir, indexed, skip_dir, skip_ext self._files = None; self._uncached = set(); self.con = None + self.prose = set() # (name, rel, line) hits of a common word with no reference syntax around it (see PROSE) if cache_dir and not os.environ.get('AXIOMCODE_NO_SCAN_CACHE'): try: os.makedirs(cache_dir, exist_ok=True) @@ -154,8 +196,12 @@ def hits(self, names): if only is not None and rel not in only: continue try: text = open(os.path.join(self.repo, rel), errors='replace').read() except OSError: continue - if not any(n in text for n in names): continue + if not any(n in text for n in names) or out_of_scope(rel, text): continue for i, line in enumerate(text.split('\n'), 1): - for m in pat.finditer(line): hits.append((m.group(1), rel, i)) + shaped = {} + for m in pat.finditer(line): + n = m.group(1); hits.append((n, rel, i)) + shaped[n] = shaped.get(n, False) or not is_common(n) or code_shaped(line, m.start(1), m.end(1)) + self.prose.update((n, rel, i) for n, ok in shaped.items() if not ok) if len(hits) > 2000: break return hits diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py index 43a10398b..a0d932d02 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py @@ -112,7 +112,14 @@ def next_path(text): rows = re.findall(r'^\s+(\d+) hop\(s\)\s+(\S+).*?\s' + LOC, text, re.M) if rows: near = min(int(h) for h, _, _ in rows) - first = [(n, loc) for h, n, loc in rows if int(h) == near][:5] + # each caller ONCE (#1389): the same caller is printed under "nearest callers" (at its call line) and again under + # "entry points" (at its declaration), so the rows are keyed on the name and its FILE, not the line. Two callers + # of one display name in two files are two callers, and stay two. + seen_, first = set(), [] + for h, n, loc in rows: + if int(h) == near and (n, loc.rsplit(':', 1)[0]) not in seen_: + seen_.add((n, loc.rsplit(':', 1)[0])); first.append((n, loc)) + first = first[:5] total = re.search(r'(\d+) (?:method|callable)s?\b', text) return (f"next: the nearest {'caller is' if len(first) == 1 else 'callers are'} at {near} hop(s): " + ', '.join(f"{n} {loc}" for n, loc in first) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index 9b380c3bf..e090f3d1d 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -571,7 +571,9 @@ def text_bindings(g, sids, under=(), want_ext=()): except Exception: return [] ns_of = {} out, seen = [], set() + prose = getattr(nonsource(g), 'prose', set()) # a common word written as a word is not a binding (ax_nonsource.py) for nm, f, line in hits: + if (nm, f, line) in prose: continue if under and not any(f.startswith(d.rstrip('/') + '/') for d in under): continue if f.endswith('.xml'): if f not in ns_of: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index d1537aad9..406b58015 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -114,6 +114,19 @@ def ckey(k): # framework's own registration, so it outranks every name match, and it is not a call to the declaration. CERT = {'resolved': 0, 'one of a set': 1, 'registered': 2, 'capped set': 3, 'remote': 4, 'framework': 5, 'in scope': 6, 'by name': 7, 'text': 8, 'alongside': 9} +def also_text(whys, shown=2): + """the other reasons a row's callable has, said on the same line: `; also: ` (one row per dependent)""" + if not whys: return '' + return '; also: ' + '; '.join(whys[:shown]) + (f" (+{len(whys) - shown} more)" if len(whys) > shown else '') + + +def merge_reasons(why, others): + """-> (why, others left): reasons that differ from the row's only in their last word -- `using the same field a`, + `... field b` -- are said once, `using the same field a, b`, rather than as a sentence each""" + head = why.rsplit(' ', 1)[0] + ' ' if ' ' in why else None + same = [o for o in others if head and o.startswith(head) and ' ' not in o[len(head):]] + if not same: return why, others + return why + ', ' + ', '.join(o[len(head):] for o in same), [o for o in others if o not in same] LOCAL_KINDS = {'LOCAL_VARIABLE', 'PARAMETER', 'LAMBDA_PARAMETER', 'VARIABLE', 'PARAM'} # a ref the parser says is a local, not a member QUALIFIED_KINDS = {'FIELD_ACCESS', 'PROPERTY_ACCESS', 'ATTRIBUTE_ACCESS'} MEMBER_KINDS = {'FIELD', 'PROPERTY', 'ATTRIBUTE', 'METHOD', 'FUNCTION'} # written `x.name`, against a receiver; the rest are bare names @@ -683,7 +696,7 @@ class Impact: return [ln for ln in range(s['line'], min(s['end_line'], len(L)) + 1) if pat.search(L[ln - 1])] # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '31' # 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 = '32' # 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 @@ -1646,7 +1659,20 @@ def main(argv): for c, why, q_ in res['contract']: contract_of[(c, why)].add(LAB[q_]) # sorted once, here: the JSON and the printed answer both read this list, and a relation has no inherent # order — leaving it as the solver emitted it made the same answer come out shuffled between engines. - contract = sorted((k for k in contract_of if k[0] in g.sym), key=lambda cw: (g.disp(cw[0]), g.loc(cw[0]), cw[1])) + # ONE ROW PER DECLARATION. The relation is keyed on (declaration, reason), so an override that is also matched by + # name printed twice, once as "overrides it" and once as "name match", and the header counted it as both. The + # surest reason names the row (a contract the engine resolved before a name match) and the others follow it as + # `also:`; the targets it is bound for are the union of both. + _cwhy = collections.defaultdict(set) + for (c, why) in contract_of: + if c in g.sym: _cwhy[c].add(why) + contract_also, contract_for = {}, {} + contract = [] + for c, whys in _cwhy.items(): + ws = sorted(whys, key=lambda w: ('name match' in w, w)) + contract.append((c, ws[0])); contract_also[c] = ws[1:] + contract_for[c] = set().union(*(contract_of[(c, w)] for w in ws)) + contract.sort(key=lambda cw: (g.disp(cw[0]), g.loc(cw[0]), cw[1])) seeds = {x[0] for x in res['seed']} # `direct` is a SET of (q, c, role, why, cert, f, l) tuples, so two calls to the target on ONE LINE derive one # row and the counter below saw one site: `shape.getCenter().getX() + "," + shape.getCenter().getY()` read as @@ -1696,7 +1722,24 @@ def main(argv): best[k] = (best[k][0], best[k][1], _lower(best[k][2], loc), best[k][3] + ns) # same certainty: the lowest line else: best[k] = (best[k][0], best[k][1], best[k][2], best[k][3] + ns) # a worse one: counted, not located - D = [(c, role, why, cert, loc, n) for (c, why), (role, cert, loc, n) in best.items()] + # ONE ROW PER DEPENDENT IN EACH SECTION. `best` is keyed on (callable, reason) so that each reason keeps its own + # site count and first line, but a callable with two reasons -- a resolved caller that is also a sibling on the + # same field, a framework hop that is also a by-name match, a writer that writes it twice over -- was printed once + # per reason, and the section header counted it once per rung, so the rungs added up to more than the callables + # (#1423). The surest reason is the row; the others are said on it as `also:`. A callable that WRITES and READS a + # field stays in both sections: those are two different answers. + _grp = lambda role: 'w' if role in ('produces', 'writes') else 'r' + by_c = collections.defaultdict(list) + for (c, why), (role, cert, loc, n) in best.items(): by_c[(c, _grp(role))].append((CERT[cert], why, role, cert, loc, n)) + D = []; direct_also = {}; direct_for = {}; direct_reasons = {} + for (c, gk), rs in by_c.items(): + rs.sort() + _r, why, role, cert, loc, n = rs[0] + D.append((c, role, why, cert, loc, n)) + direct_also[(c, gk)] = [w for _x, w, *_ in rs[1:] if w != why] + direct_for[(c, gk)] = set().union(*(from_target[(c, w)] for _x, w, *_ in rs)) + # --json keeps every reason with its own certainty, first line and site count: nothing the old rows said is lost + direct_reasons[(c, gk)] = [{'why': w, 'certainty': ct, 'at': lc, 'sites': nn} for _x, w, _ro, ct, lc, nn in rs] # `alongside` is unbounded by nature — a 200-member type would bury the rest of the answer. Keep the ones that share a # field with the target first, then the nearest by file, and say how many were left out (#762). ALONG_CAP = int(os.environ.get('AXIOMCODE_ALONGSIDE_CAP', '0')) # 0 = keep them all: the PRINTED answer is already bounded by --limit @@ -1882,18 +1925,19 @@ def main(argv): or cert in ('registered', 'capped set'): hops += 1 if not any((c, t) in real for t in asserted.get(c, ())): bad += 1 + prose_hits = getattr(getattr(I, '_ns', None), 'prose', set()) if as_json: print(json.dumps({'targets': [{'kind': k, 'label': lab} for k, lab, _ in targets], - 'contract': [{'id': c, 'display': g.disp(c), 'why': why, 'at': g.loc(c), 'for': sorted(contract_of[(c, why)])} for c, why in contract], + 'contract': [{'id': c, 'display': g.disp(c), 'why': why, 'also': contract_also.get(c, []), 'at': g.loc(c), 'for': sorted(contract_for[c])} for c, why in contract], # the sort key ends in x[0], the id: two distinct anon-class methods can share a # display AND every other component of the key, and without the id the order is # whatever the solver happened to emit — which is not the same between backends. - 'direct': [{'id': c, 'display': g.disp(c), 'role': role, 'why': why, 'certainty': cert, 'at': loc, 'sites': n, 'for': sorted(from_target[(c, why)])} for c, role, why, cert, loc, n in sorted(D, key=lambda x: (CERT[x[3]], x[1], g.disp(x[0]), x[2], x[4], x[0]))], + 'direct': [{'id': c, 'display': g.disp(c), 'role': role, 'why': why, 'also': direct_also.get((c, _grp(role)), []), 'reasons': direct_reasons.get((c, _grp(role)), []), 'certainty': cert, 'at': loc, 'sites': n, 'for': sorted(direct_for[(c, _grp(role))])} for c, role, why, cert, loc, n in sorted(D, key=lambda x: (CERT[x[3]], x[1], g.disp(x[0]), x[2], x[4], x[0]))], 'reached': [{'id': m, 'display': g.disp(m), 'hops': d, 'for': sorted(reach_from[m]), 'at': g.loc(m), 'test': bool(g.sym[m]['is_test'])} for m, d in sorted(reached.items(), key=lambda x: (x[1], g.disp(x[0]), g.loc(x[0]), x[0]))], 'tests': [{'id': m, 'display': g.disp(m), 'owner': g.sym[m]['owner'], 'name': g.sym[m]['name'], 'hops': d, 'via': g.disp(fx) if fx else None, 'at': g.loc(m), 'certainty': test_cert.get(m), 'chain': [g.disp(x) for x in chains.get(m, [])]} for m, (d, fx) in sorted(tests.items(), key=lambda kv: (kv[1][0], g.disp(kv[0]), g.loc(kv[0]), kv[0]))], 'inherited_tests': [{'id': s_, 'display': g.disp(s_), 'at': g.loc(s_), 'inherits': sorted(g.disp(m) for m in ms)} for s_, ms in sorted(inh.items(), key=lambda kv: (g.disp(kv[0]), g.loc(kv[0])))], 'byname_callers': [{'id': c, 'display': g.disp(c), 'at': g.loc(c)} for c in sorted(byname_seeds, key=by_place)], - 'external': [{'at': f'{f}:{l}', 'name': n, 'how': how} for f, l, n, how in sorted({(r[0], int(r[1]), r[2], r[3]) for r in res.get('extbind', [])})], + 'external': [{'at': f'{f}:{l}', 'name': n, 'how': how, **({'prose': True} if (n, f, l) in prose_hits else {})} for f, l, n, how in sorted({(r[0], int(r[1]), r[2], r[3]) for r in res.get('extbind', [])})], 'test_universe': universe, 'verified': bad == 0, 'checked_hops': hops, 'unresolved_inside': u}, indent=1)) return 0 for k, lab, _ in targets: print(f"change: {lab} [{k}]") @@ -1910,10 +1954,10 @@ def main(argv): break if contract: nm = sum(1 for _, why in contract if 'name match' in why) - print(f"must change with it ({len(contract)}: " + (f"{len(contract) - nm} bound by a contract the engine resolved, {nm} matched by name" if nm else "bound by a contract the engine resolved") + "):") + print(f"must change with it ({len(contract)}: " + ("matched by name" if nm == len(contract) else f"{len(contract) - nm} bound by a contract the engine resolved, {nm} matched by name" if nm else "bound by a contract the engine resolved") + "):") # printed in a fixed order — by display, then reason. The rows are a set; leaving them in the order the # engine emitted them makes the same answer come out shuffled from one solver to another. - for c, why in contract[:limit]: print(f" {g.disp(c)} {g.loc(c)} — {why}" + (f" [for {', '.join(sorted(contract_of[(c, why)]))}]" if multi else '')) + for c, why in contract[:limit]: print(f" {g.disp(c)} {g.loc(c)} — {why}" + also_text(contract_also.get(c)) + (f" [for {', '.join(sorted(contract_for[c]))}]" if multi else '')) if len(contract) > limit: print(f" … +{len(contract) - limit} (--limit N)") # the window a reader actually sees must not be decided by ASCII: ``, ``, `` sort before # every letter, and in a callback-heavy language they are most of the list, so the file that breaks is pushed into @@ -1941,13 +1985,34 @@ def main(argv): ep_by_reason = collections.defaultdict(list) for m, why in ep_rows: if not _is_t(m): ep_by_reason[why].append(m) + # WORDED FROM THE REASON (ax_edges.ENTRY). Only a surface the outside world calls (a route, a process start, a remote + # client, a message) is one whose change "changes what the outside world can call"; a hook, a lifecycle method or a + # constructor the container runs is called back by the framework, and a change to it breaks that contract instead. + # And where a row above SENDS the request or event this handles, the handler is not unreached: the framework runs + # it for what that row sends, so "no client call site reaches it" would contradict the row. + _ev_why = ax_edges.TIER_WHY.get('event_dispatch') + _senders = {c for c, role, why, cert, loc, n in D if why == _ev_why} + sent_to = lambda m: bool(_senders) and (len(seeds) == 1 or any(m in asserted.get(c, ()) for c in _senders)) for why in sorted(ep_by_reason): ms = sorted(ep_by_reason[why], key=g.disp) - if len(ms) == 1: - print(f"entry point: {g.disp(ms[0])} is a {why} entry point — a framework invokes it, so no client call site reaches it. Changing it changes what the outside world can call.") + tail = f" … +{len(ms) - limit} (--limit N)" if len(ms) > limit else '' + if any(sent_to(m) for m in ms): + one = len(ms) == 1 + print(f"entry point: {g.disp(ms[0]) + ' is' if one else f'{len(ms)} entry points, each'} {ax_edges.entry_phrase(why)} — a framework runs " + f"{'it' if one else 'them'} for the request or event the sender row(s) above send, so no call site names {'it' if one else 'them'}" + + ('' if one else ': ' + ', '.join(g.disp(m) for m in ms[:limit]) + tail)) + elif ax_edges.entry_outside(why): + if len(ms) == 1: + print(f"entry point: {g.disp(ms[0])} is a {why} entry point — a framework invokes it, so no client call site reaches it. Changing it changes what the outside world can call.") + else: + print(f"entry point: {len(ms)} {why} entry points — a framework invokes them, so no client call site reaches them. Changing one changes what the outside world can call: " + + ', '.join(g.disp(m) for m in ms[:limit]) + tail) + elif len(ms) == 1: + print(f"entry point: {g.disp(ms[0])} is {ax_edges.entry_phrase(why)} — the framework calls it back, so no client call site reaches it; " + "a change breaks that framework contract, not an outside caller.") else: - print(f"entry point: {len(ms)} {why} entry points — a framework invokes them, so no client call site reaches them. Changing one changes what the outside world can call: " - + ', '.join(g.disp(m) for m in ms[:limit]) + (f" … +{len(ms) - limit} (--limit N)" if len(ms) > limit else '')) + print(f"entry point: {len(ms)} entry points, each {ax_edges.entry_phrase(why)} — the framework calls them back, so no client call site reaches them; " + "a change to one breaks that framework contract, not an outside caller: " + ', '.join(g.disp(m) for m in ms[:limit]) + tail) n_ep_test = len({m for m, _ in ep_rows if _is_t(m)}) if n_ep_test: print(f"entry point: {n_ep_test} test entry point(s) — a runner invokes them, so no client call site reaches them either; they are not a surface the outside world calls. The tests section below is what names them.") @@ -2059,7 +2124,9 @@ def main(argv): f = (r[4] or '').rsplit(':', 1)[0] if len(shown) < limit and per[f] < cap: per[f] += 1; shown.append(r) else: hidden.append(r) - for c, role, why, cert, loc, n in shown: print(f" [{cert}] {g.disp(c)} {loc} — {why}" + (f" ({n} sites)" if n > 1 else '') + (f" [for {', '.join(sorted(from_target[(c, why)]))}]" if multi else '')) + for c, role, why, cert, loc, n in shown: + why, oth = merge_reasons(why, direct_also.get((c, _grp(role))) or []) + print(f" [{cert}] {g.disp(c)} {loc} — {why}" + (f" ({n} sites)" if n > 1 else '') + also_text(oth) + (f" [for {', '.join(sorted(direct_for[(c, _grp(role))]))}]" if multi else '')) if hidden: byf = collections.Counter((r[4] or '').rsplit(':', 1)[0] for r in hidden) print(f" … +{len(hidden)} more in {len(byf)} file(s): " + ', '.join(f"{f} ({n})" for f, n in byf.most_common(6)) + (' …' if len(byf) > 6 else '') + " (--limit N for all of them)") @@ -2078,6 +2145,12 @@ def main(argv): if any(k == 'var' for k, _, _ in targets) and not reached: print("reaches those through resolved calls: nothing — a local lives inside its method; it escapes only through what the method returns or writes, and the callers of the method are its impact only if it does") ext = sorted({(r[0], int(r[1]), r[2], r[3]) for r in res.get('extbind', [])}, key=lambda x: (x[0], x[1])) + # A COMMON WORD WRITTEN AS A WORD IS NOT A BINDING (ax_nonsource.py, PROSE): `note` in a template's text, `build` + # in a CI file, a Maven `validate`. Those rows leave `ext` here, so nothing below -- the section, + # its header count, `next:`, the --delete verdict -- reads them as a place a rename breaks; they are counted with + # the search that lists them, printed each under --page all / --limit N, and kept in --json with "prose": true. + ext_prose = [x for x in ext if (x[2], x[0], x[1]) in prose_hits] + ext = [x for x in ext if (x[2], x[0], x[1]) not in prose_hits] print(f"reaches those through resolved calls: {len(reached)} more callable(s) in {nfiles} file(s)" + (f" within {depth} hop(s)" if depth < 40 else '') + (f" under *{IN}*" if IN else '')) weak = [m for m in reached if m not in sure_set] if weak: print(f" {len(weak)} of those {len(reached)} are reached ONLY through a dependent that is not an exact edge ([by name], [text], [one of a set]): they stand or fall with it") @@ -2115,13 +2188,25 @@ def main(argv): for m, d in ent[:limit]: decs = [r[0].split('.')[-1] for r in g.q("SELECT name FROM decorations WHERE owner_id = ?", m)] if g.has('decorations') else [] print(f" {d:2} hop(s) {g.disp(m)}{' [test]' if g.sym[m]['is_test'] else ''}{(' @' + ','.join(decs[:3])) if decs else ''} {g.loc(m)}") + # the entry points' own overflow line, under their list: it sat inside the next section and printed only when + # that section did + if len(ent) > limit: print(f" … +{len(ent) - limit} (--limit N)") if ext: byfile = collections.Counter(f for f, *_ in ext) print(f"bound from outside the source ({len(ext)} site(s) in {len(byfile)} file(s): a name matched in a file the index does" " not read as source, so the compiler is silent — nothing here fails a build; a rename or a retype breaks it at RUN time):") for f, l, n, how in ext[:limit]: print(f" [text] {f}:{l} — {how} ({n})") if len(ext) > limit: print(f" … +{len(ext) - limit} (--limit N)") - if len(ent) > limit: print(f" … +{len(ent) - limit} (--limit N)") + if ext_prose: + byname_p = collections.Counter(n for _f, _l, n, _h in ext_prose) + nfp = len({f for f, *_ in ext_prose}) + print(f"{'also ' if ext else ''}written as a plain word outside the source: {len(ext_prose)} line(s) in {nfp} file(s) name " + + ', '.join(f'"{n}"' for n in sorted(byname_p)) + " with no call, quote or qualifier around it (template text, CI and" + " build files, comments) — not counted as a binding; " + ' · '.join(f'grep -rnw "{n}" .' for n in sorted(byname_p)[:2]) + + (" lists them" if not list_all else '')) + if list_all: + for f, l, n, how in ext_prose[:limit]: print(f" [text, prose] {f}:{l} — {how} ({n})") + if len(ext_prose) > limit: print(f" … +{len(ext_prose) - limit} (--limit N)") if inh: print(f" inherited by {len(inh)} test class(es) that extend a reached one — they run those tests too: " + ', '.join(f"{g.disp(s_)} ({len(ms)}) {g.loc(s_)}" for s_, ms in sorted(inh.items(), key=lambda x: (-len(x[1]), g.disp(x[0])))[:6])) tfiles = {g.sym[m]['file'] for m in tests} | {g.sym[s_]['file'] for s_ in inh if s_ in g.sym} all_tfiles = {r[0] for r in g.q("SELECT DISTINCT file FROM symbols WHERE is_test = 1 AND file IS NOT NULL")} @@ -2129,6 +2214,16 @@ def main(argv): weak_tests = [m for m in tests if m not in sure_set] sys.stdout = _stdout print(f"tests: {len(tests)} of {universe} test method(s) reach the change" + (" (their own body, or a fixture that runs before them: constructor, static initializer, @Before*/setUp)" if tests else '')) + # THE TESTS LINE AGREES WITH THE ROWS ABOVE IT. A callable in test code that this answer LISTS as reaching the change + # (a [test] entry point, a test row) but that the count does not credit -- a runner entry this tool does not + # collect (a custom test attribute, a script's main), a remote hop the test walk does not take -- made "tests: 0" + # sit under a [test] row. They are named here, and `next:` counts them as tests to run. + test_rows = sorted(({m for m, _ in ent if _is_t(m)} | {c for c, role, why, cert, loc, n in D if _is_t(c) and cert != 'alongside'}) - set(tests), + key=lambda m: (g.disp(m), g.loc(m))) + if test_rows: + print(f" {'and ' if tests else 'but '}{len(test_rows)} callable(s) in test code listed above reach it by a route this count does not credit " + "(a runner entry it does not collect, a remote or framework hop) — treat them as tests to run: " + + ', '.join(f"{g.disp(m)} {g.loc(m)}" for m in test_rows[:4]) + (f" … +{len(test_rows) - 4}" if len(test_rows) > 4 else '')) if not tests and unmod: print(f" NOT CHECKED: {unmod_sig} — a framework calls it and the graph does not model how, so a test that enters " f"through that framework (a request, a started context, an event) reaches it with no route counted here: {unmod_grep}") @@ -2338,7 +2433,9 @@ def main(argv): # AN ENTRY POINT IS CALLED BY A FRAMEWORK, AND SO IS AN UNMODELLED ONE (#1446). The entry-point line above said # a framework invokes it, and this verdict then printed the one for a class nothing registers, word for word. fw_why = []; epw = sorted(ep_by_reason) - if epw: fw_why.append(f"it is {'an' if epw[0][:1] in 'aeiou' else 'a'} {'/'.join(epw[:3])} entry point (above) — a framework calls it, so no call site in the graph is expected") + if epw and not any(ax_edges.entry_outside(w) for w in epw): + fw_why.append(f"it is {' / '.join(ax_edges.entry_phrase(w) for w in epw[:3])} (above) — the framework calls it back, so no call site in the graph is expected") + elif epw: fw_why.append(f"it is {'an' if epw[0][:1] in 'aeiou' else 'a'} {'/'.join(epw[:3])} entry point (above) — a framework calls it, so no call site in the graph is expected") if unmod: fw_why.append(f"NOT CHECKED: {unmod_sig} — a framework may call it and the graph does not model how; before deleting run: {unmod_grep}") if why: print(" NOT SAFE — " + '; '.join(why) + ". They are listed above.") elif fw_why: print(" NOT SAFE TO ASSUME — no dependent in this graph, but " + '; '.join(fw_why) + ". Check what else the graph cannot see:") @@ -2411,8 +2508,8 @@ def main(argv): # a label the answer USED must be explained here; a label it did not use must not be, or the note # describes evidence the reader cannot find above it. `registered` is new and is the one most # likely to be misread, because on JavaScript it is where a third of the routes go. - REGISTERED_NOTE = ("[registered] is a callable handed over as a value — the engine recorded the hand-off, " - "not a call to it, and cannot say the holder ever runs it") + REGISTERED_NOTE = ("[registered] is a callable handed over as a value, or a handler a framework runs for a request or event " + "sent to it — the engine recorded the hand-off or the send, not a call to it, and cannot say it ever runs") used_registered = 'registered' in set(test_cert.values()) if byname: legend = "[one of a set] is one member of a target set the engine could not narrow (its own oracle: right 126 of 351 times, against 528 of 585 for a resolved edge), [by name] / [text] may be a same-named other thing" @@ -2471,7 +2568,7 @@ def main(argv): ep_why = sorted(ep_by_reason) weak_locs = list(dict.fromkeys(loc for c, role, why, cert, loc, n in sorted(D, key=lambda x: (CERT.get(x[3], 99), x[4] or '')) if cert not in STRONG_DIRECT and cert != 'alongside' and loc and loc not in here)) - nt = len(tests) + nt = len(tests) + len(test_rows) tpart = (f"; {nt} test(s) reach it — run them after the edit (`test-impact`)" if nt else '') if places: places = list(dict.fromkeys(places + [x for x in ext_locs if x not in here])) if not places and ext_locs: @@ -2482,6 +2579,11 @@ def main(argv): print(f"next: {nobody}, but it is not local — {', '.join(foreign[:3])} calls it from outside the graph: " f"read {', '.join(here) or 'the declaration'}; a body change is safe there, a change to its name or signature " f"stops that caller reaching it{lead}") + elif not places and ep_why and not any(ax_edges.entry_outside(w) for w in ep_why): + # a framework callback (ax_edges.ENTRY): no route and no outside caller to break, only the framework's contract + print(f"next: nothing in this graph calls it, but it is not local — it is {' / '.join(ax_edges.entry_phrase(w) for w in ep_why[:3])}, so the " + f"framework calls it back: read {', '.join(here) or 'the declaration'}; a body change is safe there, a change to its name, parameters " + f"or result breaks that framework contract{tpart}{lead}") elif not places and ep_why: print(f"next: nothing in this graph calls it, but it is not local — it is {'an' if ep_why[0][:1] in 'aeiou' else 'a'} {'/'.join(ep_why[:3])} entry point, so its " f"callers are outside the graph (the framework, a request, a client, a runner, a scheduler): read {', '.join(here) or 'the declaration'}; a body change " diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 5d0277dad..0b43bbe32 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -941,24 +941,33 @@ def closure(g, sel, upstream, limit=40, depth=40): # turned 3 exact callers into 342 methods. Both numbers are true -- the first is what must run it, the second what may -- # and a count that does not say which it is reads as the first. Walk the same direction again over exact calls only. if rows: - exact_adj = collections.defaultdict(set) - for a, b, t in g.edges(): - if t != 'dispatch' and ax_edges.direct_cert(t) == 'resolved': (exact_adj[b].add(a) if upstream else exact_adj[a].add(b)) - ex, fr = set(ids), list(ids) - for _ in range(depth): - nxt = [] - for x in fr: - for y in exact_adj.get(x, ()): - if y not in ex: ex.add(y); nxt.append(y) - fr = nxt - if not fr: break + # A REQUEST OR EVENT SENT TO ITS HANDLER (`event_dispatch`) is not a dispatch choice: the framework runs the + # handler for what is sent, so what reaches the sender reaches the handler. It is walked as its own layer on top + # of the exact calls and counted apart, rather than filed with the interface fan-out as "MAY run it". + def _walk(ok): + adj_ = collections.defaultdict(set) + for a, b, t in g.edges(): + if ok(t): (adj_[b].add(a) if upstream else adj_[a].add(b)) + ex_, fr_ = set(ids), list(ids) + for _ in range(depth): + nxt = [] + for x in fr_: + for y in adj_.get(x, ()): + if y not in ex_: ex_.add(y); nxt.append(y) + fr_ = nxt + if not fr_: break + return ex_ + _exact_t = lambda t: t != 'dispatch' and ax_edges.direct_cert(t) == 'resolved' + ex = _walk(_exact_t) in_rows = {m for m, _ in rows} exact = (ex - set(ids)) & in_rows + sent = ((_walk(lambda t: _exact_t(t) or t in ax_edges.TIER_WHY) - set(ids)) & in_rows) - exact prod = lambda ms: sum(1 for m in ms if not g.sym[m]['is_test']) n_prod, n_test = prod(in_rows), len(in_rows) - prod(in_rows) e_prod, e_test = prod(exact), len(exact) - prod(exact) - print(f" {n_prod} production, {n_test} test. Through exact calls only: {e_prod} production, {e_test} test; the other " - f"{len(in_rows) - len(exact)} only through a dispatch choice (an interface or override with several implementations, " + print(f" {n_prod} production, {n_test} test. Through exact calls only: {e_prod} production, {e_test} test" + + (f"; {len(sent)} more through a request or event sent to a handler, which the framework runs for what is sent" if sent else '') + + f"; the other {len(in_rows) - len(exact) - len(sent)} only through a dispatch choice (an interface or override with several implementations, " "a call with several candidates) — they MAY run it, not must") if not upstream: print_boundary(g, ids, "it calls into libraries directly", "it also makes") @@ -1095,7 +1104,7 @@ def entered_in_process(g, ids): part = keys[k:k + 900]; q = ','.join('?' * len(part)) if g.has('entry_points'): for r in g.q(f"SELECT method_id, reason FROM entry_points WHERE method_id IN ({q})", *part): - if r['reason'] not in NOT_FROM_A_LIBRARY_CALL: out.setdefault(mid[r['method_id']], f"{r['reason']} entry point") + if r['reason'] not in NOT_FROM_A_LIBRARY_CALL: out.setdefault(mid[r['method_id']], ax_edges.entry_phrase(r['reason'])) if g.has('overrides'): for r in g.q(f"""SELECT o.overriding_method_id m, b.qualified_name b FROM overrides o JOIN methods b ON b.id = o.method_id WHERE o.overriding_method_id IN ({q}) AND b.provenance <> 'client'""", *part): diff --git a/tests/cases/csharp/entry-worded-by-reason/case.json b/tests/cases/csharp/entry-worded-by-reason/case.json new file mode 100644 index 000000000..0fa8bf9f0 --- /dev/null +++ b/tests/cases/csharp/entry-worded-by-reason/case.json @@ -0,0 +1,16 @@ +{ + "lang": "csharp", + "checks": [ + { + "why": "a model-building callback the ORM runs is worded from its reason: no 'a orm_hook entry point', no 'what the outside world can call', and next: names no route to break", + "run": ["impact", "ShopDbContext.OnModelCreating"], + "want": ["is a model hook the ORM or validation library runs — the framework calls it back", "so the framework calls it back: read"], + "avoid": ["orm_hook entry point", "outside world can call", "a change to its name, route,"] + }, + { + "why": "control: an HTTP action is a surface the outside world calls and keeps today's sentence", + "run": ["impact", "ItemsController.Get"], + "want": ["entry point — a framework invokes it, so no client call site reaches it. Changing it changes what the outside world can call.", "entry point, so its callers are outside the graph"] + } + ] +} diff --git a/tests/cases/csharp/entry-worded-by-reason/src/ItemsController.cs b/tests/cases/csharp/entry-worded-by-reason/src/ItemsController.cs new file mode 100644 index 000000000..b8e0a4128 --- /dev/null +++ b/tests/cases/csharp/entry-worded-by-reason/src/ItemsController.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Shop; + +[ApiController] +[Route("items")] +public class ItemsController : ControllerBase +{ + [HttpGet] + public string Get() => "x"; +} diff --git a/tests/cases/csharp/entry-worded-by-reason/src/Shop.csproj b/tests/cases/csharp/entry-worded-by-reason/src/Shop.csproj new file mode 100644 index 000000000..ef8279905 --- /dev/null +++ b/tests/cases/csharp/entry-worded-by-reason/src/Shop.csproj @@ -0,0 +1,3 @@ + + net8.0 + diff --git a/tests/cases/csharp/entry-worded-by-reason/src/ShopDbContext.cs b/tests/cases/csharp/entry-worded-by-reason/src/ShopDbContext.cs new file mode 100644 index 000000000..2c0920cd2 --- /dev/null +++ b/tests/cases/csharp/entry-worded-by-reason/src/ShopDbContext.cs @@ -0,0 +1,11 @@ +using Microsoft.EntityFrameworkCore; + +namespace Shop; + +public class ShopDbContext : DbContext +{ + protected override void OnModelCreating(ModelBuilder builder) + { + builder.HasDefaultSchema("shop"); + } +} diff --git a/tests/cases/csharp/unmodelled-entry-not-local/case.json b/tests/cases/csharp/unmodelled-entry-not-local/case.json index 20324c61d..510128444 100644 --- a/tests/cases/csharp/unmodelled-entry-not-local/case.json +++ b/tests/cases/csharp/unmodelled-entry-not-local/case.json @@ -4,7 +4,7 @@ { "why": "--delete on a hosted service says a framework calls it, not the dead-code verdict (#1446)", "run": ["impact", "SweepWorker", "--kind", "type", "--delete"], - "want": ["NOT SAFE TO ASSUME", "lifecycle entry point (above) — a framework calls it"], + "want": ["NOT SAFE TO ASSUME", "a lifecycle callback (above) — the framework calls it back"], "avoid": ["· nothing", "no dependent at any certainty in this graph. Before deleting"] }, { diff --git a/tests/cases/java/entry-worded-by-reason/case.json b/tests/cases/java/entry-worded-by-reason/case.json new file mode 100644 index 000000000..14ced020d --- /dev/null +++ b/tests/cases/java/entry-worded-by-reason/case.json @@ -0,0 +1,16 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "path '*' next: names the one caller once, though it is listed both as the nearest caller and as an entry point (#1389)", + "run": ["path", "*", "Wire.initContext"], + "want": ["the nearest caller is at 1 hop(s): Initializer.initialize", "read it;"], + "avoid": ["callers are", "Initializer.java:4, Initializer.initialize"]}, + {"why": "control: a caller that is not an entry point is named once as before", + "run": ["path", "*", "Wire.other"], + "want": ["the nearest caller is at 1 hop(s): Mid.x"]}, + {"why": "a constructor the container runs is a framework callback: worded from its reason, never the bare token, and never 'what the outside world can call'", + "run": ["impact", "Store.Store"], + "want": ["is a constructor the container runs to build a bean — the framework calls it back", "breaks that framework contract"], + "avoid": ["bean_ctor entry point", "outside world can call", "a change to its name, route,"]}, + {"why": "control: an HTTP route handler is a surface the outside world calls, and keeps today's sentence", + "run": ["impact", "StoreApi.items"], + "want": ["is a http entry point — a framework invokes it, so no client call site reaches it. Changing it changes what the outside world can call.", "entry point, so its callers are outside the graph"]}]} diff --git a/tests/cases/java/entry-worded-by-reason/src/com/example/Initializer.java b/tests/cases/java/entry-worded-by-reason/src/com/example/Initializer.java new file mode 100644 index 000000000..489a88981 --- /dev/null +++ b/tests/cases/java/entry-worded-by-reason/src/com/example/Initializer.java @@ -0,0 +1,5 @@ +package com.example; +public class Initializer { + private final Wire w = new Wire(); + public void initialize() { w.initContext(); } +} diff --git a/tests/cases/java/entry-worded-by-reason/src/com/example/Mid.java b/tests/cases/java/entry-worded-by-reason/src/com/example/Mid.java new file mode 100644 index 000000000..f3587209d --- /dev/null +++ b/tests/cases/java/entry-worded-by-reason/src/com/example/Mid.java @@ -0,0 +1,2 @@ +package com.example; +public class Mid { private final Wire w = new Wire(); public void x() { w.other(); } } diff --git a/tests/cases/java/entry-worded-by-reason/src/com/example/Store.java b/tests/cases/java/entry-worded-by-reason/src/com/example/Store.java new file mode 100644 index 000000000..4d6ba882c --- /dev/null +++ b/tests/cases/java/entry-worded-by-reason/src/com/example/Store.java @@ -0,0 +1,16 @@ +package com.example; + +import org.springframework.stereotype.Component; + +@Component +public class Store { + private final Wire wire; + + public Store(Wire wire) { + this.wire = wire; + } + + public String find(String id) { + return id; + } +} diff --git a/tests/cases/java/entry-worded-by-reason/src/com/example/StoreApi.java b/tests/cases/java/entry-worded-by-reason/src/com/example/StoreApi.java new file mode 100644 index 000000000..efdf6e467 --- /dev/null +++ b/tests/cases/java/entry-worded-by-reason/src/com/example/StoreApi.java @@ -0,0 +1,12 @@ +package com.example; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class StoreApi { + @GetMapping("/items") + public String items() { + return "x"; + } +} diff --git a/tests/cases/java/entry-worded-by-reason/src/com/example/Top.java b/tests/cases/java/entry-worded-by-reason/src/com/example/Top.java new file mode 100644 index 000000000..63d16f77c --- /dev/null +++ b/tests/cases/java/entry-worded-by-reason/src/com/example/Top.java @@ -0,0 +1,2 @@ +package com.example; +public class Top { private final Mid m = new Mid(); public void y() { m.x(); } } diff --git a/tests/cases/java/entry-worded-by-reason/src/com/example/Wire.java b/tests/cases/java/entry-worded-by-reason/src/com/example/Wire.java new file mode 100644 index 000000000..ac78bdc56 --- /dev/null +++ b/tests/cases/java/entry-worded-by-reason/src/com/example/Wire.java @@ -0,0 +1,5 @@ +package com.example; +public class Wire { + public void initContext() { System.out.println("a"); } + public void other() { System.out.println("b"); } +} diff --git a/tests/cases/python/common-word-text-is-prose/.github/workflows/ci.yml b/tests/cases/python/common-word-text-is-prose/.github/workflows/ci.yml new file mode 100644 index 000000000..1270a5c87 --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/.github/workflows/ci.yml @@ -0,0 +1,5 @@ +name: note checks +on: push +jobs: + build: + runs-on: ubuntu-latest diff --git a/tests/cases/python/common-word-text-is-prose/case.json b/tests/cases/python/common-word-text-is-prose/case.json new file mode 100644 index 000000000..ae8254b53 --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/case.json @@ -0,0 +1,26 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a name in a shell script (by extension or by its #! line) or a Datalog file is never a binding: those files are out of scope, so none of them is a [text] row", + "run": ["impact", "notes.py:1"], + "avoid": ["run.sh:", "rules.dl:", "wrapper:"]}, + {"why": "a common word written as a word (template text, a CI job name) is prose: counted once with the search that lists it, never listed as a place a rename breaks", + "run": ["impact", "notes.py:1"], + "want": ["written as a plain word outside the source:", "name \"note\" with no call, quote or qualifier around it", "grep -rnw \"note\" ."], + "avoid": ["[text] templates/page.html:1", "[text] .github/workflows/ci.yml:1"]}, + {"why": "control: the same common word called (onclick=\"note()\") or quoted as a whole value (a YAML list item) is written the way a binding is, and stays a [text] row", + "run": ["impact", "notes.py:1"], + "want": ["[text] config.yml:2", "[text] templates/page.html:3"]}, + {"why": "--page all lists the prose lines too, marked, so the answer stays complete", + "run": ["impact", "notes.py:1", "--page", "all"], + "want": ["[text, prose] templates/page.html:1", "[text, prose] .github/workflows/ci.yml:1"]}, + {"why": "--json keeps every row and flags the prose ones", + "run": ["impact", "notes.py:1", "--json"], + "want": ["\"at\": \".github/workflows/ci.yml:1\"", "\"prose\": true"]}, + {"why": "control: a name that is not a plain word (make_note) keeps every text row, prose or not", + "run": ["impact", "make_note"], + "want": ["[text] templates/page.html:2"], + "avoid": ["written as a plain word"]}, + {"why": "control: a qualified mention of a common word (Journal.entry in a settings file) stays a binding, while the bare word in template prose does not", + "run": ["impact", "Journal.entry"], + "want": ["[text] routes.cfg:2", "names the method in full", "name \"entry\" with no call"], + "avoid": ["[text] templates/page.html:4"]}]} diff --git a/tests/cases/python/common-word-text-is-prose/config.yml b/tests/cases/python/common-word-text-is-prose/config.yml new file mode 100644 index 000000000..e109b6118 --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/config.yml @@ -0,0 +1,2 @@ +handlers: + - "note" diff --git a/tests/cases/python/common-word-text-is-prose/notes.py b/tests/cases/python/common-word-text-is-prose/notes.py new file mode 100644 index 000000000..e9f5ee619 --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/notes.py @@ -0,0 +1,19 @@ +def note(text): + return text.strip() + + +def make_note(text): + return note(text) + + +def handle(request): + return make_note(request) + + +class Journal: + def entry(self, text): + return note(text) + + +def log(j: Journal): + return j.entry('x') diff --git a/tests/cases/python/common-word-text-is-prose/routes.cfg b/tests/cases/python/common-word-text-is-prose/routes.cfg new file mode 100644 index 000000000..459705ec5 --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/routes.cfg @@ -0,0 +1,2 @@ +[routes] +handler = Journal.entry diff --git a/tests/cases/python/common-word-text-is-prose/rules.dl b/tests/cases/python/common-word-text-is-prose/rules.dl new file mode 100644 index 000000000..e7c202439 --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/rules.dl @@ -0,0 +1,2 @@ +// note: every note is a rule +.decl note(x:symbol) diff --git a/tests/cases/python/common-word-text-is-prose/run.sh b/tests/cases/python/common-word-text-is-prose/run.sh new file mode 100644 index 000000000..92d2da9dd --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/run.sh @@ -0,0 +1,3 @@ +#!/bin/sh +# note: run the note tool +python -c "import notes; notes.note('x')" diff --git a/tests/cases/python/common-word-text-is-prose/templates/page.html b/tests/cases/python/common-word-text-is-prose/templates/page.html new file mode 100644 index 000000000..d5d82315c --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/templates/page.html @@ -0,0 +1,4 @@ +

Leave a note here, then press save.

+

The make_note helper formats it.

+ +

Each entry is kept for a week.

diff --git a/tests/cases/python/common-word-text-is-prose/wrapper b/tests/cases/python/common-word-text-is-prose/wrapper new file mode 100644 index 000000000..5c7c410eb --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/wrapper @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +note "$@" diff --git a/tests/cases/python/one-row-per-dependent/a/__init__.py b/tests/cases/python/one-row-per-dependent/a/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/one-row-per-dependent/a/views.py b/tests/cases/python/one-row-per-dependent/a/views.py new file mode 100644 index 000000000..3b4e00636 --- /dev/null +++ b/tests/cases/python/one-row-per-dependent/a/views.py @@ -0,0 +1,5 @@ +from formatters import FormatterPlugin + + +def show(f: FormatterPlugin, body): + return f.format_body(body, 'application/json') diff --git a/tests/cases/python/one-row-per-dependent/b/__init__.py b/tests/cases/python/one-row-per-dependent/b/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/one-row-per-dependent/b/views.py b/tests/cases/python/one-row-per-dependent/b/views.py new file mode 100644 index 000000000..4f07dd2b4 --- /dev/null +++ b/tests/cases/python/one-row-per-dependent/b/views.py @@ -0,0 +1,5 @@ +from formatters import FormatterPlugin + + +def show(f: FormatterPlugin, body): + return f.format_body(body, 'text/plain') diff --git a/tests/cases/python/one-row-per-dependent/case.json b/tests/cases/python/one-row-per-dependent/case.json new file mode 100644 index 000000000..fdeef1004 --- /dev/null +++ b/tests/cases/python/one-row-per-dependent/case.json @@ -0,0 +1,18 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "an override's supertype method is bound to it twice (a dispatch candidate and a name match): it is ONE row, under the surer reason, with the other said on it, and the header counts it once", + "run": ["impact", "JSONFormatter.format_body"], + "want": ["must change with it (1: bound by a contract the engine resolved):", "also: declares the same member in a supertype"]}, + {"why": "a sibling that shares two fields with the target is one row naming both fields, and the rungs in the header add up to the callables (#1423)", + "run": ["impact", "Box.grow"], + "want": ["using the same field height, width", "(7 callable(s): 7 alongside)"], + "avoid": ["using the same field width\n"]}, + {"why": "control: two distinct callables of one display name in two files are two dependents, and both stay", + "run": ["impact", "FormatterPlugin.format_body"], + "want": ["show a/views.py:5", "show b/views.py:5"]}, + {"why": "--json keeps every reason with its own certainty and line", + "run": ["impact", "Box.grow", "--json"], + "want": ["\"reasons\": [", "using the same field width"]}, + {"why": "path '*' names each nearest caller once, and a caller that is also an entry point is not named twice (#1389)", + "run": ["path", "*", "formatters.py:7"], + "avoid": ["show a/views.py:5, show a/views.py"]}]} diff --git a/tests/cases/python/one-row-per-dependent/formatters.py b/tests/cases/python/one-row-per-dependent/formatters.py new file mode 100644 index 000000000..66e3f8e33 --- /dev/null +++ b/tests/cases/python/one-row-per-dependent/formatters.py @@ -0,0 +1,22 @@ +class FormatterPlugin: + def format_body(self, content, mime): + return content + + +class JSONFormatter(FormatterPlugin): + def format_body(self, content, mime): + return content.strip() + + +class Box: + def __init__(self): + self.width = 1 + self.height = 1 + + def grow(self): + self.width += 1 + self.height += 1 + + def shrink(self): + self.width -= 1 + self.height -= 1 diff --git a/tests/cases/python/shebang-script/case.json b/tests/cases/python/shebang-script/case.json index 885b16155..b6101f99e 100644 --- a/tests/cases/python/shebang-script/case.json +++ b/tests/cases/python/shebang-script/case.json @@ -4,10 +4,10 @@ "run": ["impact", "price"], "want": ["report"], "avoid": ["[text] make-report"]}, - {"why": "a shell script that only runs python3 is not read as Python: its mention of the function stays a text match, not a caller", + {"why": "a shell script that only runs python3 is not read as Python, and a shell script is out of scope: its mention of the function is neither a caller nor a [text] row", "run": ["impact", "price"], - "want": ["[text] deploy:2"], - "avoid": ["deploy."]}, + "want": ["report"], + "avoid": ["deploy.", "[text] deploy"]}, {"why": "an edit inside a shebang script names the function it changed, so test-impact has a declaration to start from", "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new.txt", "--file", "make-report"], "want": ["body", "report"], diff --git a/tests/enrich_lines.py b/tests/enrich_lines.py index 29e3dad7e..324e205fc 100644 --- a/tests/enrich_lines.py +++ b/tests/enrich_lines.py @@ -4,7 +4,7 @@ · a caller count of 0 says why where the graph knows: `entry (http)`, `0 resolved, N by name`, `? framework (@X)`; a method with no signal still reads `0` (the control); · the Grep preview names production callers before tests (#1507), and a name declared several times lists the base - first with its override count, two different files never print as the same line, and the rest are counted (#1546); + first with its override count, two different files never print as the same line, two overloads in one file print once, and the rest are counted (#1546); · a grep run through the shell over another command's output or over files no graph indexes adds nothing, and a constant-shaped word is not looked up as a callable (#1604); · an edit that changes no declaration adds nothing, a body-only edit adds one line of reaching tests and the command @@ -50,6 +50,9 @@ ' public void pokeAll() { new Plain().countAll(); }\n}\n'), P + 'Plain.java': ('package app.orders;\n\npublic class Plain {\n public String countAll() { return "1"; }\n' ' public int unusedCount() { return Integer.parseInt(countAll()); }\n}\n'), + # two overloads in one file: one name to the reader, printed once + P + 'Pricer.java': ('package app.orders;\n\npublic class Pricer {\n public int quoteAll(int n) { return n; }\n\n' + ' public int quoteAll(String s) { return quoteAll(s.length()); }\n}\n'), } # two test stubs in modules whose paths sort before core/, with the same file name and line for m in ('json', 'xml'): @@ -126,6 +129,9 @@ def git(repo, *a): check('the production override comes before test stubs', len(rows) > 1 and 'JsonConverterFactory.widgetConverter' in rows[1], g) check('the declarations not shown are counted', '(+2 more declaration(s))' in g, g) check('two printed lines are never the same', len(rows) == len(set(rows)), g) + g = grep(repo, 'quoteAll') + rows = [l for l in g.splitlines() if l.startswith(' ') and 'Pricer.quoteAll' in l] + check('two overloads in one file print as one line that says so', len(rows) == 1 and '(2 overloads)' in rows[0], g) sys.path.insert(0, HOOKS); import _graphline two = _graphline.distinct_paths(['adapter-json/src/test/java/app/json/Stub.java', 'adapter-xml/src/test/java/app/xml/Stub.java']) check('two same-named files in different modules print with the path that tells them apart', two == ['json/Stub.java', 'xml/Stub.java'], two) From 490539f085b78aee8687c8c54da37af268561b09 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:12:01 -0700 Subject: [PATCH 27/31] path: an outside-world entry keeps its wording in the library-call note; the grouping case uses an engine reason token --- plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path | 3 ++- tests/cases/java/entry-points-grouped/case.json | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 0b43bbe32..4fec0b8f7 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -1104,7 +1104,8 @@ def entered_in_process(g, ids): part = keys[k:k + 900]; q = ','.join('?' * len(part)) if g.has('entry_points'): for r in g.q(f"SELECT method_id, reason FROM entry_points WHERE method_id IN ({q})", *part): - if r['reason'] not in NOT_FROM_A_LIBRARY_CALL: out.setdefault(mid[r['method_id']], ax_edges.entry_phrase(r['reason'])) + if r['reason'] not in NOT_FROM_A_LIBRARY_CALL: out.setdefault(mid[r['method_id']], f"{r['reason']} entry point" if ax_edges.entry_outside(r['reason']) + else ax_edges.entry_phrase(r['reason'])) if g.has('overrides'): for r in g.q(f"""SELECT o.overriding_method_id m, b.qualified_name b FROM overrides o JOIN methods b ON b.id = o.method_id WHERE o.overriding_method_id IN ({q}) AND b.provenance <> 'client'""", *part): diff --git a/tests/cases/java/entry-points-grouped/case.json b/tests/cases/java/entry-points-grouped/case.json index 6e90caec4..a3696e11b 100644 --- a/tests/cases/java/entry-points-grouped/case.json +++ b/tests/cases/java/entry-points-grouped/case.json @@ -1,13 +1,13 @@ {"lang": "java", "src": "src", "sql": ["CREATE TABLE IF NOT EXISTS entry_points (method_id TEXT, reason TEXT)", "DELETE FROM entry_points", - "INSERT INTO entry_points (method_id, reason) SELECT method_id, 'http route' FROM symbols WHERE display LIKE 'Api.%' AND method_id IS NOT NULL AND display <> 'Api.Api'", + "INSERT INTO entry_points (method_id, reason) SELECT method_id, 'http' FROM symbols WHERE display LIKE 'Api.%' AND method_id IS NOT NULL AND display <> 'Api.Api'", "INSERT INTO entry_points (method_id, reason) SELECT method_id, 'test' FROM symbols WHERE display LIKE 'ApiTest.%' AND method_id IS NOT NULL AND display <> 'ApiTest.ApiTest'"], "checks": [ {"why": "four entry points of one reason are explained ONCE and named, not one full sentence each (#1036)", "run": ["impact", "Api", "--kind", "type"], - "want": ["4 http route entry points", "Api.listOrders", "Api.deleteOrder"], - "avoid": ["Api.listOrders is a http route entry point"]}, + "want": ["4 http entry points", "Api.listOrders", "Api.deleteOrder"], + "avoid": ["Api.listOrders is a http entry point"]}, {"why": "test entry points are counted apart from the real ones and not explained one by one (#1036)", "run": ["impact", "Api", "--kind", "type"], "want": ["3 test entry point(s)"], From f8a1fdc1fbfad8dfbc9a0dfd2d259383b9da0f13 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:23:43 -0700 Subject: [PATCH 28/31] tests: the one-row-per-dependent fixture uses neutral names --- tests/cases/python/one-row-per-dependent/a/views.py | 6 +++--- tests/cases/python/one-row-per-dependent/b/views.py | 6 +++--- tests/cases/python/one-row-per-dependent/case.json | 6 +++--- .../{formatters.py => greeters.py} | 12 ++++++------ 4 files changed, 15 insertions(+), 15 deletions(-) rename tests/cases/python/one-row-per-dependent/{formatters.py => greeters.py} (53%) diff --git a/tests/cases/python/one-row-per-dependent/a/views.py b/tests/cases/python/one-row-per-dependent/a/views.py index 3b4e00636..cbb6fc360 100644 --- a/tests/cases/python/one-row-per-dependent/a/views.py +++ b/tests/cases/python/one-row-per-dependent/a/views.py @@ -1,5 +1,5 @@ -from formatters import FormatterPlugin +from greeters import Greeter -def show(f: FormatterPlugin, body): - return f.format_body(body, 'application/json') +def show(g: Greeter, name): + return g.greet(name, 'plain') diff --git a/tests/cases/python/one-row-per-dependent/b/views.py b/tests/cases/python/one-row-per-dependent/b/views.py index 4f07dd2b4..e5d4e4d09 100644 --- a/tests/cases/python/one-row-per-dependent/b/views.py +++ b/tests/cases/python/one-row-per-dependent/b/views.py @@ -1,5 +1,5 @@ -from formatters import FormatterPlugin +from greeters import Greeter -def show(f: FormatterPlugin, body): - return f.format_body(body, 'text/plain') +def show(g: Greeter, name): + return g.greet(name, 'loud') diff --git a/tests/cases/python/one-row-per-dependent/case.json b/tests/cases/python/one-row-per-dependent/case.json index fdeef1004..58a6451a7 100644 --- a/tests/cases/python/one-row-per-dependent/case.json +++ b/tests/cases/python/one-row-per-dependent/case.json @@ -1,18 +1,18 @@ {"lang": "python", "src": ".", "checks": [ {"why": "an override's supertype method is bound to it twice (a dispatch candidate and a name match): it is ONE row, under the surer reason, with the other said on it, and the header counts it once", - "run": ["impact", "JSONFormatter.format_body"], + "run": ["impact", "LoudGreeter.greet"], "want": ["must change with it (1: bound by a contract the engine resolved):", "also: declares the same member in a supertype"]}, {"why": "a sibling that shares two fields with the target is one row naming both fields, and the rungs in the header add up to the callables (#1423)", "run": ["impact", "Box.grow"], "want": ["using the same field height, width", "(7 callable(s): 7 alongside)"], "avoid": ["using the same field width\n"]}, {"why": "control: two distinct callables of one display name in two files are two dependents, and both stay", - "run": ["impact", "FormatterPlugin.format_body"], + "run": ["impact", "Greeter.greet"], "want": ["show a/views.py:5", "show b/views.py:5"]}, {"why": "--json keeps every reason with its own certainty and line", "run": ["impact", "Box.grow", "--json"], "want": ["\"reasons\": [", "using the same field width"]}, {"why": "path '*' names each nearest caller once, and a caller that is also an entry point is not named twice (#1389)", - "run": ["path", "*", "formatters.py:7"], + "run": ["path", "*", "greeters.py:7"], "avoid": ["show a/views.py:5, show a/views.py"]}]} diff --git a/tests/cases/python/one-row-per-dependent/formatters.py b/tests/cases/python/one-row-per-dependent/greeters.py similarity index 53% rename from tests/cases/python/one-row-per-dependent/formatters.py rename to tests/cases/python/one-row-per-dependent/greeters.py index 66e3f8e33..d3744b425 100644 --- a/tests/cases/python/one-row-per-dependent/formatters.py +++ b/tests/cases/python/one-row-per-dependent/greeters.py @@ -1,11 +1,11 @@ -class FormatterPlugin: - def format_body(self, content, mime): - return content +class Greeter: + def greet(self, name, style): + return name -class JSONFormatter(FormatterPlugin): - def format_body(self, content, mime): - return content.strip() +class LoudGreeter(Greeter): + def greet(self, name, style): + return name.upper() class Box: From 34197bb185a4d8d00e59278558f7717891208018 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:02:28 -0700 Subject: [PATCH 29/31] tests: the event listener's answer uses the per-tier event wording, and its entry line credits the publisher rows --- tests/cases/java/event-reaches-its-listeners/case.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/cases/java/event-reaches-its-listeners/case.json b/tests/cases/java/event-reaches-its-listeners/case.json index 8dc1e79ad..0fb5e9a8b 100644 --- a/tests/cases/java/event-reaches-its-listeners/case.json +++ b/tests/cases/java/event-reaches-its-listeners/case.json @@ -9,8 +9,12 @@ "want": ["event_dispatch"]}, {"why": "the listener's answer names the publisher as an event, not as a value handed over and not as a call that bypasses the container proxy", "run": ["impact", "Audit.on"], - "want": ["OrderService.place", "OrderService.placeExpress", "publishes an event this handles"], + "want": ["OrderService.place", "OrderService.placeExpress", "sends the request or event this handles"], "avoid": ["obtained itself", "handed over as a value", "the change is local"]}, + {"why": "and its entry line says the framework runs it for what the publisher rows send, not that no client call site reaches it", + "run": ["impact", "Audit.on"], + "want": ["for the request or event the sender row(s) above send"], + "avoid": ["so no client call site reaches it"]}, {"why": "control: a listener for a SUBTYPE does not receive the supertype's event", "run": ["path", "OrderService.place", "Audit.onExpress"], "expect_error": true, From b6425982f6c65c1e57def376622af0e5a45bb4da Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:22:44 -0700 Subject: [PATCH 30/31] javascript: on one line an anonymous function nests only where its qualified name says (#1598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `defines` is derived from line spans, and on an equal span the builder nested an anonymous callable under the named one beside it. `function first() {…} const xs = [1].map(function (v) {…});` and `const named = () => f(); const listed = [2].map(() => g());` each walked a fake hop from the named callable to the anonymous one, so every caller of `first` or `named` was reported as reaching what the other one calls. JavaScript's qualified names follow the source's nesting (`keys.outer.` is written inside `keys.outer`; `keys.` beside it), so on an equal span an anonymous JavaScript callable now nests only under a callable whose qualified name its own extends; otherwise the builder looks further down the stack. Java, C# and TypeScript name a lambda after its type, not its method, and are unchanged. Both builders (path export and the impact fast path) pass the qualified name to ax_edges.defines_edges; EXPORT_VERSION 3 and IMPACT_VERSION 33 re-export old caches. Defines edges: engine JS test graph 777 -> 773 (the two arrows at 15-jsdoc-forms main.js:8 move from the `onDone` arrow beside them to `main`); a real Express app with a minified bundle 860 -> 787, edges whose child name extends the definer's 394 -> 548; five other real apps and 13 corpus apps unchanged. Java and C# span cases unchanged (19/19). Build time unchanged (1.12 s). Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/ax_edges.py | 23 +++++++++++--- .../skills/axiomcode/scripts/axiomcode-impact | 2 +- .../skills/axiomcode/scripts/axiomcode-path | 8 ++--- .../skills/axiomcode/scripts/graph_sql.py | 10 +++--- .../case.json | 31 +++++++++++++++++++ .../src/keys.js | 14 +++++++++ .../src/package.json | 1 + 7 files changed, 75 insertions(+), 14 deletions(-) create mode 100644 tests/cases/javascript/one-line-siblings-are-not-nested/case.json create mode 100644 tests/cases/javascript/one-line-siblings-are-not-nested/src/keys.js create mode 100644 tests/cases/javascript/one-line-siblings-are-not-nested/src/package.json diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index 17323fd82..b2b97fe84 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -252,6 +252,11 @@ def entry_outside(reason): # whose sites (the `new Runnable() {…}` itself, or the call a lambda is passed to) spans a site of the anon's own. # The narrowest such site wins, so an anon inside an anon nests under the inner method, not the outer one. An anon # that makes no call has no site to place it by and keeps the stack's answer. +# · a front end whose qualified names follow the source's own nesting (JavaScript: `keys.outer.` +# is written inside `keys.outer`, `keys.` beside it) says outright which callable on a line holds an +# anonymous one, with or without a call site: on an equal span it nests only under a callable whose qualified name +# its own extends. `function first() {…} const xs = [1].map(function (v) {…});` are siblings (#1598). Java, C# and +# TypeScript name a lambda or an anonymous class after its type, not its method, so there the name says nothing. # Only these scopes are anonymous. A named constructor or initializer (``, ``, # ``, ``, ``, ``) is a sibling of the methods written beside it. _ANON_SCOPE = re.compile(r'(?:^|\.)<(?:anon[ >]|lambda>|arrow>|function-expression>|locals>)') @@ -278,14 +283,21 @@ def sites(ids): return sites +_LEXICAL_IDS = ('JS_METHOD_',) # front ends whose qualified name of a callable extends the one it is written in + + def defines_edges(callables, sites=None): - """(definer, defined, 'defines') for callables given as (file, line, end_line, id, display, method_id). + """(definer, defined, 'defines') for callables given as (file, line, end_line, id, display, method_id[, qualified_name]). `sites(method_ids)` returns (caller_id, line, col, end_line, end_col) for the call sites of those callables; it is asked only about lines where more than one callable could have defined an anonymous one.""" - byfile = {}; mid_of = {} - for f, ln, en, i, disp, mid in callables: + byfile = {}; mid_of = {}; lex = {} + for f, ln, en, i, disp, mid, *qn in callables: if not (ln and en) or str(mid or i).startswith('generated:'): continue byfile.setdefault(f, []).append((ln, -en, anon_depth(disp), i)); mid_of[i] = mid or i + if qn and qn[0] and str(mid or i).startswith(_LEXICAL_IDS): lex[i] = qn[0] + def may_hold(c, x): + """on an equal span: c may be x's definer unless both names follow the nesting and x's does not extend c's""" + return c not in lex or x not in lex or lex[x].startswith(lex[c] + '.') parent = {}; groups = [] for f, rows in byfile.items(): rows.sort(); st = [] @@ -293,7 +305,10 @@ def defines_edges(callables, sites=None): # pop what ends before this one ends (END against END, not against this one's start: the two agree on nested # spans and not on overlapping ones), and a sibling: the same span at the same depth while st and (st[-1][1] < -neg or (st[-1][0] == ln and st[-1][1] == -neg and st[-1][2] >= d)): st.pop() - if st and st[-1][3] != i: parent[f, i] = st[-1][3] + # past an equal span the name says it is not written in (kept on the stack: a later one may be) + j = len(st) - 1 + while j >= 0 and st[j][0] == ln and st[j][1] == -neg and not may_hold(st[j][3], i): j -= 1 + if j >= 0 and st[j][3] != i: parent[f, i] = st[j][3] st.append((ln, -neg, d, i)) # equal spans holding an anonymous callable and more than one other candidate to have written it span = {} diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 406b58015..56fb64c20 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -696,7 +696,7 @@ class Impact: return [ln for ln in range(s['line'], min(s['end_line'], len(L)) + 1) if pat.search(L[ln - 1])] # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '32' # 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 = '33' # 33: the defines edges it links from the path export changed (#1598); 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/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 4fec0b8f7..d799a0118 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -568,9 +568,9 @@ class G: # ── facts: exported once per graph, reused while graph.sqlite is unchanged ─────────────────────────────── # EXPORT_VERSION is in the stamp beside the graph's mtime: bump it whenever the edges this writes change, or an # upgraded plugin keeps answering from the edges the old code exported (the graph is unchanged, so `index` says it - # is up to date and nothing rebuilds). 2: defines needs a written, strictly wider span (#1402, #1399); 1 was the - # bare mtime. - EXPORT_VERSION = '2' + # is up to date and nothing rebuilds). 3: on one line a JavaScript anonymous callable nests only where its name + # does (#1598); 2: defines needs a written, strictly wider span (#1402, #1399); 1 was the bare mtime. + EXPORT_VERSION = '3' def export(self): stamp = os.path.join(self.facts, 'stamp'); want = f"{self.db_mtime}:{self.EXPORT_VERSION}" if os.path.exists(stamp) and open(stamp).read() == want: return @@ -584,7 +584,7 @@ class G: edges += [(r['caller_id'], r['callee_method_id'], 'library') for r in self.q("SELECT DISTINCT caller_id, callee_method_id FROM call_edges WHERE tier = 'boundary_lib' AND callee_method_id IS NOT NULL")] # a callable defined inside another one's body: nobody calls it by name, but it runs only after its definer did. # One builder for this and the fast path (graph_sql._edges), so the two cannot disagree on what contains what. - edges += ax_edges.defines_edges(((r['file'], r['line'], r['end_line'], r['id'], r.get('display'), r['method_id']) + edges += ax_edges.defines_edges(((r['file'], r['line'], r['end_line'], r['id'], r.get('display'), r['method_id'], r.get('qualified_name')) for r in self.sym.values() if r['method_id'] and r['kind'] != 'module'), ax_edges.sites_of(lambda s, p: self.q(s, *p)) if self.has('call_sites') else None) # a call resolved to a base may run any instantiated override: the engine's own dispatch table diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 03ac38556..5c835d986 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -471,11 +471,11 @@ def _edges(q): # second keeps it, which is not the same set. The id is a hash of the display, so an anonymous class collides # across files (`Database.Vendor..apply` has rows in six), which is why this matters at all. one = {} - for i, f, ln, en, mid, kind, disp in q("""SELECT id, file, line, end_line, method_id, kind, display FROM symbols - WHERE method_id IS NOT NULL OR type_id IS NOT NULL"""): - one[i] = (f, ln, en, mid, kind, disp) - # the same builder the path tool exports with (ax_edges.defines_edges): generated members and equal spans (#1402, #1399) - e += ax_edges.defines_edges(((f, ln, en, i, disp, mid) for i, (f, ln, en, mid, kind, disp) in one.items() + for i, f, ln, en, mid, kind, disp, qn in q("""SELECT id, file, line, end_line, method_id, kind, display, qualified_name + FROM symbols WHERE method_id IS NOT NULL OR type_id IS NOT NULL"""): + one[i] = (f, ln, en, mid, kind, disp, qn) + # the same builder the path tool exports with (ax_edges.defines_edges): generated members and equal spans (#1402, #1399, #1598) + e += ax_edges.defines_edges(((f, ln, en, i, disp, mid, qn) for i, (f, ln, en, mid, kind, disp, qn) in one.items() if mid and kind != 'module'), ax_edges.sites_of(lambda s, p: q(s, *p))) have = {(a, b) for a, b, _ in e} # the same narrowing the path tool applies: a candidate whose owner type is never instantiated anywhere is not a diff --git a/tests/cases/javascript/one-line-siblings-are-not-nested/case.json b/tests/cases/javascript/one-line-siblings-are-not-nested/case.json new file mode 100644 index 000000000..0dc75683d --- /dev/null +++ b/tests/cases/javascript/one-line-siblings-are-not-nested/case.json @@ -0,0 +1,31 @@ +{"lang": "javascript", "src": "src", + "checks": [ + {"why": "a method-shorthand member on the arrow's line is not declared in the arrow: a caller of Keys.a does not reach b", + "run": ["path", "main", "b"], + "want": ["no chain of resolved calls connects"], + "avoid": ["defines"], "expect_error": true}, + {"why": "a function declared after an arrow on its line is its sibling", + "run": ["path", "run", "helper"], + "want": ["no chain of resolved calls connects"], + "avoid": ["defines"], "expect_error": true}, + {"why": "a function expression written after a named function on its line is not inside it: a caller of first does not reach what the expression calls", + "run": ["path", "callFirst", "ping"], + "want": ["no chain of resolved calls connects"], + "avoid": ["defines"], "expect_error": true}, + {"why": "an arrow written after a named arrow on its line is not inside it", + "run": ["path", "callNamed", "ping"], + "want": ["no chain of resolved calls connects"], + "avoid": ["defines"], "expect_error": true}, + {"why": "impact on helper names its own caller, and not run's", + "run": ["impact", "helper"], + "want": ["callHelper"], + "avoid": ["main", "callFirst", "callNamed"]}, + {"why": "control: a function expression written inside a function on the same line is still defined by it", + "run": ["path", "callOuter", "pong"], + "want": ["[defines · not a call] ", "pong"]}, + {"why": "control: an arrow written inside a named arrow on the same line is still defined by it", + "run": ["path", "callWrap", "pong"], + "want": ["[defines · not a call] ", "pong"]}, + {"why": "control: the real route through the arrow under a key is kept", + "run": ["path", "main", "ping"], + "want": ["Keys.a", "ping"]}]} diff --git a/tests/cases/javascript/one-line-siblings-are-not-nested/src/keys.js b/tests/cases/javascript/one-line-siblings-are-not-nested/src/keys.js new file mode 100644 index 000000000..05d13afe2 --- /dev/null +++ b/tests/cases/javascript/one-line-siblings-are-not-nested/src/keys.js @@ -0,0 +1,14 @@ +function ping(x) { return x; } +function pong(x) { return x; } +const Keys = { a: () => ping(1), b() { return 2; } }; +function main() { Keys.a(); } +const run = () => ping(2); function helper() { return pong(3); } +function callHelper() { helper(); } +function first() { return 4; } const mapped = [1].map(function (v) { return ping(v); }); +function callFirst() { first(); } +const named = () => pong(5); const listed = [2].map(() => ping(6)); +function callNamed() { named(); } +function outer() { return function (v) { return pong(v); }; } +function callOuter() { outer(); } +const wrap = () => () => pong(7); +function callWrap() { wrap(); } diff --git a/tests/cases/javascript/one-line-siblings-are-not-nested/src/package.json b/tests/cases/javascript/one-line-siblings-are-not-nested/src/package.json new file mode 100644 index 000000000..95aa4705a --- /dev/null +++ b/tests/cases/javascript/one-line-siblings-are-not-nested/src/package.json @@ -0,0 +1 @@ +{"name": "one-line-siblings", "version": "1.0.0"} From 3b0b8cc8c96527f519fad1a96ed2d24dfe0b0487 Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:25:47 -0700 Subject: [PATCH 31/31] parser: 0.1.2, for the parser changes on 0.1.4 The vendored parser keeps its own version and moves once per release, only when parser code changed. dev and main ship it at 0.1.1. On 0.1.4, four merged changes touched parser/: #1592 (a concise arrow body is owned by the arrow), #1616 (generated-output detection), #1621 (a package's published API is rooted from package.json) and #1625 (the Java walk and build directories). So 0.1.2, one bump for the release. (cherry picked from commit 51c5e8e151b8ff5af2220f2a7709bce011877822) Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- parser/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parser/package.json b/parser/package.json index 6c9395caa..eac08e048 100644 --- a/parser/package.json +++ b/parser/package.json @@ -1,6 +1,6 @@ { "name": "@axiomcode/parser", - "version": "0.1.1", + "version": "0.1.2", "description": "AxiomCode Parser — compiles source code and build configuration into a relational intermediate representation.", "main": "dist/extract.js", "types": "dist/extract.d.ts",