4545
4646from __future__ import annotations
4747
48+ import ast
4849import inspect
4950import re
51+ from collections import defaultdict
5052from typing import Any , Dict , List
5153
5254import pytest
@@ -79,8 +81,8 @@ def _node(module: str, key: str, **props: Any) -> Dict[str, Any]:
7981 "class_methods" : (
8082 CLASS_SIG ,
8183 {
82- APP_A_MODULE : _node (APP_A_MODULE , "Widget/render" , signature = METHOD_SIG , name = "alpha_method" , path = APP_A_MODULE ),
83- APP_B_MODULE : _node (APP_B_MODULE , "Widget/render" , signature = METHOD_SIG , name = "beta_method" , path = APP_B_MODULE ),
84+ APP_A_MODULE : _node (APP_A_MODULE , "Widget/render" , signature = METHOD_SIG , name = "alpha_method" , path = APP_A_MODULE , code = "alpha code" ),
85+ APP_B_MODULE : _node (APP_B_MODULE , "Widget/render" , signature = METHOD_SIG , name = "beta_method" , path = APP_B_MODULE , code = "beta code" ),
8486 },
8587 ),
8688 "class_attributes" : (
@@ -145,6 +147,14 @@ def _node(module: str, key: str, **props: Any) -> Dict[str, Any]:
145147 _node (APP_B_MODULE , "Widget" , signature = CLASS_SIG , name = "Widget" , path = APP_B_MODULE ),
146148]
147149
150+ #: What ``_SOURCES`` (``describe``) may be handed, per label and per application: the shared method
151+ #: (by signature), each application's one call-site body node, and one ``@external`` ghost each.
152+ _SOURCE_NODES : Dict [str , List [Dict [str , Any ]]] = {
153+ "PyCallable" : list (_CHILDREN ["class_methods" ][1 ].values ()),
154+ "PyBodyNode" : list (_CHILDREN ["callable_callsites" ][1 ].values ()),
155+ "PyExternal" : [{"id" : f"can://python/{ app } /@external/os/path" , "name" : "path" , "module" : "os" } for app in (APP_A , APP_B )],
156+ }
157+
148158#: The three spellings of the application scope a statement may carry: the whole application
149159#: (``$prefix``); for a narrowed bulk fetch, a list of per-module prefixes (``$prefixes``); and for
150160#: ``locate``, one module's own prefix per position (``pos.module_prefix``, minted from the same
@@ -198,6 +208,14 @@ def _in_scope(query: str, params: Dict[str, Any], props: Dict[str, Any]) -> bool
198208
199209def _fake_two_app_cypher (query : str , ** params : Any ) -> List [Dict [str , Any ]]:
200210 """Answer the statements a class reconstruction issues, honestly (see :func:`_in_scope`)."""
211+ if "UNION MATCH (b:PyBodyNode)" in query : # _SOURCES: three arms, each judged on its own predicate
212+ rows = []
213+ for arm in query .split (" UNION " ):
214+ label = re .search (r"MATCH \(\w+:(\w+)\)" , arm )[1 ]
215+ for props in _SOURCE_NODES [label ]:
216+ if (props ["id" ] in params ["refs" ] or props .get ("signature" ) in params ["refs" ]) and _in_scope (arm , params , props ):
217+ rows .append ({"id" : props ["id" ], "sig" : props .get ("signature" ), "code" : props .get ("code" )})
218+ return rows
201219 bucket = _bucket_of (query )
202220 if bucket == "module_functions" :
203221 # Keyed by file_key: the per-parent twin names it (``$fk``), the bulk twin lists the scope
@@ -307,6 +325,18 @@ def test_a_module_key_shared_by_two_applications_does_not_leak(bulk: bool):
307325 assert set (module .functions ) == {"alpha_fn" }, "the module-keyed fetch leaked another application's function"
308326
309327
328+ def test_describe_does_not_find_another_applications_body_nodes_or_ghosts ():
329+ """``_SOURCES`` answers ``describe`` for callables, body nodes and ghosts in one statement. A
330+ body-node or ghost id embeds its application, but "found, with nothing to read" and "not found"
331+ are different answers, and a ref from another application must get the second -- so every arm
332+ carries the prefix, not only the callable one, and the shared signature resolves to A's code."""
333+ backend = _two_app_backend ()
334+ a_body , b_body = (n ["id" ] for n in _SOURCE_NODES ["PyBodyNode" ])
335+ a_ghost , b_ghost = (n ["id" ] for n in _SOURCE_NODES ["PyExternal" ])
336+ found = backend ._sources_for ([METHOD_SIG , a_body , b_body , a_ghost , b_ghost ])
337+ assert found == {METHOD_SIG : "alpha code" , a_body : None , a_ghost : None }
338+
339+
310340def test_every_child_statement_carries_the_application_scope ():
311341 """The four module-keyed collections cannot be caught by the fixture above (see the module
312342 docstring), so they are caught here: every statement either path issues to fetch children is
@@ -345,13 +375,107 @@ def _class_level_statements() -> Dict[str, str]:
345375 }
346376
347377
378+ def _inline_statements () -> Dict [str , str ]:
379+ """The Cypher written *inline* at every ``self._run(`` site, keyed ``<method>@<line>`` -- the
380+ statements the class-attribute enumeration cannot see (about twenty of the 44 sites).
381+
382+ Reassembled from the class's own source: string constants verbatim; f-strings and ``+`` chains
383+ joined; ``.format(...)`` unwrapped to its receiver; ``self.<ATTR>`` replaced by the class-level
384+ string it names; a local name replaced by every assignment to it in the enclosing method, in
385+ order (``query += " AND ..."``); anything else becomes ``{…}``. A site whose first argument is
386+ a bare parameter or a lookup resolves to ``{…}`` alone -- ``_children`` (its callers' literals
387+ are judged by the child walk above), ``_collect`` (``_BULK_CHILD_QUERIES``, judged directly)
388+ and ``_paths`` (``_PATHS`` / ``_CALL_PATHS``, class-level) -- and is reported as such.
389+ """
390+ class_strings = {name : value for name , value in vars (PyNeo4jBackend ).items () if isinstance (value , str )}
391+ out : Dict [str , str ] = {}
392+ for fn in ast .walk (ast .parse (inspect .getsource (PyNeo4jBackend ))):
393+ if not isinstance (fn , ast .FunctionDef ):
394+ continue
395+ assigned : Dict [str , List [ast .expr ]] = defaultdict (list )
396+ for node in ast .walk (fn ):
397+ if isinstance (node , ast .Assign ):
398+ for target in node .targets :
399+ if isinstance (target , ast .Name ):
400+ assigned [target .id ].append (node .value )
401+ elif isinstance (node , ast .AugAssign ) and isinstance (node .target , ast .Name ):
402+ assigned [node .target .id ].append (node .value )
403+
404+ def text (e : ast .expr , depth : int = 0 ) -> str :
405+ if depth > 8 :
406+ return "{…}"
407+ if isinstance (e , ast .Constant ) and isinstance (e .value , str ):
408+ return e .value
409+ if isinstance (e , ast .JoinedStr ):
410+ return "" .join (text (v .value if isinstance (v , ast .FormattedValue ) else v , depth + 1 ) for v in e .values )
411+ if isinstance (e , ast .BinOp ) and isinstance (e .op , ast .Add ):
412+ return text (e .left , depth + 1 ) + text (e .right , depth + 1 )
413+ if isinstance (e , ast .Call ) and isinstance (e .func , ast .Attribute ) and e .func .attr == "format" :
414+ return text (e .func .value , depth + 1 )
415+ if isinstance (e , ast .Attribute ) and isinstance (e .value , ast .Name ) and e .value .id == "self" and e .attr in class_strings :
416+ return class_strings [e .attr ]
417+ if isinstance (e , ast .Name ) and e .id in assigned :
418+ return "" .join (text (v , depth + 1 ) for v in assigned [e .id ])
419+ return "{…}"
420+
421+ for call in ast .walk (fn ):
422+ if isinstance (call , ast .Call ) and isinstance (call .func , ast .Attribute ) and call .func .attr == "_run" and isinstance (call .func .value , ast .Name ) and call .func .value .id == "self" and call .args :
423+ out .setdefault (f"{ fn .name } @{ call .lineno } " , text (call .args [0 ]))
424+ return out
425+
426+
427+ #: The four ways a statement stays inside one application. ``introspection`` is the server, not
428+ #: the data (``CALL db.relationshipTypes()``, ``CALL dbms.components()``); ``application`` walks out
429+ #: from ``(:PyApplication {name: $app})`` and cannot leave it; ``prefix`` and ``id`` are the two
430+ #: ``can://``-stamped forms the docstring below explains. Anything else is unscoped.
431+ _INTROSPECTION = re .compile (r"^\s*CALL (db|dbms)\." )
432+ _ANCHORED_ON_THE_APPLICATION = re .compile (r"\(\w*:PyApplication \{name: \$app\}\)" )
433+
434+
435+ def _scope_kind (statement : str ) -> str | None :
436+ if _INTROSPECTION .match (statement ):
437+ return "introspection"
438+ if _is_scoped (statement ):
439+ return "prefix"
440+ if _ANCHORED_ON_THE_APPLICATION .search (statement ):
441+ return "application"
442+ if _MATCHES_BY_ID .search (statement ):
443+ return "id"
444+ return None
445+
446+
447+ def _every_statement () -> Dict [str , str ]:
448+ """Class-level statements (minus fragments) plus every inline one that resolved to Cypher."""
449+ inline = {name : s for name , s in _inline_statements ().items () if s != "{…}" }
450+ return {** {n : s for n , s in _class_level_statements ().items () if n not in _FRAGMENTS }, ** inline }
451+
452+
348453def test_the_audit_sees_the_dataflow_statements_too ():
349454 names = set (_class_level_statements ())
350455 for expected in ("_REACHES" , "_CONE" , "_PATHS" , "_CALL_PATHS" , "_VALUE_REACHES" , "_CALLEE_VALUES" , "_SOURCES" , "_SLICE" , "_CALLERS" , "_CALLEES" , "_OWN_EDGES" , "_LOCATE_QUERY" , "_OVERVIEW_PROJECTION" ):
351456 assert expected in names , f"{ expected } is not a class-level statement any more; move it back or extend the audit"
352457
353458
354- @pytest .mark .parametrize ("name" , sorted (set (_class_level_statements ()) - _FRAGMENTS ))
459+ def test_the_audit_sees_every_inline_statement_too ():
460+ """One harvested statement per ``self._run(`` in the source -- a site the harvester cannot
461+ reassemble shows up as ``{…}`` and must be one of the three known indirections, so a new
462+ ``self._run(some_variable)`` cannot slip past unjudged."""
463+ inline = _inline_statements ()
464+ assert len (inline ) == inspect .getsource (PyNeo4jBackend ).count ("self._run(" ), "a self._run( site the harvester did not see"
465+ indirect = sorted (name .split ("@" )[0 ] for name , s in inline .items () if s == "{…}" )
466+ assert indirect == ["_children" , "_collect" , "_paths" ], f"unjudged statements passed through a variable: { indirect } "
467+ assert all (re .match (r"(MATCH|OPTIONAL MATCH|UNWIND|CALL)\b" , s .lstrip ()) for s in inline .values () if s != "{…}" ), "an inline statement does not start with a Cypher clause"
468+ for expected in ("get_source" , "get_class" , "get_python_file" , "get_method_bodies" , "resolve_value" , "_call_rows" , "_bounded_call_rows" , "_get_module_functions" , "get_callsites_for" , "get_config_uses" , "get_config_readers" , "get_decorated_callables" , "get_entrypoints" , "get_entrypoint_classes" , "_probe_resolution_edges" , "_own_edges" , "get_dependencies" ):
469+ assert any (name .startswith (expected + "@" ) for name in inline ), f"{ expected } 's statement is not harvested"
470+
471+
472+ def test_no_statement_names_pycannode ():
473+ """1.4.1's ``:PyCanNode`` label was measured and rejected as a seek anchor (the spec's F2 table)
474+ and does not exist on 1.4.0 graphs; a statement naming it would match nothing there."""
475+ assert [name for name , s in _every_statement ().items () if "PyCanNode" in s ] == []
476+
477+
478+ @pytest .mark .parametrize ("name" , sorted (_every_statement ()))
355479def test_every_statement_is_application_scoped_or_keyed_by_an_application_stamped_id (name ):
356480 """Two ways a statement stays inside one application, and every statement must use one.
357481
@@ -361,14 +485,16 @@ def test_every_statement_is_application_scoped_or_keyed_by_an_application_stampe
361485 A body-node or ghost **id** embeds the application (``can://python/<app>/…``) and the emitter
362486 only ever links nodes from its own run, so a statement keyed *only* by id is scoped by
363487 construction and may omit the predicate -- ``_SLICE``, ``_PATHS`` and ``_VALUE_REACHES`` do,
364- for the measured cost of testing 195,784 reached nodes against a list. A statement keyed by
365- neither would be unscoped and fails here.
488+ for the measured cost of testing 195,784 reached nodes against a list. A statement anchored
489+ on ``(:PyApplication {name: $app})`` walks out from the application node and cannot leave it
490+ (the module list, artifacts, config keys, the entrypoint report). A statement keyed by none
491+ of these would be unscoped and fails here -- class-level and inline alike.
366492 """
367- statement = _class_level_statements ()[name ]
368- by_signature , by_id , scoped = bool ( _MATCHES_BY_SIGNATURE . search ( statement )), bool ( _MATCHES_BY_ID . search ( statement )), _is_scoped (statement )
369- assert scoped or by_id , f"{ name } carries no application scope and is not keyed by an application-stamped id"
370- if by_signature :
371- assert scoped , f"{ name } matches by signature without the application scope "
493+ statement = _every_statement ()[name ]
494+ kind = _scope_kind (statement )
495+ assert kind is not None , f"{ name } carries no application scope and is not keyed by an application-stamped id: { statement [: 160 ]!r } "
496+ if _MATCHES_BY_SIGNATURE . search ( statement ) :
497+ assert kind == "prefix" , f"{ name } matches by signature without the application prefix "
372498
373499
374500def test_the_overview_projection_is_only_ever_appended_to_a_scoped_match ():
0 commit comments