java-functional-lsp 0.6.3__tar.gz → 0.7.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 (67) hide show
  1. java_functional_lsp-0.7.0/.githooks/pre-commit +41 -0
  2. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/PKG-INFO +1 -1
  3. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/pyproject.toml +1 -1
  4. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/src/java_functional_lsp/__init__.py +1 -1
  5. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/src/java_functional_lsp/analyzers/base.py +52 -0
  6. java_functional_lsp-0.7.0/src/java_functional_lsp/analyzers/exception_checker.py +218 -0
  7. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/src/java_functional_lsp/analyzers/functional_checker.py +37 -21
  8. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/src/java_functional_lsp/fixes.py +292 -1
  9. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/src/java_functional_lsp/proxy.py +50 -2
  10. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/src/java_functional_lsp/server.py +1 -0
  11. java_functional_lsp-0.7.0/tests/test_exception_checker.py +407 -0
  12. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/test_fixes.py +461 -0
  13. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/uv.lock +1 -1
  14. java_functional_lsp-0.6.3/.githooks/pre-commit +0 -35
  15. java_functional_lsp-0.6.3/src/java_functional_lsp/analyzers/exception_checker.py +0 -97
  16. java_functional_lsp-0.6.3/tests/test_exception_checker.py +0 -131
  17. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.claude-plugin/plugin.json +0 -0
  18. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.githooks/pre-push +0 -0
  19. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/CODEOWNERS +0 -0
  20. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
  21. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
  22. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  23. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/SECURITY.md +0 -0
  24. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/dependabot.yml +0 -0
  25. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/release-drafter.yml +0 -0
  26. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/workflows/publish.yml +0 -0
  27. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/workflows/release-drafter.yml +0 -0
  28. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/workflows/stale.yml +0 -0
  29. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/workflows/test.yml +0 -0
  30. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/workflows/update-homebrew.yml +0 -0
  31. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/workflows/vscode-ext.yml +0 -0
  32. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.gitignore +0 -0
  33. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/CONTRIBUTING.md +0 -0
  34. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/LICENSE +0 -0
  35. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/README.md +0 -0
  36. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/SKILL.md +0 -0
  37. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/commands/lint-java.md +0 -0
  38. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/editors/intellij/README.md +0 -0
  39. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/editors/intellij/lsp4ij-template.json +0 -0
  40. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/editors/vscode/.vscodeignore +0 -0
  41. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/editors/vscode/README.md +0 -0
  42. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/editors/vscode/package-lock.json +0 -0
  43. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/editors/vscode/package.json +0 -0
  44. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/editors/vscode/src/extension.ts +0 -0
  45. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/editors/vscode/tsconfig.json +0 -0
  46. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/hooks/hooks.json +0 -0
  47. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/hooks/java_linter_reminder.py +0 -0
  48. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/scripts/ensure-lsp.sh +0 -0
  49. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/scripts/generate-formula.py +0 -0
  50. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/src/java_functional_lsp/__main__.py +0 -0
  51. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/src/java_functional_lsp/analyzers/__init__.py +0 -0
  52. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
  53. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
  54. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
  55. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/src/java_functional_lsp/cli.py +0 -0
  56. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/__init__.py +0 -0
  57. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/conftest.py +0 -0
  58. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/test_base.py +0 -0
  59. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/test_cli.py +0 -0
  60. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/test_config.py +0 -0
  61. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/test_e2e.py +0 -0
  62. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/test_functional_checker.py +0 -0
  63. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/test_mutation_checker.py +0 -0
  64. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/test_null_checker.py +0 -0
  65. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/test_proxy.py +0 -0
  66. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/test_spring_checker.py +0 -0
  67. {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/test_suppress.py +0 -0
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env bash
2
+ # Pre-commit: run lint, format check, type check, and tests.
3
+ # Bypass with: git commit --no-verify
4
+
5
+ set -e
6
+
7
+ echo "Running pre-commit checks..."
8
+
9
+ echo " Lint..."
10
+ uv run ruff check src/ tests/
11
+
12
+ echo " Format..."
13
+ uv run ruff format --check src/ tests/
14
+
15
+ echo " Type check..."
16
+ uv run mypy src/
17
+
18
+ echo " Version bump check..."
19
+ # If source files changed, version must be bumped compared to the base branch.
20
+ # Compares the actual version value (not just file presence) so it works with --amend.
21
+ src_changed=$(git diff --cached --name-only -- 'src/' | grep -v '__pycache__' | head -1)
22
+ if [ -n "$src_changed" ]; then
23
+ # Resolve base branch: try remote main, then local main, then parent commit.
24
+ base_branch=$(git rev-parse --verify origin/main 2>/dev/null || git rev-parse --verify main 2>/dev/null || echo "HEAD~1")
25
+ # Extract the quoted version value (e.g., "0.6.4") from pyproject.toml
26
+ base_version=$(git show "$base_branch":pyproject.toml 2>/dev/null | grep -o 'version = "[^"]*"' | head -1)
27
+ staged_version=$(git show :pyproject.toml 2>/dev/null | grep -o 'version = "[^"]*"' | head -1)
28
+ # Skip check if either version is unresolvable (new project, missing file)
29
+ if [ -n "$base_version" ] && [ -n "$staged_version" ] && [ "$base_version" = "$staged_version" ]; then
30
+ echo "ERROR: Source files changed but version was not bumped."
31
+ echo "Update the version in both pyproject.toml and src/java_functional_lsp/__init__.py"
32
+ echo ""
33
+ echo "To bypass (docs/tests-only changes): git commit --no-verify"
34
+ exit 1
35
+ fi
36
+ fi
37
+
38
+ echo " Tests..."
39
+ uv run pytest -q
40
+
41
+ echo "All checks passed."
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: java-functional-lsp
3
- Version: 0.6.3
3
+ Version: 0.7.0
4
4
  Summary: Java LSP server enforcing functional programming best practices — null safety, immutability, no exceptions
5
5
  Project-URL: Homepage, https://github.com/aviadshiber/java-functional-lsp
6
6
  Project-URL: Repository, https://github.com/aviadshiber/java-functional-lsp
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "java-functional-lsp"
7
- version = "0.6.3"
7
+ version = "0.7.0"
8
8
  description = "Java LSP server enforcing functional programming best practices — null safety, immutability, no exceptions"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1,3 +1,3 @@
1
1
  """java-functional-lsp: A Java LSP server enforcing functional programming best practices."""
2
2
 
3
- __version__ = "0.6.3"
3
+ __version__ = "0.7.0"
@@ -49,6 +49,10 @@ class Analyzer(Protocol):
49
49
  ...
50
50
 
51
51
 
52
+ #: Tree-sitter child node types that should be skipped when iterating named_children.
53
+ #: Shared across analyzers and fix generators so comment-filtering stays consistent.
54
+ IGNORED_CHILDREN = ("line_comment", "block_comment")
55
+
52
56
  _parser: Parser | None = None
53
57
  _language: Language | None = None
54
58
 
@@ -144,6 +148,54 @@ def has_ancestor(node: Node, type_names: set[str]) -> bool:
144
148
  return False
145
149
 
146
150
 
151
+ def references_var(node: Node, var_name: bytes) -> bool:
152
+ """Return True if any identifier descendant of ``node`` has text equal to ``var_name``.
153
+
154
+ Used to detect whether an expression references a given variable (e.g. an
155
+ exception variable inside a catch-return expression). Uses a TreeCursor walk
156
+ for performance — no recursion, no allocation per node.
157
+ """
158
+ cursor = node.walk()
159
+ visited_children = False
160
+ while True:
161
+ if not visited_children:
162
+ cur = cursor.node
163
+ if cur is not None and cur.type == "identifier" and cur.text == var_name:
164
+ return True
165
+ if not cursor.goto_first_child():
166
+ visited_children = True
167
+ elif cursor.goto_next_sibling():
168
+ visited_children = False
169
+ elif not cursor.goto_parent():
170
+ break
171
+ return False
172
+
173
+
174
+ def has_error_or_missing(node: Node) -> bool:
175
+ """Return True if the subtree rooted at ``node`` contains any ERROR or MISSING nodes.
176
+
177
+ Used by fix generators as a defensive gate: tree-sitter's error recovery
178
+ produces partial trees for malformed input (common during incremental typing
179
+ in editors), and emitting a rewrite from such input can produce invalid edits.
180
+ """
181
+ if node.has_error or node.is_missing:
182
+ return True
183
+ cursor = node.walk()
184
+ visited_children = False
185
+ while True:
186
+ if not visited_children:
187
+ cur = cursor.node
188
+ if cur is not None and (cur.type == "ERROR" or cur.is_missing):
189
+ return True
190
+ if not cursor.goto_first_child():
191
+ visited_children = True
192
+ elif cursor.goto_next_sibling():
193
+ visited_children = False
194
+ elif not cursor.goto_parent():
195
+ break
196
+ return False
197
+
198
+
147
199
  def severity_from_config(config: dict[str, Any], rule_id: str, default: Severity = Severity.WARNING) -> Severity | None:
148
200
  """Get severity for a rule from config. Returns None if rule is disabled."""
149
201
  rules: dict[str, str] = config.get("rules", {})
@@ -0,0 +1,218 @@
1
+ """Exception handling rules: detect throw statements and catch-rethrow patterns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .base import (
8
+ IGNORED_CHILDREN,
9
+ Diagnostic,
10
+ DiagnosticData,
11
+ Severity,
12
+ collect_nodes_by_type,
13
+ has_sibling_annotation,
14
+ severity_from_config,
15
+ )
16
+
17
+ _MESSAGES = {
18
+ "throw-statement": ("Avoid throwing exceptions. Use Either.left(error) or Try.of(() -> ...).toEither()."),
19
+ "catch-rethrow": (
20
+ "Avoid catching and rethrowing. Use Try.of(() -> ...).toEither() to convert exceptions to values."
21
+ ),
22
+ "try-catch-to-monadic": ("Imperative try/catch: use Try.of(() -> ...) for monadic error handling."),
23
+ }
24
+
25
+ _DATA = {
26
+ "throw-statement": DiagnosticData(
27
+ fix_type="USE_EITHER_OR_TRY",
28
+ target_library="io.vavr.control.Either",
29
+ rationale=(
30
+ "Throwing exceptions breaks referential transparency."
31
+ " Use Either.left(error) to represent failures as values."
32
+ ),
33
+ ),
34
+ "catch-rethrow": DiagnosticData(
35
+ fix_type="USE_TRY_TO_EITHER",
36
+ target_library="io.vavr.control.Try",
37
+ rationale=(
38
+ "Catching and rethrowing adds noise. Use Try.of(() -> ...).toEither() to convert exceptions to values."
39
+ ),
40
+ ),
41
+ "try-catch-to-monadic": DiagnosticData(
42
+ fix_type="WRAP_IN_TRY",
43
+ target_library="io.vavr.control.Try",
44
+ rationale=(
45
+ "try/catch mixes control flow with value handling."
46
+ " Use Try.of(...) for composable, value-based failure handling."
47
+ ),
48
+ ),
49
+ }
50
+
51
+
52
+ def _is_in_bean_method(node: Any) -> bool:
53
+ """Check if node is inside a method annotated with @Bean."""
54
+ parent = node.parent
55
+ while parent:
56
+ if parent.type == "method_declaration":
57
+ modifiers = next((c for c in parent.children if c.type == "modifiers"), None)
58
+ if modifiers and has_sibling_annotation(modifiers, b"Bean"):
59
+ return True
60
+ return False
61
+ parent = parent.parent
62
+ return False
63
+
64
+
65
+ def _matches_try_catch_monadic_shape(try_node: Any) -> bool: # noqa: PLR0911, PLR0912
66
+ """Verify a try_statement has a shape the try-catch-to-monadic fix can rewrite.
67
+
68
+ Requirements:
69
+ - No ``resource_specification`` child (try-with-resources would lose auto-close semantics)
70
+ - No ``finally_clause`` child
71
+ - Exactly one ``catch_clause`` child
72
+ - Catch clause must use a single exception type (no union-catch ``A | B e``)
73
+ - Try body is a block with a single ``return_statement`` that has an expression
74
+ - Catch body is a block whose last named child is a ``return_statement`` with an expression,
75
+ and any prior named children are ``expression_statement`` (for the Pattern 2 logging case)
76
+
77
+ Guard-clause returns are preferred for readability; noqa silences the
78
+ "too many returns" rule since each branch fails a distinct shape check.
79
+
80
+ Note: This analyzer-side check must stay in sync with
81
+ ``_validate_and_extract_try_catch_parts`` in fixes.py — any shape the
82
+ analyzer accepts must also be rewritable by the fix, otherwise users
83
+ see a diagnostic with no working code action.
84
+ """
85
+ # Single pass over try_node.children: detect finally, resource_specification, and catches
86
+ has_finally = False
87
+ has_resources = False
88
+ catches: list[Any] = []
89
+ for c in try_node.children:
90
+ if c.type == "finally_clause":
91
+ has_finally = True
92
+ elif c.type == "resource_specification":
93
+ has_resources = True
94
+ elif c.type == "catch_clause":
95
+ catches.append(c)
96
+
97
+ if has_finally or has_resources:
98
+ return False
99
+ if len(catches) != 1:
100
+ return False
101
+
102
+ # Reject union-catch (A | B e). Tree-sitter encodes the type in catch_formal_parameter
103
+ # as either a single type node or a catch_type node containing multiple type_identifier children.
104
+ catch = catches[0]
105
+ param = next((c for c in catch.children if c.type == "catch_formal_parameter"), None)
106
+ if param is None:
107
+ return False
108
+ catch_type_node = next((c for c in param.children if c.type == "catch_type"), None)
109
+ if catch_type_node is not None and catch_type_node.text and b"|" in catch_type_node.text:
110
+ return False
111
+
112
+ # Try body must be a block with a single return_statement with an expression
113
+ body = try_node.child_by_field_name("body")
114
+ if body is None or body.type != "block":
115
+ return False
116
+ body_stmts = [c for c in body.named_children if c.type not in IGNORED_CHILDREN]
117
+ if len(body_stmts) != 1 or body_stmts[0].type != "return_statement":
118
+ return False
119
+ # Return must have an expression (not bare `return;`)
120
+ ret_children = [c for c in body_stmts[0].named_children if c.type not in IGNORED_CHILDREN]
121
+ if not ret_children:
122
+ return False
123
+
124
+ # Catch body must end with a return_statement; prior stmts must be expression_statements
125
+ catch_body = catch.child_by_field_name("body")
126
+ if catch_body is None or catch_body.type != "block":
127
+ return False
128
+ catch_stmts = [c for c in catch_body.named_children if c.type not in IGNORED_CHILDREN]
129
+ if not catch_stmts or catch_stmts[-1].type != "return_statement":
130
+ return False
131
+ if any(s.type != "expression_statement" for s in catch_stmts[:-1]):
132
+ return False
133
+ # The catch return must have an expression (not bare `return;`)
134
+ catch_ret_children = [c for c in catch_stmts[-1].named_children if c.type not in IGNORED_CHILDREN]
135
+ if not catch_ret_children:
136
+ return False
137
+
138
+ return True
139
+
140
+
141
+ class ExceptionChecker:
142
+ """Detects throw statements and catch-rethrow anti-patterns."""
143
+
144
+ def analyze(self, tree: Any, source: bytes, config: dict[str, Any]) -> list[Diagnostic]:
145
+ diagnostics: list[Diagnostic] = []
146
+
147
+ # Single tree walk collecting all three node types we care about.
148
+ # Previously this method did three separate find_nodes walks, which is 3x the traversal
149
+ # cost on every analysis pass (LSP re-runs on every document change).
150
+ buckets = collect_nodes_by_type(tree.root_node, {"throw_statement", "catch_clause", "try_statement"})
151
+
152
+ # Rule: throw-statement
153
+ severity = severity_from_config(config, "throw-statement")
154
+ if severity is not None:
155
+ for node in buckets["throw_statement"]:
156
+ if _is_in_bean_method(node):
157
+ continue
158
+ diagnostics.append(
159
+ Diagnostic(
160
+ line=node.start_point[0],
161
+ col=node.start_point[1],
162
+ end_line=node.end_point[0],
163
+ end_col=node.end_point[1],
164
+ severity=severity,
165
+ code="throw-statement",
166
+ message=_MESSAGES["throw-statement"],
167
+ data=_DATA["throw-statement"],
168
+ )
169
+ )
170
+
171
+ # Rule: catch-rethrow
172
+ severity = severity_from_config(config, "catch-rethrow")
173
+ if severity is not None:
174
+ for node in buckets["catch_clause"]:
175
+ if _is_in_bean_method(node):
176
+ continue
177
+ body = node.child_by_field_name("body")
178
+ if body is None:
179
+ continue
180
+ statements = [c for c in body.named_children if c.type not in IGNORED_CHILDREN]
181
+ if len(statements) == 1 and statements[0].type == "throw_statement":
182
+ diagnostics.append(
183
+ Diagnostic(
184
+ line=node.start_point[0],
185
+ col=node.start_point[1],
186
+ end_line=node.end_point[0],
187
+ end_col=node.end_point[1],
188
+ severity=severity,
189
+ code="catch-rethrow",
190
+ message=_MESSAGES["catch-rethrow"],
191
+ data=_DATA["catch-rethrow"],
192
+ )
193
+ )
194
+
195
+ # Rule: try-catch-to-monadic
196
+ severity = severity_from_config(config, "try-catch-to-monadic", default=Severity.HINT)
197
+ if severity is not None:
198
+ for try_node in buckets["try_statement"]:
199
+ if _is_in_bean_method(try_node):
200
+ continue
201
+ if not _matches_try_catch_monadic_shape(try_node):
202
+ continue
203
+ # Position the diagnostic on the `try` keyword only (narrow range).
204
+ try_kw = next((c for c in try_node.children if c.type == "try"), try_node)
205
+ diagnostics.append(
206
+ Diagnostic(
207
+ line=try_kw.start_point[0],
208
+ col=try_kw.start_point[1],
209
+ end_line=try_kw.end_point[0],
210
+ end_col=try_kw.end_point[1],
211
+ severity=severity,
212
+ code="try-catch-to-monadic",
213
+ message=_MESSAGES["try-catch-to-monadic"],
214
+ data=_DATA["try-catch-to-monadic"],
215
+ )
216
+ )
217
+
218
+ return diagnostics
@@ -130,6 +130,37 @@ _SIDE_EFFECT_METHODS = {
130
130
  _METHOD_SCOPES = {"method_declaration", "constructor_declaration", "lambda_expression"}
131
131
 
132
132
 
133
+ def is_side_effect_invocation(invocation: Node) -> bool:
134
+ """Check if a method_invocation node is a side-effect call.
135
+
136
+ Recognizes patterns like:
137
+ - ``System.out.println(...)`` / ``System.err.println(...)``
138
+ - ``logger.info(...)`` / ``log.debug(...)`` / ``LOG.warn(...)``
139
+ - Bare side-effect method names (e.g. ``println(...)``)
140
+ """
141
+ obj_node = invocation.child_by_field_name("object")
142
+ method_name = invocation.child_by_field_name("name")
143
+ if method_name is None:
144
+ return False
145
+
146
+ if obj_node is not None:
147
+ # System.out.println / System.err.println
148
+ if obj_node.type == "field_access":
149
+ receiver = obj_node.child_by_field_name("object")
150
+ if receiver is not None and receiver.text in _SIDE_EFFECT_RECEIVERS:
151
+ return True
152
+ # logger.info, log.debug, etc.
153
+ if obj_node.type == "identifier" and obj_node.text in _SIDE_EFFECT_RECEIVERS:
154
+ if method_name.text in _SIDE_EFFECT_METHODS:
155
+ return True
156
+
157
+ # Standalone side-effect method names
158
+ if method_name.text in _SIDE_EFFECT_METHODS and obj_node is None:
159
+ return True
160
+
161
+ return False
162
+
163
+
133
164
  class FunctionalChecker:
134
165
  """Detects frozen mutation traps, imperative null checks, and impure methods."""
135
166
 
@@ -357,25 +388,10 @@ class FunctionalChecker:
357
388
 
358
389
  @staticmethod
359
390
  def _is_side_effect_invocation(invocation: Node) -> bool:
360
- """Check if a method_invocation node is a side-effect call."""
361
- obj_node = invocation.child_by_field_name("object")
362
- method_name = invocation.child_by_field_name("name")
363
- if method_name is None:
364
- return False
365
-
366
- if obj_node is not None:
367
- # System.out.println / System.err.println
368
- if obj_node.type == "field_access":
369
- receiver = obj_node.child_by_field_name("object")
370
- if receiver is not None and receiver.text in _SIDE_EFFECT_RECEIVERS:
371
- return True
372
- # logger.info, log.debug, etc.
373
- if obj_node.type == "identifier" and obj_node.text in _SIDE_EFFECT_RECEIVERS:
374
- if method_name.text in _SIDE_EFFECT_METHODS:
375
- return True
376
-
377
- # Standalone side-effect method names
378
- if method_name.text in _SIDE_EFFECT_METHODS and obj_node is None:
379
- return True
391
+ """Check if a method_invocation node is a side-effect call.
380
392
 
381
- return False
393
+ Thin delegator kept for backward compatibility; the real logic lives in
394
+ the module-level ``is_side_effect_invocation`` so other modules (e.g.
395
+ ``fixes.py``) can reuse it without importing the class.
396
+ """
397
+ return is_side_effect_invocation(invocation)