Skip to content

feat(v3): add v3 migration codemod#334

Merged
mcous merged 1 commit into
mainfrom
feat/v3-codemod
Apr 30, 2026
Merged

feat(v3): add v3 migration codemod#334
mcous merged 1 commit into
mainfrom
feat/v3-codemod

Conversation

@mcous

@mcous mcous commented Apr 27, 2026

Copy link
Copy Markdown
Owner

Overview

This PR adds a libcst-powered codemod to migrate a codebase from Decoy v2 to the Decoy v3 preview

Change log

  • Add decoy.codemods.migrate module
  • Update migration docs

@codecov

codecov Bot commented Apr 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (dc712d2) to head (cfdf76f).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##              main      #334    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files           32        33     +1     
  Lines         1548      1706   +158     
  Branches       194       225    +31     
==========================================
+ Hits          1548      1706   +158     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@mcous mcous force-pushed the feat/v3-codemod branch from 75ee5f7 to f8b9706 Compare April 28, 2026 01:19
@mcous mcous force-pushed the fix/cached-property branch from c233c9b to 18cf22f Compare April 28, 2026 01:19
@coderabbitai

coderabbitai Bot commented Apr 28, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2569f0f4-8270-402c-b6db-2733de4097f3

📥 Commits

Reviewing files that changed from the base of the PR and between 1be9f62 and cfdf76f.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • codebook.toml
  • decoy/codemods/__init__.py
  • decoy/codemods/migrate.py
  • decoy/codemods/pyproject.toml
  • decoy/next/pyproject.toml
  • docs/v3/migration.md
  • pyproject.toml
  • tests/codemods/test_migrate.py
✅ Files skipped from review due to trivial changes (2)
  • decoy/codemods/init.py
  • decoy/codemods/pyproject.toml
🚧 Files skipped from review as they are similar to previous changes (3)
  • codebook.toml
  • decoy/next/pyproject.toml
  • pyproject.toml

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Adds an automated migration tool to upgrade Decoy v2 usage to the v3 preview API.
  • Documentation

    • Migration guide updated with codemod workflow, Python 3.10 prerequisite, and new verify context-manager examples.
    • Package docstring added describing the codemods package.
  • Tests

    • Comprehensive test suite validating migration scenarios (imports, rehearsals, matchers, captors, and conditional verify sequences).
  • Chores

    • Dev tooling/config updates and a new codebook token to support the migration tooling.

Walkthrough

Adds a LibCST-based codemod (MigrateCommand) to migrate Decoy v2 → v3 usages, per-package Ruff configs and pyproject edits, a comprehensive pytest codemod test suite (Python ≥3.10 gated), and a single token addition to codebook.toml.

Changes

Cohort / File(s) Summary
Codemod package
decoy/codemods/__init__.py, decoy/codemods/migrate.py
Adds package docstring and a new LibCST MigrateCommand that rewrites from decoy import ... into decoy vs decoy.next, converts decoy.when/decoy.verify rehearsals into matcher chains / verify_order() contexts, migrates matchers.*Matcher.*, and tracks captors to append .arg where required.
Configuration
codebook.toml, decoy/codemods/pyproject.toml, decoy/next/pyproject.toml, pyproject.toml
Adds "codemod" token to codebook.toml; adds per-subpackage Ruff configs targeting Python 3.10; updates root pyproject.toml dev deps to include libcst>=1.0.1, removes explicit Ruff target-version, and relaxes uv upper bound.
Tests
tests/codemods/test_migrate.py
Adds libcst pytest codemod test suite (skipped on Python <3.10) validating import splitting, rehearsal/verify migrations (attribute/get/set/delete, async/await), conditional/star-spread handling into verify_order() with guarded branches, matcher migrations, captor tracking, idempotency, and formatting normalization.
Documentation
docs/v3/migration.md
Adds migration docs describing the LibCST codemod workflow (run via uv), notes the codemod only rewrites calls on decoy, self.decoy, or self._decoy, and updates examples to use with decoy.verify_order():.

Sequence Diagram(s)

