Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,23 @@
neither is a class in another language family (a Swift call the graph resolved
onto a Kotlin class of the same name). The
other ``partial`` fragments of the method's own class are its own class.

Two more rules keep static utilities out of the data classes they read:

- a **static** method (Java ``static``, a Kotlin ``object`` / ``companion
object`` function) has no instance of its own to be envious from, so the
getters it calls on a parameter are its inputs, not envy. It never moves
into a class whose members it uses are all accessors (``getX`` / ``isX`` /
``setX``, a record component): an entity, a DTO or a value class.
- a move never **reverses a dependency**. On the JVM, when the method calls
something that is not on the target and the target's file cannot see it (no
import, not in its package), the target would have to depend on the
method's home, so that target is not a home for the method.
"""

from __future__ import annotations

import posixpath
import re
from typing import Any

Expand Down Expand Up @@ -119,6 +132,13 @@
)
# Python marks a factory with a decorator.
_FACTORY_DECORATORS = ("@staticmethod", "@classmethod")
# ``getX`` / ``isX`` / ``setX``: the accessor shape of a bean, an entity or a
# DTO. ``get`` alone (a map-style lookup) is behaviour, so a suffix is needed.
_ACCESSOR_NAME = re.compile(r"^(?:get|is|set)[A-Z0-9_]")
# Languages whose imports and packages decide whether one file can see another.
_VISIBILITY_LANGUAGES = frozenset({"java", "kotlin"})
# File-to-file edges that mean "this file reads that one".
_VISIBLE_EDGE_TYPES = frozenset({"imports", "type_use"})


def _node(graph: Any, node_id: str) -> dict | None:
Expand Down Expand Up @@ -296,6 +316,111 @@
return any(name in g.methods and (g.fields or g.calls) for g in groups)


def _is_static(data: dict) -> bool:
"""Whether the method has no instance: Java ``static``, a Kotlin
``object`` / ``companion object`` function (the parser records both in
``modifiers``)."""
return "static" in (data.get("modifiers") or ())


def _split_params(text: str) -> list[str]:
"""Split a parameter list on its top-level commas (``Map<K, V> m`` is one)."""
parts: list[str] = []
depth = 0
start = 0
for i, ch in enumerate(text):
if ch in "<([":
depth += 1
elif ch in ">)]":
depth -= 1
elif ch == "," and depth == 0:
parts.append(text[start:i])
start = i + 1
parts.append(text[start:])
return [p.strip() for p in parts if p.strip()]


def _constructor_param_names(graph: Any, class_id: str, cache: dict[str, set[str]]) -> set[str]:
"""The parameter names of *class_id*'s constructors. A record's components
are its canonical constructor's parameters; each has a same-named accessor."""
cached = cache.get(class_id)
if cached is not None:
return cached
class_name = _class_name(graph, class_id)
names: set[str] = set()
for member in _class_members(graph, class_id, {}):
node = _node(graph, member) or {}
signature = node.get("signature") or ""
if node.get("name") != class_name or "(" not in signature:
continue
params = signature[signature.index("(") + 1 : signature.rindex(")")]
for param in _split_params(params):
# ``String appId`` / ``final List<String> ids`` / ``val appId: String``
declared = param.split(":", 1)[0].split("=", 1)[0].split()
if declared:
names.add(declared[-1].lstrip("*&"))
cache[class_id] = names
return names


def _is_accessor(graph: Any, member_id: str, component_names: set[str]) -> bool:
"""A ``getX`` / ``isX`` / ``setX`` member, or the no-argument accessor of a
record component (``appId()``)."""
node = _node(graph, member_id) or {}
name = node.get("name") or ""
if _ACCESSOR_NAME.match(name):
return True
signature = node.get("signature") or ""
return (
name in component_names and re.search(rf"\b{re.escape(name)}\(\s*\)", signature) is not None
)


def _only_accessors(
graph: Any, class_id: str, members: set[str], cache: dict[str, set[str]]
) -> bool:
"""Whether every member in *members* is an accessor of *class_id*: the class
is a data holder as far as this method is concerned."""
component_names = _constructor_param_names(graph, class_id, cache)
return bool(members) and all(_is_accessor(graph, m, component_names) for m in members)


def _file_of(graph: Any, node_id: str) -> str | None:
return (_node(graph, node_id) or {}).get("file_path")


