Skip to content

Check Maven Plugin

The Tentackle Checker Maven Plugin

The tentackle-check-maven-plugin performs build-time consistency checks that a normal compiler cannot catch. It verifies two kinds of string references that are only resolved at runtime and would otherwise fail silently — or worse, in front of the user — when a translation or a script is missing:

  • that every resource-bundle key referenced from the Java sources actually exists in the corresponding *.properties bundle, for all checked locales, and
  • that every annotation-based validation is well-formed: its validator can be instantiated, its scripts compile, and its (possibly i18n-scripted) message resolves for every checked locale.

Both checks report every problem they find, each with its location, and then fail the build with a summary count. This turns a class of runtime MissingResourceExceptions into build-time errors.

The goals share the prefix tentackle-check, so they can be invoked directly from the command line, e.g., mvn tentackle-check:bundles.

How It Works

Both goals pass the project's sources to the in-process Java compiler. The validations goal runs it with -proc:only and lets an annotation processor inspect the annotations of the elements. Errors reported by the processor fail the build. The bundles goal runs it with -proc:none, attributes the sources (JavacTask.analyze(), no class files are written), and scans the attributed syntax trees for getString invocations. Compilation errors in the sources are only logged as warnings (see showCompileOutput).

The checks themselves load the project's compiled output: the annotation and validator classes, the *.properties bundles copied to target/classes, and the META-INF/bundles files written by the analyze goal of the tentackle-maven-plugin. They therefore run in the process-classes phase and use a classloader built from the project's compile classpath, which includes the project's own output directory.

Because the checks operate on the source tree, classes that must not be checked have to be excluded from the file set rather than annotated.

Prerequisites

The plugin requires the same JDK and Maven versions as the rest of the framework (JDK >= 25, Maven >= 3.9.10, Maven 4 is supported as well). It is platform independent. The goals declare compile-scope dependency resolution, so Maven resolves the compile classpath for them.

The plugin ships no scripting language. If validators use scripts, the language's provider, such as tentackle-script-groovy, must be added as a dependency of the plugin (see the example below).

Overview of Goals

Goal Default phase Purpose
bundles process-classes Verify that all getString(...) bundle keys found in the sources exist in the bundles, for all checked locales.
validations process-classes Verify that all @Validation-based validators instantiate and that their value/condition/message scripts compile and resolve.
help — Print plugin usage information (generated by the maven-plugin-plugin).

Neither goal is bound to a Tentackle lifecycle by default — you add the executions you need to the modules that contain bundles or validations.

Common Configuration

Like all Tentackle mojos, the goals inherit the common parameters verbosity, skip, charset, jdkToolchain and minLogLevel (see the tentackle-maven-plugin docs). As annotation-processing mojos, they also accept the compiler parameters of the analyze goal: showCompileOutput, encoding, compilerArgs and compilerArgument. On top of that, AbstractCheckMojo adds the parameters shared by both check goals:

  • sourceDir (tentackle.sourceDir): directory holding the sources to process. Defaults to ${project.build.sourceDirectory}. Use filesets instead if a subset must be checked or excluded.
  • filesets: explicit file sets to process instead of sourceDir. A file set without a directory defaults to sourceDir.
  • locales: a comma/semicolon/whitespace separated list of locales to check the bundles and validation messages against (e.g. en, de). Both the en_US and the en-US notation are accepted. The fallback locale should come first, because the bundles goal compares the other locales against it. If omitted, or if none of the given locales is usable, the build's default locale is used (and a warning is logged). The parameter has no user property, i.e. it can only be set in the POM; -Dlocales=… on the command line has no effect.
  • skipTests (${skipTests}): skips the check, so the standard -DskipTests switch disables it together with the unit tests.
  • scriptingLanguage: the default scripting language of the validator scripts, e.g. groovy. It is required if the scripts use the shorthand {…} rather than naming their language, as in #!groovy{…} (see CompoundValue). An unknown language fails the goal. If the parameter is not set, there is no default language, even if another module of the same build configured one.

A typical configuration registers both checks on a module that ships bundles and validations, with Groovy as the language of the validator scripts:

<plugin>
  <groupId>org.tentackle</groupId>
  <artifactId>tentackle-check-maven-plugin</artifactId>
  <version>${project.version}</version>
  <executions>
    <execution>
      <id>check-bundles</id>
      <goals>
        <goal>bundles</goal>
      </goals>
    </execution>
    <execution>
      <id>check-validations</id>
      <goals>
        <goal>validations</goal>
      </goals>
    </execution>
  </executions>
  <configuration>
    <locales>en, de</locales>
    <scriptingLanguage>groovy</scriptingLanguage>
  </configuration>
  <dependencies>
    <dependency>
      <groupId>org.tentackle</groupId>
      <artifactId>tentackle-script-groovy</artifactId>
      <version>${project.version}</version>
    </dependency>
  </dependencies>
</plugin>

The bundles Goal

