Skip to content
Merged
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
4 changes: 4 additions & 0 deletions docs/architecture/code-health.md
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,10 @@ runs it over growing data).
- An Extract Method step finds its lines by their start and end text (line numbers move
with the first edit); if that text repeats in the file, it says to stay inside the
function.
- An Extract Method step carries the plan's `notes`. A stage that takes the shared parameter
object shows the object's declaration above its helper and the line building it above
its call. A plan missing either is not offered as a Fix first step, since its call would
name a variable that does not exist.

### 6.4 Performance opportunities (`perf/`)

Expand Down
10 changes: 10 additions & 0 deletions docs/layers/REFACTORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,16 @@ Most plans answer a health finding, so per-path marker rules in
code declares them; `<type>` marks a type for you to fill in. It flags an
awaiting span with `needs_async: true` and a receiver-bound helper under
`new_symbol`.
- A TypeScript span is not offered when a test above or around it narrows a
value no helper parameter can carry: a property the span reads
(`if (!tc.result) continue;` before `tc.result.id`), a property compared with
a literal, or a module-level name (`assert(cursor !== undefined)`). Nor is a
span holding an `assert` the code after it relies on, or one that assigns a
name declared outside it without taking or returning it.
One such stage refuses the whole staged split; the finding records
`narrowing_lost` or `outer_declaration` in `details.extract_method_stage_refused`.
A guard or `if` narrowing a parameter gets a note to type it as narrowed, and
an output declared before the span a note to declare it in the helper.
- A step Repowise cannot express mechanically is a judgment call with a reason
code: `async_helper_unexpressible`, `receiver_unbound` or
`receiver_copy_written`.
Expand Down
15 changes: 13 additions & 2 deletions packages/core/src/repowise/core/agent_prompts/fix_first.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,22 @@ def _step(s: Mapping[str, Any]) -> str:
loc = f"{s['file_path']}{f':{line}' if line else ''}"
tag = " [mechanical]" if s.get("mechanical") else " [judgment]"
extra = [
f" {label}: `{s[key]}`"
_code_line(label, s[key])
for key, label in (("signature", "Helper"), ("call", "Call"), ("command", "Check"))
if s.get(key)
]
return "\n".join([f"{s['order']}. {s['text']} (`{loc}`){tag}", *extra])
notes = [f" Note: {' '.join(n.split())}" for n in s.get("notes") or ()]
return "\n".join([f"{s['order']}. {s['text']} (`{loc}`){tag}", *extra, *notes])


def _code_line(label: str, code: str) -> str:
"""One labelled piece of a step's code: inline, or fenced when it spans
lines (a parameter object's declaration above its helper)."""
if "\n" not in code:
return f" {label}: `{code}`"
body = "\n".join(f" {line}" if line else "" for line in code.splitlines())
fence = "````" if "```" in code else "```"
return f" {label}:\n {fence}\n{body}\n {fence}"


def render_fix_item(item: Mapping[str, Any], flavor: str = "generic", repo_name: str | None = None) -> str:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from dataclasses import dataclass
from typing import Any

from repowise.core.analysis.health.refactoring.recipe import _strs
from repowise.core.analysis.health.rows import field
from repowise.core.analysis.health.worth import WORTH_MAGNITUDE

Expand Down Expand Up @@ -154,11 +155,48 @@ def _step_command(check: Mapping[str, Any] | None, item_command: str | None) ->
return commands[0] if commands and commands[0] != item_command else None


def _helper_code(body: Mapping[str, Any]) -> dict[str, str | None]:
"""An Extract Method plan's rendered helper header and call, when it wrote them."""
symbol = body.get("new_symbol") or {}
site = body.get("call_site") or {}
return {
"signature": symbol.get("signature_text") if isinstance(symbol, dict) else None,
"call": site.get("new_text") if isinstance(site, dict) else None,
}
def shared_object(body: Mapping[str, Any]) -> Mapping[str, Any] | None:
"""The parameter object a staged plan's first stage takes, None when its
helper takes plain values only."""
obj = body.get("parameter_object")
params = body.get("params")
if not isinstance(obj, dict) or not isinstance(params, list):
return None
var = obj.get("var")
return obj if isinstance(var, str) and var in params else None