sequenceDiagram
    participant CLI as CLI (uv)
    participant Codemod as MigrateCommand
    participant LibCST as LibCST AST
    participant FS as File System
    CLI->>Codemod: invoke transform on target files
    Codemod->>FS: read source file
    FS-->>Codemod: file contents
    Codemod->>LibCST: parse to AST
    LibCST-->>Codemod: AST nodes
    Codemod->>LibCST: transform imports / when/verify / matchers / captors
    LibCST-->>Codemod: transformed AST
    Codemod->>FS: write updated file
    FS-->>CLI: exit status
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐇 I hop through trees of code tonight,
I split imports and make names bright.
Old whens become called_with in a row,
Captors nudged so their values show,
A tidy migration, soft and light.

quibble: Walkthrough
[Kept to ~50 words and strictly factual.]

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title "feat(v3): add v3 migration codemod" follows the Conventional Commits specification with a clear type (feat), scope (v3), and descriptive subject line.
Description check ✅ Passed The pull request description clearly relates to the changeset, explaining that it adds a libcst-powered codemod for migrating from Decoy v2 to v3 preview, with specific changelog items matching the actual changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Review rate limit: 2/3 reviews remaining, refill in 20 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

@mcous mcous force-pushed the feat/v3-codemod branch 2 times, most recently from 25d5c14 to e0302b2 Compare April 28, 2026 16:25
@mcous mcous force-pushed the fix/cached-property branch from 18cf22f to c5326fd Compare April 28, 2026 16:25
Base automatically changed from fix/cached-property to main April 28, 2026 16:27
@mcous mcous force-pushed the feat/v3-codemod branch 2 times, most recently from 7bb15a9 to 028d799 Compare April 28, 2026 17:55
@mcous

mcous commented Apr 28, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Apr 28, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@decoy/codemods/migrate.py`:
- Around line 417-437: The migration currently remaps matcher kwargs by position
(in the IsA and ErrorMatching branches) which swaps semantics when callers
passed keywords out of order; update the logic in the IsA and ErrorMatching
cases to locate original_args by their argument name (keyword) before
renaming—use or add a helper that searches original_args for an entry with
.keyword (or .arg name) equal to "type", "attrs", "match", etc., and fall back
to _rename_arg_at by index only if no matching keyword is found; specifically
change the IsA branch (original_method == "IsA") and the ErrorMatching branch
(original_method == "ErrorMatching") to use this name-based lookup when building
args instead of relying solely on positional _rename_arg_at calls so named
kwargs keep their intended mapping.
- Around line 121-129: The matcher for conditional-starred args can drop the
fallback branch because _extract_conditional_starred_arg() only emits the truthy
branch for patterns like m.BooleanOperation(... operator=m.Or(),
right=m.List()), so update _extract_conditional_starred_arg() to detect when the
BooleanOperation has a non-empty right (fallback) list and emit code that
preserves the fallback (e.g., produce an if/else or explicit extend of the
fallback list when the condition is falsy) instead of discarding it; locate the
handling of m.BooleanOperation and the code paths that return only the truthy
branch and add logic to generate the else branch using the
BooleanOperation.right AST node so the migrated output retains the original or
[...] fallback.
- Around line 241-252: The current migrate_matcher_arg always appends ".arg" to
the result of _migrate_matcher(updated_node), which incorrectly mutates
unknown/custom matchers; change migrate_matcher_arg to call
_migrate_matcher(updated_node) once, inspect its return and only wrap it in
cst.Attribute(..., attr=cst.Name("arg")) when the returned node is a migrated
matcher (i.e., not the original call/unchanged matchers.* call). Use identity or
a structural check on the returned value from _migrate_matcher to decide whether
to return it as-is or to return the cst.Attribute-wrapped version, keeping
references to migrate_matcher_arg and _migrate_matcher to locate the code.

In `@pyproject.toml`:
- Around line 33-34: The console script decoy-migrate points to
decoy.codemods.migrate:main but that callable doesn't exist; either add a
module-level main() in decoy.codemods.migrate that instantiates and runs
MigrateCommand, or change the pyproject.toml entry to reference an existing
callable (for example a factory/wrapper that invokes MigrateCommand). Locate the
MigrateCommand class in decoy.codemods.migrate and implement a small main() that
constructs MigrateCommand and calls its run/execute method, or update the
[project.scripts] value to the correct fully-qualified callable name that
actually exists.
- Around line 33-34: The decoy-migrate console script points to
decoy.codemods.migrate which imports libcst at module level, but libcst is only
in the dev dependency group; move the runtime dependency into the main project
dependencies by adding libcst>=1.0.1 to the [project] dependencies (so
decoy.codemods.migrate can import libcst when the package is installed and the
decoy-migrate CLI runs).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b823724c-201c-463b-bb04-95b902cc62a3

📥 Commits

Reviewing files that changed from the base of the PR and between dc712d2 and 028d799.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • codebook.toml
  • decoy/codemods/__init__.py
  • decoy/codemods/migrate.py
  • decoy/codemods/pyproject.toml
  • decoy/next/pyproject.toml
  • pyproject.toml
  • tests/codemods/test_migrate.py

Comment thread decoy/codemods/migrate.py
Comment thread decoy/codemods/migrate.py Outdated
Comment thread decoy/codemods/migrate.py
Comment thread pyproject.toml Outdated
@mcous mcous force-pushed the feat/v3-codemod branch from 028d799 to 5c34f5f Compare April 28, 2026 18:27
@mcous

mcous commented Apr 28, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Apr 28, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (2)
decoy/codemods/migrate.py (1)

121-129: ⚠️ Potential issue | 🟠 Major

Preserve the or [...] fallback when expanding conditional spreads.

*(cond and [...] or [...]) currently migrates only the truthy list. If the original spread has a non-empty fallback list, those verifications disappear from the migrated output.

Also applies to: 368-398

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@decoy/codemods/migrate.py` around lines 121 - 129, The transformation that
expands conditional spreads currently matches a nested m.BooleanOperation
(left=m.BooleanOperation(... operator=m.And() ...) operator=m.Or()
right=m.List()) but only emits the truthy branch; update the logic in migrate.py
that handles m.BooleanOperation so that when you see the pattern (outer
m.BooleanOperation with operator m.Or and inner left m.BooleanOperation with
operator m.And and both right sides as m.List) you preserve and emit both the
true-branch list and the fallback list (the outer right) instead of dropping the
fallback; adjust the expansion routine that constructs the spread output to
include the fallback list as the `or [...]` branch and apply this same fix in
the other occurrence around the 368-398 block so both sites preserve non-empty
fallback lists.
pyproject.toml (1)

