flake8-stepdown 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. flake8_stepdown-0.1.0/.github/workflows/ci.yml +18 -0
  2. flake8_stepdown-0.1.0/.github/workflows/publish.yml +16 -0
  3. flake8_stepdown-0.1.0/.gitignore +17 -0
  4. flake8_stepdown-0.1.0/.pre-commit-config.yaml +21 -0
  5. flake8_stepdown-0.1.0/.pre-commit-hooks.yaml +11 -0
  6. flake8_stepdown-0.1.0/.python-version +1 -0
  7. flake8_stepdown-0.1.0/.vscode/settings.json +9 -0
  8. flake8_stepdown-0.1.0/CONTRIBUTING.md +16 -0
  9. flake8_stepdown-0.1.0/PKG-INFO +126 -0
  10. flake8_stepdown-0.1.0/README.md +108 -0
  11. flake8_stepdown-0.1.0/flake8_stepdown/__init__.py +5 -0
  12. flake8_stepdown-0.1.0/flake8_stepdown/cli.py +168 -0
  13. flake8_stepdown-0.1.0/flake8_stepdown/core/__init__.py +1 -0
  14. flake8_stepdown-0.1.0/flake8_stepdown/core/bindings.py +148 -0
  15. flake8_stepdown-0.1.0/flake8_stepdown/core/graph.py +156 -0
  16. flake8_stepdown-0.1.0/flake8_stepdown/core/ordering.py +184 -0
  17. flake8_stepdown-0.1.0/flake8_stepdown/core/parser.py +102 -0
  18. flake8_stepdown-0.1.0/flake8_stepdown/core/references.py +260 -0
  19. flake8_stepdown-0.1.0/flake8_stepdown/flake8_plugin.py +42 -0
  20. flake8_stepdown-0.1.0/flake8_stepdown/py.typed +0 -0
  21. flake8_stepdown-0.1.0/flake8_stepdown/reporter.py +75 -0
  22. flake8_stepdown-0.1.0/flake8_stepdown/rewriter.py +99 -0
  23. flake8_stepdown-0.1.0/flake8_stepdown/types.py +78 -0
  24. flake8_stepdown-0.1.0/lint.sh +4 -0
  25. flake8_stepdown-0.1.0/pyproject.toml +92 -0
  26. flake8_stepdown-0.1.0/tests/__init__.py +1 -0
  27. flake8_stepdown-0.1.0/tests/fixtures/already_correct.py +9 -0
  28. flake8_stepdown-0.1.0/tests/fixtures/constant_docstrings.py +19 -0
  29. flake8_stepdown-0.1.0/tests/fixtures/constants_between_functions.py +14 -0
  30. flake8_stepdown-0.1.0/tests/fixtures/decorator_ordering.py +7 -0
  31. flake8_stepdown-0.1.0/tests/fixtures/main_guard.py +10 -0
  32. flake8_stepdown-0.1.0/tests/fixtures/mutual_recursion.py +6 -0
  33. flake8_stepdown-0.1.0/tests/fixtures/simple_bottomup.py +10 -0
  34. flake8_stepdown-0.1.0/tests/fixtures/simple_topdown.py +10 -0
  35. flake8_stepdown-0.1.0/tests/snapshots/already_correct.py +9 -0
  36. flake8_stepdown-0.1.0/tests/snapshots/constant_docstrings.py +19 -0
  37. flake8_stepdown-0.1.0/tests/snapshots/constants_between_functions.py +14 -0
  38. flake8_stepdown-0.1.0/tests/snapshots/decorator_ordering.py +7 -0
  39. flake8_stepdown-0.1.0/tests/snapshots/main_guard.py +10 -0
  40. flake8_stepdown-0.1.0/tests/snapshots/mutual_recursion.py +6 -0
  41. flake8_stepdown-0.1.0/tests/snapshots/simple_bottomup.py +10 -0
  42. flake8_stepdown-0.1.0/tests/snapshots/simple_topdown.py +10 -0
  43. flake8_stepdown-0.1.0/tests/test_bindings.py +251 -0
  44. flake8_stepdown-0.1.0/tests/test_cli.py +179 -0
  45. flake8_stepdown-0.1.0/tests/test_flake8_plugin.py +79 -0
  46. flake8_stepdown-0.1.0/tests/test_graph.py +304 -0
  47. flake8_stepdown-0.1.0/tests/test_ordering.py +343 -0
  48. flake8_stepdown-0.1.0/tests/test_parser.py +246 -0
  49. flake8_stepdown-0.1.0/tests/test_references.py +410 -0
  50. flake8_stepdown-0.1.0/tests/test_reporter.py +97 -0
  51. flake8_stepdown-0.1.0/tests/test_rewriter.py +347 -0
  52. flake8_stepdown-0.1.0/uv.lock +515 -0
