Skip to content

feat: add structify — params-struct rewriter with whole-program caller updates - #20

Merged
Peyton-Spencer merged 6 commits into
mainfrom
structify
Jul 4, 2026
Merged

feat: add structify — params-struct rewriter with whole-program caller updates#20
Peyton-Spencer merged 6 commits into
mainfrom
structify

Conversation

@Peyton-Spencer

@Peyton-Spencer Peyton-Spencer commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Summary

New structify module: finds functions with too many input parameters (default >4, matching the classic funcparamlint rule), generates a XxxParams struct from the parameter names, rewrites the signature and body, and — the important part — rewrites every caller across the loaded packages, including test files. A leading context.Context stays positional.

Designed as the middle step of a codegen pipeline: codegen → structify → fieldalign (structs it generates get optimally packed by the next step).

Safety model

A signature change is only safe when every reference is visible and rewritable. Functions are skipped with a reason when they:

  • are variadic, generic, or have blank (_) params
  • are referenced as function values
  • satisfy an interface in the loaded set
  • reuse a param on the left of := (selector would be invalid there)
  • have callers in generated files (regen would break the build) or callers that can't name the struct
  • carry //go:*///export directives or a suppression comment

Caller rewrites are zero-width insertions around existing argument expressions, so they compose with body rewrites happening inside those arguments — nested structified calls work in a single pass.

Testing

  • Golden rewrites: plain funcs, ctx handling, cross-package method callers, test-file callers, nested structified calls, shadowing, initialisms, name collisions, arg-name collision avoidance
  • Every skip gate has a test
  • Each rewritten test module is reloaded and re-typechecked — the strongest guarantee a signature-changing refactor can offer
  • Real-world validation: ran against ditto-assistant/backend — 264 functions / 217 files rewritten, go build ./... clean, 28 conservative skips (18 interface methods)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added structify CLI to report or automatically rewrite functions with more than the configured parameter limit into a single params-struct form, updating call sites accordingly.
    • Introduced a reusable library for planning and applying rewrites, plus analyzer support for function literals.
    • Added ignore comment support (including both modern and legacy suppression spellings), for file/line/function.
  • Bug Fixes
    • Improved safety handling for methods, context parameters, generated code, and _test.go, with deterministic edit application.
  • Tests
    • Expanded end-to-end rewrite/idempotency coverage and skip-reason assertions.
  • Chores
    • Updated Go toolchain/version and related dependencies.

…truct, callers included

Companion to fieldalign for the codegen pipeline (codegen -> structify ->
fieldalign): functions with more than -max-params inputs get a generated
XxxParams struct built from the parameter names, the signature becomes
func F(ctx, arg XxxParams), the body's parameter uses become arg.Field,
and EVERY call site across the loaded packages is rewritten in place.
A leading context.Context stays positional. Caller rewrites are pure
zero-width insertions around the existing argument expressions, so they
compose with rewrites happening inside those arguments (including calls
to other structified functions in the same pass).

Signature changes are only safe when every reference is visible and
rewritable, so functions are conservatively skipped (diagnosed, not
rewritten) when they are variadic, generic, have blank params, are used
as function values, satisfy an interface, are reassigned with :=, have
callers in generated files, or callers that can't name the struct.
Suppressions: //lint:ignore structify (funcparamlint spelling accepted)
and structify:ignore.

structify.Analyzer provides funcparamlint-compatible diagnostics for
multichecker integration; cmd/structify does the whole-program rewrite.

Tests cover the rewrite goldens plus every skip gate, and re-typecheck
each rewritten module to prove the refactor compiles; validated against
the Ditto backend: 264 functions / 217 files rewritten, builds clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Peyton-Spencer, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 50 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bc340b49-6e67-4e85-8ebd-e9c1b2ed54d2

📥 Commits

Reviewing files that changed from the base of the PR and between 10ed172 and 6c15515.

📒 Files selected for processing (2)
  • structify/structify.go
  • structify/structify_test.go
📝 Walkthrough

Walkthrough

This PR adds a new structify Go module that analyzes long parameter lists and rewrites eligible functions to use generated params structs. It includes suppression parsing, rewrite planning and application, a CLI, module metadata, and tests.