def object_code(obj: Mapping[str, Any]) -> tuple[str, str] | None:
"""The object's declaration and the line building it, None when the plan
lacks either."""
declaration, construct = _text(obj.get("declaration_text")), _text(obj.get("construct_text"))
return (declaration, construct) if declaration and construct else None


def _helper_code(body: Mapping[str, Any]) -> dict[str, Any]:
"""An Extract Method plan's rendered helper header and call, when it wrote
them, and the plan's notes on what that code cannot say (a type a guard
narrows, an import to repeat). A helper taking the shared parameter object
comes after the object's declaration, and its call after the line
building it: the call names that variable."""
symbol = body.get("new_symbol")
symbol = symbol if isinstance(symbol, dict) else {}
site = body.get("call_site")
signature = _text(symbol.get("signature_text"))
call = _text(site.get("new_text")) if isinstance(site, dict) else None
notes = _strs(symbol.get("notes"))
obj = shared_object(body)
code = object_code(obj) if obj is not None else None
if obj is not None and code is not None:
signature = "\n\n".join(t for t in (code[0], signature) if t)
call = "\n".join(t for t in (code[1], call) if t)
first = (
f"Add the {obj.get('name')} declaration above the helper, and the line building "
f"{obj.get('var')} just before the call, as shown."
)
notes = [first, *_strs(obj.get("notes")), *notes]
return {"signature": signature, "call": call, "notes": tuple(dict.fromkeys(notes))}


def _text(value: Any) -> str | None:
return value if isinstance(value, str) and value else None
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ class FixStep:
call: str | None = None
#: The command that checks this step, when it differs from the item's.
command: str | None = None
#: What the helper's code cannot say, in the plan's words: a type a guard
#: narrows, a parameter object to declare, the values read off it.
notes: tuple[str, ...] = ()


@dataclass(frozen=True, slots=True)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from repowise.core.ci.markdown import code as md_code

from . import text
from ._parts import _helper_code, _open
from ._parts import _helper_code, _open, object_code, shared_object
from .model import FixStep


Expand Down Expand Up @@ -134,6 +134,14 @@ def _extract_text(sym: str, body: Mapping[str, Any], start: Any, end: Any) -> st
return line_text


def _extract_ready(body: Mapping[str, Any]) -> bool:
"""Whether the step can be written as it reads: a helper taking the shared
parameter object needs the object's declaration and the line building it,
or its call names a variable and a type that do not exist."""
obj = shared_object(body)
return obj is None or object_code(obj) is not None


