8000 feat(plugins): The java plugin — PMD-backed structural smell detection by pfichtner · Pull Request #130 · 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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ jobs:
run: |
corepack enable pnpm
pnpm install --frozen-lockfile
- name: Install pmd
# The java plugin's sensor and its spec cases need the real PMD on PATH.
run: |
curl -fsSLo /tmp/pmd.zip https://github.com/pmd/pmd/releases/download/pmd_releases%2F7.26.0/pmd-dist-7.26.0-bin.zip
unzip -q /tmp/pmd.zip -d /tmp/pmd
echo "/tmp/pmd/pmd-bin-7.26.0/bin" >> "$GITHUB_PATH"
- name: Test
run: uv run pytest
# Dogfooding: habit-hooks gates its own source. Without this the tool can
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ jobs:
- { dist: habit_hooks_python, environment: pypi-python }
- { dist: habit_hooks_typescript, environment: pypi-typescript }
- { dist: habit_hooks_php, environment: pypi-php }
- { dist: habit_hooks_java, environment: pypi-java }
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
Expand Down
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ nothing and only reports what is missing, so it also answers "why is this run no
Setting habit-hooks up takes four steps. A run that reports nothing is almost always a skipped one:

1. **Install habit-hooks** — you get the core and the generic, language-agnostic plugin.
2. **Install the plugin for your language** — python, typescript and php ship as separate packages.
2. **Install the plugin for your language** — python, typescript, php and java ship as separate packages.
3. **Enable the plugins** by naming them in `.habit-hooks/config.toml`. Installing one does not switch it on.
4. **Install the detectors** the plugins you enabled use — `jscpd`, `ruff`, `eslint` and friends.

Expand All @@ -97,17 +97,17 @@ This gives you **core plus the generic (language-agnostic) plugin** and installs
all four plugins, so skip to step 3.

> ⚠️ **Important: this on its own checks nothing about your language.** What you have now is the generic
> plugin, which measures file length and duplication. Python, TypeScript and PHP each need their own plugin —
> installed (step 2) *and* enabled (step 3).
> plugin, which measures file length and duplication. Python, TypeScript, PHP and Java each need their own
> plugin — installed (step 2) *and* enabled (step 3).

### 2. Install the plugin for your language

The three language plugins are **opt-in** via extras:
The four language plugins are **opt-in** via extras:

```sh
uv tool install "habit-hooks[typescript]" # one language
uv tool install "habit-hooks[python,typescript]" # several — name them in one command
uv tool install "habit-hooks[all]" # all three
uv tool install "habit-hooks[all]" # all four
```

> ⚠️ Each `uv tool install` **rebuilds** the environment rather than adding to it, so a second one naming a
Expand Down Expand Up @@ -242,14 +242,15 @@ A plugin is not a language — it *declares* the language it speaks in its `conf
onto the plugin's findings. So several plugins can speak the same language using different tools, and the order
decides which one's guide wins. `generic` is listed explicitly like any other plugin, so a project can drop it.

The four plugins that ship:
The five plugins that ship:

| Plugin | Language | Sensors | Tools used |
|--------|----------|---------|------------|
| `generic` | (none) | `line-count`, `jscpd` | built-in line counter, jscpd |
| `python` | `python` | `ruff`, `deptry` | ruff, deptry |
| `typescript` | `typescript` | `eslint`, `knip`, `comment` | eslint, knip, ts-morph |
| `php` | `php` | `phpmd` | phpmd |
| `java` | `java` | `pmd` | pmd |

## Overrides: tune without forking

Expand Down
2 changes: 1 addition & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ pip install "habit-hooks[python,typescript]" # several at once
```

The `generic` plugin ships as part of the core install; each extra (`python`,
`typescript`, `php`) installs the matching
`typescript`, `php`, `java`) installs the matching
`habit-hooks-<name>` distribution; a third-party plugin is just a package you
`pip install habit-hooks-<name>` directly. The core then finds every installed
plugin through its entry point — nothing else is configured for discovery.
Expand Down
27 changes: 27 additions & 0 deletions docs/smell-vocabulary.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,33 @@ list alongside `php` to get it. PHPMD's `NPathComplexity` overlaps
`CyclomaticComplexity`, so only the latter is mapped to avoid double-reporting the
same function.

## Java plugin translation

