refactrail 0.3.1a0__py3-none-any.whl

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 (51) hide show
  1. refactrail/__init__.py +32 -0
  2. refactrail/__main__.py +6 -0
  3. refactrail/_version.py +3 -0
  4. refactrail/analysis_cli.py +82 -0
  5. refactrail/cli.py +227 -0
  6. refactrail/config.py +136 -0
  7. refactrail/correctness.py +94 -0
  8. refactrail/correctness_batch.py +145 -0
  9. refactrail/correctness_checks.py +228 -0
  10. refactrail/data/generic_names.txt +19 -0
  11. refactrail/data/protocol_hooks.txt +14 -0
  12. refactrail/data/verbs.txt +484 -0
  13. refactrail/discovery.py +62 -0
  14. refactrail/engine.py +389 -0
  15. refactrail/facts.py +321 -0
  16. refactrail/fixes.py +196 -0
  17. refactrail/fixing.py +190 -0
  18. refactrail/format_operators.py +97 -0
  19. refactrail/format_statements.py +662 -0
  20. refactrail/format_tokens.py +144 -0
  21. refactrail/format_wrapping.py +392 -0
  22. refactrail/formatting.py +304 -0
  23. refactrail/general_cli.py +193 -0
  24. refactrail/json_spans.py +97 -0
  25. refactrail/lexical.py +346 -0
  26. refactrail/lexical_scopes.py +263 -0
  27. refactrail/models.py +145 -0
  28. refactrail/notebooks.py +188 -0
  29. refactrail/project_index.py +268 -0
  30. refactrail/py.typed +0 -0
  31. refactrail/rename.py +269 -0
  32. refactrail/report.py +98 -0
  33. refactrail/rules/__init__.py +51 -0
  34. refactrail/rules/annotations.py +61 -0
  35. refactrail/rules/context.py +160 -0
  36. refactrail/rules/docs.py +287 -0
  37. refactrail/rules/layout.py +216 -0
  38. refactrail/rules/naming.py +284 -0
  39. refactrail/rules/signatures.py +103 -0
  40. refactrail/rules/size.py +55 -0
  41. refactrail/sarif.py +61 -0
  42. refactrail/scopes.py +430 -0
  43. refactrail/source.py +176 -0
  44. refactrail/validation.py +26 -0
  45. refactrail/wordlists.py +20 -0
  46. refactrail-0.3.1a0.dist-info/METADATA +233 -0
  47. refactrail-0.3.1a0.dist-info/RECORD +51 -0
  48. refactrail-0.3.1a0.dist-info/WHEEL +5 -0
  49. refactrail-0.3.1a0.dist-info/entry_points.txt +2 -0
  50. refactrail-0.3.1a0.dist-info/licenses/LICENSE +21 -0
  51. refactrail-0.3.1a0.dist-info/top_level.txt +1 -0
