The last session ended with 277 tests and a claim that the DSL was feature-complete. I wanted to verify that before pushing to Maven Central, so I did a systematic audit of every template file. What I found convinced me we weren’t done.

The audit that found ten more things

The first pass looked at every @PermuteMethod, @PermuteReturn, and @PermuteBody in the DSL and asked: is there a simpler form? Is there anything batch 6–7 unlocked that we haven’t applied yet?

The not/exists method had three capitalize(scope) calls where one macro would do. The filterLatest suppression was encoded as from="${max(2, i)}" — correct, but the actual intent was “suppress at arity 1.” @PermuteFilter("i > 1") says that directly. The NegationScope.java file, which batch 8 was supposed to delete, was still there generating orphan classes — a git rm that didn’t survive a subsequent git add -u.

That last one was caught by one of the reviewer subagents during a quality pass, not by me. I’d assumed the file was gone.

The deeper finds took longer. The Path2 class had always been hand-coded because @PermuteReturn couldn’t express “return a type parameter, not a generated class.” Adding typeParam="END" to the annotation eliminated it. The extendsRule() method was duplicated verbatim across two template files with no way to share it — ADR-0006 had documented this as structurally unavoidable. @PermuteMixin made it avoidable. We went from 26 lines of identical code in two files to a shared mixin class that both templates reference.

The second pass found what the first missed

Batch 8 shipped 10 items and brought the test count to 299. I did the same audit again.

The most significant find was @PermuteParam inside @PermuteMethod clones. The transformer that handles parameter expansion runs in the outer pipeline, where the inner method variable (m, j) isn’t in scope. JEXL throws a variable-undefined exception, which is silently swallowed. The filterVar method had a @PermuteBody annotation specifically to work around this — I’d added it knowing it was a workaround without diagnosing the root cause. The fix was to run PermuteParamTransformer on each clone inside applyPermuteMethod with the inner context. Once that landed, the @PermuteBody could be deleted and the method body just read rd.addVariableFilter(v1, predicate). Call-site anchor expansion handles the rest.

The PermuteAnnotationTransformer gap was found the same way — by trying something that should work and watching it silently not work. @PermuteAnnotation(type="FunctionalInterface") on a non-inline template did nothing. No error. The transformer that adds the target annotation before stripping @PermuteAnnotation was simply never called in the non-inline pipeline. Claude caught it in a quality review; we fixed it.

The Consumer/Predicate families — two 24-line templates, structurally identical, differing only in class name prefix, method name, and return type — collapsed into one template with @PermuteVar(F={"Consumer","Predicate"}) cross-producted with the arity loop. JEXL ternary macros drive the differences:

macros = {"method=${F == 'Consumer' ? 'accept' : 'test'}",
           "ret=${F == 'Consumer' ? 'void' : 'boolean'}"}

Twelve interfaces from one template. The arity-1 interfaces stayed hand-written — they don’t follow the parameterized pattern cleanly enough to fold into the cross-product.

Numbers

Two batches, 20 items, one long day. Test count: 277 → 305. The main templates are all shorter — JoinBuilder alone went from 354 lines to under 300.

The annotation processor now has @PermuteMacros, @PermuteMixin, @PermuteExtendsChain, @PermuteBodyFragment, @PermuteReturn(typeParam=), and @PermuteDefaultReturn(className="self"). The last one is the most satisfying — instead of spelling out the full class name and type argument expression on every fluent builder class, you write "self".


<
Previous Post
Idle is not one thing
>
Next Post
Closing Every Gap: Parity, Kogito, and ADR-0002