56-57: ⚠️ Potential issue | 🟠 Major

libcst still looks like a runtime dependency, not just a dev one.

decoy/codemods/migrate.py imports libcst at module import time, but this adds it only under dev. If the codemod ships in the published package, importing decoy.codemods.migrate in a normal install will fail unless libcst is moved into [project.dependencies] or made an explicit optional extra.

#!/bin/bash
set -euo pipefail

echo "== top-level libcst imports in the codemod =="
rg -n '^(import libcst as cst|from libcst )' decoy/codemods/migrate.py

echo
echo "== libcst declarations in pyproject.toml =="
rg -n '^\[project\]|^dependencies = \[|^\[dependency-groups\]|libcst' pyproject.toml

Expected result: libcst shows up as a top-level import in decoy/codemods/migrate.py, but only under [dependency-groups].dev in pyproject.toml.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyproject.toml` around lines 56 - 57, decoy/codemods/migrate.py performs a
top-level import of libcst but pyproject.toml only lists libcst under the dev
dependency group, causing normal installs to fail; fix by either moving the
libcst entry from the dev dependency group into [project.dependencies] in
pyproject.toml so decoy.codemods.migrate can import it at runtime, or make it an
optional extra (e.g., extras name "codemods" or "libcst") and update
pyproject.toml accordingly and/or change decoy/codemods/migrate.py to perform a
local/lazy import of libcst inside the function that uses it (rather than at
module import time) and raise a clear error if the optional extra is not
installed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@decoy/codemods/migrate.py`:
- Around line 134-140: The matcher currently only permits the keyword names
"times" and "ignore_extra_args" (in the m.Arg tuple) so calls to when(...) or
verify(...) that use is_entered= are skipped; update the m.Arg keyword tuple
(both the occurrence around m.ZeroOrMore(...) and the similar occurrence at
lines ~193-195) to include m.Name("is_entered") alongside m.Name("times") and
m.Name("ignore_extra_args") so is_entered= is recognized and preserved during
the migration; keep the rest of the matcher structure identical.
- Around line 105-114: The matcher is currently hard-coded to only match
receiver names "decoy", "self.decoy", and "self._decoy", so calls like
my_decoy.verify(...) or fixture.when(...) are missed; update the Attribute
matcher in migrate.py (the func=m.Attribute(...) node that currently uses
m.Name("decoy") / m.Name("self") / m.Name("_decoy") and attr=m.Name("verify"))
to accept any receiver Name or Attribute (i.e., remove the specific name checks
and allow a generic m.Name() or m.Attribute(...) as the value) while still
requiring attr=m.Name("verify"); apply the same generalization to the similar
matcher at the other occurrence (the block around lines referenced 181-190) so
all Decoy receiver names are matched.

