Skip to content
Closed
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
76 changes: 76 additions & 0 deletions codebase_rag/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1220,6 +1220,82 @@ def rename_command(
raise typer.Exit(code=1)


@app.command(
name=ch.CLICommandName.CHANGE_SIGNATURE,
help=ch.CMD_CHANGE_SIGNATURE,
short_help=ch.CMD_CHANGE_SIGNATURE,
epilog=ch.EXAMPLES_CHANGE_SIGNATURE,
rich_help_panel=ch.PANEL_USE,
)
def change_signature_command(
qualified_name: str = typer.Argument(..., help=ch.HELP_CHANGE_SIGNATURE_QN),
new_params: list[str] = typer.Argument(..., help=ch.HELP_CHANGE_SIGNATURE_PARAMS),
repo_path: Path = typer.Option(
Path(cs.MCP_DEFAULT_DIRECTORY),
"--repo-path",
exists=True,
file_okay=False,
help=ch.HELP_GRAPH_REPO_PATH,
),
project: str | None = typer.Option(None, "--project", help=ch.HELP_GRAPH_PROJECT),
mapping: list[str] | None = typer.Option(
None, "--map", help=ch.HELP_CHANGE_SIGNATURE_MAP
),
allow_heuristic: bool = typer.Option(
False, "--allow-heuristic", help=ch.HELP_CHANGE_SIGNATURE_ALLOW_HEURISTIC
),
dry_run: bool = typer.Option(
False, "--dry-run", help=ch.HELP_CHANGE_SIGNATURE_DRY_RUN
),
) -> None:
from .editing.signature import (
SignatureRefused,
change_signature,
parse_mapping,
sites_for,
unmapped_for,
)
from .graph_cli import _project_and_fetch

try:
parsed = parse_mapping(mapping or [])
except SignatureRefused as refused:
typer.echo(str(refused), err=True)
raise typer.Exit(code=1) from refused
name, fetch_all, ingestor = _project_and_fetch(project, repo_path)
with ingestor: # type: ignore[attr-defined]
parsers, queries = load_parsers()
updater = GraphUpdater(
ingestor=ingestor, # type: ignore[arg-type]
repo_path=repo_path.resolve(),
parsers=parsers,
queries=queries,
project_name=name,
)
try:
report = change_signature(
repo_path.resolve(),
fetch_all,
name,
qualified_name,
new_params,
parsed,
allow_heuristic=allow_heuristic,
dry_run=dry_run,
reingest=updater.reingest,
)
except SignatureRefused as refused:
typer.echo(str(refused), err=True)
raise typer.Exit(code=1) from refused
payload = dict(report._asdict())
payload[cs.KEY_SITES] = sites_for(report.sites)
payload[cs.KEY_UNMAPPED] = unmapped_for(report.unmapped)
payload[cs.KEY_VERDICT] = report.verdict._asdict() if report.verdict else None
typer.echo(json.dumps(payload, indent=cs.MCP_JSON_INDENT, sort_keys=True))
if not report.applied and not dry_run:
raise typer.Exit(code=1)


@app.command(
name=ch.CLICommandName.HELP,
help=ch.CMD_HELP,
Expand Down
27 changes: 27 additions & 0 deletions codebase_rag/cli_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class CLICommandName(StrEnum):
GRAPH = "graph"
CHECK = "check"
RENAME = "rename"
CHANGE_SIGNATURE = "change-signature"
STOP = "stop"
STATUS = "status"
HELP = "help"
Expand Down Expand Up @@ -145,6 +146,31 @@ class CLICommandName(StrEnum):
"Rewrite through heuristic, overload and trace-only sites as well."
)
HELP_RENAME_DRY_RUN = "Print the plan and diff without writing anything."
CMD_CHANGE_SIGNATURE = (
"Change a Python definition's parameter list and rewrite every call site "
"the graph knows; sites the mapping cannot complete are listed, not touched."
)
EXAMPLES_CHANGE_SIGNATURE = (
"Examples:\n"
" cgr change-signature myproj.pkg.util.helper a 'n: int' b --map n==1 --dry-run\n"
" cgr change-signature myproj.pkg.util.helper b a\n"
" cgr change-signature myproj.pkg.util.helper 'times: int' 'text: str' "
"--map times=0 --map text=1"
)
HELP_CHANGE_SIGNATURE_QN = "Qualified name of the function or method to change."
HELP_CHANGE_SIGNATURE_PARAMS = (
"The new parameters in order: NAME, 'NAME: TYPE', 'NAME = DEFAULT' or "
"'NAME: TYPE = DEFAULT'; a bare old name carries it over unchanged."
)
HELP_CHANGE_SIGNATURE_MAP = (
"NEW=SOURCE, repeatable: SOURCE is an old parameter name, its 0-based "
"index, or =LITERAL to pass that text at every site (so NEW==1 inserts 1)."
)
HELP_CHANGE_SIGNATURE_ALLOW_HEURISTIC = (
"Rewrite sites the graph resolved heuristically, by overload fan-out or by "
"a trace as well, instead of listing them as unmapped."
)
HELP_CHANGE_SIGNATURE_DRY_RUN = "Print the plan and diff without writing anything."
CMD_TRACE_INGEST = "Resolve a trace file against a project and write dynamic edges"
CMD_TRACE_CONVERT = "Convert a V8 .cpuprofile (node --cpu-prof) to a trace file"

Expand Down Expand Up @@ -466,6 +492,7 @@ class CLICommandName(StrEnum):
CLICommandName.GRAPH: CMD_GRAPH,
CLICommandName.CHECK: CMD_CHECK,
CLICommandName.RENAME: CMD_RENAME,
CLICommandName.CHANGE_SIGNATURE: CMD_CHANGE_SIGNATURE,
CLICommandName.WORKSPACE: CMD_WORKSPACE,
CLICommandName.STOP: CMD_STOP,
CLICommandName.STATUS: CMD_STATUS,
Expand Down
18 changes: 18 additions & 0 deletions codebase_rag/constants/ast_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,24 @@
TS_PY_DICTIONARY_SPLAT_PATTERN = "dictionary_splat_pattern"
TS_PY_POSITIONAL_SEPARATOR = "positional_separator"
TS_PY_KEYWORD_SEPARATOR = "keyword_separator"
# A definition's parameter list node, and the splats an argument list can
# carry (`f(*args)`, `f(**opts)`); the `_pattern` forms above are the
# definition-side spellings.
TS_PY_PARAMETERS = "parameters"
TS_PY_LIST_SPLAT = "list_splat"
TS_PY_DICTIONARY_SPLAT = "dictionary_splat"
# Scopes nested in a function body, where a parameter's name may be re-bound
# rather than read, and the statements that re-bind a name in place.
TS_PY_SET_COMPREHENSION = "set_comprehension"
TS_PY_DICTIONARY_COMPREHENSION = "dictionary_comprehension"
TS_PY_GENERATOR_EXPRESSION = "generator_expression"
TS_PY_NONLOCAL_STATEMENT = "nonlocal_statement"
# `typing` names a literal-versus-annotation check understands.
PY_TYPING_OPTIONAL = "Optional"
PY_TYPING_UNION = "Union"
# The first parameter of a method that names the instance or class; a
# signature change keeps it in place and never remaps it.
PY_RECEIVER_NAMES = frozenset({"self", "cls"})
TS_PY_SUBSCRIPT = "subscript"
# The `subscript` node's index field (`os.environ["K"]` -> the `"K"` string).
TS_PY_FIELD_SUBSCRIPT = "subscript"
Expand Down
103 changes: 103 additions & 0 deletions codebase_rag/constants/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,109 @@ class DiffMarker:
RENAME_CONTRACT_UNMEASURED = (
"Rename applied, but its postcondition could not be measured: {error}"
)
# Change signature (issue #1533).
SIGNATURE_PARAM_PROBE = "def _({text}): pass"
SIGNATURE_NOT_PYTHON = (
"Cannot change the signature of {qn}: {path} is not Python, and only Python "
"definitions are supported for now (issue #{issue})"
)
SIGNATURE_DEFINITION_UNREADABLE = (
"Cannot change the signature of {qn}: its file {path} cannot be read "
"({error}). The index names a file the tree no longer has; re-index and retry."
)
SIGNATURE_NO_HEADER = "Could not locate the definition of {qn} in {path}"
SIGNATURE_UNSUPPORTED_PARAMS = (
"Cannot change the signature of {qn}: `{text}` is not a plain "
"positional-or-keyword parameter, and only those can be remapped"
)
SIGNATURE_UNUSUAL_RECEIVER = (
"Cannot change the signature of {qn}: its first parameter `{name}` is not "
"self or cls, so the receiver cannot be told from the parameters; rename "
"it to self or cls first"
)
SIGNATURE_STAGE_FAILED = "Cannot stage the signature change: {error}"
SIGNATURE_BAD_PARAM = (
"Not a parameter: `{text}` (expected `name`, `name: type`, `name = default` "
"or `name: type = default`)"
)
SIGNATURE_DUPLICATE_NEW = "Parameter {name} is listed twice"
SIGNATURE_REQUIRED_AFTER_DEFAULT = (
"Parameter {name} has no default but comes after a defaulted parameter"
)
SIGNATURE_BODY_REBINDS = (
"Cannot rename parameter {old} of {qn} to {new}: `{old}` is used inside a "
"nested scope, or re-bound by a global, nonlocal or import statement, in "
"its body; rename it by hand first"
)
SIGNATURE_DEFAULT_REFERENCES_RENAMED = (
"Cannot rename parameter {old} of {qn} to {new}: the default of `{param}` "
"reads `{old}`, which is evaluated at definition time and would raise "
"NameError; rewrite that default by hand first"
)
SIGNATURE_DROPPED_STILL_READ = (
"Cannot drop parameter {name} of {qn}: its body still reads `{name}`, "
"which would raise NameError when called; remove that use by hand first"
)
SIGNATURE_BODY_NAME_TAKEN = (
"Cannot rename parameter {old} of {qn} to {new}: `{new}` is already used "
"in its body"
)
SIGNATURE_HIERARCHY_MISMATCH = (
"Cannot change the signature of {qn}: its override {member} declares "
"({theirs}) where {qn} declares ({ours}); make the hierarchy agree first"
)
SIGNATURE_MAPPING_UNKNOWN_NEW = (
"The mapping names {name}, which is not a new parameter ({names})"
)
SIGNATURE_MAPPING_UNKNOWN_OLD = (
"The mapping feeds {name} from `{source}`, which is neither an old parameter "
"({names}), an index into them, nor a `=literal`"
)
SIGNATURE_MAPPING_BAD_INDEX = (
"The mapping feeds {name} from index {index}, but only {count} old "
"parameter(s) exist"
)
SIGNATURE_MAPPING_EMPTY_LITERAL = "The mapping gives {name} an empty literal"
SIGNATURE_MAPPING_DUPLICATE = (
"Old parameter {old} cannot feed both {first} and {second}"
)
SIGNATURE_LITERAL_MISMATCH = (
"{literal} does not fit the declared type of {name} ({annotation})"
)
SIGNATURE_BAD_MAP = "Mapping entries take the form NEW=SOURCE, got `{entry}`"
SIGNATURE_SITE_NO_LOCATION = "site carries no location ({resolution})"
SIGNATURE_SITE_GUESSED = (
"site was resolved by {resolution}; pass allow_heuristic to rewrite it"
)
SIGNATURE_SITE_NOT_PYTHON = "{path} is not Python"
SIGNATURE_SITE_UNREADABLE_FILE = "file cannot be read ({error})"
SIGNATURE_SITE_NO_CALL = "no call found at the recorded position"
SIGNATURE_SITE_UNREADABLE = "arguments cannot be read positionally: `{text}`"
SIGNATURE_SITE_UNKNOWN_KEYWORD = "passes keyword `{name}`, which is not declared"
SIGNATURE_SITE_DUPLICATE = "passes `{name}` more than once"
SIGNATURE_SITE_TOO_MANY = (
"passes {given} positional arguments but {declared} are declared"
)
SIGNATURE_SITE_NO_VALUE = "no value for `{name}`; map it or give it a default"
SIGNATURE_SITE_OVERLAPS = "its arguments overlap another edit of this change"
SIGNATURE_PLANNED = "{count} site(s) would be rewritten, {skipped} left unmapped"
SIGNATURE_PARSE_FAILED = "Signature change rolled back: {files} would no longer parse"
SIGNATURE_CONTRACT_FAILED = (
"Signature change rolled back, postcondition failed: {reasons}"
)
SIGNATURE_ROLLBACK_REFUSED = (
"Signature change kept: its postcondition failed ({reasons}) but a later "
"edit was recorded on top of it, so it was not rolled back; undo the later "
"edit and rerun"
)
SIGNATURE_ROLLBACK_UNMEASURED = (
"Signature change rolled back after its postcondition failed ({reasons}), "
"but the graph could not be re-ingested afterwards ({error}); rebuild the "
"graph before the next graph-backed operation"
)
SIGNATURE_CONTRACT_UNMEASURED = (
"Signature change applied, but its postcondition could not be measured: {error}"
)
# Postcondition contract (issue #1531).
CONTRACT_OP_RENAME = "rename"
CONTRACT_OP_CHANGE_SIGNATURE = "change_signature"
Expand Down
1 change: 1 addition & 0 deletions codebase_rag/constants/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ class EventType(StrEnum):

CHAR_SPACE = " "
SEPARATOR_COMMA_SPACE = ", "
SEPARATOR_SEMICOLON_SPACE = "; "
PUNCTUATION_TYPES = (CHAR_PAREN_OPEN, CHAR_PAREN_CLOSE, CHAR_COMMA)

REGEX_METHOD_CHAIN_SUFFIX = r"\)\.[^)]*$"
Expand Down
4 changes: 4 additions & 0 deletions codebase_rag/constants/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@
# Rename report fields (issue #1532).
KEY_SITES = "sites"
KEY_AMBIGUOUS = "ambiguous"
# Change-signature report fields (issue #1533); `verdict` is shared
# with rename.
KEY_UNMAPPED = "unmapped"
KEY_VERDICT = "verdict"
KEY_STRUCTURAL_DELTA = "structural_delta"
KEY_DISPATCH_LITERAL = "dispatch_literal"

Expand Down
5 changes: 4 additions & 1 deletion codebase_rag/constants/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ class MCPToolName(StrEnum):
OVERRIDES = "overrides"
IMPORTERS = "importers"
TESTS_REACHING = "tests_reaching"
# Graph-driven edit operations (issue #1532).
# Graph-driven edit operations (issues #1532, #1533).
RENAME = "rename"
CHANGE_SIGNATURE = "change_signature"
QUERY_CODE_GRAPH = "query_code_graph"
GET_CODE_SNIPPET = "get_code_snippet"
SURGICAL_REPLACE_CODE = "surgical_replace_code"
Expand Down Expand Up @@ -110,6 +111,8 @@ class MCPParamName(StrEnum):
MODULE_QN = "module_qualified_name"
NEW_NAME = "new_name"
ALLOW_HEURISTIC = "allow_heuristic"
NEW_PARAMS = "new_params"
MAPPING = "mapping"


# MCP server constants
Expand Down
4 changes: 3 additions & 1 deletion codebase_rag/editing/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Graph-aware editing primitives: span-preserving patchers (issue #1529),
transactions (issue #1528) and import rewriting (issue #1530)."""
transactions (issue #1528), import rewriting (issue #1530) and the graph
operations built on them: rename (issue #1532) and change_signature (issue
#1533)."""

from .contract import (
Expectation,
Expand Down
22 changes: 18 additions & 4 deletions codebase_rag/editing/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,15 @@ def rename_expectation(
)


def change_signature_expectation(unmapped: Iterable[str]) -> Expectation:
def change_signature_expectation(
unmapped: Iterable[str], heuristic_allowed: bool = False
) -> Expectation:
return Expectation(
operation=cs.CONTRACT_OP_CHANGE_SIGNATURE,
unmapped=tuple(sorted(set(unmapped))),
# A parameter added with a default changes no caller count.
caller_count_unchanged=True,
heuristic_allowed=heuristic_allowed,
)


Expand All @@ -111,6 +114,15 @@ def _site_key(site: Mapping[str, object]) -> str:
return f"{site.get(cs.KEY_PATH)}:{site.get(cs.KEY_LINE)}"


def _call_key(site: Mapping[str, object]) -> str:
"""A call's identity for the signature checks.

Two calls can share a line (`helper(1) + helper(2)`), and rewriting or
listing one must not cover the other, so the column is part of the key.
"""
return f"{_site_key(site)}:{site.get(cs.KEY_COL)}"


def _carried_by_ancestor(
pair: tuple[str, str], expected: Iterable[tuple[str, str]]
) -> bool:
Expand Down Expand Up @@ -200,7 +212,7 @@ def _changed_site_fault(
loops plus these three verdicts put that function one point over the
threshold. The decision is per site and reads better named anyway.
"""
key = _site_key(site)
key = _call_key(site)
verdict = site["verdict"]
if verdict == cs.DELTA_ARITY_OK or key in unmapped:
return None
Expand Down Expand Up @@ -232,7 +244,7 @@ def _check_sites_mapped(
if fault := _changed_site_fault(site, unmapped, rewritten):
bad.append(fault)
for site in delta["arity_findings"]:
key = _site_key(site)
key = _call_key(site)
if key not in unmapped:
bad.append(f"{key} ({site['verdict']})")
if bad:
Expand Down Expand Up @@ -303,7 +315,9 @@ def verify(
"""Pass or fail the delta against the expectation, with reasons.

`rewritten` lists the sites the operation rewrote as `(path:line,
resolution)`; `parse_failures` the files the transaction found not to
resolution)`, or `(path:line:col, resolution)` for a signature change,
whose per-site checks key on the column; `parse_failures` the files the
transaction found not to
parse (the transaction refuses those itself, so a caller normally
passes none, but the contract states the rule in one place).
"""
Expand Down
Loading
Loading