#: A refactoring step's sentence, by plan type, from (symbol, path, plan, plan body).
_STEP_TEXT: dict[str, Callable[[str, str, Any, Mapping[str, Any]], str]] = {
"extract_method": lambda sym, path, plan, body: _extract_text(sym, body, *_span(plan)),
Expand Down Expand Up @@ -218,7 +226,7 @@ def _concrete(step: Mapping[str, Any], plan: Any) -> bool:
body = _plan_body(plan)
if kind == "extract_method":
start, end = _span(plan)
return bool(start and end)
return bool(start and end) and _extract_ready(body)
if kind == "extract_helper":
return any(
isinstance(o, dict) and o.get("line_start") for o in body.get("occurrences") or ()
Expand Down Expand Up @@ -250,7 +258,7 @@ def _extractions(plans: Iterable[Any]) -> dict[tuple[str, str], list[Any]]:
if field(plan, "refactoring_type") != "extract_method" or not symbol or not _open(plan):
continue
start, end = _span(plan)
if start and end:
if start and end and _extract_ready(_plan_body(plan)):
out[(field(plan, "file_path"), text.short_symbol(symbol).rsplit(".", 1)[-1])].append(
plan
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@
from .declarative import declarative_flat
from .helper_naming import ScopeNames, placeholder_names
from .models import RefactoringContext, RefactoringSuggestion
from .narrowing import narrowed, narrowings
from .narrowing import narrowed, narrowings, property_narrowings, span_guards
from .registry import RefactoringDetector, register

if TYPE_CHECKING:
Expand Down Expand Up @@ -235,6 +235,7 @@ def _suggest(
prefixes: dict[int, Any] = {}
refused: set[str] = set()
candidates = find_extractions(analysis, lmap, receiver, prefixes, refused)
candidates = _writable_spans(analysis, candidates, ctx.language, refused)
_mark_stage_refused(matched, frozenset(refused))
best = _choose(analysis, candidates, markers)
staged = _staged(analysis, best, lmap, receiver, ctx.language, matched, prefixes)
Expand Down Expand Up @@ -468,7 +469,12 @@ def _staged(
if max_returns is None or analysis.ccn < _STAGE_MIN_CCN:
return None
plan = find_stages(analysis, lmap, receiver, max_returns=max_returns, prefixes=prefixes)
_mark_stage_refused(findings, plan.refused)
refused = set(plan.refused)
# One stage no helper can be written for refuses the split: the others compose with it.
kept = _writable_spans(analysis, plan.stages, language, refused)
if len(kept) < len(plan.stages):
plan = replace(plan, stages=())
_mark_stage_refused(findings, frozenset(refused))
if not plan.stages or (best is not None and plan.ccn_removed < 2 * best.ccn_removed):
return None
if not all(_worth_extracting(analysis, x, markers) for x in plan.stages):
Expand Down Expand Up @@ -700,11 +706,7 @@ def _render_fields(
out_rebound=rebound,
typed_host=fn_node is not None
and fn_node.child_by_field_name("return_type") is not None,
throws_clause=(
next((child.text.decode() for child in fn_node.named_children if child.type == "throws"), None)
if language == "java" and fn_node is not None
else None
),
throws_clause=_throws_clause(fn_node, language),
)
)
rendered = {
Expand All @@ -714,8 +716,9 @@ def _render_fields(
"signature_text": texts.signature if texts else None,
"return_text": texts.returns if texts else None,
}
if texts and texts.notes:
rendered["notes"] = list(texts.notes)
notes = [*(texts.notes if texts else ()), *_undeclared_outputs_note(analysis, extraction)]
if notes:
rendered["notes"] = notes
span = {"start": extraction.start_line, "end": extraction.end_line}
call = texts.call if texts else None
return {
Expand All @@ -725,6 +728,35 @@ def _render_fields(
}


def _throws_clause(fn_node: Any, language: str | None) -> str | None:
"""A Java method's ``throws`` clause, for the helper to repeat."""
if language != "java" or fn_node is None:
return None
return next((c.text.decode() for c in fn_node.named_children if c.type == "throws"), None)


def _undeclared_outputs_note(analysis: FunctionAnalysis, extraction: Extraction) -> list[str]:
"""A note for the outputs the function declares before the span (``let
x: T;``) and the span only assigns: the helper needs its own declaration
or its first assignment names nothing."""
if not extraction.returns:
return []
inside = range(extraction.start_line, extraction.end_line + 1)
declared = [(d.var, d.line in inside) for d in analysis.def_use.definitions if d.declared_at is not None]
names = [
r
for r in extraction.returns
if r not in extraction.params and (r, False) in declared and (r, True) not in declared
]
if not names:
return []
what = "it" if len(names) == 1 else "them"
return [
f"Declare {', '.join(names)} at the top of the helper, as the function does: "
f"the lines assign {what} without declaring {what}."
]


def _declared_types(
analysis: FunctionAnalysis, extraction: Extraction, dialect: BaseDefUseDialect | None
) -> dict[str, str]:
Expand Down Expand Up @@ -755,6 +787,87 @@ def _declared_types(
return found


#: Why a span was refused: a test the span runs under narrows a value it reads
#: in a way no helper parameter keeps (a property path, a name the function
#: does not bind), so the lifted lines stop type-checking.
NARROWING_LOST = "narrowing_lost"
#: Why a span was refused: it assigns a name declared outside it that it
#: neither takes nor returns, so the helper's assignment names nothing.
OUTER_DECLARATION = "outer_declaration"


def _writable_spans(
analysis: FunctionAnalysis,
spans: list[Extraction] | tuple[Extraction, ...],
language: str | None,
refused: set[str],
) -> list[Extraction]:
"""*spans* without those whose helper cannot be written as planned; each
drop adds its reason to *refused*. Without a tree no span was cut from one."""
if analysis.fn_node is None:
return list(spans)
def_lines = _def_lines(analysis)
kept = []
for x in spans:
reason = _unwritable(analysis, x, language, def_lines)
if reason is None:
kept.append(x)
else:
refused.add(reason)
return kept


def _unwritable(
analysis: FunctionAnalysis,
x: Extraction,
language: str | None,
def_lines: dict[str, list[int]],
) -> str | None:
"""Why *x*'s helper cannot be written as planned, None when it can."""
if _assigns_outer_declaration(analysis, x):
return OUTER_DECLARATION
if language in _NARROWING_LANGUAGES and _loses_narrowing(analysis, x, language, def_lines):
return NARROWING_LOST
return None


def _assigns_outer_declaration(analysis: FunctionAnalysis, x: Extraction) -> bool:
"""Whether *x* assigns a name declared outside it that it neither takes nor
returns. A name the span declares itself is its own binding (a second
``for (int i``)."""
inside = range(x.start_line, x.end_line + 1)
defs = analysis.def_use.definitions
declared = [(d.var, d.line in inside) for d in defs if d.declared_at is not None]
outside = {v for v, held in declared if not held} - {v for v, held in declared if held}
outside -= set(x.params) | set(x.returns)
return any(d.line in inside and d.declared_at is None and d.var in outside for d in defs)


def _loses_narrowing(
analysis: FunctionAnalysis, x: Extraction, language: str, def_lines: dict[str, list[int]]
) -> bool:
"""Whether a TypeScript narrowing the lines rely on stays behind in the
function, or one the code after them relies on leaves with them
(``narrowing``)."""
inside = range(x.start_line, x.end_line + 1)
args = (analysis.fn_node, get_language_map(language), x.start_line, x.end_line)
uses = [u for bdu in analysis.def_use.blocks.values() for u in bdu.uses if not u.echo]
# Names the function never binds (a module's): the helper reads them unnarrowed.
free = {u.name for u in uses if u.line in inside} - set(def_lines)
if property_narrowings(*args, set(x.params) | free, def_lines):
return True
if free and narrowings(*args, free, def_lines):
return True
return bool(span_guards(*args) & {u.name for u in uses if u.line > x.end_line})


def _def_lines(analysis: FunctionAnalysis) -> dict[str, list[int]]:
out: dict[str, list[int]] = {}
for d in analysis.def_use.definitions:
out.setdefault(d.var, []).append(d.line)
return out


def _narrow(
analysis: FunctionAnalysis, extraction: Extraction, language: str | None, types: dict[str, str]
) -> dict[str, int]:
Expand All @@ -764,9 +877,7 @@ def _narrow(
guard's line, for the plan's note."""
if language not in _NARROWING_LANGUAGES or not extraction.params:
return {}
def_lines: dict[str, list[int]] = {}
for d in analysis.def_use.definitions:
def_lines.setdefault(d.var, []).append(d.line)
def_lines = _def_lines(analysis)
found = narrowings(
analysis.fn_node,
get_language_map(language),
Expand Down
Loading
Loading