def _can_see(graph: Any, from_file: str, to_file: str) -> bool:
"""Whether code in *from_file* can name what *to_file* declares: the same
file, the same package directory, or an import / type reference to it."""
if from_file == to_file:
return True
if posixpath.dirname(from_file) == posixpath.dirname(to_file):
return True
if from_file not in graph or to_file not in graph:
return False
data = graph.get_edge_data(from_file, to_file)
if not data:
return False
edges = data.values() if graph.is_multigraph() else (data,)
return any(e.get("edge_type") in _VISIBLE_EDGE_TYPES for e in edges)


def _reverses_dependency(
graph: Any, language: str, target_file: str, other_callees: set[str]
) -> bool:
"""Whether moving the method into *target_file* would make that file depend
on something it cannot see today: a callee that is not on the target and
lives in a file the target does not import and shares no package with. The
move would invert the dependency (the entity depends on the utility)."""
if language not in _VISIBILITY_LANGUAGES or not target_file:
return False
for callee in other_callees:
callee_file = _file_of(graph, callee)
if callee_file is not None and not _can_see(graph, target_file, callee_file):
return True
return False


def _owning_class_id(graph: Any, callee_id: str) -> str | None:
"""The class node a callee belongs to: the method's parent class, or the
class itself for a constructor call. ``None`` for free functions and
Expand Down Expand Up @@ -366,125 +491,135 @@
out.append(node_id)
return sorted(set(out))

def _envy_for(
self,
ctx: RefactoringContext,
graph: Any,
method_id: str,
members_cache: dict[str, set[str]],
) -> RefactoringSuggestion | None:
data = _node(graph, method_id)
if data is None:
return None
name = data.get("name") or ""
if name.startswith("__"): # dunder / hook methods don't move
return None
parent = data.get("parent_name")
if not parent:
return None
own_class_id = f"{ctx.file_path}::{parent}"
if own_class_id not in graph:
return None
if _overrides(graph, data, own_class_id, ctx.language) or _uses_own_state(
ctx.classes, parent, name, data.get("start_line")
):
return None

# Group the method's class-owned callees by the class they belong to.
accessed_by_class: dict[str, set[str]] = {}
home: set[str] = set()
for _u, callee, edata in graph.out_edges(method_id, data=True):
if (
not is_reliable_call_edge(edata.get("edge_type"), edata.get("resolution_origin"))
or callee == method_id
):
continue
if (_node(graph, callee) or {}).get("file_path") == ctx.file_path:
home.add(callee)
owner = _owning_class_id(graph, callee)
if owner is None:
continue
accessed_by_class.setdefault(owner, set()).add(callee)

accessed: set[str] = set()
for members in accessed_by_class.values():
accessed |= members
if not accessed:
return None

own_ids = _partial_fragments(graph, ctx.file_path, parent) | {own_class_id}
own_distinct = len(set().union(*(accessed_by_class.get(c, ()) for c in own_ids)))
if own_distinct > _MAX_OWN_MEMBERS:
return None

# Nearest foreign class by Jaccard distance (tie-break on class id).
# A constructor call lands the class id in its own member set.
own_and_inherited = heritage_ancestors(graph, own_class_id) | own_ids
static = _is_static(data)
components: dict[str, set[str]] = {}
foreign = [
(c, m)
for c, m in accessed_by_class.items()
if _is_target(graph, c, m, own_and_inherited, ctx.file_path)
# A static method reading a data holder's accessors has inputs, not envy.
and not (static and _only_accessors(graph, c, m, components))
and not _reverses_dependency(
graph,
ctx.language,
_file_of(graph, c) or c.rpartition("::")[0],
accessed - m,
)
]
if not foreign:
return None
own_distance = self._distance(graph, own_class_id, accessed, members_cache)

def _dist(item: tuple[str, set[str]]) -> tuple[float, str]:
return (self._distance(graph, item[0], accessed, members_cache), item[0])

target_id, target_accessed = min(foreign, key=_dist)
target_distance = self._distance(graph, target_id, accessed, members_cache)
foreign_distinct = len(target_accessed)

if foreign_distinct < _MIN_FOREIGN_MEMBERS:
return None
# The own class lives in the home file, so this covers it too.
if foreign_distinct <= len(home):
return None
if target_distance > _MAX_TARGET_DISTANCE:
return None
if own_distance - target_distance < _MIN_DISTANCE_MARGIN:
return None

target_node = _node(graph, target_id) or {}
target_class = target_node.get("name") or target_id.rsplit("::", 1)[-1]
target_file = target_node.get("file_path")
if target_file and _file_is_test(graph, target_file):
return None # never propose moving production code into a test class

callers = self._caller_count(graph, method_id)
plan = {
"method": name,
"from_class": parent,
"to_class": target_class,
"to_file": target_file,
}
evidence = {
"foreign_calls": foreign_distinct,
"own_calls": own_distinct,
"own_distance": round(own_distance, 3),
"target_distance": round(target_distance, 3),
}
blast_radius = {
"callers": callers,
"files": sorted({ctx.file_path, target_file} - {None}),
}
nloc = self._method_nloc(data)
confidence = "high" if own_distinct == 0 and foreign_distinct >= 3 else "medium"
return RefactoringSuggestion(
refactoring_type=self.name,
file_path=ctx.file_path,
target_symbol=f"{parent}.{name}",
line_start=data.get("start_line"),
line_end=data.get("end_line"),
plan=plan,
evidence=evidence,
impact_delta=0.0,
effort_bucket=effort_bucket(nloc),
blast_radius=blast_radius,
confidence=confidence,
source_biomarker="",
)

Check warning on line 622 in packages/core/src/repowise/core/analysis/health/refactoring/move_method.py

View check run for this annotation

Repowise Bot / Repowise / code health

Introduced: complex method

_envy_for has cyclomatic complexity 33

def _distance(
self, graph: Any, class_id: str, accessed: set[str], cache: dict[str, set[str]]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,9 @@ class LanguageConfig:
import_node_types=["import_declaration"],
export_node_types=[],
visibility_fn=java_visibility,
# ``public static`` etc. land in ``Symbol.modifiers`` (annotations in
# the same node are decorators and are skipped by the reader).
modifier_node_types=frozenset({"modifiers"}),
parent_extraction="nesting",
parent_class_types=frozenset(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,41 @@ def _declared_modifiers(def_node: Node, node_types: frozenset[str], src: str) ->
return tuple(words)


# Kotlin has no ``static`` keyword: a member of an ``object`` or of a
# ``companion object`` is the language's static member.
_KOTLIN_STATIC_OWNERS = frozenset({"companion_object", "object_declaration"})
_KOTLIN_CLASS_OWNERS = frozenset({"class_declaration", "object_literal"})


def _declared_or_implied_modifiers(
def_node: Node, node_types: frozenset[str], language: str, src: str
) -> tuple[str, ...]:
"""``_declared_modifiers`` plus ``static`` for a Kotlin ``object`` /
``companion object`` function, which the grammar does not spell out."""
modifiers = _declared_modifiers(def_node, node_types, src)
if (
language == "kotlin"
and def_node.type == "function_declaration"
and "static" not in modifiers
and _in_kotlin_static_owner(def_node)
):
return (*modifiers, "static")
return modifiers


def _in_kotlin_static_owner(def_node: Node) -> bool:
"""Whether the nearest enclosing type of *def_node* is an ``object`` or a
``companion object`` (so the function has no instance of its own)."""
node = def_node.parent
while node is not None:
if node.type in _KOTLIN_STATIC_OWNERS:
return True
if node.type in _KOTLIN_CLASS_OWNERS:
return False
node = node.parent
return False


def _rust_outer_attributes(def_node: Node, src: str) -> list[str]:
"""Rust: outer attributes (#[...]) are preceding siblings of the item."""
attrs: list[str] = []
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/repowise/core/ingestion/parser/symbols.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
_TS_JS_LANGUAGES,
_attribute_decorators,
_dart_mixin_parent,
_declared_modifiers,
_declared_or_implied_modifiers,
_drop_swift_extensions_of_local_types,
_is_declaration,
_is_fsharp_binding,
Expand Down Expand Up @@ -386,7 +386,9 @@ def _symbol_from_match(capture_dict: dict, file: _SymbolPass) -> tuple[Symbol, N
_csharp_type_parameter_count(def_node) if language == "csharp" else None
),
**_kotlin_receiver_fields(def_node, params_nodes, language, name),
modifiers=_declared_modifiers(def_node, config.modifier_node_types, src),
modifiers=_declared_or_implied_modifiers(
def_node, config.modifier_node_types, language, src
),
)
return symbol, def_node

Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/repowise/core/upgrade/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,11 @@
#:
#: v10: a JavaScript-family file under a ``tests/`` folder inside a ``src`` tree
#: is not a test by that folder alone, which moves its stored ``is_test``.
PARSER_SCHEMA_VERSION: int = 10
#:
#: v11: a Java method's symbol carries its keyword modifiers
#: (``public static``), and a Kotlin ``object`` /
#: ``companion object`` function carries ``static``.
PARSER_SCHEMA_VERSION: int = 11

#: state.json key holding the store format version that wrote the store.
STORE_FORMAT_VERSION_KEY = "store_format_version"
Expand Down
Loading
Loading