@@ -0,0 +1,18 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ ci:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: astral-sh/setup-uv@v5
14
+ - run: uv sync --dev
15
+ - run: uv run ruff check .
16
+ - run: uv run ruff format --check .
17
+ - run: uv run ty check .
18
+ - run: uv run pytest
@@ -0,0 +1,16 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ id-token: write # Required for trusted publishing
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: astral-sh/setup-uv@v5
15
+ - run: uv build
16
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,17 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .env
8
+ *.log
9
+ *.swp
10
+ *~
11
+ .DS_Store
12
+ .coverage
13
+ .coverage.*
14
+ htmlcov/
15
+ specs.md
16
+ CLAUDE.md
17
+ AGENTS.md
@@ -0,0 +1,21 @@
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.15.6
4
+ hooks:
5
+ - id: ruff-check
6
+ args: [--fix]
7
+ - id: ruff-format
8
+ - repo: local
9
+ hooks:
10
+ - id: ty
11
+ name: ty type checker
12
+ entry: uvx ty check .
13
+ language: system
14
+ pass_filenames: false
15
+ types: [python]
16
+ - id: pytest
17
+ name: pytest
18
+ entry: uv run pytest
19
+ language: system
20
+ pass_filenames: false
21
+ types: [python]
@@ -0,0 +1,11 @@
1
+ - id: flake8-stepdown-check
2
+ name: flake8-stepdown (check)
3
+ entry: stepdown check
4
+ language: python
5
+ types: [python]
6
+
7
+ - id: flake8-stepdown-fix
8
+ name: flake8-stepdown (fix)
9
+ entry: stepdown fix
10
+ language: python
11
+ types: [python]
@@ -0,0 +1 @@
1
+ 3.13
@@ -0,0 +1,9 @@
1
+ {
2
+ "[python]": {
3
+ "editor.defaultFormatter": "charliermarsh.ruff",
4
+ "editor.formatOnSave": true,
5
+ "editor.codeActionsOnSave": {
6
+ "source.organizeImports": "explicit"
7
+ }
8
+ }
9
+ }
@@ -0,0 +1,16 @@
1
+ # Contributing
2
+
3
+ ## Code style
4
+
5
+ Ruff enforces many rules automatically.
6
+ Run `./lint.sh` to auto-fix and format.
7
+
8
+ ## Commands reference
9
+
10
+ | Task | Command |
11
+ |------|---------|
12
+ | Install deps | `uv sync` |
13
+ | Lint + format | `./lint.sh` |
14
+ | Type check | `uvx ty check .` |
15
+ | Run tests | `uv run pytest` |
16
+ | Pre-commit (manual) | `uv run pre-commit run --all-files` |
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: flake8-stepdown
3
+ Version: 0.1.0
4
+ Summary: Enforce top-down / newspaper-style function ordering in Python
5
+ Project-URL: Homepage, https://github.com/<owner>/<project-name>
6
+ Project-URL: Repository, https://github.com/<owner>/<project-name>
7
+ Project-URL: Issues, https://github.com/<owner>/<project-name>/issues
8
+ Author-email: Didier Marin <mail@didiermarin.com>
9
+ License-Expression: MIT
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Requires-Python: >=3.12
14
+ Requires-Dist: libcst>=1.1.0
15
+ Provides-Extra: flake8
16
+ Requires-Dist: flake8>=6.0; extra == 'flake8'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # flake8-stepdown
20
+
21
+ ![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue)
22
+
23
+ A [flake8](https://flake8.pycqa.org/) plugin that enforces **top-down (newspaper-style) function ordering** in Python modules.
24
+
25
+ This is inspired by Robert C. Martin's "Clean Code" stepdown rule: High-level logic first, details later. When reading a module, callers should appear before callees, so you can read the code from top to bottom like a newspaper article.
26
+
27
+ ## Violation codes
28
+
29
+ | Code | Meaning |
30
+ |---------|---------|
31
+ | TDP001 | Function is defined in the wrong order (should appear after another function) |
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install flake8-stepdown
37
+ ```
38
+
39
+ The plugin registers itself with flake8 automatically. Verify it's installed:
40
+
41
+ ```bash
42
+ flake8 --version
43
+ ```
44
+
45
+ You should see `flake8-stepdown` in the list of installed plugins.
46
+
47
+ ## Usage
48
+
49
+ ### As a flake8 plugin
50
+
51
+ Just run flake8 as usual, the plugin will report `TDP001` violations:
52
+
53
+ ```bash
54
+ flake8 your_module.py
55
+ ```
56
+
57
+ ### Standalone CLI
58
+
59
+ The package also provides a `stepdown` command with three subcommands:
60
+
61
+ ```bash
62
+ # Report violations
63
+ stepdown check your_module.py
64
+
65
+ # Show a unified diff of the proposed reordering
66
+ stepdown diff your_module.py
67
+
68
+ # Rewrite files in place
69
+ stepdown fix your_module.py
70
+ ```
71
+
72
+ Use `-v` / `--verbose` to show mutual recursion info on stderr.
73
+
74
+ ## Development
75
+
76
+ ### Prerequisites
77
+
78
+ - [UV](https://docs.astral.sh/uv/) (Python package manager)
79
+
80
+ ### Setup
81
+
82
+ ```bash
83
+ uv sync
84
+ ```
85
+
86
+ ### Linting
87
+
88
+ ```bash
89
+ ./lint.sh
90
+ ```
91
+
92
+ ### Testing
93
+
94
+ ```bash
95
+ uv run pytest
96
+ ```
97
+
98
+ Tests are organized by pipeline stage (`test_parser.py`, `test_graph.py`, `test_rewriter.py`, etc.). The rewriter uses **snapshot testing**: `tests/fixtures/` contains input Python files exercising various scenarios (bottom-up ordering, mutual recursion, decorators, etc.), and `tests/snapshots/` contains the expected output after rewriting. The test suite asserts correctness against snapshots, idempotency, and syntax validity.
99
+
100
+ ### Pre-commit hooks
101
+
102
+ Pre-commit hooks are installed automatically. To run manually:
103
+
104
+ ```bash
105
+ uv run pre-commit run --all-files
106
+ ```
107
+
108
+ ## CI
109
+
110
+ GitHub Actions runs linting, type checking, and tests on every push to `main` and on pull requests.
111
+
112
+ ## Publishing to PyPI
113
+
114
+ This project uses [trusted publishing](https://docs.pypi.org/trusted-publishers/) via GitHub Actions.
115
+
116
+ To publish a new version:
117
+
118
+ 1. Bump the version with `uv version --bump patch` (or `minor`/`major`)
119
+ 2. Create a GitHub release with a tag matching the version (e.g., `v0.1.0`)
120
+ 3. The publish workflow will automatically build and upload to PyPI
121
+
122
+ > **First-time setup:** Configure a trusted publisher on PyPI under your project's settings (Publishing tab). Use `publish.yml` as the workflow name and `publish` as the environment name.
123
+
124
+ ## Contributing
125
+
126
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for code style, conventions, and development workflow.
@@ -0,0 +1,108 @@
1
+ # flake8-stepdown
2
+
3
+ ![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue)
4
+
5
+ A [flake8](https://flake8.pycqa.org/) plugin that enforces **top-down (newspaper-style) function ordering** in Python modules.
6
+
7
+ This is inspired by Robert C. Martin's "Clean Code" stepdown rule: High-level logic first, details later. When reading a module, callers should appear before callees, so you can read the code from top to bottom like a newspaper article.
8
+
9
+ ## Violation codes
10
+
11
+ | Code | Meaning |
12
+ |---------|---------|
13
+ | TDP001 | Function is defined in the wrong order (should appear after another function) |
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ pip install flake8-stepdown
19
+ ```
20
+
21
+ The plugin registers itself with flake8 automatically. Verify it's installed:
22
+
23
+ ```bash
24
+ flake8 --version
25
+ ```
26
+
27
+ You should see `flake8-stepdown` in the list of installed plugins.
28
+
29
+ ## Usage
30
+
31
+ ### As a flake8 plugin
32
+
33
+ Just run flake8 as usual, the plugin will report `TDP001` violations:
34
+
35
+ ```bash
36
+ flake8 your_module.py
37
+ ```
38
+
39
+ ### Standalone CLI
40
+
41
+ The package also provides a `stepdown` command with three subcommands:
42
+
43
+ ```bash
44
+ # Report violations
45
+ stepdown check your_module.py
46
+
47
+ # Show a unified diff of the proposed reordering
48
+ stepdown diff your_module.py
49
+
50
+ # Rewrite files in place
51
+ stepdown fix your_module.py
52
+ ```
53
+
54
+ Use `-v` / `--verbose` to show mutual recursion info on stderr.
55
+
56
+ ## Development
57
+
58
+ ### Prerequisites
59
+
60
+ - [UV](https://docs.astral.sh/uv/) (Python package manager)
61
+
62
+ ### Setup
63
+
64
+ ```bash
65
+ uv sync
66
+ ```
67
+
68
+ ### Linting
69
+
70
+ ```bash
71
+ ./lint.sh
72
+ ```
73
+
74
+ ### Testing
75
+
76
+ ```bash
77
+ uv run pytest
78
+ ```
79
+
80
+ Tests are organized by pipeline stage (`test_parser.py`, `test_graph.py`, `test_rewriter.py`, etc.). The rewriter uses **snapshot testing**: `tests/fixtures/` contains input Python files exercising various scenarios (bottom-up ordering, mutual recursion, decorators, etc.), and `tests/snapshots/` contains the expected output after rewriting. The test suite asserts correctness against snapshots, idempotency, and syntax validity.
81
+
82
+ ### Pre-commit hooks
83
+
84
+ Pre-commit hooks are installed automatically. To run manually:
85
+
86
+ ```bash
87
+ uv run pre-commit run --all-files
88
+ ```
89
+
90
+ ## CI
91
+
92
+ GitHub Actions runs linting, type checking, and tests on every push to `main` and on pull requests.
93
+
94
+ ## Publishing to PyPI
95
+
96
+ This project uses [trusted publishing](https://docs.pypi.org/trusted-publishers/) via GitHub Actions.
97
+
98
+ To publish a new version:
99
+
100
+ 1. Bump the version with `uv version --bump patch` (or `minor`/`major`)
101
+ 2. Create a GitHub release with a tag matching the version (e.g., `v0.1.0`)
102
+ 3. The publish workflow will automatically build and upload to PyPI
103
+
104
+ > **First-time setup:** Configure a trusted publisher on PyPI under your project's settings (Publishing tab). Use `publish.yml` as the workflow name and `publish` as the environment name.
105
+
106
+ ## Contributing
107
+
108
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for code style, conventions, and development workflow.
@@ -0,0 +1,5 @@
1
+ """flake8-stepdown: enforce top-down function ordering in Python."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from flake8_stepdown.core.ordering import order_module
@@ -0,0 +1,168 @@
1
+ """CLI entry point for flake8-stepdown."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from fnmatch import fnmatch
8
+ from pathlib import Path
9
+
10
+ from flake8_stepdown.core.ordering import order_module
11
+ from flake8_stepdown.reporter import format_diff, format_violations
12
+
13
+ EXIT_OK = 0
14
+ EXIT_VIOLATIONS = 1
15
+ EXIT_ERROR = 2
16
+
17
+
18
+ def main(argv: list[str] | None = None) -> int:
19
+ """CLI entry point.
20
+
21
+ Returns:
22
+ Exit code: 0 (clean), 1 (violations/changes), 2 (error).
23
+
24
+ """
25
+ parser = _build_parser()
26
+ args = parser.parse_args(argv)
27
+
28
+ # Handle stdin
29
+ if args.stdin_filename:
30
+ source = sys.stdin.read()
31
+ code, output = _process_source(source, args.stdin_filename, args)
32
+ if output:
33
+ _write_output(output)
34
+ return code
35
+
36
+ if not args.files:
37
+ sys.stderr.write("Error: no files specified\n")
38
+ return EXIT_ERROR
39
+
40
+ filepaths = _resolve_paths(args.files, args.exclude)
41
+ if not filepaths:
42
+ return EXIT_OK
43
+
44
+ exit_code = EXIT_OK
45
+ for filepath in filepaths:
46
+ code = _process_file(filepath, args)
47
+ if code == EXIT_ERROR:
48
+ return EXIT_ERROR
49
+ exit_code = max(exit_code, code)
50
+
51
+ return exit_code
52
+
53
+
54
+ def _build_parser() -> argparse.ArgumentParser:
55
+ """Build the argument parser."""
56
+ parser = argparse.ArgumentParser(
57
+ prog="stepdown",
58
+ description="Enforce top-down function ordering in Python",
59
+ )
60
+ subparsers = parser.add_subparsers(dest="command", required=True)
61
+
62
+ common = argparse.ArgumentParser(add_help=False)
63
+ common.add_argument("files", nargs="*", help="Files or directories to check")
64
+ common.add_argument("--exclude", action="append", default=[], help="Glob patterns to exclude")
65
+ common.add_argument(
66
+ "-v",
67
+ "--verbose",
68
+ action="store_true",
69
+ help="Show debug info (mutual recursion info on stderr)",
70
+ )
71
+ common.add_argument(
72
+ "--stdin-filename",
73
+ help="Read from stdin, use this filename for output",
74
+ )
75
+
76
+ check_parser = subparsers.add_parser("check", parents=[common], help="Report violations")
77
+ check_parser.add_argument(
78
+ "--format",
79
+ dest="fmt",
80
+ choices=["text", "json"],
81
+ default="text",
82
+ help="Output format",
83
+ )
84
+
85
+ subparsers.add_parser("diff", parents=[common], help="Show unified diff")
86
+ subparsers.add_parser("fix", parents=[common], help="Rewrite files in place")
87
+
88
+ return parser
89
+
90
+
91
+ def _resolve_paths(paths: list[str], exclude: list[str]) -> list[str]:
92
+ """Expand directories to .py files and apply exclude patterns."""
93
+ resolved: list[str] = []
94
+ for entry in paths:
95
+ p = Path(entry)
96
+ if p.is_dir():
97
+ for py_file in sorted(p.rglob("*.py")):
98
+ filepath = str(py_file)
99
+ if not any(fnmatch(filepath, pat) for pat in exclude):
100
+ resolved.append(filepath)
101
+ elif not any(fnmatch(entry, pat) for pat in exclude):
102
+ resolved.append(entry)
103
+ return resolved
104
+
105
+
106
+ def _process_file(filepath: str, args: argparse.Namespace) -> int:
107
+ """Process a single file and handle output."""
108
+ path = Path(filepath)
109
+ if not path.exists():
110
+ sys.stderr.write(f"Error: {filepath} not found\n")
111
+ return EXIT_ERROR
112
+
113
+ try:
114
+ source = path.read_text()
115
+ except (OSError, UnicodeDecodeError) as e:
116
+ sys.stderr.write(f"Error reading {filepath}: {e}\n")
117
+ return EXIT_ERROR
118
+
119
+ code, output = _process_source(source, filepath, args)
120
+
121
+ if args.command == "fix" and code == EXIT_VIOLATIONS and output:
122
+ path.write_text(output)
123
+ elif output:
124
+ _write_output(output)
125
+
126
+ return code
127
+
128
+
129
+ def _process_source(
130
+ source: str,
131
+ filename: str,
132
+ args: argparse.Namespace,
133
+ ) -> tuple[int, str]:
134
+ """Process a single source file and return (exit_code, output)."""
135
+ compute_rewrite = args.command != "check"
136
+ result = order_module(source, compute_rewrite=compute_rewrite)
137
+
138
+ if args.verbose and result.mutual_recursion_groups:
139
+ for group in result.mutual_recursion_groups:
140
+ sys.stderr.write(
141
+ f"{filename}: mutual recursion between {', '.join(group)}; original order preserved\n"
142
+ )
143
+
144
+ if args.command == "check":
145
+ output = format_violations(result.violations, filename=filename, fmt=args.fmt)
146
+ return (EXIT_VIOLATIONS if result.violations else EXIT_OK), output
147
+
148
+ if args.command == "diff":
149
+ if result.reordered_source is not None:
150
+ output = format_diff(source, result.reordered_source, filename=filename)
151
+ return EXIT_VIOLATIONS, output
152
+ return EXIT_OK, ""
153
+
154
+ # fix command
155
+ if result.reordered_source is not None:
156
+ return EXIT_VIOLATIONS, result.reordered_source
157
+ return EXIT_OK, ""
158
+
159
+
160
+ def _write_output(output: str) -> None:
161
+ """Write output to stdout with trailing newline if needed."""
162
+ sys.stdout.write(output)
163
+ if not output.endswith("\n"):
164
+ sys.stdout.write("\n")
165
+
166
+
167
+ if __name__ == "__main__":
168
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """Core analysis modules for flake8-stepdown."""
@@ -0,0 +1,148 @@
1
+ """Extract bindings (defined names) from module-level statements."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import libcst as cst
8
+ import libcst.matchers as m
9
+
10
+ from flake8_stepdown.types import Statement
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Mapping
14
+
15
+
16
+ def extract_bindings(
17
+ statements: list[cst.CSTNode],
18
+ positions: Mapping[cst.CSTNode, cst.metadata.CodeRange],
19
+ ) -> list[Statement]:
20
+ """Extract bindings from module-level statements.
21
+
22
+ Args:
23
+ statements: The module-level CST nodes to analyze (functions, classes,
24
+ and assignments from the reorderable zone between preamble and postamble).
25
+ positions: Position mapping from MetadataWrapper.resolve(PositionProvider).
26
+
27
+ Groups consecutive @overload stubs with their implementation into a single Statement.
28
+ Returns Statement objects with empty refs (to be populated by references module).
29
+
30
+ """
31
+ result: list[Statement] = []
32
+ i = 0
33
+ nodes = list(statements)
34
+
35
+ while i < len(nodes):
36
+ node = nodes[i]
37
+
38
+ # Check for @overload grouping
39
+ if isinstance(node, cst.FunctionDef) and _has_overload_decorator(node):
40
+ func_name = node.name.value
41
+ group_nodes: list[cst.CSTNode] = [node]
42
+
43
+ # Collect consecutive same-name functions
44
+ j = i + 1
45
+ while j < len(nodes):
46
+ next_node = nodes[j]
47
+ if isinstance(next_node, cst.FunctionDef) and next_node.name.value == func_name:
48
+ group_nodes.append(next_node)
49
+ if not _has_overload_decorator(next_node):
50
+ j += 1
51
+ break
52
+ j += 1
53
+ else:
54
+ break
55
+
56
+ # Merge if stubs + implementation (>1 node and last is not overload)
57
+ last_node = group_nodes[-1]
58
+ if (
59
+ len(group_nodes) > 1
60
+ and isinstance(last_node, cst.FunctionDef)
61
+ and not _has_overload_decorator(last_node)
62
+ ):
63
+ first_pos = positions.get(group_nodes[0])
64
+ last_pos = positions.get(last_node)
65
+ result.append(
66
+ Statement(
67
+ node=last_node,
68
+ start_line=first_pos.start.line if first_pos else 0,
69
+ end_line=last_pos.end.line if last_pos else 0,
70
+ bindings=frozenset({func_name}),
71
+ immediate_refs=frozenset(),
72
+ deferred_refs=frozenset(),
73
+ is_overload_group=True,
74
+ ),
75
+ )
76
+ i = j
77
+ continue
78
+
79
+ # Not a complete overload group — fall through to normal handling
80
+
81
+ # Normal statement
82
+ pos = positions.get(node)
83
+ start_line = pos.start.line if pos else 0
84
+ end_line = pos.end.line if pos else 0
85
+
86
+ bindings = (
87
+ _extract_binding_names(node) if isinstance(node, cst.BaseStatement) else frozenset()
88
+ )
89
+ result.append(
90
+ Statement(
91
+ node=node,
92
+ start_line=start_line,
93
+ end_line=end_line,
94
+ bindings=bindings,
95
+ immediate_refs=frozenset(),
96
+ deferred_refs=frozenset(),
97
+ is_overload_group=False,
98
+ ),
99
+ )
100
+ i += 1
101
+
102
+ return result
103
+
104
+
105
+ def _extract_binding_names(node: cst.BaseStatement) -> frozenset[str]:
106
+ """Extract the names defined by a single statement."""
107
+ if isinstance(node, cst.FunctionDef):
108
+ return frozenset({node.name.value})
109
+
110
+ if isinstance(node, cst.ClassDef):
111
+ return frozenset({node.name.value})
112
+
113
+ if isinstance(node, cst.SimpleStatementLine):
114
+ names: set[str] = set()
115
+ for stmt in node.body:
116
+ if isinstance(stmt, cst.Assign):
117
+ for target in stmt.targets:
118
+ names |= _collect_names(target.target)
119
+ elif isinstance(stmt, cst.AnnAssign) and stmt.value is not None:
120
+ names |= _collect_names(stmt.target)
121
+ return frozenset(names)
122
+
123
+ return frozenset()
124
+
125
+
126
+ def _collect_names(target: cst.BaseExpression) -> set[str]:
127
+ """Recursively collect all Name identifiers from an assignment target."""
128
+ if isinstance(target, cst.Name):
129
+ return {target.value}
130
+ if isinstance(target, cst.Tuple):
131
+ names: set[str] = set()
132
+ for element in target.elements:
133
+ names |= _collect_names(element.value)
134
+ return names
135
+ if isinstance(target, cst.StarredElement):
136
+ return _collect_names(target.value)
137
+ return set()
138
+
139
+
140
+ def _has_overload_decorator(node: cst.FunctionDef) -> bool:
141
+ """Check if a FunctionDef has @typing.overload or @overload."""
142
+ for decorator in node.decorators:
143
+ dec = decorator.decorator
144
+ if m.matches(dec, m.Name("overload")):
145
+ return True
146
+ if m.matches(dec, m.Attribute(value=m.Name("typing"), attr=m.Name("overload"))):
147
+ return True
148
+ return False