Scans the sources for every invocation of <reference>.getString(<key>) and checks that the referenced key exists in the matching bundle, loaded for each configured locale. The <key> must be a compile-time constant: a string literal such as "foo", a constant variable such as static final String FOO = "foo" (of any class, including those on the classpath), or a concatenation of those, such as PREFIX + "title". The bundle a reference belongs to is derived from the <reference>:

  • a bundle class name, e.g. BlahBundle.getString("foo"), is checked against the BlahBundle properties. The class is resolved via a single-type import or, if there is none, the package of the invoking class. Wildcard imports and fully qualified references are not resolved;
  • a method call such as getBundle().getString("foo") is checked against the bundle named after the enclosing class (Blah.properties for class Blah);
  • a variable or constant reference such as resources.getString("foo") is treated the same way — the bundle name is that of the enclosing class.

A reference whose last name starts lowercase or is all uppercase counts as a variable or constant. Any other name is taken as a bundle class. Nested, local, and anonymous classes use the bundle of their outermost class.

Since the sources are attributed, only invocations that really refer to a bundle are checked: a static getString(String) of a bundle class, such as BlahBundle.getString("foo"), or a getString invoked on a java.util.ResourceBundle (or a subclass). A bundle class is annotated with @Bundle or another annotation that registers bundles, such as @FxControllerService (any annotation whose @Analyze handler is a BundleAnalyzeHandler). Other calls, such as ResultSet.getString("name") or the static getString of a class without such an annotation, are ignored and listed with verbosity set to debug. If the invoked method or the type of the reference cannot be determined, for example because of compile errors, the invocation is checked anyway and logged as a warning, unless suppressed with @SuppressWarnings("bundle").

To narrow down the checked sources, use filesets. Unlike sourceDir, a file set is not limited to Java sources, so the include is required:

<configuration>
  <filesets>
    <fileset>
      <includes>
        <include>**/*.java</include>
      </includes>
      <excludes>
        <exclude>**/noi18n/**</exclude>
      </excludes>
    </fileset>
  </filesets>
</configuration>

Only referenced bundles are loaded: the goal first collects the used bundle names, then loads those among the bundles registered via @Bundle/@FxControllerService annotations, which the analyze goal lists in META-INF/bundles. This keeps the check fast even with many bundles on the classpath. A referenced bundle that is not registered is an error, reported as bundle … not registered by annotation.

Some classes only look like bundles: they load an ordinary resource bundle via getBundle().getString(…), but must not be registered with @Bundle, because registered bundles are pushed to and pulled from the database by the i18n Maven plugin. An example is the Version class generated by the project archetype, which reads the build properties. Annotate such a class with @NoBundle: its invocations are ignored like those not referring to a bundle, so neither errors nor warnings are reported.

Beyond checking individual keys, the goal also compares the complete key sets of each referenced bundle across the configured locales. This includes keys accessed with non-literal arguments. The keys of the first locale serve as the reference: a key missing in another locale is reported as key '…' missing in …, and a key that only another locale defines as key '…' for … not defined in all locales of …. This catches translations that would throw a MissingResourceException at runtime.

mvn tentackle-check:bundles

What is not covered

The check is deliberately limited to compile-time constant keys, because only those can be resolved statically. The following must be covered by ordinary unit tests instead:

  • non-constant keys, e.g. getBundle().getString(text), as frequently found in enums. Each such bundle invocation is logged as a warning (cannot verify getString() argument, not a constant), prefixed with its source location. Once a unit test covers the keys, annotate the method, the class, or any other enclosing declaration with @SuppressWarnings("bundle") to silence the warning;
  • PdoBundles, i.e., translations for @Singular/@Plural (see PdoUtilitiesTest);
  • ValidationBundles, i.e., translations inside validators — those are covered by the validations goal instead.

Note: due to the fallback nature of resource bundles, a key missing from bundle_de.properties but provided by the fallback bundle.properties is not reported, because it would still resolve at runtime. The cross-locale diff only reports keys that would actually cause a MissingResourceException.

The validations Goal

Scans for validator annotations (those meta-annotated with @Validation), instantiates the validator for each occurrence, and verifies its three compound-value parameters — value, condition and message:

  • a value or condition script is compiled once, catching syntax errors at build time;
  • a message script is evaluated against the class holding the annotation for every configured locale. This resolves the translations of an i18n-script (see @MessageScriptConverter), so a missing translation is reported as an error, as a single line without a stack trace, rather than surfacing later as a runtime exception.

A validator that cannot be created, e.g. because its class cannot be loaded or has no public no-argument constructor, is reported by the annotation processor and fails the build before any script is checked. Otherwise, the goal reports the number of annotations and scripts checked, where message scripts count once per locale, and fails the build if any script fails to compile or resolve.

mvn tentackle-check:validations

Use scriptingLanguage if your validators use the {…} shorthand for scripts in the default language, and add the language's provider as a plugin dependency.

How It Fits Into the Build

Both goals run in process-classes, i.e., after compilation but before tests and packaging, so a broken translation or validation script breaks the build early — locally and in CI alike. Because they share the skipTests switch with the unit tests, a mvn install -DskipTests skips them too, which keeps fast inner-loop builds fast while still guarding the full build and the CI pipeline.

A common arrangement is to enable the bundles goal in every module that carries UI- or message bundles, and the validations goal in the domain modules that define validators, listing the production locales (fallback first) in the shared plugin configuration.

Both goals are thread-safe, but configure JVM-wide singletons (the classloaders of the bundle and validation factories, and the default scripting language). In a parallel build (mvn -T …), concurrent executions are therefore serialized; see Parallel builds.

Further Reading