Changes

Structify Analyzer and Rewrite Tool

Layer / File(s) Summary
Analyzer detects long parameter lists
structify/analyzer.go
Exports Analyzer and reports functions or function literals with too many parameters while skipping generated and test files and honoring suppressions.
Suppression comment parsing
structify/suppress.go
Parses supported ignore directives into file-wide and position-based suppression state.
Module setup
structify/go.mod
Declares the module path, Go version/toolchain, and required golang.org/x/* dependencies.
Rewrite plan data types and config
structify/structify.go
Defines rewrite configuration, public result types, and internal planning records used by the engine.
Plan: candidate discovery and indexing
structify/structify.go, structify/structify.go
Implements the multi-pass planner, reference indexing, and edit deduplication.
Candidate safety gating
structify/structify.go
Applies the rewrite safety checks before accepting a candidate.
Edit emission and Apply
structify/structify.go
Generates struct, signature, body, and call-site edits, then applies edits in stable order.
CLI command wiring
structify/cmd/structify/main.go
Implements flag parsing, package loading, report/fix behavior, file rewriting, summary output, and fatal error handling.
Test suite for planning and rewriting
structify/structify_test.go
Adds harness helpers and coverage for rewrites, skips, suppression handling, context behavior, naming, collisions, and metadata.
Dependencies
structify/go.mod
Pins the module’s direct and indirect dependency versions.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant CLI as structify CLI
    participant Packages as go/packages
    participant Planner as structify.Plan
    participant Applier as structify.Apply

    User->>CLI: run structify [-fix] [patterns]
    CLI->>Packages: packages.Load(patterns)
    Packages-->>CLI: loaded packages
    CLI->>Planner: Plan(pkgs, cfg)
    Planner-->>CLI: Result{Rewritten, Skipped, Edits}
    alt report mode
        CLI-->>User: print rewritten/skipped and exit 1 if candidates exist
    else fix mode
        CLI->>Applier: Apply(source, edits)
        Applier-->>CLI: rewritten source
        CLI->>CLI: format and write files
        CLI-->>User: print rewrite summary
    end
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the new structify params-struct rewriter and its caller-updating behavior.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch structify

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

…ng param

Embedding a context in a struct field is an anti-pattern (context docs);
the convention is func F(ctx, arg Params). A misplaced ctx now skips the
function with a reason — reorder ctx first by hand, then structify keeps
it positional. Found via ditto-backend's codemode dispatch(vm, ctx, ...).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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: 3

🧹 Nitpick comments (3)
structify/analyzer.go (1)

25-25: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated default threshold constant.

analyzerMaxParams = 4 duplicates the default returned by Config.maxParams() in structify.go (also 4). If the tool's default threshold changes in one place, the analyzer's diagnostic threshold silently diverges from the rewrite tool's candidate threshold.

♻️ Suggested consolidation
-const analyzerMaxParams = 4
+// analyzerMaxParams mirrors Config{}.maxParams()'s default so the
+// diagnostic threshold and the rewrite threshold never drift apart.
+var analyzerMaxParams = Config{}.maxParams()

Also applies to: 27-60

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@structify/analyzer.go` at line 25, The analyzer’s default max-params
threshold is duplicated, with analyzerMaxParams in analyzer.go matching
Config.maxParams() in structify.go. Consolidate this into a single shared source
of truth and have the analyzer use that shared default so the diagnostic
threshold and rewrite candidate threshold stay aligned; update the analyzer
logic and any related helpers in analyzer.go to reference the shared config
value instead of the local constant.
structify/suppress.go (1)

67-67: 📐 Maintainability & Code Quality | 🔵 Trivial

readFile is unrelated to suppression parsing.

Minor organizational nit — this trivial os.ReadFile wrapper (consumed by the planner in structify.go per the linked context snippet) doesn't belong conceptually in a suppression-parsing file; could live alongside its consumer instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@structify/suppress.go` at line 67, The trivial readFile wrapper is misplaced
in the suppression parsing area and should live with its actual consumer
instead. Move readFile out of the suppression-related file and place it
alongside the planner code in structify.go (or the closest consumer-owned
location), then update any references so the planner continues to call the same
helper without changing behavior.
structify/structify.go (1)

158-158: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

walkErr is declared and checked but never assigned.

walkErr is read at Line 208 but nothing inside the forEachPkg closure ever sets it, so this error path is currently dead code that misleadingly suggests errors from the indexing pass are handled.

Also applies to: 208-210

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@structify/structify.go` at line 158, The `walkErr` path in `structify` is
dead code because `forEachPkg` never assigns to the `walkErr` variable before it
is checked later. Update the indexing flow in `structify` and its `forEachPkg`
callback so any package-walk/indexing error is actually captured into `walkErr`,
or remove the unused variable and its later check if errors are already handled
elsewhere. Focus on the `structify` function and the `forEachPkg` closure to
keep the error handling consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@structify/cmd/structify/main.go`:
- Around line 77-90: The `-fix` path in `main.go` writes each rewritten file
immediately inside the `res.Edits` loop, which can leave a partially updated
tree if a later file fails formatting or writing. Change the
`structify.Apply`/`format.Source` flow to a two-pass approach: first read,
rewrite, and format every file and keep the results in memory while validating
all succeed; only after that, write the buffered outputs back to disk in a
second pass. Use the existing `res.Edits`, `structify.Apply`, and
`format.Source` flow to locate the rewrite logic.

In `@structify/structify.go`:
- Around line 257-259: The candidate ordering in structify.go is using
target.Pos.String() for sort comparison, which is lexicographic and can misorder
positions within the same file. Update the sort in the candidate ordering logic
around sort.Slice(cands, ...) to compare the underlying token.Position fields
numerically (file, then line, then column) via the target.Pos values instead of
their String() output so the processing order of candidates, Rewritten, and
Skipped entries is stable and correct.
- Around line 158-207: The reference collection in structify.go is rescanning
p.TypesInfo.Uses once per file inside the package walk, which makes pass 1
O(files × uses) and can duplicate refInfo entries across package variants when
Tests: true. Refactor the logic around forEachPkg, refsByPos, and the per-file
loop so Uses is indexed by filename once per package, then dedupe references by
(filename, ident.Pos()) before appending or counting callers in
Target.NumCallers.

---

Nitpick comments:
In `@structify/analyzer.go`:
- Line 25: The analyzer’s default max-params threshold is duplicated, with
analyzerMaxParams in analyzer.go matching Config.maxParams() in structify.go.
Consolidate this into a single shared source of truth and have the analyzer use
that shared default so the diagnostic threshold and rewrite candidate threshold
stay aligned; update the analyzer logic and any related helpers in analyzer.go
to reference the shared config value instead of the local constant.

In `@structify/structify.go`:
- Line 158: The `walkErr` path in `structify` is dead code because `forEachPkg`
never assigns to the `walkErr` variable before it is checked later. Update the
indexing flow in `structify` and its `forEachPkg` callback so any
package-walk/indexing error is actually captured into `walkErr`, or remove the
unused variable and its later check if errors are already handled elsewhere.
Focus on the `structify` function and the `forEachPkg` closure to keep the error
handling consistent.

In `@structify/suppress.go`:
- Line 67: The trivial readFile wrapper is misplaced in the suppression parsing
area and should live with its actual consumer instead. Move readFile out of the
suppression-related file and place it alongside the planner code in structify.go
(or the closest consumer-owned location), then update any references so the
planner continues to call the same helper without changing behavior.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: dab62232-1bfd-4ff8-85bc-b6f1e0b443b7

📥 Commits

Reviewing files that changed from the base of the PR and between 2d03a76 and 5645eaf.

⛔ Files ignored due to path filters (2)
  • go.work is excluded by !**/*.work
  • structify/go.sum is excluded by !**/*.sum
📒 Files selected for processing (6)
  • structify/analyzer.go
  • structify/cmd/structify/main.go
  • structify/go.mod
  • structify/structify.go
  • structify/structify_test.go
  • structify/suppress.go

Comment thread structify/cmd/structify/main.go
Comment thread structify/structify.go
Comment thread structify/structify.go Outdated
Peyton-Spencer and others added 3 commits July 4, 2026 13:06
Instead of skipping a function whose ctx isn't the first parameter,
reorder it: func F(vm, ctx, ...) becomes func F(ctx, arg FParams), and
every call site moves its ctx argument text to the front. Safe because
only non-ctx params are renamed to arg.Field — but a ctx ARGUMENT
expression that references a parameter some rewrite renames cannot be
moved textually, so that call site (and multiple-ctx signatures) still
skip with a reason.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dern sorts

- cmd/structify: validate and gofmt every rewritten file BEFORE writing
  any — rewrites are cross-file (signature + all callers), so a mid-loop
  failure must not strand a half-applied, non-compiling tree
- Plan: index TypesInfo.Uses once per package instead of once per file
  (was O(files x uses)), and dedupe references by (func, ident) position
  so Tests:true package variants can't double-count NumCallers
- sort.Slice -> slices.SortFunc/SortStableFunc with cmp comparators;
  candidate ordering now compares (file, line, col) numerically instead
  of lexicographic Position.String()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
samePath treated 'p_test' (the external test package) as the defining
package by trimming the _test suffix, emitting unqualified struct names
where p.XxxParams is required. Only the in-package test variant shares
PkgPath; compare exactly and let external test files hit the import
scan. Found via ditto-backend's kgauth consent tests (package
kgauth_test calling mgr.Issue).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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

🧹 Nitpick comments (1)
structify/structify_test.go (1)

525-560: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider also asserting outer is rewritten to make the test's intent explicit.

The test only checks that inner is skipped; it doesn't assert outer ends up in res.Rewritten. Per the planner logic (parameter-rename set is built from all candidates before individual gating), the skip check for inner doesn't strictly depend on outer actually succeeding — but adding an explicit assertion on outer's rewrite would better capture the scenario described in the test's comment (i.e., that the orphaning risk specifically arises from outer being structified).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@structify/structify_test.go` around lines 525 - 560, The test for
ctx-argument referencing rewritten params only verifies that inner is skipped,
so the scenario described by the comment is not fully asserted. Update
TestCtxArgReferencingRewrittenParamSkips to also check that outer appears in
res.Rewritten, using the existing plan result and the outer/inner names to
locate the assertions. Keep the current skip-reason check for inner, but add an
explicit rewritten assertion for outer so the test clearly covers the orphaning
case caused by outer’s parameter rename.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@structify/structify.go`:
- Around line 437-449: The ctx-hoisting logic in structify.go can change
evaluation order when a non-leading context.Context argument is moved ahead of
earlier arguments. Update the rewrite in structify/structify.go around the ctx
handling in the struct construction path so it either skips cases where
c.ctxIndex is not the first argument or emits a transformation that preserves
original argument evaluation order. Use the existing c.ctxIndex and
ref.call.Args logic to detect and avoid rewriting calls like f(a(), ctx(), b())
into a reordered form.

---

Nitpick comments:
In `@structify/structify_test.go`:
- Around line 525-560: The test for ctx-argument referencing rewritten params
only verifies that inner is skipped, so the scenario described by the comment is
not fully asserted. Update TestCtxArgReferencingRewrittenParamSkips to also
check that outer appears in res.Rewritten, using the existing plan result and
the outer/inner names to locate the assertions. Keep the current skip-reason
check for inner, but add an explicit rewritten assertion for outer so the test
clearly covers the orphaning case caused by outer’s parameter rename.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2699988d-a129-405f-915e-c23d8a9be586

📥 Commits

Reviewing files that changed from the base of the PR and between 5645eaf and 10ed172.

📒 Files selected for processing (3)
  • structify/cmd/structify/main.go
  • structify/structify.go
  • structify/structify_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • structify/cmd/structify/main.go

Comment thread structify/structify.go
Hoisting a non-leading ctx moves its evaluation ahead of the arguments
that preceded it, which is observable when either side has effects.
Gate the hoist on it being unobservable: the ctx argument must be a
bare identifier (effect- and panic-free) or context.Background()/TODO(),
and every earlier argument must be free of calls (type conversions ok),
channel receives, and function literals. Anything else skips with an
evaluation-order reason.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Peyton-Spencer
Peyton-Spencer merged commit d395b26 into main Jul 4, 2026
3 checks passed
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