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
1,499 changes: 1,499 additions & 0 deletions docs/superpowers/plans/2026-05-08-ban-empty-java-files-package-validation.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Design: Package Name Validation in `banEmptyJavaFiles`

## Overview

Extend the existing `banEmptyJavaFiles` enforcer rule to also validate that each Java file's `package` declaration matches its directory structure relative to the source root.

## Changes

### 1. `TLDParser` interface

Replace `List<String> parse()` with a return type of `CompilationUnitInfo`:

```java
public record CompilationUnitInfo(String packageName, List<String> typeNames) {}
```

- `packageName` is `null` when no `package` declaration is present (default package).
- All four implementations must be updated: `RecursiveDescentTLDParser`, `RegexBasedTLDParser`, `AntlrTLDParser`, `JavaParserTLDParser`.
- Each implementation already encounters the `package` keyword; instead of skipping it, capture the token between `package` and `;`.

### 2. `EmptyJavaFileAnalyzer`

- Replace `boolean isEmptyJavaFile(Path)` with a method returning:

```java
record FileAnalysisResult(boolean isEmpty, boolean hasWrongPackage) {}
```

- Derive expected package from the file path relative to the source root (directory segments joined with `.`).
- Files directly in the source root → expected package is `null`. A missing declaration here is allowed; a present declaration is a violation.
- Files in a subdirectory with no package declaration → `hasWrongPackage = true` (expected a package, found none).
- Compare expected package against `CompilationUnitInfo.packageName()`.

### 3. `BanEmptyJavaFiles`

- Collect violations from both checks separately.
- Report in two sections:

```
Empty Java source files found:
- src/main/java/com/example/Empty.java
Java files with incorrect package declaration:
- src/main/java/com/example/Wrong.java (expected: com.example, found: com.wrong)
```

## Testing

**Implementation order: end-to-end integration test first, then unit tests, then implementation.**

### Integration test (written first)
- `src/it/fail-ban-empty-java-files-wrong-package/` — a `.java` file whose `package` declaration doesn't match its directory → build fails with expected error message.

### Unit tests for `EmptyJavaFileAnalyzer`
- Correct package → no violations
- Wrong package → `hasWrongPackage = true`
- File in source root, no package → no violations
- File in source root, has package → `hasWrongPackage = true`
- Empty AND wrong package → both flags set

### Parser tests (extend existing)
- `package com.example;` in input → `packageName = "com.example"`
- No package in input → `packageName = null`
- Package keyword inside block comment → `packageName = null`

## Out of Scope
- No new configuration parameters.
- No changes to the `parserId` selection mechanism.
- `package-info.java` and `module-info.java` remain excluded (existing behaviour).
4 changes: 2 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@
<maven-site-plugin.version>3.20.0</maven-site-plugin.version>
<maven-source-plugin.version>2.4</maven-source-plugin.version>
<maven-surefire-plugin.version>3.2.2</maven-surefire-plugin.version>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<mavenVersion>3.8.1</mavenVersion>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<sisu-maven-plugin.version>0.9.0.M3</sisu-maven-plugin.version>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
invoker.buildResult = failure
27 changes: 27 additions & 0 deletions src/it/fail-ban-empty-java-files-wrong-package/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>io.github.shitikanth</groupId>
<artifactId>fail-ban-empty-java-files-wrong-package</artifactId>
<version>1.0-SNAPSHOT</version>

<build>
<plugins>
<plugin>
<artifactId>maven-enforcer-plugin</artifactId>
<version>@maven-enforcer-plugin.version@</version>
<dependencies>
<dependency>
<groupId>@project.groupId@</groupId>
<artifactId>@project.artifactId@</artifactId>
<version>@project.version@</version>
</dependency>
</dependencies>
<configuration>
<rules>
<banEmptyJavaFiles/>
</rules>
</configuration>
</plugin>
</plugins>
</build>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.wrong;

public class Foo {
}
7 changes: 7 additions & 0 deletions src/it/fail-ban-empty-java-files-wrong-package/verify.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
File file = new File( basedir, "build.log" )
assert file.exists()
String text = file.getText("utf-8");

assert text.contains('[ERROR] Rule 0: io.github.shitikanth.enforcerrules.BanEmptyJavaFiles failed with message:')
assert text.contains('[ERROR] Java files with incorrect package declaration:')
assert text.contains('\t- src/main/java/com/example/Foo.java (expected: com.example, found: com.wrong)')
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
grammar JavaTLD;