---

Duplicate comments:
In `@decoy/codemods/migrate.py`:
- Around line 121-129: The transformation that expands conditional spreads
currently matches a nested m.BooleanOperation (left=m.BooleanOperation(...
operator=m.And() ...) operator=m.Or() right=m.List()) but only emits the truthy
branch; update the logic in migrate.py that handles m.BooleanOperation so that
when you see the pattern (outer m.BooleanOperation with operator m.Or and inner
left m.BooleanOperation with operator m.And and both right sides as m.List) you
preserve and emit both the true-branch list and the fallback list (the outer
right) instead of dropping the fallback; adjust the expansion routine that
constructs the spread output to include the fallback list as the `or [...]`
branch and apply this same fix in the other occurrence around the 368-398 block
so both sites preserve non-empty fallback lists.

In `@pyproject.toml`:
- Around line 56-57: decoy/codemods/migrate.py performs a top-level import of
libcst but pyproject.toml only lists libcst under the dev dependency group,
causing normal installs to fail; fix by either moving the libcst entry from the
dev dependency group into [project.dependencies] in pyproject.toml so
decoy.codemods.migrate can import it at runtime, or make it an optional extra
(e.g., extras name "codemods" or "libcst") and update pyproject.toml accordingly
and/or change decoy/codemods/migrate.py to perform a local/lazy import of libcst
inside the function that uses it (rather than at module import time) and raise a
clear error if the optional extra is not installed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a064aeb0-fe70-409f-af15-0f43074502f7

📥 Commits

Reviewing files that changed from the base of the PR and between 028d799 and 5c34f5f.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • codebook.toml
  • decoy/codemods/__init__.py
  • decoy/codemods/migrate.py
  • decoy/codemods/pyproject.toml
  • decoy/next/pyproject.toml
  • pyproject.toml
  • tests/codemods/test_migrate.py
✅ Files skipped from review due to trivial changes (4)
  • codebook.toml
  • decoy/next/pyproject.toml
  • decoy/codemods/init.py
  • decoy/codemods/pyproject.toml

