From b578c869d6c2c51e0b0854ec3d45cc230b5985ba Mon Sep 17 00:00:00 2001 From: Shitikanth Kashyap Date: Fri, 8 May 2026 13:58:11 +0530 Subject: [PATCH 01/11] Add design spec for package name validation in banEmptyJavaFiles Co-Authored-By: Claude Sonnet 4.6 (1M context) --- ...ty-java-files-package-validation-design.md | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-08-ban-empty-java-files-package-validation-design.md diff --git a/docs/superpowers/specs/2026-05-08-ban-empty-java-files-package-validation-design.md b/docs/superpowers/specs/2026-05-08-ban-empty-java-files-package-validation-design.md new file mode 100644 index 0000000..78a8931 --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-ban-empty-java-files-package-validation-design.md @@ -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 parse()` with a return type of `CompilationUnitInfo`: + +```java +public record CompilationUnitInfo(String packageName, List 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). From 8f10616e89e9af1f30325d670fb3a825525855f4 Mon Sep 17 00:00:00 2001 From: Shitikanth Kashyap Date: Fri, 8 May 2026 14:06:33 +0530 Subject: [PATCH 02/11] Add implementation plan for package name validation in banEmptyJavaFiles Co-Authored-By: Claude Sonnet 4.6 (1M context) --- ...ban-empty-java-files-package-validation.md | 1499 +++++++++++++++++ 1 file changed, 1499 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-08-ban-empty-java-files-package-validation.md diff --git a/docs/superpowers/plans/2026-05-08-ban-empty-java-files-package-validation.md b/docs/superpowers/plans/2026-05-08-ban-empty-java-files-package-validation.md new file mode 100644 index 0000000..577b65d --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-ban-empty-java-files-package-validation.md @@ -0,0 +1,1499 @@ +# Package Name Validation in `banEmptyJavaFiles` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend `banEmptyJavaFiles` to also report Java files whose `package` declaration doesn't match their directory path relative to the source root. + +**Architecture:** `TLDParser.parse()` is changed to return a new `CompilationUnitInfo` record holding both the package name and type names. All four parser implementations capture the package declaration. `EmptyJavaFileAnalyzer` gains package-validation logic and returns a `FileAnalysisResult`. `BanEmptyJavaFiles` reports both violation kinds in two sections of the error message. + +**Tech Stack:** Java 11, Maven Enforcer API, ANTLR4 (grammar update), JavaParser, JUnit 5 + +--- + +## File Map + +| Action | Path | Purpose | +|--------|------|---------| +| Create | `src/main/java/io/github/shitikanth/enforcerrules/CompilationUnitInfo.java` | New record: packageName + typeNames | +| Modify | `src/main/java/io/github/shitikanth/enforcerrules/TLDParser.java` | Change `parse()` return type | +| Modify | `src/main/java/io/github/shitikanth/enforcerrules/EmptyJavaFileAnalyzer.java` | New `FileAnalysisResult`, new `analyze(path, sourceRoot)` method | +| Modify | `src/main/java/io/github/shitikanth/enforcerrules/BanEmptyJavaFiles.java` | Collect/report both violation kinds | +| Modify | `src/main/java/io/github/shitikanth/enforcerrules/impl/RecursiveDescentTLDParser.java` | Capture package declaration | +| Modify | `src/main/java/io/github/shitikanth/enforcerrules/impl/RegexBasedTLDParser.java` | Capture package declaration | +| Modify | `src/main/java/io/github/shitikanth/enforcerrules/impl/AntlrTLDParser.java` | Capture package declaration | +| Modify | `src/main/antlr4/io/github/shitikanth/enforcerrules/JavaTLD.g4` | Add `packageDeclaration` grammar rule | +| Modify | `src/main/java/io/github/shitikanth/enforcerrules/impl/JavaParserTLDParser.java` | Capture package declaration | +| Create | `src/test/java/io/github/shitikanth/enforcerrules/EmptyJavaFileAnalyzerTest.java` | Unit tests for analyzer | +| Modify | `src/test/java/io/github/shitikanth/enforcerrules/impl/RecursiveDescentTLDParserTest.java` | Add package assertions | +| Modify | `src/test/java/io/github/shitikanth/enforcerrules/impl/RegexBasedTLDParserTest.java` | Add package assertions | +| Modify | `src/test/java/io/github/shitikanth/enforcerrules/impl/AntlrTLDParserTest.java` | Add package assertions | +| Create | `src/it/fail-ban-empty-java-files-wrong-package/invoker.properties` | IT: expect build failure | +| Create | `src/it/fail-ban-empty-java-files-wrong-package/pom.xml` | IT: maven project | +| Create | `src/it/fail-ban-empty-java-files-wrong-package/src/main/java/com/example/Foo.java` | IT: file with wrong package | +| Create | `src/it/fail-ban-empty-java-files-wrong-package/verify.groovy` | IT: assert error message | + +--- + +### Task 1: Add integration test for wrong package (end-to-end test first) + +**Files:** +- Create: `src/it/fail-ban-empty-java-files-wrong-package/invoker.properties` +- Create: `src/it/fail-ban-empty-java-files-wrong-package/pom.xml` +- Create: `src/it/fail-ban-empty-java-files-wrong-package/src/main/java/com/example/Foo.java` +- Create: `src/it/fail-ban-empty-java-files-wrong-package/verify.groovy` + +- [ ] **Step 1: Create `invoker.properties`** + +```properties +invoker.buildResult = failure +``` + +- [ ] **Step 2: Create `pom.xml`** + +Copy the structure from `src/it/fail-ban-empty-java-files/pom.xml` with a different artifactId: + +```xml + + 4.0.0 + io.github.shitikanth + fail-ban-empty-java-files-wrong-package + 1.0-SNAPSHOT + + + + + maven-enforcer-plugin + @maven-enforcer-plugin.version@ + + + @project.groupId@ + @project.artifactId@ + @project.version@ + + + + + + + + + + + +``` + +- [ ] **Step 3: Create the Java source file with wrong package** + +The file lives at `src/main/java/com/example/Foo.java` so the correct package is `com.example`, but declare a wrong one: + +```java +package com.wrong; + +public class Foo { +} +``` + +- [ ] **Step 4: Create `verify.groovy`** + +```groovy +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)') +``` + +- [ ] **Step 5: Commit (test will fail until implementation is complete)** + +```bash +git add src/it/fail-ban-empty-java-files-wrong-package/ +git commit -m "test: add IT for wrong package declaration in banEmptyJavaFiles" +``` + +--- + +### Task 2: Introduce `CompilationUnitInfo` and update `TLDParser` interface + +**Files:** +- Create: `src/main/java/io/github/shitikanth/enforcerrules/CompilationUnitInfo.java` +- Modify: `src/main/java/io/github/shitikanth/enforcerrules/TLDParser.java` + +- [ ] **Step 1: Create `CompilationUnitInfo.java`** + +```java +package io.github.shitikanth.enforcerrules; + +import java.util.List; + +public record CompilationUnitInfo(String packageName, List typeNames) {} +``` + +`packageName` is `null` when the file has no `package` declaration (default package). + +- [ ] **Step 2: Update `TLDParser.java`** + +Replace the full file: + +```java +package io.github.shitikanth.enforcerrules; + +public interface TLDParser { + /** + * @return Parsed info: package name (null if absent) and top-level type names. + */ + CompilationUnitInfo parse(); +} +``` + +- [ ] **Step 3: Verify the project does not compile (expected)** + +```bash +mvn compile -pl . 2>&1 | grep "error:" | head -20 +``` + +Expected: compilation errors in all four parser implementations and in `EmptyJavaFileAnalyzer`. This confirms the interface change propagated. Do not fix yet. + +- [ ] **Step 4: Commit the broken state** + +```bash +git add src/main/java/io/github/shitikanth/enforcerrules/CompilationUnitInfo.java \ + src/main/java/io/github/shitikanth/enforcerrules/TLDParser.java +git commit -m "feat: introduce CompilationUnitInfo and update TLDParser interface" +``` + +--- + +### Task 3: Update `RecursiveDescentTLDParser` to capture package name + +**Files:** +- Modify: `src/main/java/io/github/shitikanth/enforcerrules/impl/RecursiveDescentTLDParser.java` +- Modify: `src/test/java/io/github/shitikanth/enforcerrules/impl/RecursiveDescentTLDParserTest.java` + +- [ ] **Step 1: Write the failing test first** + +Replace `RecursiveDescentTLDParserTest.java` in full: + +```java +package io.github.shitikanth.enforcerrules.impl; + +import java.io.BufferedReader; +import java.io.StringReader; + +import org.junit.jupiter.api.Test; + +import io.github.shitikanth.enforcerrules.CompilationUnitInfo; + +import static org.junit.jupiter.api.Assertions.*; + +class RecursiveDescentTLDParserTest { + + private CompilationUnitInfo parse(String source) { + return new RecursiveDescentTLDParser(new BufferedReader(new StringReader(source))).parse(); + } + + @Test + void capturesPackageName() { + var info = parse("package com.example;\nclass Foo {}"); + assertEquals("com.example", info.packageName()); + assertEquals(java.util.List.of("Foo"), info.typeNames()); + } + + @Test + void noPackage_returnsNull() { + var info = parse("class Foo {}"); + assertNull(info.packageName()); + assertEquals(java.util.List.of("Foo"), info.typeNames()); + } + + @Test + void packageInBlockComment_isIgnored() { + var info = parse("/* package com.fake; */ class Foo {}"); + assertNull(info.packageName()); + } + + @Test + void packageInLineComment_isIgnored() { + var info = parse("// package com.fake;\nclass Foo {}"); + assertNull(info.packageName()); + } + + @Test + void multipleTypesWithPackage() { + var info = parse("package org.example;\nclass A {}\ninterface B {}"); + assertEquals("org.example", info.packageName()); + assertTrue(info.typeNames().contains("A")); + assertTrue(info.typeNames().contains("B")); + } +} +``` + +- [ ] **Step 2: Run to confirm failure** + +```bash +mvn test -pl . -Dtest=RecursiveDescentTLDParserTest -Dsurefire.failIfNoSpecifiedTests=false 2>&1 | tail -20 +``` + +Expected: compile error because `parse()` still returns `List`. + +- [ ] **Step 3: Update `RecursiveDescentTLDParser.java`** + +Add a `packageName` field and a `PackageDeclarationState`. Change `parse()` to return `CompilationUnitInfo`. Split the existing `lookingAt("package") || lookingAt("import")` branch. + +Replace the full file: + +```java +package io.github.shitikanth.enforcerrules.impl; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.annotation.Nullable; + +import org.apache.commons.io.IOUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.github.shitikanth.enforcerrules.AbstractTLDParser; +import io.github.shitikanth.enforcerrules.CompilationUnitInfo; + +class RecursiveDescentTLDParser extends AbstractTLDParser { + static final Logger LOGGER = LoggerFactory.getLogger(RecursiveDescentTLDParser.class); + private String input; + private State state; + private int start = 0; + private int pos = 0; + private final List collector = new ArrayList<>(); + private String packageName = null; + + public RecursiveDescentTLDParser(Path path) { + super(path); + } + + public RecursiveDescentTLDParser(BufferedReader reader) { + super(reader); + } + + @Override + public CompilationUnitInfo parse() { + String input; + try { + input = IOUtils.toString(getReader()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + + this.input = input; + this.state = new InitialState(); + + run(); + return new CompilationUnitInfo(packageName, List.copyOf(collector)); + } + + private void run() { + while (state != null) { + state = state.runState(); + } + } + + private void next() { + pos++; + } + + private char cur() { + return input.charAt(pos - 1); + } + + private char peek() { + return input.charAt(pos); + } + + private boolean eof() { + return pos >= input.length(); + } + + private void skip() { + start = pos; + } + + private void emit() { + collector.add(input.substring(start, pos)); + } + + private boolean lookingAt(String s) { + boolean match = input.regionMatches(pos, s, 0, s.length()); + if (match) { + pos += s.length(); + } + return match; + } + + private boolean lookingAt(Pattern pattern) { + var matcher = pattern.matcher(input); + boolean matched = matcher.region(pos, input.length()).lookingAt(); + if (matched) { + pos = matcher.end(); + } + return matched; + } + + private void skipWs() { + while (!eof() && Character.isWhitespace(peek())) { + next(); + } + skip(); + } + + private void skipUntil(char marker) { + int index = input.indexOf(marker, pos); + if (index != -1) { + pos = index + 1; + } else { + pos = input.length(); + } + skip(); + } + + private void skipUntil(String marker) { + int index = input.indexOf(marker, pos); + if (index != -1) { + pos = index + marker.length(); + } else { + pos = input.length(); + } + skip(); + } + + private void skipWord() { + Pattern pattern = Pattern.compile("\\w\\b"); + Matcher matcher = pattern.matcher(input); + matcher.region(pos, input.length()); + if (matcher.find(pos)) { + pos = matcher.end(); + } + skip(); + } + + interface State { + @Nullable + State runState(); + } + + class InitialState implements State { + @Nullable + @Override + public State runState() { + Pattern classKeyword = Pattern.compile("(class|record|@?interface|enum)\\b"); + while (!eof()) { + skipWs(); + if (lookingAt("\"\"\"")) { + return new InsideTextState(this); + } else if (lookingAt("\"")) { + return new InsideStringState(this); + } else if (lookingAt("(")) { + return new SkipParentheticalBlockState(this, '(', ')'); + } else if (lookingAt("//")) { + return new LineCommentState(this); + } else if (lookingAt("/*")) { + return new BlockCommentState(this); + } else if (lookingAt("package")) { + return new PackageDeclarationState(); + } else if (lookingAt("import")) { + return new SkipToSemicolonState(this); + } else if (lookingAt(classKeyword)) { + return new TypeDeclarationState(); + } else { + skipWord(); + } + } + return null; + } + } + + class PackageDeclarationState implements State { + @Nullable + @Override + public State runState() { + LOGGER.debug("package declaration"); + skipWs(); + int pkgStart = pos; + while (!eof() && peek() != ';') { + next(); + } + String pkg = input.substring(pkgStart, pos).stripTrailing(); + packageName = pkg.isEmpty() ? null : pkg; + if (!eof()) next(); // consume ';' + skip(); + return new InitialState(); + } + } + + class SkipToSemicolonState implements State { + private final State parent; + + SkipToSemicolonState(State parent) { + this.parent = parent; + } + + @Nullable + @Override + public State runState() { + LOGGER.debug("skip to semicolon"); + skipUntil(';'); + return parent; + } + } + + class InsideTextState implements State { + private final State parent; + + InsideTextState(State parent) { + this.parent = parent; + } + + @Nullable + @Override + public State runState() { + LOGGER.debug("inside text"); + skipUntil("\"\"\""); + return parent; + } + } + + class InsideStringState implements State { + private final State parent; + + public InsideStringState(State parent) { + this.parent = parent; + } + + @Nullable + @Override + public State runState() { + LOGGER.debug("{}\tinside string: {}", pos, input.substring(pos, Math.min(pos + 10, input.length()))); + boolean escaped = false; + while (!eof()) { + char c = peek(); + next(); + if (c == '\"' && !escaped) { + break; + } + if (c == '\\') { + escaped = !escaped; + } else { + escaped = false; + } + } + return parent; + } + } + + class InsideCharacterLiteralState implements State { + private final State parent; + + public InsideCharacterLiteralState(State parent) { + this.parent = parent; + } + + @Nullable + @Override + public State runState() { + LOGGER.debug( + "{}\tinside character literal: {}", pos, input.substring(pos, Math.min(pos + 10, input.length()))); + boolean escaped = false; + while (!eof()) { + char c = peek(); + next(); + if (c == '\'' && !escaped) { + break; + } + if (c == '\\') { + escaped = !escaped; + } else { + escaped = false; + } + } + return parent; + } + } + + class LineCommentState implements State { + private final State parent; + + LineCommentState(State parent) { + this.parent = parent; + } + + @Nullable + @Override + public State runState() { + LOGGER.debug("line comment"); + skipUntil('\n'); + return parent; + } + } + + class BlockCommentState implements State { + private final State parent; + + public BlockCommentState(State parent) { + this.parent = parent; + } + + @Nullable + @Override + public State runState() { + LOGGER.debug("block comment"); + skipUntil("*/"); + return parent; + } + } + + class TypeDeclarationState implements State { + @Nullable + @Override + public State runState() { + LOGGER.debug("type declaration"); + skipWs(); + char c = peek(); + while (!eof() && Character.isJavaIdentifierPart(c)) { + next(); + c = peek(); + } + emit(); + skipUntil('{'); + return new SkipParentheticalBlockState(new InitialState(), '{', '}'); + } + } + + class SkipParentheticalBlockState implements State { + private final State nextState; + private final char startMarker; + private final char endMarker; + int depth = 1; + + public SkipParentheticalBlockState(State nextState, char startMarker, char endMarker) { + LOGGER.debug("skip parenthentical {} {}", startMarker, endMarker); + this.nextState = nextState; + this.startMarker = startMarker; + this.endMarker = endMarker; + } + + @Nullable + @Override + public State runState() { + char c; + while (!eof()) { + skipWs(); + if (lookingAt("\"\"\"")) { + return new InsideTextState(this); + } else if (lookingAt("\"")) { + return new InsideStringState(this); + } + if (lookingAt("//")) { + return new LineCommentState(this); + } else if (lookingAt("/*")) { + return new BlockCommentState(this); + } else if (lookingAt("'")) { + return new InsideCharacterLiteralState(this); + } + c = peek(); + if (c == startMarker) { + depth++; + } + if (c == endMarker) { + depth--; + if (depth == 0) { + next(); + return nextState; + } + } + next(); + } + throw new RuntimeException("Failed to parse: " + getPath()); + } + } +} +``` + +Note: `PackageOrImportState` is replaced by `PackageDeclarationState` (captures package name) and `SkipToSemicolonState` (skips imports). The existing `PackageOrImportState` class is removed. + +- [ ] **Step 4: Run the tests** + +```bash +mvn test -pl . -Dtest=RecursiveDescentTLDParserTest -Dsurefire.failIfNoSpecifiedTests=false 2>&1 | tail -20 +``` + +Expected: all 5 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/io/github/shitikanth/enforcerrules/impl/RecursiveDescentTLDParser.java \ + src/test/java/io/github/shitikanth/enforcerrules/impl/RecursiveDescentTLDParserTest.java +git commit -m "feat: RecursiveDescentTLDParser captures package name" +``` + +--- + +### Task 4: Update `RegexBasedTLDParser` to capture package name + +**Files:** +- Modify: `src/main/java/io/github/shitikanth/enforcerrules/impl/RegexBasedTLDParser.java` +- Modify: `src/test/java/io/github/shitikanth/enforcerrules/impl/RegexBasedTLDParserTest.java` + +- [ ] **Step 1: Write the failing test** + +Replace `RegexBasedTLDParserTest.java` in full: + +```java +package io.github.shitikanth.enforcerrules.impl; + +import java.io.BufferedReader; +import java.io.StringReader; + +import org.junit.jupiter.api.Test; + +import io.github.shitikanth.enforcerrules.CompilationUnitInfo; + +import static org.junit.jupiter.api.Assertions.*; + +class RegexBasedTLDParserTest { + + private CompilationUnitInfo parse(String source) { + return new RegexBasedTLDParser(new BufferedReader(new StringReader(source))).parse(); + } + + @Test + void capturesPackageName() { + var info = parse("package com.example;\npublic class Foo {}"); + assertEquals("com.example", info.packageName()); + assertTrue(info.typeNames().contains("Foo")); + } + + @Test + void noPackage_returnsNull() { + var info = parse("public class Foo {}"); + assertNull(info.packageName()); + } + + @Test + void multipleTypes() { + var info = parse("package org.example;\npublic class A {}\npublic interface B {}"); + assertEquals("org.example", info.packageName()); + assertTrue(info.typeNames().contains("A")); + assertTrue(info.typeNames().contains("B")); + } +} +``` + +- [ ] **Step 2: Run to confirm failure** + +```bash +mvn test -pl . -Dtest=RegexBasedTLDParserTest -Dsurefire.failIfNoSpecifiedTests=false 2>&1 | tail -20 +``` + +Expected: compile error because `parse()` still returns `List` in `RegexBasedTLDParser`. + +- [ ] **Step 3: Update `RegexBasedTLDParser.java`** + +Replace the full file: + +```java +package io.github.shitikanth.enforcerrules.impl; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.google.common.annotations.VisibleForTesting; + +import io.github.shitikanth.enforcerrules.AbstractTLDParser; +import io.github.shitikanth.enforcerrules.CompilationUnitInfo; + +class RegexBasedTLDParser extends AbstractTLDParser { + private static final Pattern TYPE_PATTERN = Pattern.compile( + "^((public|protected|private|static|abstract|final|sealed|non_sealed)\\s+)*(class|interface|@interface|enum|record)\\s+(\\w+)"); + private static final Pattern PKG_PATTERN = Pattern.compile( + "^package\\s+([\\w.]+)\\s*;"); + + public RegexBasedTLDParser(Path path) { + super(path); + } + + public RegexBasedTLDParser(BufferedReader reader) { + super(reader); + } + + @Override + public CompilationUnitInfo parse() { + try (var bufferedReader = getReader()) { + return parse(bufferedReader); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @VisibleForTesting + public CompilationUnitInfo parse(BufferedReader bufferedReader) { + List types = new ArrayList<>(); + String[] packageName = {null}; + bufferedReader.lines().forEach(line -> { + Matcher pkgMatcher = PKG_PATTERN.matcher(line); + if (pkgMatcher.find()) { + packageName[0] = pkgMatcher.group(1); + } + Matcher typeMatcher = TYPE_PATTERN.matcher(line); + if (typeMatcher.find()) { + types.add(typeMatcher.group(4)); + } + }); + return new CompilationUnitInfo(packageName[0], types); + } +} +``` + +- [ ] **Step 4: Run the tests** + +```bash +mvn test -pl . -Dtest=RegexBasedTLDParserTest -Dsurefire.failIfNoSpecifiedTests=false 2>&1 | tail -20 +``` + +Expected: all 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/io/github/shitikanth/enforcerrules/impl/RegexBasedTLDParser.java \ + src/test/java/io/github/shitikanth/enforcerrules/impl/RegexBasedTLDParserTest.java +git commit -m "feat: RegexBasedTLDParser captures package name" +``` + +--- + +### Task 5: Update ANTLR grammar and `AntlrTLDParser` to capture package name + +**Files:** +- Modify: `src/main/antlr4/io/github/shitikanth/enforcerrules/JavaTLD.g4` +- Modify: `src/main/java/io/github/shitikanth/enforcerrules/impl/AntlrTLDParser.java` +- Modify: `src/test/java/io/github/shitikanth/enforcerrules/impl/AntlrTLDParserTest.java` + +- [ ] **Step 1: Write the failing test** + +Replace `AntlrTLDParserTest.java` in full: + +```java +package io.github.shitikanth.enforcerrules.impl; + +import java.io.BufferedReader; +import java.io.StringReader; + +import org.junit.jupiter.api.Test; + +import io.github.shitikanth.enforcerrules.CompilationUnitInfo; + +import static org.junit.jupiter.api.Assertions.*; + +class AntlrTLDParserTest { + + private CompilationUnitInfo parse(String source) { + return new AntlrTLDParser(new BufferedReader(new StringReader(source))).parse(); + } + + @Test + void capturesPackageName() { + var info = parse("package com.example;\nclass Foo {}"); + assertEquals("com.example", info.packageName()); + assertTrue(info.typeNames().contains("Foo")); + } + + @Test + void noPackage_returnsNull() { + var info = parse("class Foo {}"); + assertNull(info.packageName()); + } + + @Test + void multipleTypes() { + var info = parse("package org.example;\nclass A {}\ninterface B {}"); + assertEquals("org.example", info.packageName()); + assertTrue(info.typeNames().contains("A")); + assertTrue(info.typeNames().contains("B")); + } +} +``` + +- [ ] **Step 2: Run to confirm failure** + +```bash +mvn test -pl . -Dtest=AntlrTLDParserTest -Dsurefire.failIfNoSpecifiedTests=false 2>&1 | tail -20 +``` + +Expected: compile error. + +- [ ] **Step 3: Update `JavaTLD.g4`** + +Replace the full grammar file: + +```antlr +grammar JavaTLD; + +compilationUnit : packageDeclaration? (typeDeclaration | .)* ; + +packageDeclaration : PACKAGE ID ('.' ID)* ';' ; + +typeDeclaration : ('class'|'interface'|'enum'|'@interface'|'record') ID .*? block ; + +block : '{' (block | .)*? '}' ; + +WS : [ \r\t\n]+ -> skip ; + +COMMENT : '/*' .*? '*/' -> skip; + +LINE_COMMENT : '//' ~[\r\n]* -> skip; + +PACKAGE : 'package' ; + +ID : [a-zA-Z$_] [a-zA-Z$_0-9]* ; + +STRING_LITERAL: '"' (~["\\\r\n] | EscapeSequence)* '"'; + +TEXT_BLOCK: '"""' [ \t]* [\r\n] (. | EscapeSequence)*? '"""'; + +fragment EscapeSequence: + '\\' 'u005c'? [btnfr"'\\] + | '\\' 'u005c'? ([0-3]? [0-7])? [0-7] + | '\\' 'u'+ HexDigit HexDigit HexDigit HexDigit +; + +fragment HexDigit: [0-9a-fA-F]; + +ANY : . ; +``` + +Key changes: `PACKAGE` explicit lexer rule (before `ID` so it takes priority), `packageDeclaration` parser rule, `compilationUnit` updated to `packageDeclaration? (typeDeclaration | .)*`. + +- [ ] **Step 4: Update `AntlrTLDParser.java`** + +Replace the full file: + +```java +package io.github.shitikanth.enforcerrules.impl; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Collectors; + +import com.google.common.annotations.VisibleForTesting; +import org.antlr.v4.runtime.CharStreams; +import org.antlr.v4.runtime.CommonTokenStream; +import org.antlr.v4.runtime.tree.TerminalNode; + +import io.github.shitikanth.enforcerrules.AbstractTLDParser; +import io.github.shitikanth.enforcerrules.CompilationUnitInfo; +import io.github.shitikanth.enforcerrules.JavaTLDLexer; +import io.github.shitikanth.enforcerrules.JavaTLDParser; + +class AntlrTLDParser extends AbstractTLDParser { + public AntlrTLDParser(Path path) { + super(path); + } + + public AntlrTLDParser(BufferedReader reader) { + super(reader); + } + + @Override + public CompilationUnitInfo parse() { + try (BufferedReader reader = this.getReader()) { + return parse(reader); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @VisibleForTesting + public CompilationUnitInfo parse(BufferedReader bufferedReader) throws IOException { + var lexer = new JavaTLDLexer(CharStreams.fromReader(bufferedReader)); + var parser = new JavaTLDParser(new CommonTokenStream(lexer)); + var compilationUnit = parser.compilationUnit(); + + String packageName = null; + var pkgDecl = compilationUnit.packageDeclaration(); + if (pkgDecl != null) { + packageName = pkgDecl.ID().stream() + .map(TerminalNode::getText) + .collect(Collectors.joining(".")); + } + + List types = compilationUnit.typeDeclaration().stream() + .map(typeDeclaration -> typeDeclaration.ID().toString()) + .collect(Collectors.toList()); + + return new CompilationUnitInfo(packageName, types); + } +} +``` + +- [ ] **Step 5: Run the tests** + +The ANTLR plugin regenerates `JavaTLDLexer` and `JavaTLDParser` during `generate-sources`. Run: + +```bash +mvn test -pl . -Dtest=AntlrTLDParserTest -Dsurefire.failIfNoSpecifiedTests=false 2>&1 | tail -30 +``` + +Expected: all 3 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/main/antlr4/io/github/shitikanth/enforcerrules/JavaTLD.g4 \ + src/main/java/io/github/shitikanth/enforcerrules/impl/AntlrTLDParser.java \ + src/test/java/io/github/shitikanth/enforcerrules/impl/AntlrTLDParserTest.java +git commit -m "feat: AntlrTLDParser captures package name" +``` + +--- + +### Task 6: Update `JavaParserTLDParser` to capture package name + +**Files:** +- Modify: `src/main/java/io/github/shitikanth/enforcerrules/impl/JavaParserTLDParser.java` + +There is no dedicated test class for `JavaParserTLDParser`; it is exercised through integration tests via the `parserId=java-parser` configuration. Adding one is out of scope for this task. + +- [ ] **Step 1: Update `JavaParserTLDParser.java`** + +Replace the full file: + +```java +package io.github.shitikanth.enforcerrules.impl; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import com.github.javaparser.JavaParser; +import com.github.javaparser.JavaParserAdapter; +import com.github.javaparser.ast.body.TypeDeclaration; + +import io.github.shitikanth.enforcerrules.AbstractTLDParser; +import io.github.shitikanth.enforcerrules.CompilationUnitInfo; + +class JavaParserTLDParser extends AbstractTLDParser { + private final JavaParserAdapter parser; + + public JavaParserTLDParser(JavaParser javaParser, Path path) { + super(path); + this.parser = new JavaParserAdapter(javaParser); + } + + @Override + public CompilationUnitInfo parse() { + com.github.javaparser.ast.CompilationUnit compilationUnit; + try (BufferedReader reader = this.getReader()) { + compilationUnit = parser.parse(reader); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + String packageName = compilationUnit.getPackageDeclaration() + .map(pd -> pd.getNameAsString()) + .orElse(null); + List typeNames = new ArrayList<>(); + for (TypeDeclaration typeDeclaration : compilationUnit.getTypes()) { + typeNames.add(typeDeclaration.getNameAsString()); + } + return new CompilationUnitInfo(packageName, typeNames); + } +} +``` + +- [ ] **Step 2: Run full compile to check all parsers compile** + +```bash +mvn compile -pl . 2>&1 | tail -10 +``` + +Expected: `BUILD SUCCESS`. Only `EmptyJavaFileAnalyzer` and `BanEmptyJavaFiles` remain broken. + +- [ ] **Step 3: Commit** + +```bash +git add src/main/java/io/github/shitikanth/enforcerrules/impl/JavaParserTLDParser.java +git commit -m "feat: JavaParserTLDParser captures package name" +``` + +--- + +### Task 7: Update `EmptyJavaFileAnalyzer` with package validation + +**Files:** +- Modify: `src/main/java/io/github/shitikanth/enforcerrules/EmptyJavaFileAnalyzer.java` +- Create: `src/test/java/io/github/shitikanth/enforcerrules/EmptyJavaFileAnalyzerTest.java` + +- [ ] **Step 1: Write the failing tests** + +Create `EmptyJavaFileAnalyzerTest.java`: + +```java +package io.github.shitikanth.enforcerrules; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import io.github.shitikanth.enforcerrules.impl.TLDParserFactories; + +import static org.junit.jupiter.api.Assertions.*; + +class EmptyJavaFileAnalyzerTest { + + @TempDir + Path sourceRoot; + + private EmptyJavaFileAnalyzer analyzer() { + return new EmptyJavaFileAnalyzer(TLDParserFactories.getParserFactory(null)); + } + + private Path writeJavaFile(String relativePath, String content) throws IOException { + Path file = sourceRoot.resolve(relativePath); + Files.createDirectories(file.getParent()); + Files.writeString(file, content); + return file; + } + + @Test + void correctPackage_noViolation() throws IOException { + Path file = writeJavaFile("com/example/Foo.java", + "package com.example;\npublic class Foo {}"); + var result = analyzer().analyze(file, sourceRoot); + assertFalse(result.isEmpty()); + assertFalse(result.hasWrongPackage()); + } + + @Test + void wrongPackage_flagged() throws IOException { + Path file = writeJavaFile("com/example/Foo.java", + "package com.wrong;\npublic class Foo {}"); + var result = analyzer().analyze(file, sourceRoot); + assertFalse(result.isEmpty()); + assertTrue(result.hasWrongPackage()); + assertEquals("com.example", result.expectedPackage()); + assertEquals("com.wrong", result.actualPackage()); + } + + @Test + void defaultPackage_allowedWhenInRoot() throws IOException { + Path file = writeJavaFile("Foo.java", "public class Foo {}"); + var result = analyzer().analyze(file, sourceRoot); + assertFalse(result.isEmpty()); + assertFalse(result.hasWrongPackage()); + } + + @Test + void packageDeclaredInRoot_isViolation() throws IOException { + Path file = writeJavaFile("Foo.java", "package com.example;\npublic class Foo {}"); + var result = analyzer().analyze(file, sourceRoot); + assertFalse(result.isEmpty()); + assertTrue(result.hasWrongPackage()); + assertNull(result.expectedPackage()); + assertEquals("com.example", result.actualPackage()); + } + + @Test + void noPackageInSubdir_isViolation() throws IOException { + Path file = writeJavaFile("com/example/Foo.java", "public class Foo {}"); + var result = analyzer().analyze(file, sourceRoot); + assertFalse(result.isEmpty()); + assertTrue(result.hasWrongPackage()); + assertEquals("com.example", result.expectedPackage()); + assertNull(result.actualPackage()); + } + + @Test + void emptyFile_flaggedAsEmpty() throws IOException { + Path file = writeJavaFile("com/example/Empty.java", + "package com.example;\n// no type"); + var result = analyzer().analyze(file, sourceRoot); + assertTrue(result.isEmpty()); + assertFalse(result.hasWrongPackage()); + } + + @Test + void emptyFileAndWrongPackage_bothFlagged() throws IOException { + Path file = writeJavaFile("com/example/Empty.java", + "package com.wrong;\n// no type"); + var result = analyzer().analyze(file, sourceRoot); + assertTrue(result.isEmpty()); + assertTrue(result.hasWrongPackage()); + } +} +``` + +- [ ] **Step 2: Run to confirm failure** + +```bash +mvn test -pl . -Dtest=EmptyJavaFileAnalyzerTest -Dsurefire.failIfNoSpecifiedTests=false 2>&1 | tail -20 +``` + +Expected: compile error (no `analyze(path, sourceRoot)` method yet). + +- [ ] **Step 3: Update `EmptyJavaFileAnalyzer.java`** + +Replace the full file: + +```java +package io.github.shitikanth.enforcerrules; + +import java.io.File; +import java.nio.file.Path; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class EmptyJavaFileAnalyzer { + static final Logger LOGGER = LoggerFactory.getLogger(EmptyJavaFileAnalyzer.class); + private final TLDParserFactory parserFactory; + + EmptyJavaFileAnalyzer(TLDParserFactory parserFactory) { + this.parserFactory = parserFactory; + } + + 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", ""); + 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 = !java.util.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, '.'); + } +} +``` + +- [ ] **Step 4: Run the tests** + +```bash +mvn test -pl . -Dtest=EmptyJavaFileAnalyzerTest -Dsurefire.failIfNoSpecifiedTests=false 2>&1 | tail -20 +``` + +Expected: all 7 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/io/github/shitikanth/enforcerrules/EmptyJavaFileAnalyzer.java \ + src/test/java/io/github/shitikanth/enforcerrules/EmptyJavaFileAnalyzerTest.java +git commit -m "feat: EmptyJavaFileAnalyzer validates package name" +``` + +--- + +### Task 8: Update `BanEmptyJavaFiles` to report both violation kinds + +**Files:** +- Modify: `src/main/java/io/github/shitikanth/enforcerrules/BanEmptyJavaFiles.java` + +- [ ] **Step 1: Update `BanEmptyJavaFiles.java`** + +Replace the full file. Key changes: pass `sourceRoot` to `analyzer.analyze()`, extend `AnalysisResult` to carry package info, collect wrong-package violations separately, report in two sections. + +```java +package io.github.shitikanth.enforcerrules; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.stream.Collectors; + +import javax.annotation.PreDestroy; +import javax.inject.Inject; +import javax.inject.Named; + +import org.apache.maven.enforcer.rule.api.AbstractEnforcerRule; +import org.apache.maven.enforcer.rule.api.EnforcerRuleException; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.project.MavenProject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.github.shitikanth.enforcerrules.impl.TLDParserFactories; + +@Named("banEmptyJavaFiles") +class BanEmptyJavaFiles extends AbstractEnforcerRule { + private static final Logger LOGGER = LoggerFactory.getLogger(BanEmptyJavaFiles.class); + + private final MavenSession session; + + private ExecutorService executor; + + /** + * Parameter to select parser implementation. + */ + String parserId; + + @Inject + public BanEmptyJavaFiles(MavenSession session) { + this.session = session; + this.executor = null; + } + + @PreDestroy + void shutdownExecutor() { + if (executor != null) { + for (Runnable task : this.executor.shutdownNow()) { + if (task instanceof Future) { + Future future = (Future) task; + future.cancel(true); + } + } + } + } + + @Override + public void execute() throws EnforcerRuleException { + MavenProject project = session.getCurrentProject(); + int threads = session.getRequest().getDegreeOfConcurrency(); + LOGGER.info("threads: {}", threads); + List compileSourceRoots = new ArrayList<>(); + List testCompileSourceRoots = new ArrayList<>(); + for (String s : project.getCompileSourceRoots()) { + compileSourceRoots.add(Paths.get(s)); + } + for (String s : project.getTestCompileSourceRoots()) { + testCompileSourceRoots.add(Paths.get(s)); + } + analyzeSourceRoots(compileSourceRoots); + analyzeSourceRoots(testCompileSourceRoots); + } + + private void analyzeSourceRoots(List sourceRoots) throws EnforcerRuleException { + List emptyJavaSourceFiles = new ArrayList<>(); + List wrongPackageResults = new ArrayList<>(); + EmptyJavaFileAnalyzer analyzer = new EmptyJavaFileAnalyzer(TLDParserFactories.getParserFactory(parserId)); + for (Path sourceRoot : sourceRoots) { + LOGGER.debug("Analyzing source root {}", sourceRoot); + if (!Files.isDirectory(sourceRoot)) { + continue; + } + try { + var sourceFiles = Files.find( + sourceRoot, + Integer.MAX_VALUE, + (path, attr) -> attr.isRegularFile() + && path.toFile().getName().endsWith(".java")) + .collect(Collectors.toList()); + long startTime = System.currentTimeMillis(); + LOGGER.info("Analyzing {} files", sourceFiles.size()); + executor = Executors.newFixedThreadPool(4); + executor.invokeAll(sourceFiles.stream() + .filter(path -> { + String fileName = null; + if (path.getFileName() != null) { + fileName = path.getFileName().toString(); + } + return fileName != null + && !fileName.equals("package-info.java") + && !fileName.equals("module-info.java"); + }) + .map(path -> (Callable) () -> { + var result = analyzer.analyze(path, sourceRoot); + return new AnalysisResult(path, result.isEmpty(), + result.hasWrongPackage(), result.expectedPackage(), result.actualPackage()); + }) + .collect(Collectors.toList())) + .forEach(future -> { + if (future.isDone()) { + try { + 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()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + }); + long endTime = System.currentTimeMillis(); + LOGGER.info("Finished in {}ms", endTime - startTime); + } catch (IOException e) { + throw new UncheckedIOException(e); + } catch (InterruptedException e) { + LOGGER.error("Interrupted", e); + return; + } + } + + StringBuilder sb = new StringBuilder(); + if (!emptyJavaSourceFiles.isEmpty()) { + sb.append("Empty Java source files found:\n"); + for (Path path : emptyJavaSourceFiles) { + sb.append("\t- ") + .append(session.getTopLevelProject() + .getBasedir() + .toPath() + .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, boolean hasWrongPackage, + String expectedPackage, String actualPackage) { + this.path = path; + this.isEmpty = isEmpty; + this.hasWrongPackage = hasWrongPackage; + this.expectedPackage = expectedPackage; + this.actualPackage = actualPackage; + } + + public Path path() { return path; } + 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 + && 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, hasWrongPackage, expectedPackage, actualPackage); + } + + @Override + public String toString() { + return "AnalysisResult[path=" + path + ", isEmpty=" + isEmpty + + ", hasWrongPackage=" + hasWrongPackage + ']'; + } + } +} +``` + +- [ ] **Step 2: Run the full unit test suite** + +```bash +mvn test -pl . 2>&1 | tail -20 +``` + +Expected: `BUILD SUCCESS`, all tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/main/java/io/github/shitikanth/enforcerrules/BanEmptyJavaFiles.java +git commit -m "feat: banEmptyJavaFiles reports wrong package declarations" +``` + +--- + +### Task 9: Run integration tests and verify + +**Files:** None changed. This task just validates the full end-to-end flow. + +- [ ] **Step 1: Run integration tests** + +```bash +mvn verify 2>&1 | tail -30 +``` + +Expected: `BUILD SUCCESS`. The `fail-ban-empty-java-files-wrong-package` IT should now pass (build fails with the expected error, `verify.groovy` asserts pass). The existing ITs (`fail-ban-empty-java-files`, `fail-ban-empty-java-files-inside-package`, `fail-ban-empty-java-files-nested`, `success`, `success-require-dependency-management`, etc.) should all still pass. + +- [ ] **Step 2: If the wrong-package IT fails, debug** + +Check `src/it/fail-ban-empty-java-files-wrong-package/target/build.log` for the actual error message. Adjust `verify.groovy` assertions or the `BanEmptyJavaFiles` message format if they don't match. + +- [ ] **Step 3: Commit any adjustments, then final commit** + +```bash +git add -u +git commit -m "test: verify integration tests pass after package validation" +``` From 251a78e1fb4830765eccc81f457b4ce0f97be937 Mon Sep 17 00:00:00 2001 From: Shitikanth Kashyap Date: Fri, 8 May 2026 16:09:33 +0530 Subject: [PATCH 03/11] test: add IT for wrong package declaration in banEmptyJavaFiles Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../invoker.properties | 1 + .../pom.xml | 27 +++++++++++++++++++ .../src/main/java/com/example/Foo.java | 4 +++ .../verify.groovy | 7 +++++ 4 files changed, 39 insertions(+) create mode 100644 src/it/fail-ban-empty-java-files-wrong-package/invoker.properties create mode 100644 src/it/fail-ban-empty-java-files-wrong-package/pom.xml create mode 100644 src/it/fail-ban-empty-java-files-wrong-package/src/main/java/com/example/Foo.java create mode 100644 src/it/fail-ban-empty-java-files-wrong-package/verify.groovy diff --git a/src/it/fail-ban-empty-java-files-wrong-package/invoker.properties b/src/it/fail-ban-empty-java-files-wrong-package/invoker.properties new file mode 100644 index 0000000..c21e972 --- /dev/null +++ b/src/it/fail-ban-empty-java-files-wrong-package/invoker.properties @@ -0,0 +1 @@ +invoker.buildResult = failure diff --git a/src/it/fail-ban-empty-java-files-wrong-package/pom.xml b/src/it/fail-ban-empty-java-files-wrong-package/pom.xml new file mode 100644 index 0000000..07dadb0 --- /dev/null +++ b/src/it/fail-ban-empty-java-files-wrong-package/pom.xml @@ -0,0 +1,27 @@ + + 4.0.0 + io.github.shitikanth + fail-ban-empty-java-files-wrong-package + 1.0-SNAPSHOT + + + + + maven-enforcer-plugin + @maven-enforcer-plugin.version@ + + + @project.groupId@ + @project.artifactId@ + @project.version@ + + + + + + + + + + + diff --git a/src/it/fail-ban-empty-java-files-wrong-package/src/main/java/com/example/Foo.java b/src/it/fail-ban-empty-java-files-wrong-package/src/main/java/com/example/Foo.java new file mode 100644 index 0000000..34caec6 --- /dev/null +++ b/src/it/fail-ban-empty-java-files-wrong-package/src/main/java/com/example/Foo.java @@ -0,0 +1,4 @@ +package com.wrong; + +public class Foo { +} diff --git a/src/it/fail-ban-empty-java-files-wrong-package/verify.groovy b/src/it/fail-ban-empty-java-files-wrong-package/verify.groovy new file mode 100644 index 0000000..606fcb4 --- /dev/null +++ b/src/it/fail-ban-empty-java-files-wrong-package/verify.groovy @@ -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)') From 42663ffd5f7a9724ef43a633d9b124cef71c446e Mon Sep 17 00:00:00 2001 From: Shitikanth Kashyap Date: Fri, 8 May 2026 16:13:56 +0530 Subject: [PATCH 04/11] feat: introduce CompilationUnitInfo and update TLDParser interface Bump Java source/target to 17 to enable records; add CompilationUnitInfo record (packageName + typeNames) and update TLDParser.parse() to return it. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- pom.xml | 4 ++-- .../shitikanth/enforcerrules/CompilationUnitInfo.java | 5 +++++ .../io/github/shitikanth/enforcerrules/TLDParser.java | 9 ++------- 3 files changed, 9 insertions(+), 9 deletions(-) create mode 100644 src/main/java/io/github/shitikanth/enforcerrules/CompilationUnitInfo.java diff --git a/pom.xml b/pom.xml index 2b4d0ec..806153f 100644 --- a/pom.xml +++ b/pom.xml @@ -55,8 +55,8 @@ 3.20.0 2.4 3.2.2 - 11 - 11 + 17 + 17 3.8.1 UTF-8 0.9.0.M3 diff --git a/src/main/java/io/github/shitikanth/enforcerrules/CompilationUnitInfo.java b/src/main/java/io/github/shitikanth/enforcerrules/CompilationUnitInfo.java new file mode 100644 index 0000000..212ddcd --- /dev/null +++ b/src/main/java/io/github/shitikanth/enforcerrules/CompilationUnitInfo.java @@ -0,0 +1,5 @@ +package io.github.shitikanth.enforcerrules; + +import java.util.List; + +public record CompilationUnitInfo(String packageName, List typeNames) {} diff --git a/src/main/java/io/github/shitikanth/enforcerrules/TLDParser.java b/src/main/java/io/github/shitikanth/enforcerrules/TLDParser.java index cf65129..58567f8 100644 --- a/src/main/java/io/github/shitikanth/enforcerrules/TLDParser.java +++ b/src/main/java/io/github/shitikanth/enforcerrules/TLDParser.java @@ -1,13 +1,8 @@ package io.github.shitikanth.enforcerrules; -import java.util.List; - -/** - * This interface represents a parser for top-level Java declarations. - */ 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 parse(); + CompilationUnitInfo parse(); } From 5ff10624858f472d994051418f5dbb1e73f7444f Mon Sep 17 00:00:00 2001 From: Shitikanth Kashyap Date: Fri, 8 May 2026 16:17:03 +0530 Subject: [PATCH 05/11] fix: harden CompilationUnitInfo immutability; restore TLDParser javadoc --- .../shitikanth/enforcerrules/CompilationUnitInfo.java | 6 +++++- .../java/io/github/shitikanth/enforcerrules/TLDParser.java | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/github/shitikanth/enforcerrules/CompilationUnitInfo.java b/src/main/java/io/github/shitikanth/enforcerrules/CompilationUnitInfo.java index 212ddcd..4439cdf 100644 --- a/src/main/java/io/github/shitikanth/enforcerrules/CompilationUnitInfo.java +++ b/src/main/java/io/github/shitikanth/enforcerrules/CompilationUnitInfo.java @@ -2,4 +2,8 @@ import java.util.List; -public record CompilationUnitInfo(String packageName, List typeNames) {} +public record CompilationUnitInfo(String packageName, List typeNames) { + public CompilationUnitInfo { + typeNames = List.copyOf(typeNames); + } +} diff --git a/src/main/java/io/github/shitikanth/enforcerrules/TLDParser.java b/src/main/java/io/github/shitikanth/enforcerrules/TLDParser.java index 58567f8..4e0d5dc 100644 --- a/src/main/java/io/github/shitikanth/enforcerrules/TLDParser.java +++ b/src/main/java/io/github/shitikanth/enforcerrules/TLDParser.java @@ -1,5 +1,8 @@ package io.github.shitikanth.enforcerrules; +/** + * Parser for top-level Java declarations in a compilation unit. + */ public interface TLDParser { /** * @return Parsed info: package name (null if absent) and top-level type names. From 5f26d900a8f64266431b8d790e7f32cc3fed43d7 Mon Sep 17 00:00:00 2001 From: Shitikanth Kashyap Date: Fri, 8 May 2026 16:20:54 +0530 Subject: [PATCH 06/11] feat: RecursiveDescentTLDParser captures package name Add PackageDeclarationState to extract the package name from Java source, returning CompilationUnitInfo instead of List. Update AbstractTLDParser and stub other parsers to compile cleanly. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../enforcerrules/AbstractTLDParser.java | 3 +- .../enforcerrules/EmptyJavaFileAnalyzer.java | 2 +- .../enforcerrules/impl/AntlrTLDParser.java | 7 ++- .../impl/JavaParserTLDParser.java | 8 +-- .../impl/RecursiveDescentTLDParser.java | 42 +++++++++++--- .../impl/RegexBasedTLDParser.java | 7 ++- .../impl/RecursiveDescentTLDParserTest.java | 56 ++++++++++++------- 7 files changed, 85 insertions(+), 40 deletions(-) diff --git a/src/main/java/io/github/shitikanth/enforcerrules/AbstractTLDParser.java b/src/main/java/io/github/shitikanth/enforcerrules/AbstractTLDParser.java index 84e698c..ebabd93 100644 --- a/src/main/java/io/github/shitikanth/enforcerrules/AbstractTLDParser.java +++ b/src/main/java/io/github/shitikanth/enforcerrules/AbstractTLDParser.java @@ -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; @@ -34,5 +33,5 @@ protected Path getPath() { } @Override - public abstract List parse(); + public abstract CompilationUnitInfo parse(); } diff --git a/src/main/java/io/github/shitikanth/enforcerrules/EmptyJavaFileAnalyzer.java b/src/main/java/io/github/shitikanth/enforcerrules/EmptyJavaFileAnalyzer.java index 51cceee..d1cbbe4 100644 --- a/src/main/java/io/github/shitikanth/enforcerrules/EmptyJavaFileAnalyzer.java +++ b/src/main/java/io/github/shitikanth/enforcerrules/EmptyJavaFileAnalyzer.java @@ -18,7 +18,7 @@ boolean isEmptyJavaFile(Path path) { LOGGER.debug("Analyzing: {}", path); String expectedTypeName = path.getFileName().toString().replace(".java", ""); TLDParser parser = parserFactory.createTLDParser(path); - List typeNames = parser.parse(); + List typeNames = parser.parse().typeNames(); LOGGER.debug("Found types: {}", typeNames); return !typeNames.contains(expectedTypeName); } diff --git a/src/main/java/io/github/shitikanth/enforcerrules/impl/AntlrTLDParser.java b/src/main/java/io/github/shitikanth/enforcerrules/impl/AntlrTLDParser.java index e2ce9a0..8155742 100644 --- a/src/main/java/io/github/shitikanth/enforcerrules/impl/AntlrTLDParser.java +++ b/src/main/java/io/github/shitikanth/enforcerrules/impl/AntlrTLDParser.java @@ -12,6 +12,7 @@ import org.antlr.v4.runtime.CommonTokenStream; import io.github.shitikanth.enforcerrules.AbstractTLDParser; +import io.github.shitikanth.enforcerrules.CompilationUnitInfo; import io.github.shitikanth.enforcerrules.JavaTLDLexer; import io.github.shitikanth.enforcerrules.JavaTLDParser; @@ -25,16 +26,16 @@ public AntlrTLDParser(BufferedReader reader) { } @Override - public List parse() { + public CompilationUnitInfo parse() { try (BufferedReader reader = this.getReader()) { - return parse(reader); + return new CompilationUnitInfo(null, parseTypes(reader)); } catch (IOException e) { throw new UncheckedIOException(e); } } @VisibleForTesting - public List parse(BufferedReader bufferedReader) throws IOException { + public List parseTypes(BufferedReader bufferedReader) throws IOException { var lexer = new JavaTLDLexer(CharStreams.fromReader(bufferedReader)); var parser = new JavaTLDParser(new CommonTokenStream(lexer)); var compilationUnit = parser.compilationUnit(); diff --git a/src/main/java/io/github/shitikanth/enforcerrules/impl/JavaParserTLDParser.java b/src/main/java/io/github/shitikanth/enforcerrules/impl/JavaParserTLDParser.java index b1ed36c..4c57cb6 100644 --- a/src/main/java/io/github/shitikanth/enforcerrules/impl/JavaParserTLDParser.java +++ b/src/main/java/io/github/shitikanth/enforcerrules/impl/JavaParserTLDParser.java @@ -9,10 +9,10 @@ import com.github.javaparser.JavaParser; import com.github.javaparser.JavaParserAdapter; -import com.github.javaparser.ast.CompilationUnit; import com.github.javaparser.ast.body.TypeDeclaration; import io.github.shitikanth.enforcerrules.AbstractTLDParser; +import io.github.shitikanth.enforcerrules.CompilationUnitInfo; class JavaParserTLDParser extends AbstractTLDParser { private final JavaParserAdapter parser; @@ -23,8 +23,8 @@ public JavaParserTLDParser(JavaParser javaParser, Path path) { } @Override - public List parse() { - CompilationUnit compilationUnit = null; + public CompilationUnitInfo parse() { + com.github.javaparser.ast.CompilationUnit compilationUnit = null; try (BufferedReader reader = this.getReader()) { compilationUnit = parser.parse(reader); } catch (IOException e) { @@ -35,6 +35,6 @@ public List parse() { for (TypeDeclaration typeDeclaration : typeDeclarations) { result.add(typeDeclaration.getNameAsString()); } - return result; + return new CompilationUnitInfo(null, result); } } diff --git a/src/main/java/io/github/shitikanth/enforcerrules/impl/RecursiveDescentTLDParser.java b/src/main/java/io/github/shitikanth/enforcerrules/impl/RecursiveDescentTLDParser.java index e9e246a..2851767 100644 --- a/src/main/java/io/github/shitikanth/enforcerrules/impl/RecursiveDescentTLDParser.java +++ b/src/main/java/io/github/shitikanth/enforcerrules/impl/RecursiveDescentTLDParser.java @@ -16,6 +16,7 @@ import org.slf4j.LoggerFactory; import io.github.shitikanth.enforcerrules.AbstractTLDParser; +import io.github.shitikanth.enforcerrules.CompilationUnitInfo; class RecursiveDescentTLDParser extends AbstractTLDParser { static final Logger LOGGER = LoggerFactory.getLogger(RecursiveDescentTLDParser.class); @@ -24,6 +25,7 @@ class RecursiveDescentTLDParser extends AbstractTLDParser { private int start = 0; private int pos = 0; private final List collector = new ArrayList<>(); + private String packageName = null; public RecursiveDescentTLDParser(Path path) { super(path); @@ -34,7 +36,7 @@ public RecursiveDescentTLDParser(BufferedReader reader) { } @Override - public List parse() { + public CompilationUnitInfo parse() { String input; try { input = IOUtils.toString(getReader()); @@ -46,7 +48,7 @@ public List parse() { this.state = new InitialState(); run(); - return collector; + return new CompilationUnitInfo(packageName, List.copyOf(collector)); } private void run() { @@ -155,8 +157,10 @@ public State runState() { return new LineCommentState(this); } else if (lookingAt("/*")) { return new BlockCommentState(this); - } else if (lookingAt("package") || lookingAt("import")) { - return new PackageOrImportState(); + } else if (lookingAt("package")) { + return new PackageDeclarationState(); + } else if (lookingAt("import")) { + return new SkipToSemicolonState(this); } else if (lookingAt(classKeyword)) { return new TypeDeclarationState(); } else { @@ -167,16 +171,40 @@ public State runState() { } } - class PackageOrImportState implements State { + class PackageDeclarationState implements State { @Nullable @Override public State runState() { - LOGGER.debug("package or import"); - skipUntil(';'); + LOGGER.debug("package declaration"); + skipWs(); + int pkgStart = pos; + while (!eof() && peek() != ';') { + next(); + } + String pkg = input.substring(pkgStart, pos).stripTrailing(); + packageName = pkg.isEmpty() ? null : pkg; + if (!eof()) next(); // consume ';' + skip(); return new InitialState(); } } + class SkipToSemicolonState implements State { + private final State parent; + + SkipToSemicolonState(State parent) { + this.parent = parent; + } + + @Nullable + @Override + public State runState() { + LOGGER.debug("skip to semicolon"); + skipUntil(';'); + return parent; + } + } + class InsideTextState implements State { private final State parent; diff --git a/src/main/java/io/github/shitikanth/enforcerrules/impl/RegexBasedTLDParser.java b/src/main/java/io/github/shitikanth/enforcerrules/impl/RegexBasedTLDParser.java index f6c9531..f342261 100644 --- a/src/main/java/io/github/shitikanth/enforcerrules/impl/RegexBasedTLDParser.java +++ b/src/main/java/io/github/shitikanth/enforcerrules/impl/RegexBasedTLDParser.java @@ -12,6 +12,7 @@ import com.google.common.annotations.VisibleForTesting; import io.github.shitikanth.enforcerrules.AbstractTLDParser; +import io.github.shitikanth.enforcerrules.CompilationUnitInfo; class RegexBasedTLDParser extends AbstractTLDParser { private Pattern pattern = Pattern.compile( @@ -26,16 +27,16 @@ public RegexBasedTLDParser(BufferedReader reader) { } @Override - public List parse() { + public CompilationUnitInfo parse() { try (var bufferedReader = getReader()) { - return parse(bufferedReader); + return new CompilationUnitInfo(null, parseTypes(bufferedReader)); } catch (IOException e) { throw new UncheckedIOException(e); } } @VisibleForTesting - public List parse(BufferedReader bufferedReader) { + public List parseTypes(BufferedReader bufferedReader) { List types = new ArrayList<>(); bufferedReader.lines().forEach(line -> { Matcher matcher = pattern.matcher(line); diff --git a/src/test/java/io/github/shitikanth/enforcerrules/impl/RecursiveDescentTLDParserTest.java b/src/test/java/io/github/shitikanth/enforcerrules/impl/RecursiveDescentTLDParserTest.java index 23e86c2..6f3746d 100644 --- a/src/test/java/io/github/shitikanth/enforcerrules/impl/RecursiveDescentTLDParserTest.java +++ b/src/test/java/io/github/shitikanth/enforcerrules/impl/RecursiveDescentTLDParserTest.java @@ -1,35 +1,51 @@ package io.github.shitikanth.enforcerrules.impl; import java.io.BufferedReader; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.nio.file.Path; -import java.nio.file.Paths; +import java.io.StringReader; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import io.github.shitikanth.enforcerrules.CompilationUnitInfo; + import static org.junit.jupiter.api.Assertions.*; class RecursiveDescentTLDParserTest { + + private CompilationUnitInfo parse(String source) { + return new RecursiveDescentTLDParser(new BufferedReader(new StringReader(source))).parse(); + } + + @Test + void capturesPackageName() { + var info = parse("package com.example;\nclass Foo {}"); + assertEquals("com.example", info.packageName()); + assertEquals(java.util.List.of("Foo"), info.typeNames()); + } + + @Test + void noPackage_returnsNull() { + var info = parse("class Foo {}"); + assertNull(info.packageName()); + assertEquals(java.util.List.of("Foo"), info.typeNames()); + } + + @Test + void packageInBlockComment_isIgnored() { + var info = parse("/* package com.fake; */ class Foo {}"); + assertNull(info.packageName()); + } + @Test - void testExamples() { - InputStream inputStream = getClass().getResourceAsStream("/examples/Examples.java"); - if (inputStream == null) { - fail("Could not open test resource"); - } - var bufferedReader = new BufferedReader(new InputStreamReader(inputStream)); - var parser = new RecursiveDescentTLDParser(bufferedReader); - var types = parser.parse(); - System.out.println(types); + void packageInLineComment_isIgnored() { + var info = parse("// package com.fake;\nclass Foo {}"); + assertNull(info.packageName()); } @Test - @Disabled - void debugFile() { - String filename = ""; - Path path = Paths.get(filename); - var types = new RecursiveDescentTLDParser(path).parse(); - System.out.println(types); + void multipleTypesWithPackage() { + var info = parse("package org.example;\nclass A {}\ninterface B {}"); + assertEquals("org.example", info.packageName()); + assertTrue(info.typeNames().contains("A")); + assertTrue(info.typeNames().contains("B")); } } From d1d5914fcf6e6ec571e82598de43449675e42d86 Mon Sep 17 00:00:00 2001 From: Shitikanth Kashyap Date: Fri, 8 May 2026 16:25:39 +0530 Subject: [PATCH 07/11] feat: RegexBasedTLDParser captures package name Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../impl/RegexBasedTLDParser.java | 21 ++++++---- .../impl/RegexBasedTLDParserTest.java | 38 ++++++++++++------- 2 files changed, 37 insertions(+), 22 deletions(-) diff --git a/src/main/java/io/github/shitikanth/enforcerrules/impl/RegexBasedTLDParser.java b/src/main/java/io/github/shitikanth/enforcerrules/impl/RegexBasedTLDParser.java index f342261..4fd8d17 100644 --- a/src/main/java/io/github/shitikanth/enforcerrules/impl/RegexBasedTLDParser.java +++ b/src/main/java/io/github/shitikanth/enforcerrules/impl/RegexBasedTLDParser.java @@ -15,8 +15,9 @@ import io.github.shitikanth.enforcerrules.CompilationUnitInfo; class RegexBasedTLDParser extends AbstractTLDParser { - private Pattern pattern = Pattern.compile( + private static final Pattern TYPE_PATTERN = Pattern.compile( "^((public|protected|private|static|abstract|final|sealed|non_sealed)\\s+)*(class|interface|@interface|enum|record)\\s+(\\w+)"); + private static final Pattern PKG_PATTERN = Pattern.compile("^package\\s+([\\w.]+)\\s*;"); public RegexBasedTLDParser(Path path) { super(path); @@ -29,22 +30,26 @@ public RegexBasedTLDParser(BufferedReader reader) { @Override public CompilationUnitInfo parse() { try (var bufferedReader = getReader()) { - return new CompilationUnitInfo(null, parseTypes(bufferedReader)); + return parse(bufferedReader); } catch (IOException e) { throw new UncheckedIOException(e); } } @VisibleForTesting - public List parseTypes(BufferedReader bufferedReader) { + public CompilationUnitInfo parse(BufferedReader bufferedReader) { List types = new ArrayList<>(); + String[] packageName = {null}; bufferedReader.lines().forEach(line -> { - Matcher matcher = pattern.matcher(line); - if (matcher.find()) { - String name = matcher.group(4); - types.add(name); + Matcher pkgMatcher = PKG_PATTERN.matcher(line); + if (pkgMatcher.find()) { + packageName[0] = pkgMatcher.group(1); + } + Matcher typeMatcher = TYPE_PATTERN.matcher(line); + if (typeMatcher.find()) { + types.add(typeMatcher.group(4)); } }); - return types; + return new CompilationUnitInfo(packageName[0], types); } } diff --git a/src/test/java/io/github/shitikanth/enforcerrules/impl/RegexBasedTLDParserTest.java b/src/test/java/io/github/shitikanth/enforcerrules/impl/RegexBasedTLDParserTest.java index eb7c3e9..ea1761d 100644 --- a/src/test/java/io/github/shitikanth/enforcerrules/impl/RegexBasedTLDParserTest.java +++ b/src/test/java/io/github/shitikanth/enforcerrules/impl/RegexBasedTLDParserTest.java @@ -1,28 +1,38 @@ package io.github.shitikanth.enforcerrules.impl; import java.io.BufferedReader; -import java.io.InputStream; -import java.io.InputStreamReader; +import java.io.StringReader; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import io.github.shitikanth.enforcerrules.CompilationUnitInfo; + import static org.junit.jupiter.api.Assertions.*; class RegexBasedTLDParserTest { - @BeforeEach - void setUp() {} + private CompilationUnitInfo parse(String source) { + return new RegexBasedTLDParser(new BufferedReader(new StringReader(source))).parse(); + } + + @Test + void capturesPackageName() { + var info = parse("package com.example;\npublic class Foo {}"); + assertEquals("com.example", info.packageName()); + assertTrue(info.typeNames().contains("Foo")); + } + + @Test + void noPackage_returnsNull() { + var info = parse("public class Foo {}"); + assertNull(info.packageName()); + } @Test - void example() { - InputStream inputStream = getClass().getResourceAsStream("/examples/Examples.java"); - if (inputStream == null) { - fail("Could not open test resource"); - } - var bufferedReader = new BufferedReader(new InputStreamReader(inputStream)); - RegexBasedTLDParser parser = new RegexBasedTLDParser(bufferedReader); - var types = parser.parse(); - System.out.println(types); + void multipleTypes() { + var info = parse("package org.example;\npublic class A {}\npublic interface B {}"); + assertEquals("org.example", info.packageName()); + assertTrue(info.typeNames().contains("A")); + assertTrue(info.typeNames().contains("B")); } } From fe807cce30fe587c78adab15da281aa16d218f59 Mon Sep 17 00:00:00 2001 From: Shitikanth Kashyap Date: Fri, 8 May 2026 16:27:57 +0530 Subject: [PATCH 08/11] feat: AntlrTLDParser captures package name Add PACKAGE lexer token and packageDeclaration rule to JavaTLD.g4; update AntlrTLDParser.parse() to extract and return the package name via CompilationUnitInfo. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../shitikanth/enforcerrules/JavaTLD.g4 | 6 ++- .../enforcerrules/impl/AntlrTLDParser.java | 16 ++++++-- .../impl/AntlrTLDParserTest.java | 38 ++++++++++++------- 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/src/main/antlr4/io/github/shitikanth/enforcerrules/JavaTLD.g4 b/src/main/antlr4/io/github/shitikanth/enforcerrules/JavaTLD.g4 index cc6c651..51efc6b 100644 --- a/src/main/antlr4/io/github/shitikanth/enforcerrules/JavaTLD.g4 +++ b/src/main/antlr4/io/github/shitikanth/enforcerrules/JavaTLD.g4 @@ -1,6 +1,8 @@ grammar JavaTLD; -compilationUnit : (typeDeclaration | .)* ; +compilationUnit : packageDeclaration? (typeDeclaration | .)* ; + +packageDeclaration : PACKAGE ID ('.' ID)* ';' ; typeDeclaration : ('class'|'interface'|'enum'|'@interface'|'record') ID .*? block ; @@ -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)* '"'; diff --git a/src/main/java/io/github/shitikanth/enforcerrules/impl/AntlrTLDParser.java b/src/main/java/io/github/shitikanth/enforcerrules/impl/AntlrTLDParser.java index 8155742..b0f5149 100644 --- a/src/main/java/io/github/shitikanth/enforcerrules/impl/AntlrTLDParser.java +++ b/src/main/java/io/github/shitikanth/enforcerrules/impl/AntlrTLDParser.java @@ -10,6 +10,7 @@ import com.google.common.annotations.VisibleForTesting; import org.antlr.v4.runtime.CharStreams; import org.antlr.v4.runtime.CommonTokenStream; +import org.antlr.v4.runtime.tree.TerminalNode; import io.github.shitikanth.enforcerrules.AbstractTLDParser; import io.github.shitikanth.enforcerrules.CompilationUnitInfo; @@ -28,19 +29,28 @@ public AntlrTLDParser(BufferedReader reader) { @Override public CompilationUnitInfo parse() { try (BufferedReader reader = this.getReader()) { - return new CompilationUnitInfo(null, parseTypes(reader)); + return parse(reader); } catch (IOException e) { throw new UncheckedIOException(e); } } @VisibleForTesting - public List parseTypes(BufferedReader bufferedReader) throws IOException { + public CompilationUnitInfo parse(BufferedReader bufferedReader) throws IOException { var lexer = new JavaTLDLexer(CharStreams.fromReader(bufferedReader)); var parser = new JavaTLDParser(new CommonTokenStream(lexer)); var compilationUnit = parser.compilationUnit(); - return compilationUnit.typeDeclaration().stream() + + String packageName = null; + var pkgDecl = compilationUnit.packageDeclaration(); + if (pkgDecl != null) { + packageName = pkgDecl.ID().stream().map(TerminalNode::getText).collect(Collectors.joining(".")); + } + + List types = compilationUnit.typeDeclaration().stream() .map(typeDeclaration -> typeDeclaration.ID().toString()) .collect(Collectors.toList()); + + return new CompilationUnitInfo(packageName, types); } } diff --git a/src/test/java/io/github/shitikanth/enforcerrules/impl/AntlrTLDParserTest.java b/src/test/java/io/github/shitikanth/enforcerrules/impl/AntlrTLDParserTest.java index 6f3b8e1..e4bd014 100644 --- a/src/test/java/io/github/shitikanth/enforcerrules/impl/AntlrTLDParserTest.java +++ b/src/test/java/io/github/shitikanth/enforcerrules/impl/AntlrTLDParserTest.java @@ -1,26 +1,38 @@ package io.github.shitikanth.enforcerrules.impl; import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; +import java.io.StringReader; import org.junit.jupiter.api.Test; -import io.github.shitikanth.enforcerrules.TLDParser; +import io.github.shitikanth.enforcerrules.CompilationUnitInfo; -import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.*; class AntlrTLDParserTest { + private CompilationUnitInfo parse(String source) { + return new AntlrTLDParser(new BufferedReader(new StringReader(source))).parse(); + } + + @Test + void capturesPackageName() { + var info = parse("package com.example;\nclass Foo {}"); + assertEquals("com.example", info.packageName()); + assertTrue(info.typeNames().contains("Foo")); + } + + @Test + void noPackage_returnsNull() { + var info = parse("class Foo {}"); + assertNull(info.packageName()); + } + @Test - void example1() throws IOException { - InputStream inputStream = getClass().getResourceAsStream("/examples/Examples.java"); - if (inputStream == null) { - fail("Could not open test resource"); - } - TLDParser parser = new AntlrTLDParser(new BufferedReader(new InputStreamReader(inputStream))); - var types = parser.parse(); - System.out.println(types); + void multipleTypes() { + var info = parse("package org.example;\nclass A {}\ninterface B {}"); + assertEquals("org.example", info.packageName()); + assertTrue(info.typeNames().contains("A")); + assertTrue(info.typeNames().contains("B")); } } From 28a2a16c276a057c695e18f4b429746fd76623e3 Mon Sep 17 00:00:00 2001 From: Shitikanth Kashyap Date: Fri, 8 May 2026 16:29:48 +0530 Subject: [PATCH 09/11] feat: JavaParserTLDParser captures package name Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../enforcerrules/impl/JavaParserTLDParser.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/main/java/io/github/shitikanth/enforcerrules/impl/JavaParserTLDParser.java b/src/main/java/io/github/shitikanth/enforcerrules/impl/JavaParserTLDParser.java index 4c57cb6..12c20b0 100644 --- a/src/main/java/io/github/shitikanth/enforcerrules/impl/JavaParserTLDParser.java +++ b/src/main/java/io/github/shitikanth/enforcerrules/impl/JavaParserTLDParser.java @@ -24,17 +24,20 @@ public JavaParserTLDParser(JavaParser javaParser, Path path) { @Override public CompilationUnitInfo parse() { - com.github.javaparser.ast.CompilationUnit compilationUnit = null; + com.github.javaparser.ast.CompilationUnit compilationUnit; try (BufferedReader reader = this.getReader()) { compilationUnit = parser.parse(reader); } catch (IOException e) { throw new UncheckedIOException(e); } - List> typeDeclarations = compilationUnit.getTypes(); - List result = new ArrayList<>(); - for (TypeDeclaration typeDeclaration : typeDeclarations) { - result.add(typeDeclaration.getNameAsString()); + String packageName = compilationUnit + .getPackageDeclaration() + .map(pd -> pd.getNameAsString()) + .orElse(null); + List typeNames = new ArrayList<>(); + for (TypeDeclaration typeDeclaration : compilationUnit.getTypes()) { + typeNames.add(typeDeclaration.getNameAsString()); } - return new CompilationUnitInfo(null, result); + return new CompilationUnitInfo(packageName, typeNames); } } From d2d5847c609004c68c80437bb5e4f6e4ad9d8481 Mon Sep 17 00:00:00 2001 From: Shitikanth Kashyap Date: Fri, 8 May 2026 16:32:20 +0530 Subject: [PATCH 10/11] feat: EmptyJavaFileAnalyzer validates package name Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../enforcerrules/EmptyJavaFileAnalyzer.java | 29 ++++-- .../EmptyJavaFileAnalyzerTest.java | 91 +++++++++++++++++++ 2 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 src/test/java/io/github/shitikanth/enforcerrules/EmptyJavaFileAnalyzerTest.java diff --git a/src/main/java/io/github/shitikanth/enforcerrules/EmptyJavaFileAnalyzer.java b/src/main/java/io/github/shitikanth/enforcerrules/EmptyJavaFileAnalyzer.java index d1cbbe4..446de82 100644 --- a/src/main/java/io/github/shitikanth/enforcerrules/EmptyJavaFileAnalyzer.java +++ b/src/main/java/io/github/shitikanth/enforcerrules/EmptyJavaFileAnalyzer.java @@ -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; @@ -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 typeNames = parser.parse().typeNames(); - 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, '.'); } } diff --git a/src/test/java/io/github/shitikanth/enforcerrules/EmptyJavaFileAnalyzerTest.java b/src/test/java/io/github/shitikanth/enforcerrules/EmptyJavaFileAnalyzerTest.java new file mode 100644 index 0000000..b3245d7 --- /dev/null +++ b/src/test/java/io/github/shitikanth/enforcerrules/EmptyJavaFileAnalyzerTest.java @@ -0,0 +1,91 @@ +package io.github.shitikanth.enforcerrules; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import io.github.shitikanth.enforcerrules.impl.TLDParserFactories; + +import static org.junit.jupiter.api.Assertions.*; + +class EmptyJavaFileAnalyzerTest { + + @TempDir + Path sourceRoot; + + private EmptyJavaFileAnalyzer analyzer() { + return new EmptyJavaFileAnalyzer(TLDParserFactories.getParserFactory(null)); + } + + private Path writeJavaFile(String relativePath, String content) throws IOException { + Path file = sourceRoot.resolve(relativePath); + Files.createDirectories(file.getParent()); + Files.writeString(file, content); + return file; + } + + @Test + void correctPackage_noViolation() throws IOException { + Path file = writeJavaFile("com/example/Foo.java", "package com.example;\npublic class Foo {}"); + var result = analyzer().analyze(file, sourceRoot); + assertFalse(result.isEmpty()); + assertFalse(result.hasWrongPackage()); + } + + @Test + void wrongPackage_flagged() throws IOException { + Path file = writeJavaFile("com/example/Foo.java", "package com.wrong;\npublic class Foo {}"); + var result = analyzer().analyze(file, sourceRoot); + assertFalse(result.isEmpty()); + assertTrue(result.hasWrongPackage()); + assertEquals("com.example", result.expectedPackage()); + assertEquals("com.wrong", result.actualPackage()); + } + + @Test + void defaultPackage_allowedWhenInRoot() throws IOException { + Path file = writeJavaFile("Foo.java", "public class Foo {}"); + var result = analyzer().analyze(file, sourceRoot); + assertFalse(result.isEmpty()); + assertFalse(result.hasWrongPackage()); + } + + @Test + void packageDeclaredInRoot_isViolation() throws IOException { + Path file = writeJavaFile("Foo.java", "package com.example;\npublic class Foo {}"); + var result = analyzer().analyze(file, sourceRoot); + assertFalse(result.isEmpty()); + assertTrue(result.hasWrongPackage()); + assertNull(result.expectedPackage()); + assertEquals("com.example", result.actualPackage()); + } + + @Test + void noPackageInSubdir_isViolation() throws IOException { + Path file = writeJavaFile("com/example/Foo.java", "public class Foo {}"); + var result = analyzer().analyze(file, sourceRoot); + assertFalse(result.isEmpty()); + assertTrue(result.hasWrongPackage()); + assertEquals("com.example", result.expectedPackage()); + assertNull(result.actualPackage()); + } + + @Test + void emptyFile_flaggedAsEmpty() throws IOException { + Path file = writeJavaFile("com/example/Empty.java", "package com.example;\n// no type"); + var result = analyzer().analyze(file, sourceRoot); + assertTrue(result.isEmpty()); + assertFalse(result.hasWrongPackage()); + } + + @Test + void emptyFileAndWrongPackage_bothFlagged() throws IOException { + Path file = writeJavaFile("com/example/Empty.java", "package com.wrong;\n// no type"); + var result = analyzer().analyze(file, sourceRoot); + assertTrue(result.isEmpty()); + assertTrue(result.hasWrongPackage()); + } +} From d6ac16ebf777250d6e47bf0af6eed6fae6bd592d Mon Sep 17 00:00:00 2001 From: Shitikanth Kashyap Date: Fri, 8 May 2026 16:34:33 +0530 Subject: [PATCH 11/11] feat: banEmptyJavaFiles reports wrong package declarations --- .../enforcerrules/BanEmptyJavaFiles.java | 80 +++++++++++++++---- 1 file changed, 64 insertions(+), 16 deletions(-) diff --git a/src/main/java/io/github/shitikanth/enforcerrules/BanEmptyJavaFiles.java b/src/main/java/io/github/shitikanth/enforcerrules/BanEmptyJavaFiles.java index ec87b18..f5448a4 100644 --- a/src/main/java/io/github/shitikanth/enforcerrules/BanEmptyJavaFiles.java +++ b/src/main/java/io/github/shitikanth/enforcerrules/BanEmptyJavaFiles.java @@ -67,12 +67,10 @@ public void execute() throws EnforcerRuleException { List compileSourceRoots = new ArrayList<>(); List 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); @@ -80,6 +78,7 @@ public void execute() throws EnforcerRuleException { private void analyzeSourceRoots(List sourceRoots) throws EnforcerRuleException { List emptyJavaSourceFiles = new ArrayList<>(); + List wrongPackageResults = new ArrayList<>(); EmptyJavaFileAnalyzer analyzer = new EmptyJavaFileAnalyzer(TLDParserFactories.getParserFactory(parserId)); for (Path sourceRoot : sourceRoots) { LOGGER.debug("Analyzing source root {}", sourceRoot); @@ -107,16 +106,24 @@ private void analyzeSourceRoots(List sourceRoots) throws EnforcerRuleExcep && !fileName.equals("module-info.java"); }) .map(path -> (Callable) () -> { - 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()); @@ -135,8 +142,9 @@ private void analyzeSourceRoots(List 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() @@ -145,6 +153,22 @@ private void analyzeSourceRoots(List 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()); } } @@ -152,10 +176,17 @@ private void analyzeSourceRoots(List sourceRoots) throws EnforcerRuleExcep 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() { @@ -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 + + ']'; } } }