habit-hooks-java 1.3.0__tar.gz → 1.4.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 (22) hide show
  1. habit_hooks_java-1.4.0/PKG-INFO +50 -0
  2. habit_hooks_java-1.4.0/README.md +26 -0
  3. {habit_hooks_java-1.3.0 → habit_hooks_java-1.4.0}/docs/java-plugin.spec.md +1 -1
  4. habit_hooks_java-1.4.0/pyproject.toml +51 -0
  5. habit_hooks_java-1.4.0/src/habit_hooks_java/sensors/pmd.toml +1 -0
  6. habit_hooks_java-1.4.0/src/habit_hooks_java/sensors/pmd_ruleset.py +50 -0
  7. {habit_hooks_java-1.3.0 → habit_hooks_java-1.4.0}/src/habit_hooks_java/sensors/pmd_sensor.py +33 -68
  8. habit_hooks_java-1.4.0/tests/conftest.py +41 -0
  9. habit_hooks_java-1.4.0/tests/test_a_vendored_sensor_finds_its_neighbour.py +59 -0
  10. {habit_hooks_java-1.3.0 → habit_hooks_java-1.4.0}/tests/test_class_level_metric_violations_are_dropped.py +1 -1
  11. {habit_hooks_java-1.3.0 → habit_hooks_java-1.4.0}/tests/test_every_ruleset_spelling_wins.py +2 -2
  12. {habit_hooks_java-1.3.0 → habit_hooks_java-1.4.0}/tests/test_sensor_args_reach_pmd_not_the_file_list.py +22 -15
  13. habit_hooks_java-1.4.0/tests/test_the_sensor_runs_the_pmd_it_is_handed.py +53 -0
  14. habit_hooks_java-1.3.0/PKG-INFO +0 -5
  15. habit_hooks_java-1.3.0/pyproject.toml +0 -16
  16. habit_hooks_java-1.3.0/src/habit_hooks_java/sensors/pmd.toml +0 -1
  17. habit_hooks_java-1.3.0/tests/test_a_java_pmd_nobody_installed_is_named.py +0 -35
  18. {habit_hooks_java-1.3.0 → habit_hooks_java-1.4.0}/.gitignore +0 -0
  19. {habit_hooks_java-1.3.0 → habit_hooks_java-1.4.0}/src/habit_hooks_java/__init__.py +0 -0
  20. {habit_hooks_java-1.3.0 → habit_hooks_java-1.4.0}/src/habit_hooks_java/config.toml +0 -0
  21. {habit_hooks_java-1.3.0 → habit_hooks_java-1.4.0}/src/habit_hooks_java/sensors/pmd-ruleset.xml +0 -0
  22. {habit_hooks_java-1.3.0 → habit_hooks_java-1.4.0}/tests/test_the_java_files_leave_build_output_alone.py +0 -0
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.5
2
+ Name: habit-hooks-java
3
+ Version: 1.4.0
4
+ Summary: The Java Habit Hooks plugin
5
+ Project-URL: Homepage, https://habit-hooks.com
6
+ Project-URL: Repository, https://github.com/habit-hooks/habit-hooks
7
+ Project-URL: Issues, https://github.com/habit-hooks/habit-hooks/issues
8
+ Project-URL: Changelog, https://github.com/habit-hooks/habit-hooks/blob/main/CHANGELOG.md
9
+ License-Expression: MIT
10
+ Keywords: ai-coding-agents,ci,code-quality,code-review,code-smells,coding-agents,developer-tools,java,linter,refactoring,static-analysis,technical-debt
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Quality Assurance
20
+ Classifier: Topic :: Software Development :: Testing
21
+ Classifier: Topic :: Utilities
22
+ Requires-Python: >=3.11
23
+ Description-Content-Type: text/markdown
24
+
25
+ # habit-hooks-java
26
+
27
+ The Java Habit Hooks plugin: wraps [`pmd`](https://pmd.github.io/) for
28
+ structural code-smell detection.
29
+
30
+ ## Install
31
+
32
+ ```sh
33
+ pip install "habit-hooks[java]"
34
+ ```
35
+
36
+ ## Enable
37
+
38
+ ```toml
39
+ # .habit-hooks/config.toml
40
+ plugins = ["java", "generic"]
41
+ ```
42
+
43
+ Installing a plugin does not switch it on — it has to be named in
44
+ `plugins` before habit-hooks runs it.
45
+
46
+ ## Detectors
47
+
48
+ - [`pmd`](https://pmd.github.io/) — `brew install pmd` (it brings its own Java runtime)
49
+
50
+ Part of [habit-hooks](https://github.com/habit-hooks/habit-hooks).
@@ -0,0 +1,26 @@
1
+ # habit-hooks-java
2
+
3
+ The Java Habit Hooks plugin: wraps [`pmd`](https://pmd.github.io/) for
4
+ structural code-smell detection.
5
+
6
+ ## Install
7
+
8
+ ```sh
9
+ pip install "habit-hooks[java]"
10
+ ```
11
+
12
+ ## Enable
13
+
14
+ ```toml
15
+ # .habit-hooks/config.toml
16
+ plugins = ["java", "generic"]
17
+ ```
18
+
19
+ Installing a plugin does not switch it on — it has to be named in
20
+ `plugins` before habit-hooks runs it.
21
+
22
+ ## Detectors
23
+
24
+ - [`pmd`](https://pmd.github.io/) — `brew install pmd` (it brings its own Java runtime)
25
+
26
+ Part of [habit-hooks](https://github.com/habit-hooks/habit-hooks).
@@ -211,5 +211,5 @@ habit-sensors --all 2>&1 >/dev/null | sed -n 1p
211
211
 
212
212
  🖥️ ❌ 1
213
213
  ```text
214
- habit-sensors: sensor 'pmd' failed: ${python} ${dir}/pmd_sensor.py ${args} -- ${files}
214
+ habit-sensors: sensor 'pmd' failed: '${python}' '${dir}/pmd_sensor.py' '${detector:pmd}' '${args}' -- '${files}'
215
215
  ```
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "habit-hooks-java"
3
+ version = "1.4.0"
4
+ description = "The Java Habit Hooks plugin"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ keywords = [
8
+ "code-quality",
9
+ "code-smells",
10
+ "static-analysis",
11
+ "linter",
12
+ "ci",
13
+ "code-review",
14
+ "technical-debt",
15
+ "refactoring",
16
+ "ai-coding-agents",
17
+ "coding-agents",
18
+ "developer-tools",
19
+ "java",
20
+ ]
21
+ classifiers = [
22
+ "Development Status :: 4 - Beta",
23
+ "Environment :: Console",
24
+ "Intended Audience :: Developers",
25
+ "Operating System :: OS Independent",
26
+ "Programming Language :: Python :: 3",
27
+ "Programming Language :: Python :: 3.11",
28
+ "Programming Language :: Python :: 3.12",
29
+ "Programming Language :: Python :: 3.13",
30
+ "Topic :: Software Development :: Quality Assurance",
31
+ "Topic :: Software Development :: Testing",
32
+ "Topic :: Utilities",
33
+ ]
34
+ requires-python = ">=3.11"
35
+ dependencies = []
36
+
37
+ [project.entry-points."habit_hooks.plugins"]
38
+ java = "habit_hooks_java"
39
+
40
+ [project.urls]
41
+ Homepage = "https://habit-hooks.com"
42
+ Repository = "https://github.com/habit-hooks/habit-hooks"
43
+ Issues = "https://github.com/habit-hooks/habit-hooks/issues"
44
+ Changelog = "https://github.com/habit-hooks/habit-hooks/blob/main/CHANGELOG.md"
45
+
46
+ [build-system]
47
+ requires = ["hatchling"]
48
+ build-backend = "hatchling.build"
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["src/habit_hooks_java"]
@@ -0,0 +1 @@
1
+ argv = ["${python}", "${dir}/pmd_sensor.py", "${detector:pmd}", "${args}", "--", "${files}"]
@@ -0,0 +1,50 @@
1
+ """Decide which ruleset PMD is run with.
2
+
3
+ PMD never discovers a project ruleset on its own — ``-R`` is required — so the
4
+ answer is chosen here: a ``--rulesets``/``-R`` among the sensor's ``args`` (the
5
+ project naming its config explicitly) wins; then the first conventional ruleset
6
+ file the Java ecosystem's build tools point at, in the project directory only;
7
+ then the plugin's bundled ``pmd-ruleset.xml`` as the answer to "the project has
8
+ none".
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ # The ruleset names Maven and Gradle PMD setups conventionally point at, in
16
+ # the order a project directory is checked. PMD itself offers no discovery
17
+ # signal (it never looks one up), so this is the knip-shaped search for the
18
+ # project's own config; a ``--rulesets`` in the sensor's args overrides it.
19
+ RULESET_LOCATIONS = (
20
+ "src/main/resources/pmd/ruleset.xml",
21
+ "pmd/ruleset.xml",
22
+ "ruleset.xml",
23
+ "pmd.xml",
24
+ )
25
+
26
+ RULESET_OPTIONS = ("--rulesets", "-R")
27
+ # The attached spellings picocli also takes, longest prefix first so `-R=x` is
28
+ # not read as a bare `-R` with `=x` on it. A spelling missed here does not fall
29
+ # back: the project's `-R` stays in the tail, ours goes in beside it, and PMD
30
+ # unions the two rulesets rather than using theirs.
31
+ ATTACHED_RULESET_PREFIXES = ("--rulesets=", "-R=", "-R")
32
+
33
+
34
+ def ruleset_of(argv: list[str], project: Path) -> tuple[Path, list[str]]:
35
+ """The ruleset in force, and the remaining args with no ruleset named.
36
+
37
+ A ``--rulesets``/``-R`` among the sensor's args is the project's own config
38
+ and wins over everything; PMD only ever gets one, so it is pulled out of
39
+ the tail rather than left beside the wrapper's own.
40
+ """
41
+ for i, token in enumerate(argv):
42
+ if token in RULESET_OPTIONS and i + 1 < len(argv):
43
+ return Path(argv[i + 1]), [*argv[:i], *argv[i + 2 :]]
44
+ for prefix in ATTACHED_RULESET_PREFIXES:
45
+ if token.startswith(prefix) and len(token) > len(prefix):
46
+ return Path(token[len(prefix) :]), [*argv[:i], *argv[i + 1 :]]
47
+ for name in RULESET_LOCATIONS:
48
+ if (project / name).is_file():
49
+ return project / name, argv
50
+ return Path(__file__).with_name("pmd-ruleset.xml"), argv
@@ -4,25 +4,25 @@ PMD exits 4 when it finds violations, 0 when clean, and 1/2/5 on exceptions,
4
4
  usage errors and recoverable errors (since 7.3.0) — so a bare pipe cannot tell
5
5
  a clean run from a crash. This wrapper runs PMD against the scoped files,
6
6
  treats only 0/4 as success, and shapes each violation into the canonical
7
- finding, mapping PMD rule names to smell keys.
7
+ finding, mapping PMD rule names to smell keys. Which ruleset it runs with is
8
+ the neighbouring ``pmd_ruleset``'s decision.
8
9
 
9
- PMD never discovers a project ruleset on its own — ``-R`` is required — so the
10
- ruleset is resolved here: a ``--rulesets`` among the sensor's ``args`` (the
11
- project naming its config explicitly) wins; then the first conventional ruleset
12
- file the Java ecosystem's build tools point at, in the project directory only;
13
- then the plugin's bundled ``pmd-ruleset.xml`` as the answer to "the project has
14
- none".
10
+ The sensor is run as a loose script (``${python} ${dir}/pmd_sensor.py``), so its
11
+ own directory is ``sys.path[0]`` and that neighbour is a plain top-level import
12
+ the same in the installed package and in a vendored copy, since ``${dir}``
13
+ expands to whichever of the two won the override chain.
15
14
 
16
15
  PMD 7's picocli reads a positional path that directly follows the ruleset value
17
16
  as another ``-R`` value (``-R ruleset.xml file.java`` analyses nothing), so the
18
17
  wrapper uses the short forms ``-R`` and per-file ``-d``, which do not. Verified
19
18
  against PMD 7.26.0.
20
19
 
21
- The sensor's own command spells ``${args} -- ${files}``, so ``sys.argv[1:]``
22
- carries both halves of ``[sensors.pmd] args`` on one side of a literal ``--``
23
- and the scoped files on the other that is what lets a project pass any PMD
24
- flag (``--aux-classpath``, ``--minimum-priority``, ...) through untouched
25
- instead of every argv token becoming a bogus ``-d`` file argument.
20
+ The sensor's own argv spells ``${detector:pmd} ${args} -- ${files}``, so
21
+ ``sys.argv[1]`` is the file to run PMD by and ``sys.argv[2:]`` carries both
22
+ halves of ``[sensors.pmd] args`` on one side of a literal ``--`` and the scoped
23
+ files on the other that is what lets a project pass any PMD flag
24
+ (``--aux-classpath``, ``--minimum-priority``, ...) through untouched instead of
25
+ every argv token becoming a bogus ``-d`` file argument.
26
26
  """
27
27
 
28
28
  from __future__ import annotations
@@ -32,6 +32,8 @@ import subprocess
32
32
  import sys
33
33
  from pathlib import Path
34
34
 
35
+ from pmd_ruleset import ruleset_of
36
+
35
37
  RULE_SMELLS = {
36
38
  "ExcessiveParameterList": "too-many-parameters",
37
39
  "CyclomaticComplexity": "high-complexity",
@@ -49,43 +51,25 @@ RULE_SMELLS = {
49
51
  METHOD_LEVEL_RULES = ("NcssCount", "CyclomaticComplexity")
50
52
  METHOD_LEVEL_PREFIXES = ("The method", "The constructor")
51
53
 
52
- # The ruleset names Maven and Gradle PMD setups conventionally point at, in
53
- # the order a project directory is checked. PMD itself offers no discovery
54
- # signal (it never looks one up), so this is the knip-shaped search for the
55
- # project's own config; a ``--rulesets`` in the sensor's args overrides it.
56
- RULESET_LOCATIONS = (
57
- "src/main/resources/pmd/ruleset.xml",
58
- "pmd/ruleset.xml",
59
- "ruleset.xml",
60
- "pmd.xml",
61
- )
62
-
63
54
  SUCCESS_EXIT_CODES = (0, 4)
64
- RULESET_OPTIONS = ("--rulesets", "-R")
65
- # The attached spellings picocli also takes, longest prefix first so `-R=x` is
66
- # not read as a bare `-R` with `=x` on it. A spelling missed here does not fall
67
- # back: the project's `-R` stays in the tail, ours goes in beside it, and PMD
68
- # unions the two rulesets rather than using theirs.
69
- ATTACHED_RULESET_PREFIXES = ("--rulesets=", "-R=", "-R")
70
-
71
-
72
- def run_pmd(arguments: list[str]) -> subprocess.CompletedProcess[str]:
73
- """What PMD said, or what a shell says about a PMD nobody installed.
74
-
75
- The plugin does not ship the distribution, so ``pmd`` is the command that
76
- goes missing — and an absent one raised a ``FileNotFoundError`` out of
77
- here, making twenty lines of Python internals the sensor's diagnosis
78
- (#114). This wrapper is what looks for pmd, so it answers the way the
79
- shell would have, and that phrase is what the run recognises to name the
80
- missing tool.
55
+
56
+
57
+ def run_pmd(pmd: str, arguments: list[str]) -> subprocess.CompletedProcess[str]:
58
+ """What PMD said, run as the file this sensor was handed.
59
+
60
+ ``pmd`` is a file and never a name to look up. The plugin declares the tool
61
+ its sensor reaches for, and the run resolves that declaration to the very
62
+ file the setup cleared it by before spawning this helper — which is how the
63
+ ``pmd.bat`` PMD ships is reached on Windows, where a spawn adds ``.exe`` to
64
+ a bare name and nothing else. A PMD nobody installed never reaches here: the
65
+ sensor fails first, named, as the missing command it is.
81
66
  """
82
- command = ["pmd", "check", "--no-cache", "--format", "json"]
83
- try:
84
- return subprocess.run(
85
- [*command, *arguments], capture_output=True, text=True
86
- )
87
- except FileNotFoundError:
88
- return subprocess.CompletedProcess(command, 127, "", "pmd: command not found\n")
67
+ return subprocess.run(
68
+ [pmd, "check", "--no-cache", "--format", "json", *arguments],
69
+ capture_output=True,
70
+ encoding="utf-8",
71
+ errors="replace", # one invalid byte must not take the sensor down
72
+ )
89
73
 
90
74
 
91
75
  def split_argv(argv: list[str]) -> tuple[list[str], list[str]]:
@@ -102,25 +86,6 @@ def split_argv(argv: list[str]) -> tuple[list[str], list[str]]:
102
86
  return argv[:index], argv[index + 1 :]
103
87
 
104
88
 
105
- def ruleset_of(argv: list[str], project: Path) -> tuple[Path, list[str]]:
106
- """The ruleset in force, and the remaining args with no ruleset named.
107
-
108
- A ``--rulesets``/``-R`` among the sensor's args is the project's own config
109
- and wins over everything; PMD only ever gets one, so it is pulled out of
110
- the tail rather than left beside the wrapper's own.
111
- """
112
- for i, token in enumerate(argv):
113
- if token in RULESET_OPTIONS and i + 1 < len(argv):
114
- return Path(argv[i + 1]), [*argv[:i], *argv[i + 2 :]]
115
- for prefix in ATTACHED_RULESET_PREFIXES:
116
- if token.startswith(prefix) and len(token) > len(prefix):
117
- return Path(token[len(prefix) :]), [*argv[:i], *argv[i + 1 :]]
118
- for name in RULESET_LOCATIONS:
119
- if (project / name).is_file():
120
- return project / name, argv
121
- return Path(__file__).with_name("pmd-ruleset.xml"), argv
122
-
123
-
124
89
  def violations(report: dict) -> list[dict]:
125
90
  return [
126
91
  {"file": entry["filename"], "violation": violation}
@@ -165,14 +130,14 @@ def findings(entries: list[dict]) -> list[dict]:
165
130
 
166
131
 
167
132
  def main() -> int:
168
- argv = sys.argv[1:]
133
+ pmd, argv = sys.argv[1], sys.argv[2:]
169
134
  if not argv:
170
135
  print("[]")
171
136
  return 0
172
137
  pmd_args, files = split_argv(argv)
173
138
  ruleset, remaining_args = ruleset_of(pmd_args, Path.cwd())
174
139
  file_args = [token for file in files for token in ("-d", file)]
175
- result = run_pmd(["-R", str(ruleset), *remaining_args, *file_args])
140
+ result = run_pmd(pmd, ["-R", str(ruleset), *remaining_args, *file_args])
176
141
  if result.returncode not in SUCCESS_EXIT_CODES:
177
142
  sys.stderr.write(processing_errors(result.stdout) or result.stderr or result.stdout)
178
143
  return 2
@@ -0,0 +1,41 @@
1
+ """Load the pmd sensor the way it is actually run, and hand it what a run hands it.
2
+
3
+ **A helper loads as a loose script.** The sensor spec spells
4
+ ``${python} ${dir}/pmd_sensor.py``, so the interpreter puts the helper's own
5
+ directory first on ``sys.path`` and its neighbour ``pmd_ruleset`` is a plain
6
+ top-level import. A unit test here does the same rather than reaching the code as
7
+ ``habit_hooks_java.sensors.pmd_sensor`` — a load path no run takes, and the only
8
+ one that import fails under.
9
+
10
+ **A helper is handed its tool as a file.** The spec names it with
11
+ ``${detector:pmd}``, which the run resolves before the helper is spawned
12
+ (``project_paths.tool_executable``). A test spawning the helper directly stands
13
+ in for the run, so it asks the same question and hands over the same file.
14
+ Absent is a failure rather than a skip: CI installs PMD on both legs, so a
15
+ machine without it is a suite that has quietly stopped gating.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import shutil
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ import pytest
25
+
26
+ SENSORS = Path(__file__).resolve().parents[1] / "src" / "habit_hooks_java" / "sensors"
27
+
28
+ sys.path.insert(0, str(SENSORS))
29
+
30
+
31
+ @pytest.fixture(scope="session")
32
+ def pmd() -> str:
33
+ """The file this machine runs PMD by, as the sensor's first argument.
34
+
35
+ ``shutil.which`` rather than the name on disk: PMD ships ``pmd.bat``, which
36
+ Windows finds by a lookup and cannot spawn by the bare name.
37
+ """
38
+ found = shutil.which("pmd")
39
+ if found is None:
40
+ pytest.fail("pmd is not on PATH — 'brew install pmd'")
41
+ return found
@@ -0,0 +1,59 @@
1
+ """The route that copies the plugin's files into a project must still import.
2
+
3
+ Vendoring under ``.habit-hooks/<plugin>/`` is the extras-free install the README
4
+ advertises: the copied files win the override chain, ``${dir}`` in the sensor
5
+ command expands to wherever they landed, and the plugin package is typically not
6
+ installed at all — that is the point of vendoring. The sensor imports
7
+ ``pmd_ruleset`` from beside itself, which has to resolve because a loose
8
+ script's own directory is ``sys.path[0]``, never because ``habit_hooks_java``
9
+ happens to be importable.
10
+
11
+ ``-S`` is what tells those two apart: it denies the child the site-packages this
12
+ checkout has the plugin installed into, so an import reaching for the package
13
+ fails here exactly as it would for a project that vendored instead of
14
+ installing. Without it the copy would pass on the installed package's modules
15
+ and prove nothing about the files it just copied.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ SENSORS = Path(__file__).resolve().parents[1] / "src" / "habit_hooks_java" / "sensors"
27
+
28
+ FIVE_PARAMETER_METHOD = """class Billing {
29
+ double charge(double a, double b, double c, double d, double e) {
30
+ return a + b + c + d + e;
31
+ }
32
+ }
33
+ """
34
+
35
+
36
+ def _vendored_sensor(project: Path) -> Path:
37
+ """The plugin's sensor files copied where a project vendoring them puts them."""
38
+ sensors = project / ".habit-hooks" / "java" / "sensors"
39
+ shutil.copytree(SENSORS, sensors, ignore=shutil.ignore_patterns("__pycache__"))
40
+ return sensors / "pmd_sensor.py"
41
+
42
+
43
+ def test_a_vendored_sensor_reports_a_smell_with_no_package_around_it(
44
+ tmp_path: Path, pmd: str
45
+ ) -> None:
46
+ (tmp_path / "Billing.java").write_text(FIVE_PARAMETER_METHOD, encoding="utf-8")
47
+ sensor = _vendored_sensor(tmp_path)
48
+
49
+ result = subprocess.run(
50
+ [sys.executable, "-S", str(sensor), pmd, "--", "Billing.java"],
51
+ cwd=tmp_path,
52
+ capture_output=True,
53
+ encoding="utf-8",
54
+ errors="replace",
55
+ )
56
+
57
+ assert result.returncode == 0, result.stderr
58
+ findings = json.loads(result.stdout)
59
+ assert [finding["smell"] for finding in findings] == ["too-many-parameters"]
@@ -14,7 +14,7 @@ over-complex function anywhere in the file.
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
- from habit_hooks_java.sensors.pmd_sensor import findings, smell_of
17
+ from pmd_sensor import findings, smell_of
18
18
 
19
19
 
20
20
  def _entry(rule: str, description: str, beginline: int = 1) -> dict:
@@ -15,7 +15,7 @@ from __future__ import annotations
15
15
  from pathlib import Path
16
16
 
17
17
  import pytest
18
- from habit_hooks_java.sensors.pmd_sensor import ruleset_of
18
+ from pmd_ruleset import ruleset_of
19
19
 
20
20
  BUNDLED = "pmd-ruleset.xml"
21
21
  THEIRS = "mine.xml"
@@ -59,7 +59,7 @@ def test_a_project_naming_none_gets_the_bundled_ruleset(tmp_path: Path) -> None:
59
59
  def test_a_conventional_ruleset_beats_the_bundled_one(tmp_path: Path) -> None:
60
60
  theirs = tmp_path / "pmd" / "ruleset.xml"
61
61
  theirs.parent.mkdir()
62
- theirs.write_text("<ruleset/>")
62
+ theirs.write_text("<ruleset/>", encoding="utf-8")
63
63
 
64
64
  assert ruleset_of([], tmp_path) == (theirs, [])
65
65
 
@@ -4,10 +4,16 @@ Before this fix ``main()`` turned every argv token that was not the ruleset
4
4
  into a ``-d <path>`` PMD file argument, so a genuine PMD flag such as
5
5
  ``--minimum-priority`` broke the run outright (picocli: "Expected parameter
6
6
  for option '--dir' but found '--minimum-priority'"). The sensor's command now
7
- spells ``${args} -- ${files}``, and the wrapper splits ``sys.argv[1:]`` on the
8
- *last* ``--``: everything before it goes to PMD verbatim, everything after
9
- becomes a file. A ``--rulesets``/``-R`` on the PMD-flag half is still pulled
10
- out for `-R`, exactly as it was before this split existed.
7
+ spells ``${detector:pmd} ${args} -- ${files}``: ``sys.argv[1]`` is the file to
8
+ run PMD by, and the wrapper splits what follows on the *last* ``--`` —
9
+ everything before it goes to PMD verbatim, everything after becomes a file. A
10
+ ``--rulesets``/``-R`` on the PMD-flag half is still pulled out for `-R`,
11
+ exactly as it was before this split existed.
12
+
13
+ The cases below hand the tool over themselves, so it is ``java-plugin.spec.md``'s
14
+ ``[sensors.pmd] args`` case — the one that runs the real pipeline — that holds
15
+ the *spec's* own ordering in place: spell ``${detector:pmd}`` after ``${args}``
16
+ there and ``--minimum-priority`` is what gets spawned.
11
17
  """
12
18
 
13
19
  from __future__ import annotations
@@ -47,16 +53,17 @@ TWO_IS_TOO_MANY_RULESET = """<?xml version="1.0"?>
47
53
  """
48
54
 
49
55
 
50
- def _run(cwd: Path, arguments: list[str]) -> subprocess.CompletedProcess[str]:
56
+ def _run(cwd: Path, pmd: str, arguments: list[str]) -> subprocess.CompletedProcess[str]:
51
57
  return subprocess.run(
52
- [sys.executable, str(SENSOR), *arguments],
58
+ [sys.executable, str(SENSOR), pmd, *arguments],
53
59
  cwd=cwd,
54
60
  capture_output=True,
55
- text=True,
61
+ encoding="utf-8",
62
+ errors="replace",
56
63
  )
57
64
 
58
65
 
59
- def test_a_pmd_flag_in_args_reaches_pmd(tmp_path: Path) -> None:
66
+ def test_a_pmd_flag_in_args_reaches_pmd(tmp_path: Path, pmd: str) -> None:
60
67
  """ExcessiveParameterList reports at priority 3 and UnnecessaryImport at
61
68
  priority 4 (verified against PMD 7.26.0), so ``--minimum-priority 3``
62
69
  keeps the first and drops the second — proof the flag reached PMD's own
@@ -64,10 +71,10 @@ def test_a_pmd_flag_in_args_reaches_pmd(tmp_path: Path) -> None:
64
71
  that dropped every rule would pass as trivially as one that reached
65
72
  nothing at all, so the assertion has to be a smell that survives, not an
66
73
  empty result."""
67
- (tmp_path / "Billing.java").write_text(FIVE_PARAMETER_METHOD_WITH_UNUSED_IMPORT)
74
+ (tmp_path / "Billing.java").write_text(FIVE_PARAMETER_METHOD_WITH_UNUSED_IMPORT, encoding="utf-8")
68
75
 
69
- without_the_flag = _run(tmp_path, ["--", "Billing.java"])
70
- with_the_flag = _run(tmp_path, ["--minimum-priority", "3", "--", "Billing.java"])
76
+ without_the_flag = _run(tmp_path, pmd, ["--", "Billing.java"])
77
+ with_the_flag = _run(tmp_path, pmd, ["--minimum-priority", "3", "--", "Billing.java"])
71
78
 
72
79
  assert without_the_flag.returncode == 0, without_the_flag.stderr
73
80
  without_smells = {finding["smell"] for finding in json.loads(without_the_flag.stdout)}
@@ -78,12 +85,12 @@ def test_a_pmd_flag_in_args_reaches_pmd(tmp_path: Path) -> None:
78
85
  assert with_smells == {"too-many-parameters"}
79
86
 
80
87
 
81
- def test_a_ruleset_named_in_args_is_still_honoured(tmp_path: Path) -> None:
82
- (tmp_path / "Project.java").write_text(TWO_PARAMETER_METHOD)
88
+ def test_a_ruleset_named_in_args_is_still_honoured(tmp_path: Path, pmd: str) -> None:
89
+ (tmp_path / "Project.java").write_text(TWO_PARAMETER_METHOD, encoding="utf-8")
83
90
  ruleset = tmp_path / "strict.xml"
84
- ruleset.write_text(TWO_IS_TOO_MANY_RULESET)
91
+ ruleset.write_text(TWO_IS_TOO_MANY_RULESET, encoding="utf-8")
85
92
 
86
- result = _run(tmp_path, ["--rulesets", str(ruleset), "--", "Project.java"])
93
+ result = _run(tmp_path, pmd, ["--rulesets", str(ruleset), "--", "Project.java"])
87
94
 
88
95
  assert result.returncode == 0, result.stderr
89
96
  findings = json.loads(result.stdout)
@@ -0,0 +1,53 @@
1
+ """The PMD this run resolved is the one the sensor spawns.
2
+
3
+ ``sensors/pmd.toml`` names its tool with ``${detector:pmd}``, so the run
4
+ resolves it to a file and hands that file over as the helper's first argument.
5
+ Spawning it — rather than the bare name it was resolved from — is the whole of
6
+ what the sensor owes: a name is looked up again by whatever spawns it, and
7
+ Windows' own lookup adds ``.exe`` and nothing else, where PMD installs as a
8
+ ``pmd.bat``.
9
+
10
+ A PMD nobody installed is no longer answered here. The part carries no file for
11
+ it, so nothing is ever spawned and the run answers as it does for any missing
12
+ command — the notice, the failed run, and that sensor's dropped findings
13
+ (``sensors/broken_part.py``).
14
+ """
15
+
16
+ from __future__ import annotations
17
+ import json
18
+ import subprocess
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from tool_lookup import where_the_bare_name_reaches_nothing
23
+
24
+ SENSOR = (
25
+ Path(__file__).resolve().parents[1] / "src/habit_hooks_java/sensors/pmd_sensor.py"
26
+ )
27
+
28
+ FIVE_PARAMETER_METHOD = """class Billing {
29
+ double charge(double a, double b, double c, double d, double e) {
30
+ return a + b + c + d + e;
31
+ }
32
+ }
33
+ """
34
+
35
+
36
+ def test_the_pmd_it_is_handed_runs_where_the_name_reaches_nothing(
37
+ tmp_path: Path, pmd: str
38
+ ) -> None:
39
+ (tmp_path / "Billing.java").write_text(FIVE_PARAMETER_METHOD, encoding="utf-8")
40
+
41
+ result = subprocess.run(
42
+ [sys.executable, str(SENSOR), pmd, "--", "Billing.java"],
43
+ cwd=tmp_path,
44
+ capture_output=True,
45
+ encoding="utf-8",
46
+ errors="replace",
47
+ env=where_the_bare_name_reaches_nothing("pmd"),
48
+ )
49
+
50
+ assert result.returncode == 0, result.stderr
51
+ assert [finding["smell"] for finding in json.loads(result.stdout)] == [
52
+ "too-many-parameters"
53
+ ]
@@ -1,5 +0,0 @@
1
- Metadata-Version: 2.5
2
- Name: habit-hooks-java
3
- Version: 1.3.0
4
- Summary: The Java Habit Hooks plugin
5
- Requires-Python: >=3.11
@@ -1,16 +0,0 @@
1
- [project]
2
- name = "habit-hooks-java"
3
- version = "1.3.0"
4
- description = "The Java Habit Hooks plugin"
5
- requires-python = ">=3.11"
6
- dependencies = []
7
-
8
- [project.entry-points."habit_hooks.plugins"]
9
- java = "habit_hooks_java"
10
-
11
- [build-system]
12
- requires = ["hatchling"]
13
- build-backend = "hatchling.build"
14
-
15
- [tool.hatch.build.targets.wheel]
16
- packages = ["src/habit_hooks_java"]
@@ -1 +0,0 @@
1
- command = "${python} ${dir}/pmd_sensor.py ${args} -- ${files}"
@@ -1,35 +0,0 @@
1
- """The tool this plugin does not ship must still answer in one line.
2
-
3
- The plugin does not bundle the PMD distribution, so ``pmd`` is the command that
4
- goes missing — and it is spawned from Python, where an absent tool is a
5
- ``FileNotFoundError`` and twenty lines of internals would otherwise become the
6
- sensor's diagnosis (#114).
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import subprocess
12
- import sys
13
- from pathlib import Path
14
-
15
- SENSOR = (
16
- Path(__file__).resolve().parents[1] / "src/habit_hooks_java/sensors/pmd_sensor.py"
17
- )
18
-
19
-
20
- def test_a_java_pmd_nobody_installed_answers_the_way_a_shell_does(
21
- tmp_path: Path,
22
- ) -> None:
23
- (tmp_path / "App.java").write_text("class App {}\n")
24
-
25
- result = subprocess.run(
26
- [sys.executable, str(SENSOR), "App.java"],
27
- cwd=tmp_path,
28
- capture_output=True,
29
- text=True,
30
- env={"PATH": "/nonexistent"},
31
- )
32
-
33
- assert result.returncode != 0
34
- assert result.stdout.strip() == ""
35
- assert result.stderr.strip() == "pmd: command not found"