refactrail/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """RefacTrail: check and safely refactor Python against a rule profile."""
2
+
3
+ from refactrail._version import __version__
4
+ from refactrail.config import load_settings
5
+ from refactrail.correctness import check_correctness_list
6
+ from refactrail.engine import check_paths_list, check_text_list
7
+ from refactrail.formatting import (
8
+ FormatPlan, format_source_str, plan_format, write_format_none,
9
+ )
10
+ from refactrail.models import Finding, Settings
11
+ from refactrail.lexical import analyze_lexical_dict
12
+ from refactrail.project_index import analyze_project_dict
13
+ from refactrail.rename import plan_rename_dict
14
+ from refactrail.correctness_batch import check_correctness_paths_list
15
+
16
+ __all__ = [
17
+ "analyze_lexical_dict",
18
+ "analyze_project_dict",
19
+ "plan_rename_dict",
20
+ "check_correctness_paths_list",
21
+ "Finding",
22
+ "FormatPlan",
23
+ "Settings",
24
+ "__version__",
25
+ "check_paths_list",
26
+ "check_text_list",
27
+ "load_settings",
28
+ "check_correctness_list",
29
+ "format_source_str",
30
+ "plan_format",
31
+ "write_format_none",
32
+ ]
refactrail/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow python -m refactrail."""
2
+
3
+ from refactrail.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
refactrail/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """RefacTrail version."""
2
+
3
+ __version__ = "0.3.1a0"
@@ -0,0 +1,82 @@
1
+ """Thin interfaces for partial scope, project and local rename evidence."""
2
+
3
+ import argparse
4
+ from hashlib import sha256
5
+ import json
6
+ from pathlib import Path
7
+ import sys
8
+
9
+ from refactrail.lexical import analyze_lexical_dict
10
+ from refactrail.project_index import analyze_project_dict
11
+ from refactrail.rename import plan_rename_dict
12
+ from refactrail.source import decode_source_text
13
+
14
+
15
+ def add_analysis_commands_none(commands_info: argparse._SubParsersAction
16
+ ) -> None:
17
+ """Register source-linked analysis and read-only rename commands.
18
+
19
+ Args:
20
+ commands_info (argparse._SubParsersAction): Main command registry.
21
+ Returns:
22
+ None: Adds scope, index and rename subcommands.
23
+ Warnings:
24
+ The rename command produces a review proposal without applying it.
25
+ """
26
+ scope_parser = commands_info.add_parser(
27
+ "scope", help="Inspect lexical evidence")
28
+ scope_parser.add_argument("path")
29
+ index_parser = commands_info.add_parser(
30
+ "index", help="Inspect project import impact")
31
+ index_parser.add_argument(
32
+ "root", help="Directory used as Python import root")
33
+ index_parser.add_argument("--changed", action="append", default=[])
34
+ rename_parser = commands_info.add_parser(
35
+ "rename", help="Preview a local variable rename")
36
+ rename_parser.add_argument("path")
37
+ rename_parser.add_argument("--function", required=True)
38
+ rename_parser.add_argument("--old", required=True)
39
+ rename_parser.add_argument("--new", required=True)
40
+
41
+
42
+ def read_scope_report_dict(path_str: str) -> dict:
43
+ """Read compiler evidence with an original-source fingerprint.
44
+
45
+ Args:
46
+ path_str (str): Regular Python source path.
47
+ Returns:
48
+ dict: Versioned partial scope report.
49
+ Warnings:
50
+ Notebook kernel state and unsupported encodings are not inferred.
51
+ """
52
+ path_info = Path(path_str)
53
+ if path_info.is_symlink() or path_info.is_junction():
54
+ raise ValueError("Linked scope inputs are unsupported")
55
+ raw_bytes = path_info.read_bytes()
56
+ text_str = decode_source_text(raw_bytes)
57
+ if text_str is None:
58
+ raise ValueError("Scope input must be UTF-8 Python source")
59
+ report_dict = analyze_lexical_dict(text_str, path_str)
60
+ return {"schema_version": "refactrail-scope-1", "path": path_str,
61
+ "source_sha256": sha256(raw_bytes).hexdigest(), **report_dict}
62
+
63
+
64
+ def run_analysis_int(arguments: argparse.Namespace) -> int:
65
+ """Render exactly one JSON document from the selected analysis API.
66
+
67
+ Args:
68
+ arguments (argparse.Namespace): Parsed source and query options.
69
+ Returns:
70
+ int: Zero for completed partial analysis; errors propagate.
71
+ Warnings:
72
+ Coverage and unresolved semantics are explicit report fields.
73
+ """
74
+ if arguments.command == "scope":
75
+ report_dict = read_scope_report_dict(arguments.path)
76
+ elif arguments.command == "index":
77
+ report_dict = analyze_project_dict(arguments.root, arguments.changed)
78
+ else:
79
+ report_dict = plan_rename_dict(arguments.path, arguments.function,
80
+ arguments.old, arguments.new)
81
+ sys.stdout.write(json.dumps(report_dict, indent=2) + "\n")
82
+ return 0
refactrail/cli.py ADDED
@@ -0,0 +1,227 @@
1
+ """Command line: refactrail check / rules / version."""
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+ import sys
6
+
7
+ from refactrail._version import __version__
8
+ from refactrail.analysis_cli import (
9
+ add_analysis_commands_none, run_analysis_int,
10
+ )
11
+ from refactrail.config import PROFILES_TUPLE, load_settings
12
+ from refactrail.correctness import CORRECTNESS_TITLES_DICT
13
+ from refactrail.discovery import discover_python_files_list
14
+ from refactrail.engine import (
15
+ CACHE_FOLDER_STR, ENGINES_TUPLE, check_paths_list,
16
+ )
17
+ from refactrail.fixing import fix_file, render_diff_str, write_fix_none
18
+ from refactrail.general_cli import (
19
+ add_general_commands_none, run_format_int, run_lint_int,
20
+ )
21
+ from refactrail.report import (
22
+ OUTPUT_FORMATS_TUPLE, render_github_str, render_json_str,
23
+ render_statistics_str, render_text_str,
24
+ )
25
+ from refactrail.rules import RULE_TITLES_DICT
26
+ from refactrail.sarif import render_sarif_str
27
+
28
+
29
+ QUICK_START_STR = """\
30
+ RefacTrail - the Python refactorizer. Paths default to the current folder.
31
+
32
+ refactrail lint
33
+ find likely bugs (comparisons with None, duplicate keys, ...)
34
+ refactrail format --diff
35
+ show formatting changes; use --write to apply them
36
+ refactrail check
37
+ check structure, naming, type hints and docstrings
38
+ refactrail fix --diff
39
+ preview safe fixes; run without --diff to apply them
40
+ refactrail rules
41
+ list every rule code
42
+
43
+ Run "refactrail COMMAND --help" for options or "refactrail --help" for all
44
+ commands.
45
+ """
46
+
47
+
48
+ def add_settings_options_none(parser: argparse.ArgumentParser) -> None:
49
+ """Add the paths and settings options shared by check and fix.
50
+
51
+ Args:
52
+ parser (argparse.ArgumentParser): Sub-command parser.
53
+ Returns:
54
+ None: Adds arguments in place.
55
+ Warnings:
56
+ None.
57
+ """
58
+ parser.add_argument("paths", nargs="*", default=["."])
59
+ parser.add_argument("--profile", choices=PROFILES_TUPLE)
60
+ parser.add_argument("--line-length", type=int)
61
+ parser.add_argument("--select", help="Comma-separated codes")
62
+ parser.add_argument("--ignore", help="Comma-separated codes")
63
+
64
+
65
+ def build_parser() -> argparse.ArgumentParser:
66
+ """Define the command-line interface.
67
+
68
+ Args:
69
+ None: The interface is fixed for this version.
70
+ Returns:
71
+ argparse.ArgumentParser: Parser with the check and rules commands.
72
+ Warnings:
73
+ Option names follow Ruff where the meaning is the same.
74
+ """
75
+ parser = argparse.ArgumentParser(
76
+ prog="refactrail", description="Check Python code against a "
77
+ "refactoring rule profile.")
78
+ parser.add_argument("--version", action="version", version=__version__)
79
+ commands = parser.add_subparsers(dest="command", required=True)
80
+ check_parser = commands.add_parser("check", help="Report findings")
81
+ add_settings_options_none(check_parser)
82
+ check_parser.add_argument("--output-format", default="text",
83
+ choices=OUTPUT_FORMATS_TUPLE)
84
+ check_parser.add_argument("--statistics", action="store_true")
85
+ check_parser.add_argument("--exit-zero", action="store_true")
86
+ check_parser.add_argument("--no-cache", action="store_true")
87
+ check_parser.add_argument("--jobs", type=int, default=0,
88
+ help="Worker processes (0: one per core)")
89
+ check_parser.add_argument("--engine", default="auto",
90
+ choices=ENGINES_TUPLE,
91
+ help="auto uses Rust when installed")
92
+ fix_parser = commands.add_parser(
93
+ "fix", help="Apply bounded refactoring edits")
94
+ add_settings_options_none(fix_parser)
95
+ fix_parser.add_argument("--diff", action="store_true",
96
+ help="Show the fixes; do not write files")
97
+ commands.add_parser("rules", help="List RT style and RC correctness codes")
98
+ add_general_commands_none(commands)
99
+ add_analysis_commands_none(commands)
100
+ return parser
101
+
102
+
103
+ def run_fix_int(arguments: argparse.Namespace) -> int:
104
+ """Run the fix command: write fixes, or print them with --diff.
105
+
106
+ Args:
107
+ arguments (argparse.Namespace): Parsed options.
108
+ Returns:
109
+ int: With --diff, 1 when changes are pending; otherwise 1 when
110
+ findings remain after fixing, else 0.
111
+ Warnings:
112
+ Files are changed in place unless --diff is given.
113
+ """
114
+ if getattr(arguments, "jobs", 0) < 0:
115
+ raise ValueError("jobs must be nonnegative")
116
+ settings_info = load_settings(Path(arguments.paths[0]), {
117
+ "profile": arguments.profile, "line_length": arguments.line_length,
118
+ "select": split_codes_tuple(arguments.select),
119
+ "ignore": split_codes_tuple(arguments.ignore)})
120
+ files_list = discover_python_files_list(arguments.paths, settings_info)
121
+ fixes_list = [fix_file(path_str, settings_info)
122
+ for path_str in files_list]
123
+ for file_fix in fixes_list:
124
+ if file_fix.skip_reason:
125
+ sys.stderr.write(f"{file_fix.path}: skipped: "
126
+ f"{file_fix.skip_reason}\n")
127
+ elif arguments.diff:
128
+ sys.stdout.write(render_diff_str(file_fix))
129
+ elif file_fix.changed_bool:
130
+ write_fix_none(file_fix)
131
+ sys.stdout.write("".join(f"{file_fix.path}: {line_str}\n" for
132
+ line_str in file_fix.outcome.applied))
133
+ changed_int = sum(file_fix.changed_bool for file_fix in fixes_list)
134
+ skipped_bool = any(fix_info.skip_reason for fix_info in fixes_list)
135
+ if arguments.diff:
136
+ sys.stdout.write(f"{changed_int} file(s) would be changed.\n")
137
+ return int(bool(changed_int) or skipped_bool)
138
+ remaining_list = check_paths_list(files_list, settings_info)
139
+ sys.stdout.write(f"Fixed {changed_int} file(s); {len(remaining_list)} "
140
+ "finding(s) remain for the selected rules (run "
141
+ "'refactrail check').\n")
142
+ return int(bool(remaining_list) or skipped_bool)
143
+
144
+
145
+ def split_codes_tuple(codes_str: str | None) -> tuple[str, ...] | None:
146
+ """Turn "RT1,RT201" into a tuple of codes.
147
+
148
+ Args:
149
+ codes_str (str | None): Comma-separated codes, or None.
150
+ Returns:
151
+ tuple[str, ...] | None: Codes, or None when not given.
152
+ Warnings:
153
+ Codes are upper-cased.
154
+ """
155
+ if codes_str is None:
156
+ return None
157
+ return tuple(code_str.strip().upper() for code_str in codes_str.split(",")
158
+ if code_str.strip())
159
+
160
+
161
+ def run_check_int(arguments: argparse.Namespace) -> int:
162
+ """Run the check command and print its report.
163
+
164
+ Args:
165
+ arguments (argparse.Namespace): Parsed options.
166
+ Returns:
167
+ int: 0 without findings (or with --exit-zero), 1 with findings.
168
+ Warnings:
169
+ Settings come from the pyproject.toml nearest the first path.
170
+ """
171
+ if getattr(arguments, "jobs", 0) < 0:
172
+ raise ValueError("jobs must be nonnegative")
173
+ settings_info = load_settings(Path(arguments.paths[0]), {
174
+ "profile": arguments.profile, "line_length": arguments.line_length,
175
+ "select": split_codes_tuple(arguments.select),
176
+ "ignore": split_codes_tuple(arguments.ignore)})
177
+ files_list = discover_python_files_list(arguments.paths, settings_info)
178
+ cache_path = None if arguments.no_cache else (
179
+ Path.cwd() / CACHE_FOLDER_STR / "results-v1.json")
180
+ findings_list = check_paths_list(files_list, settings_info,
181
+ arguments.jobs, cache_path,
182
+ arguments.engine)
183
+ renderers_dict = {
184
+ "text": lambda: render_text_str(findings_list, len(files_list)),
185
+ "json": lambda: render_json_str(findings_list),
186
+ "github": lambda: render_github_str(findings_list),
187
+ "sarif": lambda: render_sarif_str(findings_list),
188
+ }
189
+ sys.stdout.write(renderers_dict[arguments.output_format]())
190
+ if arguments.statistics:
191
+ sys.stderr.write(render_statistics_str(findings_list))
192
+ return 0 if arguments.exit_zero or not findings_list else 1
193
+
194
+
195
+ def main(argv_list: list[str] | None = None) -> int:
196
+ """Run the command line and return the exit status.
197
+
198
+ Args:
199
+ argv_list (list[str] | None): Arguments; None reads sys.argv.
200
+ Returns:
201
+ int: 0 success, 1 findings, 2 usage or configuration error.
202
+ Warnings:
203
+ Configuration and path errors are printed, not raised.
204
+ """
205
+ if not (sys.argv[1:] if argv_list is None else argv_list):
206
+ sys.stdout.write(QUICK_START_STR)
207
+ return 0
208
+ arguments = build_parser().parse_args(argv_list)
209
+ try:
210
+ if arguments.command in ("scope", "index", "rename"):
211
+ return run_analysis_int(arguments)
212
+ if arguments.command == "lint":
213
+ return run_lint_int(arguments)
214
+ if arguments.command == "format":
215
+ return run_format_int(arguments)
216
+ if arguments.command == "fix":
217
+ return run_fix_int(arguments)
218
+ if arguments.command == "rules":
219
+ sys.stdout.write("".join(f"{code_str} {title_str}\n" for
220
+ code_str, title_str in
221
+ {**RULE_TITLES_DICT,
222
+ **CORRECTNESS_TITLES_DICT}.items()))
223
+ return 0
224
+ return run_check_int(arguments)
225
+ except (ValueError, OSError, SyntaxError) as error:
226
+ sys.stderr.write(f"refactrail: error: {error}\n")
227
+ return 2
refactrail/config.py ADDED
@@ -0,0 +1,136 @@
1
+ """Load RefacTrail settings from pyproject.toml and command-line options."""
2
+
3
+ from dataclasses import fields, replace
4
+ from pathlib import Path
5
+ import tomllib
6
+
7
+ from refactrail.models import Settings
8
+
9
+ PROFILES_TUPLE = ("standard", "strict")
10
+ INTEGER_KEYS_TUPLE = (
11
+ "line_length", "function_preferred_lines", "function_max_lines",
12
+ "main_max_lines",
13
+ )
14
+ LIST_KEYS_TUPLE = ("select", "ignore", "exclude")
15
+
16
+
17
+ def find_pyproject_path(start_path: Path) -> Path | None:
18
+ """Find the nearest pyproject.toml at or above a folder.
19
+
20
+ Args:
21
+ start_path (Path): File or folder where the search begins.
22
+ Returns:
23
+ Path | None: The configuration file, or None when there is none.
24
+ Warnings:
25
+ The search stops at the file system root.
26
+ """
27
+ folder_path = start_path.resolve()
28
+ if folder_path.is_file():
29
+ folder_path = folder_path.parent
30
+ for candidate_path in (folder_path, *folder_path.parents):
31
+ pyproject_path = candidate_path / "pyproject.toml"
32
+ if pyproject_path.is_file():
33
+ return pyproject_path
34
+ return None
35
+
36
+
37
+ def read_tool_table_dict(pyproject_path: Path | None) -> dict:
38
+ """Read the [tool.refactrail] table of a pyproject.toml file.
39
+
40
+ Args:
41
+ pyproject_path (Path | None): File to read, or None.
42
+ Returns:
43
+ dict: Table with dashes turned into underscores; empty if absent.
44
+ Warnings:
45
+ Invalid TOML raises ValueError with the file name.
46
+ """
47
+ if pyproject_path is None:
48
+ return {}
49
+ try:
50
+ document_dict = tomllib.loads(
51
+ pyproject_path.read_text(encoding="utf-8"))
52
+ except tomllib.TOMLDecodeError as error:
53
+ raise ValueError(f"{pyproject_path}: invalid TOML: {error}") from error
54
+ table_dict = document_dict.get("tool", {}).get("refactrail", {})
55
+ if not isinstance(table_dict, dict):
56
+ raise ValueError("tool.refactrail must be a table")
57
+ return {key_str.replace("-", "_"): setting_value
58
+ for key_str, setting_value in table_dict.items()}
59
+
60
+
61
+ def build_settings(
62
+ table_dict: dict, overrides_dict: dict | None = None,
63
+ ) -> Settings:
64
+ """Validate configuration values and build Settings.
65
+ Args:
66
+ table_dict (dict): Values from pyproject.toml.
67
+ overrides_dict (dict | None): Command-line values, which win.
68
+ Returns:
69
+ Settings: Validated settings.
70
+ Warnings:
71
+ Unknown keys and invalid values raise ValueError naming them.
72
+ """
73
+ given_dict = {key_str: setting_value for key_str, setting_value
74
+ in (overrides_dict or {}).items()
75
+ if setting_value is not None}
76
+ merged_dict = {**table_dict, **given_dict}
77
+ known_set = {field_info.name for field_info in fields(Settings)}
78
+ unknown_list = sorted(set(merged_dict) - known_set)
79
+ if unknown_list:
80
+ raise ValueError(f"Unknown setting(s): {', '.join(unknown_list)}")
81
+ if merged_dict.get("profile", "standard") not in PROFILES_TUPLE:
82
+ raise ValueError(f"Profile must be one of {PROFILES_TUPLE}")
83
+ for key_str in INTEGER_KEYS_TUPLE:
84
+ setting_value = merged_dict.get(key_str)
85
+ if setting_value is not None and (
86
+ not isinstance(setting_value, int)
87
+ or isinstance(setting_value, bool) or setting_value < 1
88
+ ):
89
+ raise ValueError(f"{key_str} must be a positive integer")
90
+ validate_limits_none(merged_dict)
91
+ for key_str in LIST_KEYS_TUPLE:
92
+ if key_str in merged_dict:
93
+ values_list = merged_dict[key_str]
94
+ if not isinstance(values_list, (list, tuple)) or not all(
95
+ isinstance(entry_str, str) and entry_str.strip()
96
+ for entry_str in values_list
97
+ ):
98
+ raise ValueError(f"{key_str} must be a list of strings")
99
+ merged_dict[key_str] = tuple(values_list)
100
+ return replace(Settings(), **merged_dict)
101
+
102
+
103
+ def load_settings(
104
+ start_path: Path, overrides_dict: dict | None = None,
105
+ ) -> Settings:
106
+ """Load settings for a path from its nearest pyproject.toml.
107
+
108
+ Args:
109
+ start_path (Path): File or folder being checked.
110
+ overrides_dict (dict | None): Command-line values.
111
+ Returns:
112
+ Settings: Validated settings.
113
+ Warnings:
114
+ Command-line values override the file.
115
+ """
116
+ return build_settings(
117
+ read_tool_table_dict(find_pyproject_path(start_path)),
118
+ overrides_dict)
119
+
120
+
121
+ def validate_limits_none(settings_dict: dict) -> None:
122
+ """Validate related size limits before constructing settings.
123
+
124
+ Args:
125
+ settings_dict (dict): Validated integer settings.
126
+ Returns:
127
+ None: Raises ValueError for inconsistent limits.
128
+ Warnings:
129
+ Width matches the FuncLoom API used by the fix command.
130
+ """
131
+ if not 40 <= settings_dict.get("line_length", 79) <= 200:
132
+ raise ValueError("line_length must be from 40 to 200")
133
+ if settings_dict.get("function_preferred_lines", 40) > (
134
+ settings_dict.get("function_max_lines", 50)
135
+ ):
136
+ raise ValueError("Preferred function size exceeds maximum")
@@ -0,0 +1,94 @@
1
+ """General correctness analysis independent of RefacTrail style profiles."""
2
+
3
+ import ast
4
+
5
+ from refactrail.correctness_checks import (
6
+ FUNCTION_NODES_TUPLE, check_comparison_none, check_defaults_none,
7
+ check_dictionary_none, check_finally_none, check_statement_lists_none,
8
+ report_node_none,
9
+ )
10
+ from refactrail.engine import parse_quietly_node
11
+ from refactrail.models import Finding, Settings
12
+ from refactrail.lexical import check_lexical_none
13
+ from refactrail.rules.context import RuleContext
14
+ from refactrail.source import build_source_file, decode_source_text
15
+
16
+ CORRECTNESS_TITLES_DICT = {
17
+ "RC201": "Unresolved lexical name",
18
+ "RC202": "Import without lexical use",
19
+ "RC101": "Mutable default argument",
20
+ "RC102": "Duplicate literal dictionary key",
21
+ "RC103": "Identity comparison with a non-singleton literal",
22
+ "RC104": "Bare except handler",
23
+ "RC105": "Unreachable statement after a direct terminator",
24
+ "RC106": "Nonempty tuple assertion",
25
+ "RC107": "Control-flow exit from finally",
26
+ }
27
+
28
+
29
+ def check_node_none(context_info: RuleContext, node: ast.AST) -> None:
30
+ """Dispatch independent checks during a shared syntax traversal.
31
+
32
+ Args:
33
+ context_info (RuleContext): Findings and suppression state.
34
+ node (ast.AST): Current syntax node.
35
+ Returns:
36
+ None: Adds applicable RC observations.
37
+ Warnings:
38
+ Checks are syntax evidence, not whole-program proofs.
39
+ """
40
+ if isinstance(node, FUNCTION_NODES_TUPLE):
41
+ check_defaults_none(context_info, node)
42
+ if isinstance(node, ast.Dict):
43
+ check_dictionary_none(context_info, node)
44
+ if isinstance(node, ast.Compare):
45
+ check_comparison_none(context_info, node)
46
+ if isinstance(node, ast.ExceptHandler) and node.type is None:
47
+ report_node_none(context_info, node, "RC104",
48
+ "Bare except also catches process-control "
49
+ "exceptions such as KeyboardInterrupt.")
50
+ if isinstance(node, ast.Assert) and isinstance(node.test, ast.Tuple):
51
+ if node.test.elts and not any(isinstance(element, ast.Starred)
52
+ for element in node.test.elts):
53
+ report_node_none(context_info, node.test, "RC106",
54
+ "A nonempty tuple assertion tests the tuple's "
55
+ "truthiness, not its first element.")
56
+ if isinstance(node, (ast.Try, ast.TryStar)):
57
+ check_finally_none(context_info, node)
58
+ check_statement_lists_none(context_info, node)
59
+
60
+
61
+ def check_correctness_list(
62
+ path_str: str, raw_bytes: bytes,
63
+ select_tuple: tuple[str, ...] = ("RC",),
64
+ ignore_tuple: tuple[str, ...] = (),
65
+ ) -> list[Finding]:
66
+ """Check general correctness without importing or executing the source.
67
+
68
+ Args:
69
+ path_str (str): Source label for findings.
70
+ raw_bytes (bytes): Original UTF-8 source, optionally with a BOM.
71
+ select_tuple (tuple[str, ...]): Enabled rule prefixes.
72
+ ignore_tuple (tuple[str, ...]): Disabled ordinary rule prefixes.
73
+ Returns:
74
+ list[Finding]: Sorted observations or an unsuppressible input error.
75
+ Warnings:
76
+ This independent engine does not infer domain intent or runtime types.
77
+ """
78
+ settings_info = Settings(select=select_tuple, ignore=ignore_tuple)
79
+ text_str = decode_source_text(raw_bytes)
80
+ if text_str is None:
81
+ return [Finding(path_str, 1, 1, "RT002", "error",
82
+ "File is not UTF-8 or declares another encoding.")]
83
+ try:
84
+ tree_node = parse_quietly_node(text_str, path_str)
85
+ except (SyntaxError, ValueError) as error:
86
+ return [Finding(path_str, getattr(error, "lineno", None) or 1,
87
+ getattr(error, "offset", None) or 1, "RT001",
88
+ "error", f"Syntax error: {error}")]
89
+ context_info = RuleContext(build_source_file(path_str, text_str),
90
+ tree_node, settings_info)
91
+ for node in ast.walk(tree_node):
92
+ check_node_none(context_info, node)
93
+ check_lexical_none(context_info)
94
+ return sorted(set(context_info.findings_list))