Comment thread decoy/codemods/migrate.py
Comment thread decoy/codemods/migrate.py
@mcous mcous force-pushed the feat/v3-codemod branch 2 times, most recently from 4fa5cf7 to 0cbf918 Compare April 28, 2026 19:54
@mcous mcous marked this pull request as ready for review April 28, 2026 20:10

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@decoy/codemods/migrate.py`:
- Around line 119-145: The multi-rehearsal matcher for verify_order currently
forces every positional rehearsal through _as_call(), which skips attribute-get
rehearsals like decoy.verify(mock.attr, ...); update the AtLeastN/ZeroOrMore
matcher to accept attribute expressions (m.Attribute) and/or the same
m.Arg(m.Call() | m.Await(m.Call()) | m.Arg(m.Attribute() ...)) pattern used in
the single-rehearsal path, and instead of assuming calls convert attributes via
the same normalization helper used for single rehearsals (reuse the _as_call
normalization logic), so attribute-get rehearsals are normalized to .get();
apply the same change to the other two matcher sites mentioned (the blocks
around the ranges that correspond to lines 163-170 and 412-415).
- Around line 286-290: track_captor_assignment() is adding every assignment in
the scope with the same identifier instead of only the assignment introduced by
the captor statement, causing later rebindings to be mis-marked; change the
update to add only the specific assignment node created by the current captor
statement (use the exact assignment object/AST node from the current
visit/handler rather than filtering scope.assignments by name). Apply the same
change to the analogous block around lines 309-312 so only the exact assignment
node (not all assignments with that name) is recorded in
self._captor_assignments, ensuring migrate_captor_arg() only treats the original
captor binding as a captor.
- Around line 335-349: The property-specific match only recognizes a bare decoy
receiver; update the m.matches pattern used around rehearsal (the m.Call ->
m.Attribute -> m.Call -> m.Attribute chain that currently checks m.Name("decoy")
and m.Name("prop")) so it also accepts m.Attribute receivers for self.decoy and
self._decoy (i.e., allow the inner value to be either m.Name("decoy") or an
m.Attribute whose value is m.Name("self") and attr is m.Name("decoy") or
m.Name("_decoy")); ensure the branch that preserves .set(...) / .delete() still
triggers for these receiver forms so that the later rewrite does not fall
through to the generic call path.

In `@docs/v3/migration.md`:
- Around line 15-18: The example command shown ("uv run --with libcst python -m
libcst.tool codemod decoy.codemods.migrate.MigrateCommand .") contradicts the
“run it against your test files” guidance; update the example in the migration
guide to either target the common test directory (replace the trailing "." with
"tests") or reword the sentence to say “run it against the relevant Python
paths” so the command and guidance match.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f6ab6b72-2aab-4e59-9cbd-77647844d436

📥 Commits

Reviewing files that changed from the base of the PR and between 5c34f5f and 0cbf918.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • codebook.toml
  • decoy/codemods/__init__.py
  • decoy/codemods/migrate.py
  • decoy/codemods/pyproject.toml
  • decoy/next/pyproject.toml
  • docs/v3/migration.md
  • pyproject.toml
  • tests/codemods/test_migrate.py
✅ Files skipped from review due to trivial changes (3)
  • codebook.toml
  • decoy/codemods/init.py
  • decoy/codemods/pyproject.toml
🚧 Files skipped from review as they are similar to previous changes (2)
  • decoy/next/pyproject.toml
  • pyproject.toml

Comment thread decoy/codemods/migrate.py
Comment thread decoy/codemods/migrate.py
Comment thread decoy/codemods/migrate.py
Comment thread docs/v3/migration.md Outdated
@mcous mcous force-pushed the feat/v3-codemod branch from 0cbf918 to 1be9f62 Compare April 30, 2026 14:31

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (3)
decoy/codemods/migrate.py (3)

286-290: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

issue: Captor tracking adds all scope assignments with matching name, not just the captor assignment.

When track_captor_assignment fires, it queries scope.assignments and filters by name. This returns ALL assignments to that name in the scope—including later rebindings like captor = SomethingElse(). The pipeline failure in test_migrate_captor_rebound_noop confirms this: the second mock("world", captor) incorrectly gets .arg appended.

Could the code track only the specific assignment node being visited rather than all same-named assignments?

Suggested approach
     def track_captor_assignment(self, node: cst.Assign) -> None:
         """Find captor assignments to add `.arg`."""
         scope = self._get_scope(node)
-        names = {
-            cst.ensure_type(target.target, cst.Name).value
-            for target in node.targets
-            if m.matches(target.target, m.Name())
-        }
-        self._captor_assignments.update(
-            a
-            for a in scope.assignments  # pyright: ignore[reportAttributeAccessIssue]
-            if a.name in names
-        )
+        for target in node.targets:
+            if m.matches(target.target, m.Name()):
+                name = cst.ensure_type(target.target, cst.Name).value
+                for assignment in scope[name]:
+                    if assignment.node is target:
+                        self._captor_assignments.add(assignment)

This matches only the assignment node from this specific captor statement.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@decoy/codemods/migrate.py` around lines 286 - 290, The current update to
self._captor_assignments iterates scope.assignments and picks all assignments
with matching names (via scope.assignments and a.name in names), which captures
later rebindings; instead, add only the specific AST assignment node for this
captor visit. Change the code in track_captor_assignment so it does not iterate
scope.assignments but directly inserts the exact assignment node being visited
(the local parameter representing the captor assignment) into
self._captor_assignments, using the same set/update structure but with that
single node instead of a comprehension over scope.assignments.

119-145: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