compilationUnit : (typeDeclaration | .)* ;
compilationUnit : packageDeclaration? (typeDeclaration | .)* ;

packageDeclaration : PACKAGE ID ('.' ID)* ';' ;

typeDeclaration : ('class'|'interface'|'enum'|'@interface'|'record') ID .*? block ;

Expand All @@ -12,6 +14,8 @@ COMMENT : '/*' .*? '*/' -> skip;

LINE_COMMENT : '//' ~[\r\n]* -> skip;

PACKAGE : 'package' ;

ID : [a-zA-Z$_] [a-zA-Z$_0-9]* ;

STRING_LITERAL: '"' (~["\\\r\n] | EscapeSequence)* '"';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import java.io.FileReader;
import java.io.UncheckedIOException;
import java.nio.file.Path;
import java.util.List;

public abstract class AbstractTLDParser implements TLDParser {
private Path path;
Expand Down Expand Up @@ -34,5 +33,5 @@ protected Path getPath() {
}

@Override
public abstract List<String> parse();
public abstract CompilationUnitInfo parse();
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,19 +67,18 @@ public void execute() throws EnforcerRuleException {
List<Path> compileSourceRoots = new ArrayList<>();
List<Path> testCompileSourceRoots = new ArrayList<>();
for (String s : project.getCompileSourceRoots()) {
Path path = Paths.get(s);
compileSourceRoots.add(path);
compileSourceRoots.add(Paths.get(s));
}
for (String s : project.getTestCompileSourceRoots()) {
Path path = Paths.get(s);
testCompileSourceRoots.add(path);
testCompileSourceRoots.add(Paths.get(s));
}
analyzeSourceRoots(compileSourceRoots);
analyzeSourceRoots(testCompileSourceRoots);
}

private void analyzeSourceRoots(List<Path> sourceRoots) throws EnforcerRuleException {
List<Path> emptyJavaSourceFiles = new ArrayList<>();
List<AnalysisResult> wrongPackageResults = new ArrayList<>();
EmptyJavaFileAnalyzer analyzer = new EmptyJavaFileAnalyzer(TLDParserFactories.getParserFactory(parserId));
for (Path sourceRoot : sourceRoots) {
LOGGER.debug("Analyzing source root {}", sourceRoot);
Expand Down Expand Up @@ -107,16 +106,24 @@ private void analyzeSourceRoots(List<Path> sourceRoots) throws EnforcerRuleExcep
&& !fileName.equals("module-info.java");
})
.map(path -> (Callable<AnalysisResult>) () -> {
boolean isEmpty = analyzer.isEmptyJavaFile(path);
return new AnalysisResult(path, isEmpty);
var result = analyzer.analyze(path, sourceRoot);
return new AnalysisResult(
path,
result.isEmpty(),
result.hasWrongPackage(),
result.expectedPackage(),
result.actualPackage());
})
.collect(Collectors.toList()))
.forEach(result -> {
if (result.isDone()) {
.forEach(future -> {
if (future.isDone()) {
try {
var analysisResult = result.get();
if (analysisResult.isEmpty()) {
emptyJavaSourceFiles.add(analysisResult.path());
var result = future.get();
if (result.isEmpty()) {
emptyJavaSourceFiles.add(result.path());
}
if (result.hasWrongPackage()) {
wrongPackageResults.add(result);
}
} catch (ExecutionException e) {
LOGGER.error("Task encountered exception: ", e.getCause());
Expand All @@ -135,8 +142,9 @@ private void analyzeSourceRoots(List<Path> sourceRoots) throws EnforcerRuleExcep
}
}

StringBuilder sb = new StringBuilder();
if (!emptyJavaSourceFiles.isEmpty()) {
StringBuilder sb = new StringBuilder("Empty Java source files found:\n");
sb.append("Empty Java source files found:\n");
for (Path path : emptyJavaSourceFiles) {
sb.append("\t- ")
.append(session.getTopLevelProject()
Expand All @@ -145,17 +153,40 @@ private void analyzeSourceRoots(List<Path> sourceRoots) throws EnforcerRuleExcep
.relativize(path))
.append("\n");
}
}
if (!wrongPackageResults.isEmpty()) {
sb.append("Java files with incorrect package declaration:\n");
for (AnalysisResult result : wrongPackageResults) {
Path relativePath =
session.getTopLevelProject().getBasedir().toPath().relativize(result.path());
sb.append("\t- ")
.append(relativePath)
.append(" (expected: ")
.append(result.expectedPackage())
.append(", found: ")
.append(result.actualPackage())
.append(")\n");
}
}
if (sb.length() > 0) {
throw new EnforcerRuleException(sb.toString());
}
}

static final class AnalysisResult {
private final Path path;
private final boolean isEmpty;
private final boolean hasWrongPackage;
private final String expectedPackage;
private final String actualPackage;

AnalysisResult(Path path, boolean isEmpty) {
AnalysisResult(
Path path, boolean isEmpty, boolean hasWrongPackage, String expectedPackage, String actualPackage) {
this.path = path;
this.isEmpty = isEmpty;
this.hasWrongPackage = hasWrongPackage;
this.expectedPackage = expectedPackage;
this.actualPackage = actualPackage;
}

public Path path() {
Expand All @@ -166,22 +197,39 @@ public boolean isEmpty() {
return isEmpty;
}

public boolean hasWrongPackage() {
return hasWrongPackage;
}

public String expectedPackage() {
return expectedPackage;
}

public String actualPackage() {
return actualPackage;
}

@Override
public boolean equals(Object obj) {
if (obj == this) return true;
if (obj == null || obj.getClass() != this.getClass()) return false;
var that = (AnalysisResult) obj;
return Objects.equals(this.path, that.path) && this.isEmpty == that.isEmpty;
return Objects.equals(this.path, that.path)
&& this.isEmpty == that.isEmpty
&& this.hasWrongPackage == that.hasWrongPackage
&& Objects.equals(this.expectedPackage, that.expectedPackage)
&& Objects.equals(this.actualPackage, that.actualPackage);
}

@Override
public int hashCode() {
return Objects.hash(path, isEmpty);
return Objects.hash(path, isEmpty, hasWrongPackage, expectedPackage, actualPackage);
}

@Override
public String toString() {
return "AnalysisResult[" + "path=" + path + ", " + "isEmpty=" + isEmpty + ']';
return "AnalysisResult[path=" + path + ", isEmpty=" + isEmpty + ", hasWrongPackage=" + hasWrongPackage
+ ']';
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package io.github.shitikanth.enforcerrules;

import java.util.List;

public record CompilationUnitInfo(String packageName, List<String> typeNames) {
public CompilationUnitInfo {
typeNames = List.copyOf(typeNames);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package io.github.shitikanth.enforcerrules;

import java.io.File;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand All @@ -14,12 +15,28 @@ class EmptyJavaFileAnalyzer {
this.parserFactory = parserFactory;
}

boolean isEmptyJavaFile(Path path) {
record FileAnalysisResult(boolean isEmpty, boolean hasWrongPackage, String expectedPackage, String actualPackage) {}

FileAnalysisResult analyze(Path path, Path sourceRoot) {
LOGGER.debug("Analyzing: {}", path);
String expectedTypeName = path.getFileName().toString().replace(".java", "");
TLDParser parser = parserFactory.createTLDParser(path);
List<String> typeNames = parser.parse();
LOGGER.debug("Found types: {}", typeNames);
return !typeNames.contains(expectedTypeName);
CompilationUnitInfo info = parserFactory.createTLDParser(path).parse();
LOGGER.debug("Found types: {}, package: {}", info.typeNames(), info.packageName());

boolean isEmpty = !info.typeNames().contains(expectedTypeName);

String expectedPackage = computeExpectedPackage(path, sourceRoot);
boolean hasWrongPackage = !Objects.equals(expectedPackage, info.packageName());

return new FileAnalysisResult(isEmpty, hasWrongPackage, expectedPackage, info.packageName());
}

private String computeExpectedPackage(Path path, Path sourceRoot) {
Path relativeDir = sourceRoot.relativize(path.getParent());
String rel = relativeDir.toString();
if (rel.isEmpty()) {
return null;
}
return rel.replace(File.separatorChar, '.');
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
package io.github.shitikanth.enforcerrules;

import java.util.List;

/**
* This interface represents a parser for top-level Java declarations.
* Parser for top-level Java declarations in a compilation unit.
*/
public interface TLDParser {
/**
* @return List of names of top-level types declared in the compilation unit.
* @return Parsed info: package name (null if absent) and top-level type names.
*/
List<String> parse();
CompilationUnitInfo parse();
}
Loading
Loading