The raw rule names PMD emits, and the smell key each maps to (the rest of the
catalogue is shared — only the plugin's sensors differ).

| Raw key (tool:rule) | Smell key |
|-----------------------------|-----------------------|
| `pmd:ExcessiveParameterList` | `too-many-parameters` |
| `pmd:CyclomaticComplexity` | `high-complexity` |
| `pmd:NcssCount` | `oversized-function` |
| `pmd:UnusedLocalVariable` | `unused-variable` |
| `pmd:UnnecessaryImport` | `unused-import` |
| `pmd:EmptyCatchBlock` | `swallowed-exception` |

The Java plugin runs PMD (`pmd check --format json`) through a thin sensor that
normalises PMD's exit-4-on-violations and maps its rule names to canonical smells.
PMD 7 no longer ships `ExcessiveMethodLength`/`ExcessiveClassLength`, so
`oversized-function` comes from `NcssCount`, which reports classes, methods and
constructors off one rule — the sensor keeps only the method/constructor
violations (PMD's own message distinguishes them) and drops the class-level ones.
PMD never discovers a project ruleset, so the sensor reaches for one only after
checking the conventional locations (`src/main/resources/pmd/ruleset.xml`,
`pmd/ruleset.xml`, `ruleset.xml`, `pmd.xml`) or a `--rulesets` in its args, then
falls back to the bundled `pmd-ruleset.xml` — a project's own ruleset wins
([config.md](config.md)). PMD 7's `UnnecessaryImport` is the renamed
`UnusedImports`.

## Uncoached smells

A smell with no entry above still renders — through the generic `uncoached.md`
Expand Down
1 change: 1 addition & 0 deletions packaging/npm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ plugin. Language plugins are opt-in extras:
- `habit-hooks[python]`
- `habit-hooks[typescript]`
- `habit-hooks[php]`
- `habit-hooks[java]`
- `habit-hooks[all]`

Project: https://github.com/habit-hooks/habit-hooks
1 change: 1 addition & 0 deletions packaging/npm/bin/habit-hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ plugin. Language plugins are opt-in extras:
habit-hooks[python]
habit-hooks[typescript]
habit-hooks[php]
habit-hooks[java]
habit-hooks[all]

Project: https://github.com/habit-hooks/habit-hooks
Expand Down
176 changes: 176 additions & 0 deletions plugins/java/docs/java-plugin.spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# The java plugin — acceptance

The java plugin runs its sensor through the real `habit-sensors` pipeline. These
cases run the **actual** tool (PMD, from the system `PATH`) against a fixture
with a known smell and assert the canonical finding comes out, mapped to the
smell keys in [smell-vocabulary.md](smell-vocabulary.md).

`habit-sensors` is the installed CLI; `pmd` is on the system `PATH`. The sensor
runs `pmd check --format json`, normalises PMD's exit-4-on-violations into a
clean run, and reaches for a ruleset the project wrote only after checking the
conventional Java locations, then falls back to the bundled `pmd-ruleset.xml`
when the project has none (PMD itself never discovers one).

📄.habit-hooks/config.toml
```toml
plugins = ["java"]
```

## pmd sensor maps rule names to canonical smells

The `pmd` sensor runs PMD with the bundled fallback ruleset and shapes each
violation into one finding per smell, stamping `source: "pmd:<rule>"` on each
issue. A five-parameter constructor trips `ExcessiveParameterList` →
`too-many-parameters`, an unused import trips `UnnecessaryImport` →
`unused-import`, and a dead local trips `UnusedLocalVariable` →
`unused-variable`.

📄Billing.java
```java
import java.io.File;
import java.io.IOException;
class Billing {
double charge(double a, double b, double c, double d, double e) {
int dead = 1;
return a + b + c + d + e;
}
}
```

```bash
habit-sensors --all | jq 'sort_by(.smell)[] | {smell, language, key: (.issues[0].key | sub(".*/"; "")), line: .issues[0].details.line, source: .issues[0].details.source}'
```

🖥️ ✅
```json
{
"smell": "too-many-parameters",
"language": "java",
"key": "Billing.java",
"line": 4,
"source": "pmd:ExcessiveParameterList"
}
{
"smell": "unused-import",
"language": "java",
"key": "Billing.java",
"line": 1,
"source": "pmd:UnnecessaryImport"
}
{
"smell": "unused-variable",
"language": "java",
"key": "Billing.java",
"line": 5,
"source": "pmd:UnusedLocalVariable"
}
```

## pmd sensor maps a deeply-branched method to high-complexity

A method whose conditions are littered with `||` exceeds PMD's cyclomatic
complexity threshold, tripping `CyclomaticComplexity` → `high-complexity` — while
staying short enough on NCSS that the method is not also flagged oversized.

📄Report.java
```java
class Report {
int classify(int n) {
if (n == 1 || n == 2 || n == 3 || n == 4 || n == 5) return 1;
if (n == 6 || n == 7 || n == 8 || n == 9 || n == 10) return 2;
if (n == 11 || n == 12 || n == 13 || n == 14 || n == 15) return 3;
if (n == 16 || n == 17 || n == 18) return 4;
return 0;
}
}
```

```bash
habit-sensors --all | jq '.[] | {smell, language, source: .issues[0].details.source}'
```

🖥️ ✅
```json
{
"smell": "high-complexity",
"language": "java",
"source": "pmd:CyclomaticComplexity"
}
```

## A project's own ruleset wins over the bundled one

PMD never discovers a project ruleset, so the sensor reaches for one only where
the Java ecosystem conventionally keeps it. A `src/main/resources/pmd/ruleset.xml`
that lowers the parameter threshold is in force for the run — the bundled
fallback is only the answer to "this project has none".

📄src/main/resources/pmd/ruleset.xml
```xml
<?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>two parameters is already too many</description>
<rule ref="category/java/design.xml/ExcessiveParameterList">
<properties><property name="minimum" value="2"/></properties>
</rule>
</ruleset>
```

📄Project.java
```java
class Project {
void save(String a, String b) {
}
}
```

```bash
habit-sensors --all | jq '.[] | {smell, language, key: (.issues[0].key | sub(".*/"; ""))}'
```

🖥️ ✅
```json
{
"smell": "too-many-parameters",
"language": "java",
"key": "Project.java"
}
```

## A crashing pmd fails the run, never reports clean

PMD exits non-zero on a file it cannot parse. The sensor surfaces that as a
failure — a crashed tool is never a clean run. It exits with a code outside the
findings range, so `habit-sensors` raises, names the sensor on stderr, and exits
1 rather than printing an empty (false-clean) result. The failed run carries only
the reserved `incomplete-run` marker on stdout
([habit-sensors.spec.md](../../../docs/habit-sensors.spec.md)).

The notice carries PMD's own diagnosis after that first line.

📄broken.java
```java
class Broken {
void oops( {
}
```

```bash
habit-sensors --all | jq -c '[.[].smell]'
```

🖥️ ❌ 1
```json
["incomplete-run"]
```

```bash
habit-sensors --all 2>&1 >/dev/null | sed -n 1p
```

🖥️ ❌ 1
```text
habit-sensors: sensor 'pmd' failed: ${python} ${dir}/pmd_sensor.py ${args} ${files}
```
16 changes: 16 additions & 0 deletions plugins/java/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[project]
name = "habit-hooks-java"
version = "1.2.1"
description = "The Java Habit Hooks plugin"
requires-python = ">=3.11"
dependencies = []

[project.entry-points."habit_hooks.plugins"]
java = "habit_hooks_java"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/habit_hooks_java"]
1 change: 1 addition & 0 deletions plugins/java/src/habit_hooks_java/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""The java Habit Hooks plugin: package data discovered via the habit_hooks.plugins entry point."""
4 changes: 4 additions & 0 deletions plugins/java/src/habit_hooks_java/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
language = "java"
files = ["**/*.java"]
sensors = ["pmd"]
transformers = []
20 changes: 20 additions & 0 deletions plugins/java/src/habit_hooks_java/sensors/pmd-ruleset.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<ruleset name="habit-hooks"
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>Habit-hooks structural smell rules. Reached for only when the
project names no PMD ruleset of its own; a project's own ruleset wins.</description>
<rule ref="category/java/design.xml/ExcessiveParameterList">
<properties><property name="minimum" value="4"/></properties>
</rule>
<rule ref="category/java/design.xml/CyclomaticComplexity">
<properties><property name="methodReportLevel" value="10"/></properties>
</rule>
<rule ref="category/java/design.xml/NcssCount">
<properties><property name="methodReportLevel" value="12"/></properties>
</rule>
<rule ref="category/java/bestpractices.xml/UnusedLocalVariable"/>
<rule ref="category/java/codestyle.xml/UnnecessaryImport"/>
<rule ref="category/java/errorprone.xml/EmptyCatchBlock"/>
</ruleset>
1 change: 1 addition & 0 deletions plugins/java/src/habit_hooks_java/sensors/pmd.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
command = "${python} ${dir}/pmd_sensor.py ${args} ${files}"
Loading
0