issue: The verify_order matcher excludes attribute-get rehearsals, causing pipeline failures.

The matcher at lines 120-136 only accepts m.Arg(m.Call() | m.Await(m.Call())) for positional arguments. When a verify call contains an attribute rehearsal like mock.some_prop, the matcher doesn't fire and the code falls through incorrectly. This is confirmed by the pipeline error in test_verify_order_attribute_get.

Could the matcher be extended to also accept m.Attribute() in the positional arg patterns?

 args=[
     m.AtLeastN(
         n=2,
         matcher=(
-            m.Arg(m.Call() | m.Await(m.Call()))
+            m.Arg(m.Call() | m.Attribute() | m.Await(m.Call() | m.Attribute()))
             | m.Arg(
                 m.BooleanOperation(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@decoy/codemods/migrate.py` around lines 119 - 145, The positional-argument
matcher inside the AtLeastN block (the m.Arg(...) branch) currently only accepts
m.Call() or m.Await(m.Call()), which excludes attribute-get rehearsals like
mock.some_prop; update that matcher to also accept m.Attribute() (and the
awaited form m.Await(m.Attribute())) so attribute accesses are matched as valid
positional args—i.e., in the AtLeastN args list replace/add alternatives in the
m.Arg(...) clause to include m.Attribute() and m.Await(m.Attribute()) alongside
m.Call()/m.Await(m.Call()).

163-171: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

issue: _as_call will raise ValueError for attribute-get rehearsals in verify_order.

When processing positional args at line 167, the code calls _as_call(arg.value) which expects a Call node. For attribute-get rehearsals (mock.some_prop), the value is an Attribute, causing the ValueError: Expected a Call but got a Attribute pipeline failure.

Would it make sense to handle the attribute case here similar to how migrate_call does at lines 235-239?

Suggested approach
         verify_statements: list[cst.BaseStatement] = [
             _extract_conditional_starred_arg(arg.value, attr, kwargs)
             if arg.star == "*"
+            else cst.SimpleStatementLine(
+                body=[cst.Expr(_migrate_attribute_rehearsal(attr, arg.value, kwargs))]
+            )
+            if m.matches(arg.value, m.Attribute())
             else cst.SimpleStatementLine(
                 body=[cst.Expr(_migrate_rehearsal(attr, _as_call(arg.value), kwargs))]
             )
             for arg in call.args
             if not arg.keyword
         ]

This would require a helper like _migrate_attribute_rehearsal that produces the .get() chain, or refactoring _migrate_rehearsal to accept both Calls and Attributes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@decoy/codemods/migrate.py` around lines 163 - 171, The list comprehension in
verify_statements calls _as_call(arg.value) assuming a Call, which raises
ValueError for Attribute nodes (e.g., mock.some_prop); update the non-starred
branch to detect Attribute values and handle them like migrate_call does: either
create a new helper _migrate_attribute_rehearsal(attribute_node, kwargs) that
returns the .get() chain and call _migrate_rehearsal with that, or refactor
_migrate_rehearsal to accept both Call and Attribute; replace the direct
_as_call(arg.value) usage in verify_statements with logic that uses _as_call
when arg.value is a Call and uses _migrate_attribute_rehearsal (or the
refactored _migrate_rehearsal) when arg.value is an Attribute so attribute-get
rehearsals no longer raise ValueError.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@decoy/codemods/migrate.py`:
- Around line 391-407: The helper _statements currently always wraps each
element with _as_call(...) which raises ValueError for attribute rehearsals
inside conditional spreads (e.g., *(flag and [mock.some_prop] or [])); update
_statements so it mirrors the main verify_order fix: attempt to convert with
_as_call(cst.ensure_type(element, cst.Element).value) but catch ValueError (or
detect non-Call shapes like attribute/conditional elements) and fall back to
passing the raw element value through to _migrate_rehearsal; keep references to
_statements, _as_call, _migrate_rehearsal and decoy_func so the migration
handles attribute rehearsals in conditional spreads without raising.

---

Duplicate comments:
In `@decoy/codemods/migrate.py`:
- Around line 286-290: The current update to self._captor_assignments iterates
scope.assignments and picks all assignments with matching names (via
scope.assignments and a.name in names), which captures later rebindings;
instead, add only the specific AST assignment node for this captor visit. Change
the code in track_captor_assignment so it does not iterate scope.assignments but
directly inserts the exact assignment node being visited (the local parameter
representing the captor assignment) into self._captor_assignments, using the
same set/update structure but with that single node instead of a comprehension
over scope.assignments.
- Around line 119-145: The positional-argument matcher inside the AtLeastN block
(the m.Arg(...) branch) currently only accepts m.Call() or m.Await(m.Call()),
which excludes attribute-get rehearsals like mock.some_prop; update that matcher
to also accept m.Attribute() (and the awaited form m.Await(m.Attribute())) so
attribute accesses are matched as valid positional args—i.e., in the AtLeastN
args list replace/add alternatives in the m.Arg(...) clause to include
m.Attribute() and m.Await(m.Attribute()) alongside m.Call()/m.Await(m.Call()).
- Around line 163-171: The list comprehension in verify_statements calls
_as_call(arg.value) assuming a Call, which raises ValueError for Attribute nodes
(e.g., mock.some_prop); update the non-starred branch to detect Attribute values
and handle them like migrate_call does: either create a new helper
_migrate_attribute_rehearsal(attribute_node, kwargs) that returns the .get()
chain and call _migrate_rehearsal with that, or refactor _migrate_rehearsal to
accept both Call and Attribute; replace the direct _as_call(arg.value) usage in
verify_statements with logic that uses _as_call when arg.value is a Call and
uses _migrate_attribute_rehearsal (or the refactored _migrate_rehearsal) when
arg.value is an Attribute so attribute-get rehearsals no longer raise
ValueError.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2b059076-86d1-4693-9f8d-28d2cb96b24a

📥 Commits

Reviewing files that changed from the base of the PR and between 0cbf918 and 1be9f62.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • codebook.toml
  • decoy/codemods/__init__.py
  • decoy/codemods/migrate.py
  • decoy/codemods/pyproject.toml
  • decoy/next/pyproject.toml
  • docs/v3/migration.md
  • pyproject.toml
  • tests/codemods/test_migrate.py
✅ Files skipped from review due to trivial changes (4)
  • codebook.toml
  • decoy/next/pyproject.toml
  • decoy/codemods/pyproject.toml
  • decoy/codemods/init.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • pyproject.toml

Comment thread decoy/codemods/migrate.py
@mcous mcous force-pushed the feat/v3-codemod branch from 1be9f62 to 211cfe6 Compare April 30, 2026 15:55

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/v3/migration.md`:
- Around line 13-18: The Codemod documentation omits the Python version
requirement and should state that Python 3.10+ is required to run the codemod;
update the "Codemod" section to add a brief note that the codemod targets Python
3.10 (see decoy/codemods/pyproject.toml target-version = "py310") and uses
3.10-only syntax, and instruct users to run the command with Python 3.10+ (e.g.,
before the uv run line mention "Requires Python 3.10+") so users invoking the
MigrateCommand via `python -m libcst.tool codemod
decoy.codemods.migrate.MigrateCommand` won’t hit syntax errors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1ca820fd-b76d-478c-b715-34acc3d9c2a9

📥 Commits

Reviewing files that changed from the base of the PR and between 1be9f62 and 211cfe6.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • codebook.toml
  • decoy/codemods/__init__.py
  • decoy/codemods/migrate.py
  • decoy/codemods/pyproject.toml
  • decoy/next/pyproject.toml
  • docs/v3/migration.md
  • pyproject.toml
  • tests/codemods/test_migrate.py
✅ Files skipped from review due to trivial changes (2)
  • decoy/codemods/pyproject.toml
  • decoy/codemods/init.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • decoy/next/pyproject.toml
  • codebook.toml
  • tests/codemods/test_migrate.py

Comment thread decoy/codemods/migrate.py
Comment thread docs/v3/migration.md
@mcous mcous force-pushed the feat/v3-codemod branch from 211cfe6 to cfdf76f Compare April 30, 2026 17:19
@mcous mcous merged commit 2aae514 into main Apr 30, 2026
18 checks passed
@mcous mcous deleted the feat/v3-codemod branch April 30, 2026 19:43
@mcous mcous mentioned this pull request Apr 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant