@@ -258,10 +258,6 @@ def _arg(args: Sequence[cst.Arg], pos_index: int, *kwarg_names: str) -> Optional
258258 return _kwarg (args , * kwarg_names ) if kwarg_names else None
259259
260260
261- def _positional_count (args : Sequence [cst .Arg ]) -> int :
262- return sum (1 for a in args if a .keyword is None )
263-
264-
265261# Canonical v0 kwarg names per filter method. Used by both the migrator and the
266262# manual-review finder so they agree on what counts as "recognized arg shape".
267263# These names come from the b10-era QueryBuilder.filter_* signatures and the
@@ -372,8 +368,14 @@ def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Bas
372368
373369 # ----------------------------------------------------------------
374370 # .filter_*(...) -> .where(col(...) ...)
371+ # Only rewrite when the args match the documented v0 shape exactly.
372+ # If they don't (e.g. extra positionals, unknown kwargs), leave the
373+ # call untouched -- the _ManualReviewFinder will emit a [MANUAL] note
374+ # so the user notices and rewrites it by hand. Without the shape gate
375+ # here, calls like .filter_raw('a', 'EXTRA') would be silently
376+ # rewritten to .where(raw('a')) with the 'EXTRA' arg dropped.
375377 # ----------------------------------------------------------------
376- if method_name in _ALL_FILTER_METHODS :
378+ if method_name in _ALL_FILTER_METHODS and _can_extract_filter_method_args ( method_name , updated_node . args ) :
377379 where_arg = self ._build_filter_arg (method_name , updated_node .args )
378380 if where_arg is not None :
379381 return updated_node .with_changes (
@@ -748,9 +750,44 @@ def _append_aliases_preserving_layout(
748750 if not new_names :
749751 return existing
750752
753+ # Separator-comma template -- the comma+whitespace placed between *non-final*
754+ # aliases. For multi-alias inputs we copy existing[0].comma (which already
755+ # captures the original block's style). For single-alias inputs we still need
756+ # to detect a multi-line layout: ``from X import (\n raw,\n)`` has only
757+ # one alias but its trailing comma carries the newline trivia, so we copy
758+ # *that* as the separator. Truly inline single-alias imports (``import raw``)
759+ # have ``MaybeSentinel.DEFAULT`` for the comma -- fall back to single-space.
760+ separator_template : Optional [cst .Comma ] = None
751761 if len (existing ) >= 2 and isinstance (existing [0 ].comma , cst .Comma ):
752762 separator_template = existing [0 ].comma
753- else :
763+ elif len (existing ) == 1 and isinstance (existing [0 ].comma , cst .Comma ):
764+ # Single-alias multi-line case: ``from X import (\n raw,\n)`` has only
765+ # one alias, so existing[0].comma is the *trailing* comma whose
766+ # whitespace_after carries only ``\n`` (the closing paren sits at col 0
767+ # with no indent). That bare ``\n`` is not a usable separator -- using
768+ # it would put ``col`` flush against the left margin. Detect the
769+ # multi-line shape and synthesize a separator that re-indents to a
770+ # 4-space PEP 8 default (matches what black would produce for the
771+ # multi-alias form).
772+ sole = existing [0 ].comma
773+ ws = sole .whitespace_after
774+ is_multiline = False
775+ if isinstance (ws , cst .SimpleWhitespace ) and "\n " in ws .value :
776+ is_multiline = True
777+ elif isinstance (ws , cst .ParenthesizedWhitespace ):
778+ # ParenthesizedWhitespace always implies multi-line (it owns at least
779+ # one newline) -- safe to treat as the multi-line case.
780+ is_multiline = True
781+ if is_multiline :
782+ separator_template = cst .Comma (
783+ whitespace_after = cst .ParenthesizedWhitespace (
784+ first_line = cst .TrailingWhitespace (),
785+ empty_lines = [],
786+ indent = True ,
787+ last_line = cst .SimpleWhitespace (" " ),
788+ )
789+ )
790+ if separator_template is None :
754791 separator_template = cst .Comma (whitespace_after = cst .SimpleWhitespace (" " ))
755792 trailing_template = existing [- 1 ].comma
756793
@@ -797,18 +834,42 @@ def _can_extract_filter_method_args(method_name: str, args: Sequence[cst.Arg]) -
797834 that the migrator and the manual-review finder agree on what counts as a
798835 rewriteable call. Used by the finder to emit a [MANUAL] note for any
799836 .filter_*() call shape the migrator cannot rewrite (e.g. unrecognized kwargs).
837+
838+ Also rejects calls that contain *extra* args beyond the documented shape.
839+ Previously a call like ``.filter_raw('a eq 1', 'EXTRA')`` returned True
840+ (because position 0 was present) and the migrator silently dropped the
841+ extra arg in the rewrite -- losing real user code. By insisting on exact
842+ arity here, malformed calls fall into the ``[MANUAL]`` path instead of
843+ being miscompiled.
800844 """
801845 if method_name not in _ALL_FILTER_METHODS :
802846 return False
803847 col_name , val_name , extra_name = _FILTER_KWARGS .get (method_name , ("column" , "value" , None ))
804848 if _arg (args , 0 , col_name ) is None :
805849 return False
850+
851+ # Determine the exact expected arity for this method.
806852 if method_name == "filter_raw" or method_name in _UNARY_FILTER_MAP :
807- return True
808- if val_name is None or _arg (args , 1 , val_name ) is None :
853+ expected_arity = 1
854+ allowed_kwargs = {col_name }
855+ elif method_name in ("filter_between" , "filter_not_between" ):
856+ if val_name is None or _arg (args , 1 , val_name ) is None :
857+ return False
858+ if extra_name is None or _arg (args , 2 , extra_name ) is None :
859+ return False
860+ expected_arity = 3
861+ allowed_kwargs = {col_name , val_name , extra_name }
862+ else :
863+ if val_name is None or _arg (args , 1 , val_name ) is None :
864+ return False
865+ expected_arity = 2
866+ allowed_kwargs = {col_name , val_name }
867+
868+ if len (args ) != expected_arity :
809869 return False
810- if method_name in ("filter_between" , "filter_not_between" ):
811- return extra_name is not None and _arg (args , 2 , extra_name ) is not None
870+ for a in args :
871+ if isinstance (a .keyword , cst .Name ) and a .keyword .value not in allowed_kwargs :
872+ return False
812873 return True
813874
814875
@@ -1048,8 +1109,20 @@ def visit_For(self, node: cst.For) -> None:
10481109 return
10491110 if not (isinstance (inner .attr , cst .Name ) and inner .attr .value == "records" ):
10501111 return
1051- # The outer iter is some .records.get(...) call. Match only when the loop
1052- # target is a single Name so we can compare with the inner For's iter.
1112+ # Require the .records receiver to actually be the configured client
1113+ # variable (or a known alias of it). Otherwise an unrelated
1114+ # ``database_pool.records.get(...)`` would be flagged with a note that
1115+ # advises rewriting to ``client.records.list(...)`` -- replacing the
1116+ # wrong receiver name. Comparing to _known_client_names (rather than
1117+ # only _client_var) lets the check work when the user has aliased
1118+ # the client (handled by _flag_non_default_client_assignment above).
1119+ receiver = inner .value
1120+ if not isinstance (receiver , cst .Name ):
1121+ return
1122+ if receiver .value not in self ._known_client_names :
1123+ return
1124+ # The outer iter is some <client>.records.get(...) call. Match only when
1125+ # the loop target is a single Name so we can compare with the inner For's iter.
10531126 if not isinstance (node .target , cst .Name ):
10541127 return
10551128 outer_var = node .target .value
@@ -1274,7 +1347,15 @@ def migrate_file(path: Path, *, dry_run: bool = False, client_var: str = "client
12741347 except ValueError as exc :
12751348 print (f" [SKIP] { path } : { exc } " , file = sys .stderr )
12761349 return False , False , []
1277- raw_notes = find_manual_patterns (original , client_var = client_var )
1350+ # Run the manual-pattern finder against the *migrated* source so the
1351+ # reported <line>:<col> coordinates match the file the user is now looking
1352+ # at on disk. Running it against ``original`` produced stale coordinates
1353+ # whenever the codemod inserted a new import (or otherwise shifted line
1354+ # numbers), so jumping from a CI/editor message landed on the wrong line.
1355+ # Calls that the codemod successfully rewrote no longer match the v0 finder
1356+ # in ``migrated`` -- that is the desired behavior: a rewritten call should
1357+ # not also produce a [MANUAL] note about its v0 shape.
1358+ raw_notes = find_manual_patterns (migrated , client_var = client_var )
12781359 manual = [f"{ path } :{ note } " for note in raw_notes ]
12791360 changed = migrated != original
12801361 if changed and dry_run :
0 commit comments