8000 feat: report unused Java private members by renanfranca · Pull Request #165 · habit-hooks/habit-hooks · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
An unused field, method, or property is dead surface that makes a type appear to
have responsibilities and collaborations it does not exercise. It asks readers
to understand and maintain behavior that may not exist.

Investigate every occurrence before changing it. Static analysis may not see
reflection, annotations or decorators, serialization, framework lifecycle
hooks, dependency injection, generated callers, or other dynamic uses. When one
of those mechanisms owns the call, preserve the member and make that use legible
to the detector or future readers.

Once those hidden uses are ruled out, choose the design that makes the type
honest: connect an intended caller, extract a cohesive group of members into a
real unit, or remove a member confirmed to be dead together with code that
exists only to support it. Do not remove a member automatically from this
report alone.

The unused members:

{% include "includes/line_level_issues.md" %}
33 changes: 33 additions & 0 deletions plugins/java/docs/java-plugin.spec.md
Comment thread
renanfranca marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,39 @@ habit-sensors --all | jq 'sort_by(.smell)[] | {smell, language, key: (.issues[0]
}
```

## bundled PMD reports unused private fields and methods

The fallback ruleset enables PMD's `UnusedPrivateField` and
`UnusedPrivateMethod` rules. Both translate to one `unused-class-member`
finding. The referenced field and method are controls: PMD does not report them.

📄Members.java
```java
class Members {
private int staleField = 1;
private int liveField = 2;
private void staleMethod() {}
private int liveMethod() { return liveField; }
int value() { return liveMethod(); }
}
```

```bash
habit-sensors --all | jq '.[] | select(.smell == "unused-class-member") | {smell, language, sources: [.issues[].details.source]}'
```

🖥️ ✅
```json
{
"smell": "unused-class-member",
"language": "java",
"sources": [
"pmd:UnusedPrivateField",
"pmd:UnusedPrivateMethod"
]
}
```

## pmd sensor reports the third nested if from the bundled ruleset

With no PMD ruleset in the project, the plugin's bundled fallback explicitly
Expand Down
2 changes: 2 additions & 0 deletions plugins/java/src/habit_hooks_java/sensors/pmd-ruleset.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
<properties><property name="methodReportLevel" value="12"/></properties>
</rule>
<rule ref="category/java/bestpractices.xml/UnusedLocalVariable"/>
<rule ref="category/java/bestpractices.xml/UnusedPrivateField"/>
<rule ref="category/java/bestpractices.xml/UnusedPrivateMethod"/>
<rule ref="category/java/codestyle.xml/UnnecessaryImport"/>
<rule ref="category/java/errorprone.xml/EmptyCatchBlock"/>
</ruleset>
34 changes: 26 additions & 8 deletions plugins/java/src/habit_hooks_java/sensors/pmd_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
"CyclomaticComplexity": "high-complexity",
"NcssCount": "oversized-function",
"UnusedLocalVariable": "unused-variable",
"UnusedPrivateField": "unused-class-member",
"UnusedPrivateMethod": "unused-class-member",
"UnnecessaryImport": "unused-import",
"EmptyCatchBlock": "swallowed-exception",
}
Expand Down Expand Up @@ -105,16 +107,32 @@ def smell_of(entry: dict) -> str | None:
return RULE_SMELLS.get(rule)


def issue(entry: dict) -> dict:
def source_line(entry: dict) -> str | None:
violation = entry["violation"]
try:
line = Path(entry["file"]).read_text(
encoding="utf-8", errors="replace"
).splitlines()[violation["beginline"] - 1]
except (OSError, IndexError):
return None
return line.strip() or None


def issue(entry: dict, smell: str) -> dict:
violation = entry["violation"]
details = {
"file": entry["file"],
"line": violation["beginline"],
"message": violation["description"],
"source": "pmd:" + violation["rule"],
}
if smell == "unused-class-member":
content = source_line(entry)
if content is not None:
details["content"] = content
return {
"key": entry["file"],
"details": {
"file": entry["file"],
"line": violation["beginline"],
"message": violation["description"],
"source": "pmd:" + violation["rule"],
},
"details": details,
}


Expand All @@ -123,7 +141,7 @@ def findings(entries: list[dict]) -> list[dict]:
for entry in entries:
smell = smell_of(entry)
if smell is not None:
by_smell.setdefault(smell, []).append(issue(entry))
by_smell.setdefault(smell, []).append(issue(entry, smell))
return [
{"smell": smell, "details": {}, "issues": issues}
for smell, issues in by_smell.items()
Expand Down
17 changes: 17 additions & 0 deletions plugins/java/tests/test_bundled_ruleset_declares_its_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
)
PMD_NAMESPACE = {"pmd": "http://pmd.sourceforge.net/ruleset/2.0.0"}
DEEP_NESTING_RULE = "category/java/design.xml/AvoidDeeplyNestedIfStmts"
UNUSED_PRIVATE_MEMBER_RULES = (
"category/java/bestpractices.xml/UnusedPrivateField",
"category/java/bestpractices.xml/UnusedPrivateMethod",
)


def test_the_bundled_ruleset_explicitly_reports_the_third_nested_if() -> None:
Expand All @@ -31,3 +35,16 @@ def test_the_bundled_ruleset_explicitly_reports_the_third_nested_if() -> None:
)
assert len(problem_depth) == 1
assert problem_depth[0].get("value") == "3"


def test_the_bundled_ruleset_enables_pmds_unused_private_member_defaults() -> None:
root = ElementTree.parse(RULESET).getroot()

rules = [
rule
for rule in root.findall("pmd:rule", PMD_NAMESPACE)
if rule.get("ref") in UNUSED_PRIVATE_MEMBER_RULES
]

assert [rule.get("ref") for rule in rules] == list(UNUSED_PRIVATE_MEMBER_RULES)
assert all(len(rule) == 0 for rule in rules)
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,40 @@ def test_a_class_level_violation_never_reaches_findings() -> None:
assert len(result) == 1
assert result[0]["smell"] == "high-complexity"
assert len(result[0]["issues"]) == 1


def test_unused_private_members_preserve_opaque_pmd_diagnostics() -> None:
entries = [
_entry("UnusedPrivateField", "Field is retained only by custom wiring", 7),
_entry("UnusedPrivateMethod", "Lifecycle entry point is never invoked", 11),
]

result = findings(entries)

assert result == [
{
"smell": "unused-class-member",
"details": {},
"issues": [
{
"key": "Fat.java",
"details": {
"file": "Fat.java",
"line": 7,
"message": "Field is retained only by custom wiring",
"source": "pmd:UnusedPrivateField",
},
},
{
"key": "Fat.java",
"details": {
"file": "Fat.java",
"line": 11,
"message": "Lifecycle entry point is never invoked",
"source": "pmd:UnusedPrivateMethod",
},
},
],
}
]
assert all("name" not in issue["details"] for issue in result[0]["issues"])
166 changes: 166 additions & 0 deletions plugins/java/tests/test_unused_private_members_follow_pmd.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""Unused private-member detection remains PMD's decision."""

from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path

SENSOR = (
Path(__file__).resolve().parents[1] / "src/habit_hooks_java/sensors/pmd_sensor.py"
)
RULESET_HEADER = """<?xml version="1.0"?>
<ruleset name="custom" xmlns="http://pmd.sourceforge.net/ruleset/2.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0 https://pmd.sourceforge.io/ruleset_2_0_0.xsd">
<description>project policy</description>
"""


def _run(project: Path, pmd: str, *arguments: str) -> list[dict]:
result = subprocess.run(
[sys.executable, str(SENSOR), pmd, *arguments, "--", "Members.java"],
cwd=project,
capture_output=True,
encoding="utf-8",
errors="replace",
)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)


def test_the_bundled_rules_report_only_unreferenced_private_members(
tmp_path: Path, pmd: str
) -> None:
(tmp_path / "Members.java").write_text(
"class Members {\n"
" private int staleField = 1;\n"
" private int liveField = 2;\n"
" private void staleMethod() {}\n"
" private int liveMethod() { return liveField; }\n"
" int value() { return liveMethod(); }\n"
"}\n",
encoding="utf-8",
)

findings = _run(tmp_path, pmd)

assert [finding["smell"] for finding in findings] == ["unused-class-member"]
issues = findings[0]["issues"]
assert [issue["details"]["line"] for issue in issues] == [2, 4]
assert [issue["details"]["message"] for issue in issues] == [
"Avoid unused private fields such as 'staleField'.",
"Avoid unused private methods such as 'staleMethod()'.",
]
assert [issue["details"]["source"] for issue in issues] == [
"pmd:UnusedPrivateField",
"pmd:UnusedPrivateMethod",
]
assert [issue["details"]["content"] for issue in issues] == [
"private int staleField = 1;",
"private void staleMethod() {}",
]
assert all(Path(issue["key"]).name == "Members.java" for issue in issues)
assert all("name" not in issue["details"] for issue in issues)


def test_the_bundled_rules_leave_framework_and_serialization_exclusions_to_pmd(
tmp_path: Path, pmd: str
) -> None:
(tmp_path / "Members.java").write_text(
"@interface Managed {}\n"
"class Members implements java.io.Serializable {\n"
" private static final long serialVersionUID = 1L;\n"
" @Managed private Object injected;\n"
" private Object staleField;\n"
" private void readObject(java.io.ObjectInputStream stream)\n"
" throws java.io.IOException, ClassNotFoundException {\n"
" stream.defaultReadObject();\n"
" }\n"
" private void staleMethod() {}\n"
"}\n",
encoding="utf-8",
)

findings = _run(tmp_path, pmd)

assert [finding["smell"] for finding in findings] == ["unused-class-member"]
issues = findings[0]["issues"]
assert [issue["details"]["line"] for issue in issues] == [5, 10]
assert [issue["details"]["source"] for issue in issues] == [
"pmd:UnusedPrivateField",
"pmd:UnusedPrivateMethod",
]
messages = [issue["details"]["message"] for issue in issues]
assert all("serialVersionUID" not in message for message in messages)
assert all("injected" not in message for message in messages)
assert all("readObject" not in message for message in messages)


def test_a_project_ruleset_decides_whether_unused_members_are_enabled(
tmp_path: Path, pmd: str
) -> None:
(tmp_path / "Members.java").write_text(
"class Members {\n"
" private int staleField;\n"
" private void staleMethod() {}\n"
"}\n",
encoding="utf-8",
)
ruleset = tmp_path / "ruleset.xml"
ruleset.write_text(
RULESET_HEADER
+ ' <rule ref="category/java/design.xml/AvoidDeeplyNestedIfStmts"/>\n'
+ "</ruleset>\n",
encoding="utf-8",
)

omitted = _run(tmp_path, pmd)

assert omitted == []

ruleset.write_text(
RULESET_HEADER
+ ' <rule ref="category/java/bestpractices.xml/UnusedPrivateField"/>\n'
+ ' <rule ref="category/java/bestpractices.xml/UnusedPrivateMethod"/>\n'
+ "</ruleset>\n",
encoding="utf-8",
)

enabled = _run(tmp_path, pmd)

assert [issue["details"]["source"] for issue in enabled[0]["issues"]] == [
"pmd:UnusedPrivateField",
"pmd:UnusedPrivateMethod",
]


def test_a_project_ruleset_can_customize_pmds_ignored_field_names(
tmp_path: Path, pmd: str
) -> None:
(tmp_path / "Members.java").write_text(
"class Members {\n"
" private int retainedByPolicy;\n"
" private int staleField;\n"
"}\n",
encoding="utf-8",
)
(tmp_path / "ruleset.xml").write_text(
RULESET_HEADER
+ ' <rule ref="category/java/bestpractices.xml/UnusedPrivateField">\n'
+ " <properties>\n"
+ ' <property name="ignoredFieldNames" value="retainedByPolicy"/>\n'
+ " </properties>\n"
+ " </rule>\n"
+ "</ruleset>\n",
encoding="utf-8",
)

findings = _run(tmp_path, pmd)

issues = findings[0]["issues"]
assert [issue["details"]["line"] for issue in issues] == [3]
assert issues[0]["details"]["message"] == (
"Avoid unused private fields such as 'staleField'."
)

This file was deleted.

Loading
0