Claude-Session: https://claude.ai/code/session_01RvxJko6xiCx83LxKgj92mH
---
.../aiindex/indexer/ProjectIndexer.java | 43 +++++++++++++------
.../aiindex/indexer/ProjectIndexerTest.java | 41 ++++++++++--------
2 files changed, 52 insertions(+), 32 deletions(-)
diff --git a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/indexer/ProjectIndexer.java b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/indexer/ProjectIndexer.java
index c3ac2ef..2cc6413 100644
--- a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/indexer/ProjectIndexer.java
+++ b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/indexer/ProjectIndexer.java
@@ -26,11 +26,12 @@
* Builds the single project-level AI index — the top of the three-level index.
*
* Walks the output tree, finds every {@link AiMdHeaderCodec#PACKAGE_AI_MD_FILENAME}, and writes
- * one {@link AiMdHeaderCodec#PROJECT_AI_MD_FILENAME} into the output root that lists each package on
- * a single line: the package's one-sentence lead (harvested from its already-generated body
- * by {@link AiMdLeadExtractor}) plus a relative link to its {@code package.ai.md}. The result is a
- * compact, always-loadable table of contents an agent reads first to decide which package to open,
- * instead of scanning thousands of file summaries.
+ * one {@link AiMdHeaderCodec#PROJECT_AI_MD_FILENAME} into the output root. The body lists each package
+ * on a single line — its one-sentence lead (harvested from its already-generated body by
+ * {@link AiMdLeadExtractor}) — while the header {@code F} list carries the matching clickable links to
+ * each {@code package.ai.md}, mirroring the package level so navigation is uniformly in the header. The
+ * result is a compact, always-loadable table of contents an agent reads first to decide which package
+ * to open, instead of scanning thousands of file summaries.
*
* This step is fully deterministic: it harvests existing leads and never calls
* the AI model, so it adds negligible cost and scales to projects with hundreds of packages. The
@@ -132,6 +133,11 @@ public int aggregate(final Path rootDirectory) throws IOException {
}
entries.sort(Comparator.comparing(PackageEntry::link));
+ final List packageLinks = new ArrayList<>(entries.size());
+ for (final PackageEntry entry : entries) {
+ packageLinks.add(markdownLink(entry.displayPath(), entry.link()));
+ }
+
final String body = buildBody(entries);
final String checksum = checksumSupport.calculateCrc32Hex(body);
final String generatedAt = timeSupport.formatInstant(java.time.Instant.now());
@@ -144,7 +150,8 @@ public int aggregate(final Path rootDirectory) throws IOException {
generatedAt,
pluginVersion,
aiVersion,
- AiMdHeaderCodec.NODE_TYPE_PROJECT);
+ AiMdHeaderCodec.NODE_TYPE_PROJECT,
+ packageLinks);
final Path projectFile = rootDirectory.resolve(AiMdHeaderCodec.PROJECT_AI_MD_FILENAME);
@@ -171,8 +178,9 @@ private boolean isPackageAiMdFile(final Path path) {
/**
* Renders the project index body: a deterministic blockquote lead followed by one listing line
- * per package ({@code - [display](link) — lead}; the lead and its separator are omitted when the
- * package has no lead).
+ * per package ({@code - — }; the lead and its separator are omitted when the
+ * package has no lead). The clickable links live in the header {@code F} list, not the body, so
+ * the body stays compact at hundreds of packages and the path doubles as the header-link anchor.
*
* @param entries package entries, already sorted by link
* @return the rendered markdown body
@@ -180,15 +188,11 @@ private boolean isPackageAiMdFile(final Path path) {
private String buildBody(final List entries) {
final StringBuilder builder = new StringBuilder();
final String leadLine = AiMdLeadExtractor.BLOCKQUOTE_MARKER + " Project index of " + projectTitle
- + ": one line per package, each linking to its package summary.";
+ + ": one line per package; leads here, clickable links in the header F list.";
builder.append(leadLine).append('\n').append('\n');
builder.append(PACKAGES_HEADING).append('\n');
for (final PackageEntry entry : entries) {
- builder.append("- [")
- .append(entry.displayPath())
- .append("](")
- .append(entry.link())
- .append(')');
+ builder.append("- ").append(entry.displayPath());
if (!compatibilityHelper.isBlank(entry.lead())) {
builder.append(LEAD_SEPARATOR).append(entry.lead());
}
@@ -197,6 +201,17 @@ private String buildBody(final List entries) {
return builder.toString();
}
+ /**
+ * Formats a markdown link {@code [label](target)} for the header {@code F} child-link list.
+ *
+ * @param label link label (the package display path)
+ * @param target link target (the relative path to the package's {@code package.ai.md})
+ * @return the markdown link string
+ */
+ private String markdownLink(final String label, final String target) {
+ return "[" + label + "](" + target + ")";
+ }
+
/**
* Returns the display name for the package owning {@code packageFile}: its directory relativised
* against {@code rootDirectory} with forward slashes, or {@value #ROOT_DISPLAY} for the
diff --git a/src/test/java/net/ladenthin/maven/llamacpp/aiindex/indexer/ProjectIndexerTest.java b/src/test/java/net/ladenthin/maven/llamacpp/aiindex/indexer/ProjectIndexerTest.java
index 088a4bf..12e7208 100644
--- a/src/test/java/net/ladenthin/maven/llamacpp/aiindex/indexer/ProjectIndexerTest.java
+++ b/src/test/java/net/ladenthin/maven/llamacpp/aiindex/indexer/ProjectIndexerTest.java
@@ -13,6 +13,7 @@
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.Arrays;
import net.ladenthin.maven.llamacpp.aiindex.document.AiMdDocument;
import net.ladenthin.maven.llamacpp.aiindex.document.AiMdDocumentCodec;
import net.ladenthin.maven.llamacpp.aiindex.document.AiMdHeader;
@@ -62,13 +63,18 @@ public void aggregate_multiplePackages_writesProjectIndexHeaderAndListing() thro
assertThat(document.header().d().trim().isEmpty(), is(false));
assertThat(document.header().t().trim().isEmpty(), is(false));
- // assert: body harvests each package's lead and links to its package.ai.md
+ // assert: the body carries each package's path + lead (one line each, no link)
final String body = document.body();
assertThat(body, containsString("#### Packages"));
- assertThat(body, containsString("Calculates VAT for invoices."));
- assertThat(body, containsString("Creates and sends invoices."));
- assertThat(body, containsString("(main/java/com/example/fiscal/package.ai.md)"));
- assertThat(body, containsString("(main/java/com/example/billing/package.ai.md)"));
+ assertThat(body, containsString("- main/java/com/example/fiscal — Calculates VAT for invoices."));
+ assertThat(body, containsString("- main/java/com/example/billing — Creates and sends invoices."));
+
+ // assert: the clickable links live in the header F list, sorted ascending by link
+ assertThat(
+ document.header().children(),
+ is(equalTo(Arrays.asList(
+ "[main/java/com/example/billing](main/java/com/example/billing/package.ai.md)",
+ "[main/java/com/example/fiscal](main/java/com/example/fiscal/package.ai.md)"))));
}
//
@@ -135,7 +141,7 @@ public void aggregate_force_rewritesEvenWhenUnchanged() throws Exception {
//
@Test
- public void aggregate_packageWithoutLead_listsLinkWithoutSeparator() throws Exception {
+ public void aggregate_packageWithoutLead_listsPathWithoutLeadSeparator() throws Exception {
// arrange: a package whose body is blank yields no lead
final Path outputRoot = Files.createTempDirectory("ai-index-test").resolve("ai");
writePackageFile(outputRoot.resolve("main/java/com/example/package.ai.md"), "main/java/com/example", "");
@@ -146,12 +152,13 @@ public void aggregate_packageWithoutLead_listsLinkWithoutSeparator() throws Exce
// act
indexer.aggregate(outputRoot);
- // assert: the listing links the package but carries no lead separator
- final String body = documentCodec
- .read(outputRoot.resolve(AiMdHeaderCodec.PROJECT_AI_MD_FILENAME))
- .body();
- assertThat(body, containsString("(main/java/com/example/package.ai.md)"));
- assertThat(body, not(containsString(" — ")));
+ // assert: the header links the package; the body lists the bare path with no lead separator
+ final AiMdDocument document = documentCodec.read(outputRoot.resolve(AiMdHeaderCodec.PROJECT_AI_MD_FILENAME));
+ assertThat(
+ document.header().children(),
+ is(equalTo(Arrays.asList("[main/java/com/example](main/java/com/example/package.ai.md)"))));
+ assertThat(document.body(), containsString("- main/java/com/example\n"));
+ assertThat(document.body(), not(containsString(" — ")));
}
@Test
@@ -166,12 +173,10 @@ public void aggregate_rootPackage_isListedAsDot() throws Exception {
// act
indexer.aggregate(outputRoot);
- // assert: the root package is shown with the "." display path and a self link
- final String body = documentCodec
- .read(outputRoot.resolve(AiMdHeaderCodec.PROJECT_AI_MD_FILENAME))
- .body();
- assertThat(body, containsString("[.](package.ai.md)"));
- assertThat(body, containsString("The whole project."));
+ // assert: the root package shows the "." display path in the body and a self link in the header
+ final AiMdDocument document = documentCodec.read(outputRoot.resolve(AiMdHeaderCodec.PROJECT_AI_MD_FILENAME));
+ assertThat(document.header().children(), is(equalTo(Arrays.asList("[.](package.ai.md)"))));
+ assertThat(document.body(), containsString("- . — The whole project."));
}
//
From 33926ca49a681179374354e61021468c4cd6dc6e Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 26 Jun 2026 08:54:20 +0000
Subject: [PATCH 06/13] docs: document the header F child-link list and the
project-index split
- CLAUDE.md: replace the stale `` example with the real
`### title` / `- H:` format; document the repeatable `F` child-link field
(header table + rationale + the "a body `- F:` line is never read as a link"
note); Phase 3 now reads lead-in-body / link-in-header-F.
- README: the package phase notes the header `F` link list; the project phase
shows the lead in the body with clickable links in the header `F` list.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01RvxJko6xiCx83LxKgj92mH
---
CLAUDE.md | 35 +++++++++++++++++++++--------------
README.md | 4 ++--
2 files changed, 23 insertions(+), 16 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 320e2dd..2e33382 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -134,7 +134,7 @@ The plugin operates in three logical phases, building a navigable index from fin
**Phase 3 — Project Index (deterministic, no AI call)**
```
-[package.ai.md files] → ProjectIndexer → [one project.ai.md: per-package lead + link]
+[package.ai.md files] → ProjectIndexer → [one project.ai.md: per-package lead (body) + link (header F)]
```
Phase 3 harvests the one-sentence blockquote lead each `package.ai.md` already begins with
(via `AiMdLeadExtractor`) and writes a single, always-loadable table of contents linking to
@@ -168,24 +168,30 @@ It calls no model, so it is cheap and scales to hundreds of packages (no embeddi
Each `.ai.md` file begins with a metadata header block:
```
-
-This class handles parsing of Markdown headers...
+### main/java/com/example
+- H: 1.0
+- C: A1B2C3D4
+- D: 2026-01-01T00:00:00Z
+- T: 2026-01-01T00:01:00Z
+- G: 0.1.0
+- A: 1.0.0
+- X: package
+- F: [MyClass.java](MyClass.java.ai.md)
+- F: [sub/](sub/package.ai.md)
+---
+> Handles example domain logic for ...
(AI-generated body text continues here)
```
The header carries only deterministic metadata. All AI-generated
-content lives in the document body after the header block, keeping
+content lives in the document body after the `---` separator, keeping
the header machine-parseable without AI involvement (see
-`AiMdHeader.java` Javadoc for the rationale).
+`AiMdHeader.java` Javadoc for the rationale). The repeatable `F` field
+holds the deterministic child links — for a `package`, one per child
+`.ai.md` (files and sub-packages); for a `project`, one per package —
+so navigation (project → package → file) stays uniformly in the header
+while the body stays free-form. `AiMdDocumentCodec` parses only the
+header block, so a `- F:` line in the body is never read as a link.
| Field | Meaning |
|---|---|
@@ -197,6 +203,7 @@ the header machine-parseable without AI involvement (see
| `g` | Plugin version (`project.version`) |
| `a` | AI model version |
| `x` | Node type: `file`, `package`, or `project` |
+| `F` | Repeatable child link (markdown), e.g. `[Foo.java](Foo.java.ai.md)`; the navigation list for `package`/`project` nodes, absent for `file` |
### Provider Pattern
diff --git a/README.md b/README.md
index e2b3d9e..cc7dd67 100644
--- a/README.md
+++ b/README.md
@@ -92,10 +92,10 @@ The plugin runs in three phases, building a navigable index from fine to coarse.
### 2. Package Aggregation (aggregate-packages)
- Traverses generated `.ai.md` files
- Builds hierarchical package summaries
-- Produces `package.ai.md` files
+- Produces `package.ai.md` files; the header carries a deterministic `F` link list to each child (package → file navigation)
### 3. Project Index (aggregate-project)
- Harvests the one-line lead from every `package.ai.md` — deterministic, no AI call
-- Produces a single `project.ai.md`: one line per package — its lead plus a relative link
+- Produces a single `project.ai.md`: one body line per package (its lead) with the clickable links in the header `F` list
- A compact, always-loadable table of contents an agent reads first to navigate down
## Example Output
```
From f0988bf0a6d9230a2f6a4d6279902913764e569c Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 26 Jun 2026 09:24:10 +0000
Subject: [PATCH 07/13] feat(C1): select the file-level prompt by source
extension (java/sql/fallback)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add an extension-based selection step so the per-file body prompt can vary by
language while one AI model stays loaded for the whole run.
- AiFieldGenerationConfig gains an optional fileExtensions list (defensively
copied; getter returns an unmodifiable view, mirroring AiModelDefinition's
stopStrings). Non-empty selects the entry for files whose name ends with one
of the extensions; null/empty marks it the extension-agnostic fallback.
- AiFieldGenerationSelector resolves, in declaration order: the first
extension-matching entry, else the first fallback, else null.
- SourceFileIndexer selects one field generation per file via the selector and
throws IllegalArgumentException when no entry matches and no fallback exists.
Backward compatible: a configuration with a single fieldGeneration that has no
fileExtensions keeps working unchanged — that entry is the fallback for every
file.
Gates: 146 tests green; SpotBugs 0; PIT 12/12 (100%) on the selector + config.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01RvxJko6xiCx83LxKgj92mH
---
pom.xml | 1 +
.../config/AiFieldGenerationConfig.java | 35 +++++
.../config/AiFieldGenerationSelector.java | 61 +++++++++
.../aiindex/indexer/SourceFileIndexer.java | 11 +-
.../config/AiFieldGenerationConfigTest.java | 61 +++++++++
.../config/AiFieldGenerationSelectorTest.java | 122 ++++++++++++++++++
.../indexer/SourceFileIndexerTest.java | 110 ++++++++++++++++
7 files changed, 400 insertions(+), 1 deletion(-)
create mode 100644 src/main/java/net/ladenthin/maven/llamacpp/aiindex/config/AiFieldGenerationSelector.java
create mode 100644 src/test/java/net/ladenthin/maven/llamacpp/aiindex/config/AiFieldGenerationConfigTest.java
create mode 100644 src/test/java/net/ladenthin/maven/llamacpp/aiindex/config/AiFieldGenerationSelectorTest.java
diff --git a/pom.xml b/pom.xml
index b6384dc..72715d8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -673,6 +673,7 @@ SPDX-License-Identifier: Apache-2.0
net.ladenthin.maven.llamacpp.aiindex.config.AiFieldGenerationConfig
+ net.ladenthin.maven.llamacpp.aiindex.config.AiFieldGenerationSelector
net.ladenthin.maven.llamacpp.aiindex.config.AiGenerationConfig
net.ladenthin.maven.llamacpp.aiindex.config.AiModelDefinition
net.ladenthin.maven.llamacpp.aiindex.config.AiModelDefinitionSupport
diff --git a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/config/AiFieldGenerationConfig.java b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/config/AiFieldGenerationConfig.java
index a0625d5..dc15e78 100644
--- a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/config/AiFieldGenerationConfig.java
+++ b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/config/AiFieldGenerationConfig.java
@@ -3,7 +3,12 @@
// SPDX-License-Identifier: Apache-2.0
package net.ladenthin.maven.llamacpp.aiindex.config;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
import lombok.ToString;
+import org.jspecify.annotations.Nullable;
/**
* Maven plugin configuration POJO that associates a prompt template (by key) with an
@@ -51,6 +56,16 @@ public AiFieldGenerationConfig() {
*/
private String aiDefinitionKey;
+ /**
+ * Optional source file extensions (e.g. {@code .java}, {@code .sql}) that select this field
+ * generation for a file. When non-empty, this entry applies only to files whose name ends with
+ * one of the listed extensions. When {@code null} or empty, this entry is the fallback applied to
+ * any file that no extension-specific entry matched.
+ *
+ * @see AiFieldGenerationSelector
+ */
+ private @Nullable List fileExtensions;
+
/**
* Returns the prompt template key.
*
@@ -86,4 +101,24 @@ public String getAiDefinitionKey() {
public void setAiDefinitionKey(final String aiDefinitionKey) {
this.aiDefinitionKey = aiDefinitionKey;
}
+
+ /**
+ * Returns the source file extensions that select this entry, or {@code null} when this entry is
+ * the extension-agnostic fallback.
+ *
+ * @return the selecting file extensions, or {@code null}
+ */
+ public @Nullable List getFileExtensions() {
+ return fileExtensions != null ? Collections.unmodifiableList(fileExtensions) : null;
+ }
+
+ /**
+ * Sets the source file extensions that select this entry. The list is defensively copied.
+ *
+ * @param fileExtensions selecting file extensions (e.g. {@code .java}); {@code null} or empty
+ * makes this entry the fallback
+ */
+ public void setFileExtensions(final @Nullable Collection fileExtensions) {
+ this.fileExtensions = fileExtensions != null ? new ArrayList<>(fileExtensions) : null;
+ }
}
diff --git a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/config/AiFieldGenerationSelector.java b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/config/AiFieldGenerationSelector.java
new file mode 100644
index 0000000..58ce351
--- /dev/null
+++ b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/config/AiFieldGenerationSelector.java
@@ -0,0 +1,61 @@
+// SPDX-FileCopyrightText: 2026 Bernard Ladenthin
+//
+// SPDX-License-Identifier: Apache-2.0
+package net.ladenthin.maven.llamacpp.aiindex.config;
+
+import java.util.List;
+import lombok.ToString;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Selects the {@link AiFieldGenerationConfig} that applies to a given source file, based on the
+ * entries' {@link AiFieldGenerationConfig#getFileExtensions() file extensions}.
+ *
+ * Selection rule, in list order: the first entry whose non-empty extension list matches the file
+ * name wins; otherwise the first extension-agnostic entry (empty or absent extension list) is the
+ * fallback. Returns {@code null} when no entry matches and no fallback is configured.
+ *
+ * This lets the prompt vary by language while a single AI model stays loaded for the whole run —
+ * e.g. a Java-specific prompt for {@code .java}, a SQL-schema prompt for {@code .sql}, and a generic
+ * fallback for everything else. A configuration with one extension-agnostic entry (the historical
+ * shape) keeps working unchanged: that entry is the fallback and applies to every file.
+ */
+@ToString
+public final class AiFieldGenerationSelector {
+
+ /** Creates a new {@link AiFieldGenerationSelector}. */
+ public AiFieldGenerationSelector() {
+ // no-op
+ }
+
+ /**
+ * Returns the field generation that applies to {@code fileName}.
+ *
+ * @param configs the configured field generations, in declaration order; {@code null} entries
+ * are skipped
+ * @param fileName the source file name (e.g. {@code Foo.java})
+ * @return the first extension-matching entry, else the first fallback entry, else {@code null}
+ */
+ public @Nullable AiFieldGenerationConfig selectForFileName(
+ final Iterable configs, final String fileName) {
+ AiFieldGenerationConfig fallback = null;
+ for (final AiFieldGenerationConfig config : configs) {
+ if (config == null) {
+ continue;
+ }
+ final List extensions = config.getFileExtensions();
+ if (extensions == null || extensions.isEmpty()) {
+ if (fallback == null) {
+ fallback = config;
+ }
+ continue;
+ }
+ for (final String extension : extensions) {
+ if (fileName.endsWith(extension)) {
+ return config;
+ }
+ }
+ }
+ return fallback;
+ }
+}
diff --git a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/indexer/SourceFileIndexer.java b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/indexer/SourceFileIndexer.java
index f6db294..f67d1ac 100644
--- a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/indexer/SourceFileIndexer.java
+++ b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/indexer/SourceFileIndexer.java
@@ -8,10 +8,12 @@
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
+import java.util.Collections;
import java.util.List;
import java.util.stream.Stream;
import lombok.ToString;
import net.ladenthin.maven.llamacpp.aiindex.config.AiFieldGenerationConfig;
+import net.ladenthin.maven.llamacpp.aiindex.config.AiFieldGenerationSelector;
import net.ladenthin.maven.llamacpp.aiindex.config.AiModelDefinitionSupport;
import net.ladenthin.maven.llamacpp.aiindex.document.AiGenerationResult;
import net.ladenthin.maven.llamacpp.aiindex.document.AiMdDocument;
@@ -68,6 +70,7 @@ public class SourceFileIndexer {
private final AiMdHeaderSupport headerSupport = new AiMdHeaderSupport();
private final AiMdDocumentCodec documentCodec = new AiMdDocumentCodec();
private final Java8CompatibilityHelper compatibilityHelper = new Java8CompatibilityHelper();
+ private final AiFieldGenerationSelector fieldGenerationSelector = new AiFieldGenerationSelector();
private final AiFieldGenerationSupport fieldGenerationSupport;
@@ -195,8 +198,14 @@ private void writeAiFile(final Path sourceFile) throws IOException {
final String sourceText = compatibilityHelper.readString(sourceFile);
+ final AiFieldGenerationConfig selected = fieldGenerationSelector.selectForFileName(fieldGenerations, fileName);
+ if (selected == null) {
+ throw new IllegalArgumentException("No field generation matches file '" + fileName
+ + "' and no extension-agnostic fallback is configured: " + sourceFile);
+ }
+
final AiGenerationResult result = fieldGenerationSupport.processFieldGenerations(
- fieldGenerations, sourceFile, CONTEXT_TYPE_FILE, sourceText, baseHeader);
+ Collections.singletonList(selected), sourceFile, CONTEXT_TYPE_FILE, sourceText, baseHeader);
final AiMdDocument document = new AiMdDocument(baseHeader, result.body());
documentCodec.write(targetFile, document);
diff --git a/src/test/java/net/ladenthin/maven/llamacpp/aiindex/config/AiFieldGenerationConfigTest.java b/src/test/java/net/ladenthin/maven/llamacpp/aiindex/config/AiFieldGenerationConfigTest.java
new file mode 100644
index 0000000..e0f1b2a
--- /dev/null
+++ b/src/test/java/net/ladenthin/maven/llamacpp/aiindex/config/AiFieldGenerationConfigTest.java
@@ -0,0 +1,61 @@
+// SPDX-FileCopyrightText: 2026 Bernard Ladenthin
+//
+// SPDX-License-Identifier: Apache-2.0
+package net.ladenthin.maven.llamacpp.aiindex.config;
+
+import static org.hamcrest.CoreMatchers.equalTo;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.nullValue;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+public class AiFieldGenerationConfigTest {
+
+ @Test
+ public void fileExtensions_defaultsToNull() {
+ assertThat(new AiFieldGenerationConfig().getFileExtensions(), is(nullValue()));
+ }
+
+ @Test
+ public void setFileExtensions_null_getterReturnsNull() {
+ final AiFieldGenerationConfig config = new AiFieldGenerationConfig();
+ config.setFileExtensions(null);
+ assertThat(config.getFileExtensions(), is(nullValue()));
+ }
+
+ @Test
+ public void setFileExtensions_storesValuesInOrder() {
+ final AiFieldGenerationConfig config = new AiFieldGenerationConfig();
+ config.setFileExtensions(Arrays.asList(".java", ".sql"));
+ assertThat(config.getFileExtensions(), is(equalTo(Arrays.asList(".java", ".sql"))));
+ }
+
+ @Test
+ public void setFileExtensions_defensivelyCopiesSource() {
+ // arrange
+ final AiFieldGenerationConfig config = new AiFieldGenerationConfig();
+ final List source = new ArrayList<>();
+ source.add(".java");
+ config.setFileExtensions(source);
+
+ // act: mutating the source after the set must not affect the stored value
+ source.add(".sql");
+
+ // assert
+ assertThat(config.getFileExtensions(), is(equalTo(Arrays.asList(".java"))));
+ }
+
+ @Test
+ public void getFileExtensions_returnedListIsUnmodifiable() {
+ final AiFieldGenerationConfig config = new AiFieldGenerationConfig();
+ config.setFileExtensions(Arrays.asList(".java"));
+ assertThrows(
+ UnsupportedOperationException.class,
+ () -> config.getFileExtensions().add(".sql"));
+ }
+}
diff --git a/src/test/java/net/ladenthin/maven/llamacpp/aiindex/config/AiFieldGenerationSelectorTest.java b/src/test/java/net/ladenthin/maven/llamacpp/aiindex/config/AiFieldGenerationSelectorTest.java
new file mode 100644
index 0000000..8c935fe
--- /dev/null
+++ b/src/test/java/net/ladenthin/maven/llamacpp/aiindex/config/AiFieldGenerationSelectorTest.java
@@ -0,0 +1,122 @@
+// SPDX-FileCopyrightText: 2026 Bernard Ladenthin
+//
+// SPDX-License-Identifier: Apache-2.0
+package net.ladenthin.maven.llamacpp.aiindex.config;
+
+import static org.hamcrest.CoreMatchers.equalTo;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.nullValue;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+public class AiFieldGenerationSelectorTest {
+
+ private final AiFieldGenerationSelector selector = new AiFieldGenerationSelector();
+
+ private static AiFieldGenerationConfig config(final String promptKey, final List extensions) {
+ final AiFieldGenerationConfig config = new AiFieldGenerationConfig();
+ config.setPromptKey(promptKey);
+ config.setFileExtensions(extensions);
+ return config;
+ }
+
+ //
+ @Test
+ public void selectForFileName_extensionMatch_returnsSpecificEntry() {
+ final AiFieldGenerationConfig java = config("java", Arrays.asList(".java"));
+ final AiFieldGenerationConfig fallback = config("fallback", null);
+
+ final AiFieldGenerationConfig selected = selector.selectForFileName(Arrays.asList(java, fallback), "Foo.java");
+
+ assertThat(selected.getPromptKey(), is(equalTo("java")));
+ }
+
+ @Test
+ public void selectForFileName_specificWinsOverEarlierFallback() {
+ final AiFieldGenerationConfig fallback = config("fallback", null);
+ final AiFieldGenerationConfig java = config("java", Arrays.asList(".java"));
+
+ final AiFieldGenerationConfig selected = selector.selectForFileName(Arrays.asList(fallback, java), "Foo.java");
+
+ assertThat(selected.getPromptKey(), is(equalTo("java")));
+ }
+
+ @Test
+ public void selectForFileName_firstSpecificMatchWins() {
+ final AiFieldGenerationConfig first = config("first", Arrays.asList(".java"));
+ final AiFieldGenerationConfig second = config("second", Arrays.asList(".java"));
+
+ final AiFieldGenerationConfig selected = selector.selectForFileName(Arrays.asList(first, second), "Foo.java");
+
+ assertThat(selected.getPromptKey(), is(equalTo("first")));
+ }
+
+ @Test
+ public void selectForFileName_anyOfMultipleExtensionsMatches() {
+ final AiFieldGenerationConfig sql = config("sql", Arrays.asList(".sql", ".ddl"));
+
+ final AiFieldGenerationConfig selected =
+ selector.selectForFileName(Collections.singletonList(sql), "schema.ddl");
+
+ assertThat(selected.getPromptKey(), is(equalTo("sql")));
+ }
+ //
+
+ //
+ @Test
+ public void selectForFileName_noSpecificMatch_returnsFallback() {
+ final AiFieldGenerationConfig java = config("java", Arrays.asList(".java"));
+ final AiFieldGenerationConfig fallback = config("fallback", null);
+
+ final AiFieldGenerationConfig selected = selector.selectForFileName(Arrays.asList(java, fallback), "data.json");
+
+ assertThat(selected.getPromptKey(), is(equalTo("fallback")));
+ }
+
+ @Test
+ public void selectForFileName_emptyExtensionListIsFallback() {
+ final AiFieldGenerationConfig empty = config("empty", Collections.emptyList());
+
+ final AiFieldGenerationConfig selected =
+ selector.selectForFileName(Collections.singletonList(empty), "anything.xyz");
+
+ assertThat(selected.getPromptKey(), is(equalTo("empty")));
+ }
+
+ @Test
+ public void selectForFileName_firstFallbackWins() {
+ final AiFieldGenerationConfig first = config("first", null);
+ final AiFieldGenerationConfig second = config("second", Collections.emptyList());
+
+ final AiFieldGenerationConfig selected = selector.selectForFileName(Arrays.asList(first, second), "data.json");
+
+ assertThat(selected.getPromptKey(), is(equalTo("first")));
+ }
+ //
+
+ //
+ @Test
+ public void selectForFileName_noMatchNoFallback_returnsNull() {
+ final AiFieldGenerationConfig java = config("java", Arrays.asList(".java"));
+
+ final AiFieldGenerationConfig selected =
+ selector.selectForFileName(Collections.singletonList(java), "data.json");
+
+ assertThat(selected, is(nullValue()));
+ }
+
+ @Test
+ public void selectForFileName_nullEntrySkipped() {
+ final AiFieldGenerationConfig java = config("java", Arrays.asList(".java"));
+
+ final AiFieldGenerationConfig selected =
+ selector.selectForFileName(Arrays.asList(null, java), "Foo.java");
+
+ assertThat(selected.getPromptKey(), is(equalTo("java")));
+ }
+ //
+}
diff --git a/src/test/java/net/ladenthin/maven/llamacpp/aiindex/indexer/SourceFileIndexerTest.java b/src/test/java/net/ladenthin/maven/llamacpp/aiindex/indexer/SourceFileIndexerTest.java
index 1722dba..7c344ff 100644
--- a/src/test/java/net/ladenthin/maven/llamacpp/aiindex/indexer/SourceFileIndexerTest.java
+++ b/src/test/java/net/ladenthin/maven/llamacpp/aiindex/indexer/SourceFileIndexerTest.java
@@ -7,17 +7,24 @@
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.notNullValue;
import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.List;
import net.ladenthin.maven.llamacpp.aiindex.CommonTestFixtures;
+import net.ladenthin.maven.llamacpp.aiindex.config.AiFieldGenerationConfig;
+import net.ladenthin.maven.llamacpp.aiindex.document.AiGenerationRequest;
import net.ladenthin.maven.llamacpp.aiindex.document.AiMdDocument;
import net.ladenthin.maven.llamacpp.aiindex.document.AiMdDocumentCodec;
import net.ladenthin.maven.llamacpp.aiindex.document.AiMdHeaderCodec;
+import net.ladenthin.maven.llamacpp.aiindex.prompt.AiPromptDefinition;
import net.ladenthin.maven.llamacpp.aiindex.prompt.AiPromptSupport;
+import net.ladenthin.maven.llamacpp.aiindex.provider.AiGenerationProvider;
import net.ladenthin.maven.llamacpp.aiindex.provider.MockAiGenerationProvider;
import org.apache.maven.plugin.logging.SystemStreamLog;
import org.junit.jupiter.api.Test;
@@ -89,4 +96,107 @@ public void indexSourceRoot_singleJavaFile_createsAiMdFile() throws Exception {
assertThat(document.body().trim().isEmpty(), is(false));
}
//
+
+ //
+ @Test
+ public void indexSourceRoot_selectsPromptByFileExtension() throws Exception {
+ // arrange: a .java source; config maps .java -> java prompt, .sql -> sql, plus a fallback
+ final Path temp = Files.createTempDirectory("ai-index-test");
+ final Path outputRoot = temp.resolve("src/site/ai");
+ final Path sourceRoot = temp.resolve("src/main/java");
+ Files.createDirectories(sourceRoot.resolve("com/example"));
+ Files.write(
+ sourceRoot.resolve("com/example/Foo.java"),
+ "package com.example;\nclass Foo {}\n".getBytes(StandardCharsets.UTF_8));
+
+ final AiPromptSupport promptSupport = new AiPromptSupport(Arrays.asList(
+ promptDefinition("file-body-java"),
+ promptDefinition("file-body-sql"),
+ promptDefinition("file-body-fallback")));
+ final CapturingProvider provider = new CapturingProvider();
+ final SourceFileIndexer indexer = new SourceFileIndexer(
+ new SystemStreamLog(),
+ temp,
+ outputRoot,
+ Arrays.asList(".java"),
+ "1.0.0",
+ "0.0.0",
+ Collections.emptyList(),
+ false,
+ provider,
+ Arrays.asList(
+ fieldGeneration("file-body-java", Arrays.asList(".java")),
+ fieldGeneration("file-body-sql", Arrays.asList(".sql")),
+ fieldGeneration("file-body-fallback", null)),
+ promptSupport,
+ CommonTestFixtures.createDefaultAiModelDefinitionSupport());
+
+ // act
+ indexer.indexSourceRoot(sourceRoot);
+
+ // assert: only the java prompt ran for the .java file
+ assertThat(provider.promptKeys(), is(equalTo(Arrays.asList("file-body-java"))));
+ }
+
+ @Test
+ public void indexSourceRoot_noMatchingExtensionAndNoFallback_throws() throws Exception {
+ // arrange: a .java file, but the only field generation targets .sql with no fallback
+ final Path temp = Files.createTempDirectory("ai-index-test");
+ final Path outputRoot = temp.resolve("src/site/ai");
+ final Path sourceRoot = temp.resolve("src/main/java");
+ Files.createDirectories(sourceRoot.resolve("com/example"));
+ Files.write(
+ sourceRoot.resolve("com/example/Foo.java"),
+ "package com.example;\nclass Foo {}\n".getBytes(StandardCharsets.UTF_8));
+
+ final AiPromptSupport promptSupport =
+ new AiPromptSupport(Collections.singletonList(promptDefinition("file-body-sql")));
+ final SourceFileIndexer indexer = new SourceFileIndexer(
+ new SystemStreamLog(),
+ temp,
+ outputRoot,
+ Arrays.asList(".java"),
+ "1.0.0",
+ "0.0.0",
+ Collections.emptyList(),
+ false,
+ new MockAiGenerationProvider(),
+ Collections.singletonList(fieldGeneration("file-body-sql", Arrays.asList(".sql"))),
+ promptSupport,
+ CommonTestFixtures.createDefaultAiModelDefinitionSupport());
+
+ // act + assert
+ assertThrows(IllegalArgumentException.class, () -> indexer.indexSourceRoot(sourceRoot));
+ }
+ //
+
+ private static AiPromptDefinition promptDefinition(final String key) {
+ final AiPromptDefinition definition = new AiPromptDefinition();
+ definition.setKey(key);
+ definition.setTemplate("Summarize.\nFile: %s\nSource:\n%s\n");
+ return definition;
+ }
+
+ private static AiFieldGenerationConfig fieldGeneration(final String promptKey, final List extensions) {
+ final AiFieldGenerationConfig config = new AiFieldGenerationConfig();
+ config.setPromptKey(promptKey);
+ config.setAiDefinitionKey(CommonTestFixtures.AI_DEFINITION_KEY_DEFAULT);
+ config.setFileExtensions(extensions);
+ return config;
+ }
+
+ /** Test provider that records the prompt key of each request so prompt selection can be asserted. */
+ private static final class CapturingProvider implements AiGenerationProvider {
+ private final List promptKeys = new ArrayList<>();
+
+ @Override
+ public String generate(final AiGenerationRequest request) {
+ promptKeys.add(request.promptKey());
+ return "body for " + request.promptKey();
+ }
+
+ List promptKeys() {
+ return promptKeys;
+ }
+ }
}
From f4b120917bdf502130934b78191fcdf55102ae9b Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 26 Jun 2026 09:29:03 +0000
Subject: [PATCH 08/13] feat(C2): java/sql/fallback file-body prompts wired by
extension
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Split the single multi-language file-body prompt into three dedicated
templates and map them by source extension in the self-test generate pass:
- file-body-java — Java-focused: kind/modifiers/annotations, public API as
name(params) -> returnType, dependencies, exceptions, concurrency.
- file-body-sql — SQL-schema-focused: object kind, column schema with
constraints, the tables/columns READ vs WRITTEN, foreign-key/join
relationships, routine logic for procedures/functions/triggers.
- file-body-fallback — the previous generic multi-language prompt, verbatim,
now the catch-all for any other language.
The ai-generate execution walks .java and .sql and selects the prompt by
extension (.java -> java, .sql -> sql, else -> fallback). package-body is
unchanged — it aggregates child summaries and stays language-agnostic.
Docs: README Prompt System + quickstart and CLAUDE.md updated for the
extension-based selection and the four self-test prompts.
POM validates with -P ai-index-selftest; 146 tests green.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01RvxJko6xiCx83LxKgj92mH
---
CLAUDE.md | 4 +-
README.md | 46 ++++++++++++++++---
pom.xml | 132 +++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 171 insertions(+), 11 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 2e33382..88bf7d0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -99,7 +99,7 @@ llamacpp-ai-index-maven-plugin/
│ │ │ # AiGenerationRequest, AiGenerationResult (carry an AiMdHeader)
│ │ ├── prompt/ # AiPromptDefinition, AiPreparedPrompt, AiPromptSupport, AiPromptPreparationSupport
│ │ ├── config/ # AiGenerationConfig, AiGenerationKind, AiFieldGenerationConfig,
-│ │ │ # AiModelDefinition, AiModelDefinitionSupport
+│ │ │ # AiFieldGenerationSelector, AiModelDefinition, AiModelDefinitionSupport
│ │ └── support/ # Foundation: AiChecksumSupport, AiTimeSupport, AiPathSupport,
│ │ # Java8CompatibilityHelper, ConvertToRecord
│ ├── site/
@@ -350,7 +350,7 @@ See [`../workspace/workflows/pull-request-workflow.md`](../workspace/workflows/p
3. **Incremental updates** — files with existing summaries are skipped unless `force=true`; checksums detect source changes.
4. **Unified indexing and summarization** — each indexer (`SourceFileIndexer`, `PackageIndexer`) both creates the `.ai.md` skeleton and fills in AI fields in a single pass; no separate summarization step is needed.
5. **Provider abstraction** — AI backends are pluggable through `AiGenerationProvider`; mock provider enables fully deterministic tests.
-6. **Configuration-driven prompts** — prompt templates are defined in POM configuration, not hardcoded in Java; changing a prompt requires no code change.
+6. **Configuration-driven prompts** — prompt templates are defined in POM configuration, not hardcoded in Java; changing a prompt requires no code change. For the `generate` goal the per-file prompt is chosen by source extension (`AiFieldGenerationSelector`): each `` may carry an optional `` filter (e.g. `.java`/`.sql`), and an entry without one is the fallback — so a Java prompt, a SQL-schema prompt, and a generic fallback can coexist while one model stays loaded.
## Javadoc Conventions
diff --git a/README.md b/README.md
index cc7dd67..adeb20b 100644
--- a/README.md
+++ b/README.md
@@ -145,7 +145,10 @@ The plugin is configured from three building blocks, declared on the plugin insi
2. **``** — define each prompt template once, each with a ``. A template takes
two `%s` placeholders: the file/package name and the source (or, for packages, the child summaries).
3. **``** — per goal, map one `` to one ``. This is
- **required**: a goal with no field generation fails fast.
+ **required**: a goal with no field generation fails fast. The `generate` goal may list several
+ field generations and give each an optional `` filter so the per-file prompt is
+ chosen by source language (`.java` → a Java prompt, `.sql` → a SQL-schema prompt); an entry with
+ no `` is the fallback applied to any file no extension-specific entry matched.
```xml
@@ -173,11 +176,21 @@ The plugin is configured from three building blocks, declared on the plugin insi
-
+
- file-body
+ file-body-java
+
+
+
+ file-body-fallback
generate-resources
generate
+
- file-body
+ file-body-java
+ coder
+
+ .java
+
+
+
+ file-body-fallback
coder
@@ -258,9 +281,18 @@ Per-model parameters — model path, context size, output tokens, temperature, t
repeat penalty, threads — live inside each ``, not as top-level parameters.
## Prompt System
Prompts are defined in the plugin configuration (``) and referenced by key
-from ``. The self-test profile defines two:
-- `file-body` — summarizes a single source file
+from ``. The self-test profile defines four:
+- `file-body-java` — summarizes a single Java source file (types, public API, dependencies)
+- `file-body-sql` — summarizes a single SQL file as schema (tables/views/procedures, columns,
+ the tables it reads vs writes, and relationships)
+- `file-body-fallback` — generic multi-language summary for any other source file
- `package-body` — synthesizes a package summary from the already-generated file summaries
+
+For the `generate` goal the file-level prompt is selected per file by extension: the first field
+generation whose `` matches the file name wins; otherwise the first entry without a
+`` filter is the fallback. A single field generation with no filter (the historical
+shape) keeps working — it is simply the fallback for every file.
+
Each summary begins with a one-sentence blockquote lead, followed by structured `####` sections.
Prompts are optimized to avoid code blocks, formatter artifacts, and empty outputs, and to produce structured markdown.
## Output Structure
diff --git a/pom.xml b/pom.xml
index 72715d8..590049d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1217,7 +1217,110 @@ SPDX-License-Identifier: Apache-2.0
-->
- file-body
+ file-body-java
+
+
+Then output these sections as markdown. Omit any section that does not apply. Use '####' for section labels. Never use '###' or higher.
+
+#### Purpose
+One or two bullets: what the type is for.
+
+#### Type
+Kind (class / interface / enum / record / annotation) and modifiers (abstract, final, sealed). extends X; implements Y; key generics and type bounds. Notable annotations (@Mojo, @Entity, Lombok, etc.).
+
+#### Input
+What comes in: constructor and method parameters, injected dependencies, consumed fields, read resources (files, buffers, streams, config).
+
+#### Output
+What comes out: return types, produced state, mutated fields, written resources, side effects.
+
+#### Core logic
+The essential steps / algorithm / responsibility as bullets. This is the most important section.
+
+#### Public API
+Each public/protected member as `name(params) -> returnType` + a clause (max 6 words) on its purpose.
+
+#### Dependencies
+Imports and referenced types, as searchable names.
+
+#### Exceptions / Errors
+Notable thrown/caught exceptions, null-handling, and error conditions.
+
+#### Concurrency
+Threading, synchronization, immutability, or thread-safety notes, if any.
+
+## Rules
+- Markdown bullets and short clauses, not prose paragraphs. Sentences allowed only where a bullet would lose meaning.
+- Weave class / type / domain names into the bullets so they are searchable.
+- No code fences, no ```markdown / ```java, no XML tags, no formatter comments.
+- The filename heading is already rendered above your output. Start immediately with the blockquote lead. Never produce empty output.
+
+## Critical
+If the source contains "[EOF - source was truncated]", index ONLY what is literally present. Never infer, guess, or invent any unit, field, or behavior beyond the shown source.
+
+File: %s
+
+Source:
+%s
+ ]]>
+
+
+ file-body-sql
+
+
+Then output these sections as markdown. Omit any section that does not apply. Use '####' for section labels. Never use '###' or higher.
+
+#### Purpose
+One or two bullets: what this SQL object is for.
+
+#### Type
+SQL object kind: table / view / materialized view / procedure / function / trigger / index / sequence. Note the dialect if evident (PostgreSQL, MySQL, Oracle, T-SQL).
+
+#### Schema
+For tables/views: each column as `name type` plus constraints (PK, FK, NOT NULL, UNIQUE, DEFAULT, CHECK). Mark the primary key and any indexes.
+
+#### Reads
+Tables and columns this object READS from (FROM / JOIN / subquery sources, function inputs).
+
+#### Writes
+Tables and columns this object WRITES (INSERT / UPDATE / DELETE / MERGE targets, or the table a trigger fires on).
+
+#### Relationships
+Foreign keys and join relationships to other tables, as searchable `table.column -> table.column` pairs.
+
+#### Routine logic
+For procedures / functions / triggers: parameters, the essential steps, and the result or effect, as bullets.
+
+#### Dependencies
+Other tables, views, functions, or sequences referenced, as searchable names.
+
+## Rules
+- Markdown bullets and short clauses, not prose paragraphs.
+- Weave table / column / constraint / domain names into the bullets so they are searchable.
+- No code fences, no ```markdown / ```sql, no XML tags, no formatter comments.
+- The filename heading is already rendered above your output. Start immediately with the blockquote lead. Never produce empty output.
+
+## Critical
+If the source contains "[EOF - source was truncated]", index ONLY what is literally present. Never infer, guess, or invent any table, column, or relationship beyond the shown source.
+
+File: %s
+
+Source:
+%s
+ ]]>
+
+
+ file-body-fallback
generate
+
+
+ .java
+ .sql
+
- file-body
+ file-body-java
+ Qwen3-Coder-30B-A3B-Instruct-UD-Q4_K_XL-C64k
+
+ .java
+
+
+
+ file-body-sql
+ Qwen3-Coder-30B-A3B-Instruct-UD-Q4_K_XL-C64k
+
+ .sql
+
+
+
+ file-body-fallback
Qwen3-Coder-30B-A3B-Instruct-UD-Q4_K_XL-C64k
From 98bb6ac7dab13f18140c9820dd15f8e8f5b0da54 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 26 Jun 2026 09:51:09 +0000
Subject: [PATCH 09/13] feat(D1): exclude globs to skip trivial/generated
source files
Add an opt-in exclude filter so the generate goal can skip files that add
noise to the index (e.g. package-info.java, module-info.java, generated
sources), keeping a large, days-long index compact and focused.
- AiSourceExcludeFilter: pure, cross-platform glob matcher. Patterns match the
file path relative to the base dir with '/' separators; '*' stays within one
segment, '**' spans directories ('**/' also matches zero dirs), '?' is one
char, everything else literal. Anchored, case-sensitive. Empty/null list
excludes nothing (historical behaviour preserved).
- GenerateMojo gains an 'excludes' List @Parameter (aiIndex.excludes),
threaded into SourceFileIndexer, which skips matching files during the walk.
- spotbugs-exclude: IMC_IMMATURE_CLASS_NO_EQUALS suppressed for the stateless
matcher (identity semantics, like AiModelDefinitionSupport).
- .gitignore: ignore *.hprof OOM heap dumps from test/PIT forks.
Gates: 165 tests green; SpotBugs 0; PIT 39/39 (100%) on AiSourceExcludeFilter.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01RvxJko6xiCx83LxKgj92mH
---
.gitignore | 2 +
CLAUDE.md | 3 +-
README.md | 3 +
pom.xml | 1 +
spotbugs-exclude.xml | 21 +++
.../aiindex/indexer/SourceFileIndexer.java | 24 +++
.../llamacpp/aiindex/mojo/GenerateMojo.java | 12 ++
.../support/AiSourceExcludeFilter.java | 127 +++++++++++++++
.../indexer/SourceFileIndexerTest.java | 44 ++++++
.../support/AiSourceExcludeFilterTest.java | 148 ++++++++++++++++++
10 files changed, 384 insertions(+), 1 deletion(-)
create mode 100644 src/main/java/net/ladenthin/maven/llamacpp/aiindex/support/AiSourceExcludeFilter.java
create mode 100644 src/test/java/net/ladenthin/maven/llamacpp/aiindex/support/AiSourceExcludeFilterTest.java
diff --git a/.gitignore b/.gitignore
index f4d4bc4..1d16974 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,6 +29,8 @@ target/
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*
+# heap dumps from OOM (e.g. -XX:+HeapDumpOnOutOfMemoryError on test/PIT forks)
+*.hprof
# jcstress / jqwik test outputs (generated in repo root)
/.jqwik-database
diff --git a/CLAUDE.md b/CLAUDE.md
index 88bf7d0..0abf372 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -101,7 +101,7 @@ llamacpp-ai-index-maven-plugin/
│ │ ├── config/ # AiGenerationConfig, AiGenerationKind, AiFieldGenerationConfig,
│ │ │ # AiFieldGenerationSelector, AiModelDefinition, AiModelDefinitionSupport
│ │ └── support/ # Foundation: AiChecksumSupport, AiTimeSupport, AiPathSupport,
-│ │ # Java8CompatibilityHelper, ConvertToRecord
+│ │ # AiSourceExcludeFilter, Java8CompatibilityHelper, ConvertToRecord
│ ├── site/
│ │ └── ai/ # Output directory for .ai.md files
│ └── test/
@@ -235,6 +235,7 @@ header block, so a `- F:` line in the body is never read as a link.
| `force` | `aiIndex.force` | `false` | Regenerate even if summary exists |
| `subtrees` | `aiIndex.subtrees` | *(all)* | Limit to specific source subdirectories |
| `fileExtensions` | `aiIndex.fileExtensions` | `.java` | File extensions to index |
+| `excludes` | `aiIndex.excludes` | *(none)* | Glob patterns (base-relative, `/` separators) for source files to skip, e.g. `**/package-info.java`, `**/generated/**` (`AiSourceExcludeFilter`) |
| `generationProvider` | `aiIndex.generationProvider` | `mock` | `mock` or `llamacpp-jni` |
| `llamaModelPath` | `aiIndex.llama.modelPath` | — | Path to GGUF model file |
| `llamaContextSize` | `aiIndex.llama.contextSize` | `2048` | Context window size |
diff --git a/README.md b/README.md
index adeb20b..95f9863 100644
--- a/README.md
+++ b/README.md
@@ -271,6 +271,9 @@ Run-level parameters (set in ``):
- `outputDirectory` — target directory for `.ai.md` files (default: `${project.basedir}/src/site/ai`)
- `subtrees` — source directories to index, relative to the project base dir (default: `src/main/java`)
- `fileExtensions` — file extensions to index (default: `.java`)
+- `excludes` — glob patterns for source files to skip, matched against each file's path relative to
+ the base dir with `/` separators, e.g. `**/package-info.java`, `**/generated/**` (default: none).
+ `*` stays within one path segment, `**` spans directories, `?` is a single character.
- `generationProvider` — AI backend: `mock` (default) or `llamacpp-jni`
- `force` — regenerate even when a body already exists (default: `false`)
- `skip` — skip the goal entirely (default: `false`)
diff --git a/pom.xml b/pom.xml
index 590049d..415ed8d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -693,6 +693,7 @@ SPDX-License-Identifier: Apache-2.0
net.ladenthin.maven.llamacpp.aiindex.provider.MockAiGenerationProvider
net.ladenthin.maven.llamacpp.aiindex.support.AiChecksumSupport
net.ladenthin.maven.llamacpp.aiindex.support.AiPathSupport
+ net.ladenthin.maven.llamacpp.aiindex.support.AiSourceExcludeFilter
net.ladenthin.maven.llamacpp.aiindex.support.AiTimeSupport
net.ladenthin.maven.llamacpp.aiindex.support.Java8CompatibilityHelper
diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml
index 2a71331..baedd30 100644
--- a/spotbugs-exclude.xml
+++ b/spotbugs-exclude.xml
@@ -168,6 +168,27 @@ SPDX-License-Identifier: Apache-2.0
+
+
+
+
+
+
+
ai-aggregate-project
prepare-package
aggregate-project
+
+
+
+ project-body
+ coder
+
+
+
@@ -284,12 +297,13 @@ Per-model parameters — model path, context size, output tokens, temperature, t
repeat penalty, threads — live inside each ``, not as top-level parameters.
## Prompt System
Prompts are defined in the plugin configuration (``) and referenced by key
-from ``. The self-test profile defines four:
+from ``. The self-test profile defines five:
- `file-body-java` — summarizes a single Java source file (types, public API, dependencies)
- `file-body-sql` — summarizes a single SQL file as schema (tables/views/procedures, columns,
the tables it reads vs writes, and relationships)
- `file-body-fallback` — generic multi-language summary for any other source file
- `package-body` — synthesizes a package summary from the already-generated file summaries
+- `project-body` — (optional) synthesizes the short project `#### Overview` paragraph from the package leads
For the `generate` goal the file-level prompt is selected per file by extension: the first field
generation whose `` matches the file name wins; otherwise the first entry without a
diff --git a/pom.xml b/pom.xml
index 415ed8d..58dd748 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1412,6 +1412,20 @@ If the input contains "[EOF - source was truncated]", summarize ONLY what is pre
Package: %s
File summaries:
+%s
+ ]]>
+
+
+ project-body
+
@@ -1478,10 +1492,14 @@ File summaries:
ai-aggregate-project
@@ -1489,6 +1507,14 @@ File summaries:
aggregate-project
+
+
+
+ project-body
+ Qwen3-Coder-30B-A3B-Instruct-UD-Q4_K_XL-C64k
+
+
+
diff --git a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/indexer/ProjectIndexer.java b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/indexer/ProjectIndexer.java
index 2cc6413..c298933 100644
--- a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/indexer/ProjectIndexer.java
+++ b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/indexer/ProjectIndexer.java
@@ -7,20 +7,28 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Stream;
import lombok.ToString;
+import net.ladenthin.maven.llamacpp.aiindex.config.AiFieldGenerationConfig;
+import net.ladenthin.maven.llamacpp.aiindex.config.AiModelDefinitionSupport;
+import net.ladenthin.maven.llamacpp.aiindex.document.AiGenerationResult;
import net.ladenthin.maven.llamacpp.aiindex.document.AiMdDocument;
import net.ladenthin.maven.llamacpp.aiindex.document.AiMdDocumentCodec;
import net.ladenthin.maven.llamacpp.aiindex.document.AiMdHeader;
import net.ladenthin.maven.llamacpp.aiindex.document.AiMdHeaderCodec;
import net.ladenthin.maven.llamacpp.aiindex.document.AiMdHeaderSupport;
import net.ladenthin.maven.llamacpp.aiindex.document.AiMdLeadExtractor;
+import net.ladenthin.maven.llamacpp.aiindex.prompt.AiPromptPreparationSupport;
+import net.ladenthin.maven.llamacpp.aiindex.prompt.AiPromptSupport;
+import net.ladenthin.maven.llamacpp.aiindex.provider.AiGenerationProvider;
import net.ladenthin.maven.llamacpp.aiindex.support.AiChecksumSupport;
import net.ladenthin.maven.llamacpp.aiindex.support.AiTimeSupport;
import net.ladenthin.maven.llamacpp.aiindex.support.Java8CompatibilityHelper;
import org.apache.maven.plugin.logging.Log;
+import org.jspecify.annotations.Nullable;
/**
* Builds the single project-level AI index — the top of the three-level index.
@@ -33,11 +41,23 @@
* result is a compact, always-loadable table of contents an agent reads first to decide which package
* to open, instead of scanning thousands of file summaries.
*
- * This step is fully deterministic: it harvests existing leads and never calls
- * the AI model, so it adds negligible cost and scales to projects with hundreds of packages. The
- * body is content-addressed — its {@code c} checksum is the CRC32 of the rendered body — so the file
- * is only rewritten when a package's lead, link, or set changes (or {@code force} is set), mirroring
- * the incremental contract of {@link SourceFileIndexer} and {@link PackageIndexer}.
+ * By default this step is fully deterministic: it harvests existing leads and never
+ * calls the AI model, so it adds negligible cost and scales to projects with hundreds of packages.
+ *
+ * Optional AI overview. When an overview field generation (prompt + model) is
+ * supplied, one — and only one — extra AI call writes a short project overview paragraph under a
+ * {@code #### Overview} heading, synthesised from the per-package leads alone (not the full package
+ * bodies), so the entry document opens with a project narrative. It is opt-in: with no overview field
+ * generation the behaviour is exactly the deterministic one above.
+ *
+ * Incrementality. The header {@code c} checksum is the CRC32 of the deterministic
+ * body (the per-package listing) plus the overview generation signature
+ * ({@code promptKey:aiDefinitionKey}, empty when the overview is off). The AI overview text itself is
+ * deliberately not part of the checksum, so an unchanged project is never re-inferred (the
+ * paragraph stays stable across a multi-day run), yet the file regenerates when a package's lead/link/set
+ * changes or the overview configuration changes (so enabling the overview, or switching its
+ * model, triggers a rebuild) — or when {@code force} is set. This mirrors the incremental contract of
+ * {@link SourceFileIndexer} and {@link PackageIndexer}.
*
* {@code toString} is generated by Lombok over the configuration and collaborator fields. The
* Maven {@link Log} is excluded because its default {@code toString} prints implementation details
@@ -50,12 +70,36 @@ public class ProjectIndexer {
/** Markdown heading introducing the per-package listing in the project index body. */
private static final String PACKAGES_HEADING = "#### Packages";
+ /** Markdown heading introducing the optional AI-generated project overview paragraph. */
+ private static final String OVERVIEW_HEADING = "#### Overview";
+
/** Separator placed between a package's link and its lead in a listing line. */
private static final String LEAD_SEPARATOR = " — ";
/** Display name used for the output-root package (whose relativised path is empty). */
private static final String ROOT_DISPLAY = ".";
+ /**
+ * Context-type label passed to {@link AiFieldGenerationSupport} so that trim-warning log messages
+ * read "Trimmed AI input for project field '…'".
+ */
+ private static final String CONTEXT_TYPE_PROJECT = "project";
+
+ /**
+ * Separator joining the overview prompt key and AI definition key into the generation signature
+ * folded into the {@code c} checksum, so a change of overview prompt or model rebuilds the index.
+ */
+ private static final String OVERVIEW_SIGNATURE_SEPARATOR = ":";
+
+ /** Prefix of the first line of the overview source, naming the project for the model. */
+ private static final String OVERVIEW_SOURCE_TITLE_PREFIX = "Project: ";
+
+ /** Heading of the per-package lead listing in the overview source fed to the model. */
+ private static final String OVERVIEW_SOURCE_LEADS_HEADING = "Package leads (one per package):";
+
+ /** Separator between a package path and its lead in the overview source listing. */
+ private static final String OVERVIEW_SOURCE_LEAD_SEPARATOR = ": ";
+
/**
* Earliest possible date value used as the starting point when scanning packages to find the
* latest index creation date recorded in the project header's {@code d} field.
@@ -72,6 +116,12 @@ public class ProjectIndexer {
private final String aiVersion;
private final boolean force;
+ /** Overview field generation (prompt + model); {@code null} disables the AI overview. */
+ private final @Nullable AiFieldGenerationConfig overviewFieldGeneration;
+
+ /** AI generation loop used for the overview; {@code null} when the overview is disabled. */
+ private final @Nullable AiFieldGenerationSupport fieldGenerationSupport;
+
private final AiTimeSupport timeSupport = new AiTimeSupport();
private final AiChecksumSupport checksumSupport = new AiChecksumSupport();
private final AiMdHeaderSupport headerSupport = new AiMdHeaderSupport();
@@ -80,7 +130,7 @@ public class ProjectIndexer {
private final Java8CompatibilityHelper compatibilityHelper = new Java8CompatibilityHelper();
/**
- * Creates a new {@link ProjectIndexer}.
+ * Creates a deterministic {@link ProjectIndexer} with no AI overview.
*
* @param log Maven plugin logger
* @param projectTitle title recorded in the project index header (typically the Maven project name)
@@ -94,11 +144,72 @@ public ProjectIndexer(
final String pluginVersion,
final String aiVersion,
final boolean force) {
+ this(log, projectTitle, pluginVersion, aiVersion, force, null, null, null, null);
+ }
+
+ /**
+ * Creates a {@link ProjectIndexer} that optionally generates an AI project overview.
+ *
+ *
The overview is enabled only when {@code generationProvider}, {@code overviewFieldGeneration},
+ * {@code promptSupport}, and {@code modelDefinitionSupport} are all non-{@code null}; otherwise the
+ * indexer behaves exactly like the deterministic constructor above.
+ *
+ * @param log Maven plugin logger
+ * @param projectTitle title recorded in the project index header
+ * @param pluginVersion plugin version recorded in the header
+ * @param aiVersion AI summarisation logic version recorded in the header
+ * @param force when {@code true}, rewrite the project index even when unchanged
+ * @param generationProvider AI provider for the overview; {@code null} disables it
+ * @param overviewFieldGeneration overview prompt + model; {@code null} disables it
+ * @param promptSupport prompt lookup; {@code null} disables the overview
+ * @param modelDefinitionSupport AI model definition lookup; {@code null} disables the overview
+ */
+ public ProjectIndexer(
+ final Log log,
+ final String projectTitle,
+ final String pluginVersion,
+ final String aiVersion,
+ final boolean force,
+ final @Nullable AiGenerationProvider generationProvider,
+ final @Nullable AiFieldGenerationConfig overviewFieldGeneration,
+ final @Nullable AiPromptSupport promptSupport,
+ final @Nullable AiModelDefinitionSupport modelDefinitionSupport) {
this.log = log;
this.projectTitle = projectTitle;
this.pluginVersion = pluginVersion;
this.aiVersion = aiVersion;
this.force = force;
+ // Defensive copy: the field generation is a mutable Maven JavaBean owned by the caller.
+ this.overviewFieldGeneration = copyFieldGeneration(overviewFieldGeneration);
+ if (generationProvider != null
+ && overviewFieldGeneration != null
+ && promptSupport != null
+ && modelDefinitionSupport != null) {
+ this.fieldGenerationSupport = new AiFieldGenerationSupport(
+ log, generationProvider, new AiPromptPreparationSupport(promptSupport), modelDefinitionSupport);
+ } else {
+ this.fieldGenerationSupport = null;
+ }
+ }
+
+ /**
+ * Returns a defensive copy of {@code source}, or {@code null} when {@code source} is {@code null}.
+ * The overview field generation is a mutable Maven {@link AiFieldGenerationConfig} JavaBean owned
+ * by the caller; copying it prevents external mutation after construction.
+ *
+ * @param source the caller-supplied field generation, or {@code null}
+ * @return an independent copy, or {@code null}
+ */
+ private static @Nullable AiFieldGenerationConfig copyFieldGeneration(
+ final @Nullable AiFieldGenerationConfig source) {
+ if (source == null) {
+ return null;
+ }
+ final AiFieldGenerationConfig copy = new AiFieldGenerationConfig();
+ copy.setPromptKey(source.getPromptKey());
+ copy.setAiDefinitionKey(source.getAiDefinitionKey());
+ copy.setFileExtensions(source.getFileExtensions());
+ return copy;
}
/**
@@ -138,8 +249,23 @@ public int aggregate(final Path rootDirectory) throws IOException {
packageLinks.add(markdownLink(entry.displayPath(), entry.link()));
}
- final String body = buildBody(entries);
- final String checksum = checksumSupport.calculateCrc32Hex(body);
+ final String leadLine = buildLeadLine();
+ final String packagesSection = buildPackagesSection(entries);
+ final String deterministicBody = leadLine + "\n\n" + packagesSection;
+
+ // The overview generation signature (empty when off) is folded into the checksum so the
+ // index rebuilds when the overview prompt/model changes — but the AI output is not, so an
+ // unchanged project is never re-inferred. The same (overview != null && support != null) guard
+ // gates both the signature and the generation below, so the two stay consistent.
+ final AiFieldGenerationConfig overview = this.overviewFieldGeneration;
+ final AiFieldGenerationSupport support = this.fieldGenerationSupport;
+ final String signature;
+ if (overview != null && support != null) {
+ signature = overview.getPromptKey() + OVERVIEW_SIGNATURE_SEPARATOR + overview.getAiDefinitionKey();
+ } else {
+ signature = "";
+ }
+ final String checksum = checksumSupport.calculateCrc32Hex(deterministicBody + signature);
final String generatedAt = timeSupport.formatInstant(java.time.Instant.now());
final AiMdHeader baseHeader = new AiMdHeader(
@@ -160,6 +286,16 @@ public int aggregate(final Path rootDirectory) throws IOException {
return 0;
}
+ final String body;
+ if (overview != null && support != null) {
+ final String overviewText = generateOverview(support, overview, projectFile, entries, baseHeader);
+ body = compatibilityHelper.isBlank(overviewText)
+ ? deterministicBody
+ : leadLine + "\n\n" + OVERVIEW_HEADING + "\n" + overviewText.trim() + "\n\n" + packagesSection;
+ } else {
+ body = deterministicBody;
+ }
+
documentCodec.write(projectFile, new AiMdDocument(baseHeader, body));
log.info("Wrote project index file: " + projectFile + " (" + entries.size() + " packages)");
return 1;
@@ -177,19 +313,27 @@ private boolean isPackageAiMdFile(final Path path) {
}
/**
- * Renders the project index body: a deterministic blockquote lead followed by one listing line
- * per package ({@code - — }; the lead and its separator are omitted when the
- * package has no lead). The clickable links live in the header {@code F} list, not the body, so
- * the body stays compact at hundreds of packages and the path doubles as the header-link anchor.
+ * Returns the deterministic blockquote lead line that opens the project index body. Kept stable
+ * (never AI-generated) so any tooling that harvests the project lead reads a predictable sentence.
+ *
+ * @return the blockquote lead line (no trailing newline)
+ */
+ private String buildLeadLine() {
+ return AiMdLeadExtractor.BLOCKQUOTE_MARKER + " Project index of " + projectTitle
+ + ": one line per package; leads here, clickable links in the header F list.";
+ }
+
+ /**
+ * Renders the deterministic per-package listing section: the {@value #PACKAGES_HEADING} heading
+ * followed by one line per package ({@code - — }; the lead and its separator are
+ * omitted when the package has no lead). The clickable links live in the header {@code F} list, not
+ * the body, so the body stays compact at hundreds of packages.
*
* @param entries package entries, already sorted by link
- * @return the rendered markdown body
+ * @return the rendered packages section (ends with a newline)
*/
- private String buildBody(final List entries) {
+ private String buildPackagesSection(final List entries) {
final StringBuilder builder = new StringBuilder();
- final String leadLine = AiMdLeadExtractor.BLOCKQUOTE_MARKER + " Project index of " + projectTitle
- + ": one line per package; leads here, clickable links in the header F list.";
- builder.append(leadLine).append('\n').append('\n');
builder.append(PACKAGES_HEADING).append('\n');
for (final PackageEntry entry : entries) {
builder.append("- ").append(entry.displayPath());
@@ -201,6 +345,56 @@ private String buildBody(final List entries) {
return builder.toString();
}
+ /**
+ * Generates the AI overview paragraph from the per-package leads via {@link AiFieldGenerationSupport}
+ * (which handles trimming, retries, and empty-output warnings). The model receives only the project
+ * title and the one-line package leads, never the full package bodies, keeping the input compact.
+ *
+ * @param support the AI generation loop (non-null)
+ * @param overview the overview field generation (non-null)
+ * @param projectFile the project index file path, used as the request context file
+ * @param entries the harvested package entries
+ * @param baseHeader the project index header passed through to the generation request
+ * @return the generated overview text (possibly blank if the model produced nothing)
+ * @throws IOException if the AI provider throws during generation
+ */
+ private String generateOverview(
+ final AiFieldGenerationSupport support,
+ final AiFieldGenerationConfig overview,
+ final Path projectFile,
+ final List entries,
+ final AiMdHeader baseHeader)
+ throws IOException {
+ final String overviewSource = buildOverviewSource(entries);
+ final AiGenerationResult result = support.processFieldGenerations(
+ Collections.singletonList(overview), projectFile, CONTEXT_TYPE_PROJECT, overviewSource, baseHeader);
+ return result.body();
+ }
+
+ /**
+ * Builds the source text fed to the overview model: the project title followed by the one-line lead
+ * of every package. Packages without a lead contribute their path alone.
+ *
+ * @param entries the harvested package entries, already sorted
+ * @return the overview source text
+ */
+ private String buildOverviewSource(final List entries) {
+ final StringBuilder builder = new StringBuilder();
+ builder.append(OVERVIEW_SOURCE_TITLE_PREFIX)
+ .append(projectTitle)
+ .append('\n')
+ .append('\n');
+ builder.append(OVERVIEW_SOURCE_LEADS_HEADING).append('\n');
+ for (final PackageEntry entry : entries) {
+ builder.append("- ").append(entry.displayPath());
+ if (!compatibilityHelper.isBlank(entry.lead())) {
+ builder.append(OVERVIEW_SOURCE_LEAD_SEPARATOR).append(entry.lead());
+ }
+ builder.append('\n');
+ }
+ return builder.toString();
+ }
+
/**
* Formats a markdown link {@code [label](target)} for the header {@code F} child-link list.
*
diff --git a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/AggregateProjectMojo.java b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/AggregateProjectMojo.java
index ce2044d..ea76363 100644
--- a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/AggregateProjectMojo.java
+++ b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/AggregateProjectMojo.java
@@ -3,13 +3,16 @@
// SPDX-License-Identifier: Apache-2.0
package net.ladenthin.maven.llamacpp.aiindex.mojo;
-import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import lombok.ToString;
+import net.ladenthin.maven.llamacpp.aiindex.config.AiFieldGenerationConfig;
+import net.ladenthin.maven.llamacpp.aiindex.config.AiModelDefinitionSupport;
import net.ladenthin.maven.llamacpp.aiindex.indexer.ProjectIndexer;
+import net.ladenthin.maven.llamacpp.aiindex.prompt.AiPromptSupport;
+import net.ladenthin.maven.llamacpp.aiindex.provider.AiGenerationProvider;
+import net.ladenthin.maven.llamacpp.aiindex.provider.AiGenerationProviderFactory;
import net.ladenthin.maven.llamacpp.aiindex.support.Java8CompatibilityHelper;
-import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
@@ -19,12 +22,17 @@
* {@code package.ai.md} into a single project-level {@code project.ai.md} — the top of the
* three-level index.
*
- * Unlike {@code generate} and {@code aggregate-packages}, this goal is fully deterministic: it
- * harvests the one-line lead already present in each package summary and writes a compact, navigable
- * table of contents that links to every package. It calls no AI model, so it needs no provider,
- * model, or prompt configuration — only {@link #outputDirectory}, {@link #force}, and {@link #skip}.
- * It therefore extends {@link AbstractMojo} directly rather than the provider-oriented
- * {@code AbstractAiIndexMojo}, exposing only the parameters it actually uses.
+ * By default this goal is fully deterministic: it harvests the one-line lead already present in
+ * each package summary and writes a compact, navigable table of contents that links to every
+ * package, calling no AI model. It then needs no provider, model, or prompt configuration — only
+ * {@link #outputDirectory}, {@link #force}, and {@link #skip}.
+ *
+ * Optional AI overview (opt-in). When a {@code } is configured
+ * (a {@code promptKey} + {@code aiDefinitionKey}), the goal additionally makes one AI call
+ * to write a short project overview paragraph from the per-package leads (see {@link ProjectIndexer}).
+ * Because the overview reuses the shared provider/prompt/model parameters, this mojo extends
+ * {@code AbstractAiIndexMojo} (the provider-oriented base) — but it builds a provider only when a
+ * field generation is present, so the deterministic default needs no model and pays no model cost.
*
* {@code toString} is generated by Lombok over the {@code @Parameter} fields so Maven debug logs
* ({@code -X}) print a useful dump of the goal's resolved configuration. {@code equals}/{@code hashCode}
@@ -35,8 +43,8 @@
// Framework has no equivalent option for plugin-framework fields, so we suppress class-level.
@SuppressWarnings("initialization.fields.uninitialized")
@Mojo(name = "aggregate-project", threadSafe = true)
-@ToString
-public class AggregateProjectMojo extends AbstractMojo {
+@ToString(callSuper = true)
+public class AggregateProjectMojo extends AbstractAiIndexMojo {
/** Creates a new {@link AggregateProjectMojo}. */
public AggregateProjectMojo() {
@@ -48,21 +56,6 @@ public AggregateProjectMojo() {
*/
private static final String DEFAULT_PROJECT_TITLE = "project";
- /**
- * Directory that holds the {@code .ai.md} tree and into which {@code project.ai.md} is written.
- * Defaults to {@code ${project.basedir}/src/site/ai}.
- */
- @Parameter(property = "aiIndex.outputDirectory", defaultValue = "${project.basedir}/src/site/ai")
- private File outputDirectory;
-
- /** When {@code true}, the goal skips all processing and returns immediately. */
- @Parameter(property = "aiIndex.skip", defaultValue = "false")
- private boolean skip;
-
- /** When {@code true}, rewrites {@code project.ai.md} even when it is unchanged. */
- @Parameter(property = "aiIndex.force", defaultValue = "false")
- private boolean force;
-
/** Plugin version recorded in the project index header. */
@Parameter(defaultValue = "${project.version}", readonly = true)
private String pluginVersion;
@@ -75,8 +68,29 @@ public AggregateProjectMojo() {
@Parameter(defaultValue = "${project.name}", readonly = true)
private String projectName;
+ /**
+ * llama.cpp context window size used only as the fallback when no {@code } is
+ * configured; with an overview field generation present, the size comes from its AI definition.
+ */
+ @Parameter(property = "aiIndex.llama.contextSize", defaultValue = "4096")
+ private int llamaContextSize;
+
+ /** CPU threads for llama.cpp inference, used only on the no-field-generation fallback path. */
+ @Parameter(property = "aiIndex.llama.threads", defaultValue = "2")
+ private int llamaThreads;
+
private final Java8CompatibilityHelper compatibilityHelper = new Java8CompatibilityHelper();
+ @Override
+ protected int getLlamaContextSize() {
+ return llamaContextSize;
+ }
+
+ @Override
+ protected int getLlamaThreads() {
+ return llamaThreads;
+ }
+
@Override
public void execute() throws MojoExecutionException {
if (skip) {
@@ -99,8 +113,14 @@ public void execute() throws MojoExecutionException {
projectName == null || compatibilityHelper.isBlank(projectName) ? DEFAULT_PROJECT_TITLE : projectName;
try {
- final ProjectIndexer indexer = new ProjectIndexer(getLog(), title, pluginVersion, aiVersion, force);
- final int written = indexer.aggregate(outputPath);
+ final int written;
+ if (fieldGenerations != null && !fieldGenerations.isEmpty()) {
+ written = aggregateWithOverview(outputPath, title, fieldGenerations.get(0));
+ } else {
+ getLog().info("Project overview generation: disabled (no fieldGenerations configured)");
+ final ProjectIndexer indexer = new ProjectIndexer(getLog(), title, pluginVersion, aiVersion, force);
+ written = indexer.aggregate(outputPath);
+ }
getLog().info("Project index files written: " + written);
} catch (IOException e) {
throw new MojoExecutionException("Failed to aggregate project AI index file under " + outputPath, e);
@@ -108,4 +128,38 @@ public void execute() throws MojoExecutionException {
getLog().info("AI project index aggregation finished.");
}
+
+ /**
+ * Aggregates the project index with the optional AI overview paragraph enabled, generated by the
+ * given field generation. A provider is created (and closed) only on this path, so the
+ * deterministic default never instantiates a model.
+ *
+ * @param outputPath the resolved output directory holding the {@code .ai.md} tree
+ * @param title the resolved project title
+ * @param overview the overview field generation (prompt + model)
+ * @return {@code 1} if the project index was written or refreshed, {@code 0} otherwise
+ * @throws MojoExecutionException if the provider or model definitions are misconfigured
+ * @throws IOException if the output tree cannot be read or the index cannot be written
+ */
+ private int aggregateWithOverview(final Path outputPath, final String title, final AiFieldGenerationConfig overview)
+ throws MojoExecutionException, IOException {
+ final AiPromptSupport promptSupport = buildPromptSupport();
+ final AiModelDefinitionSupport modelDefinitionSupport = buildAiModelDefinitionSupport();
+ getLog().info("Project overview generation: enabled (prompt '" + overview.getPromptKey() + "')");
+ final AiGenerationProviderFactory providerFactory = new AiGenerationProviderFactory();
+ try (AiGenerationProvider provider =
+ providerFactory.create(generationProvider, buildLlamaCppJniConfig(), promptSupport)) {
+ final ProjectIndexer indexer = new ProjectIndexer(
+ getLog(),
+ title,
+ pluginVersion,
+ aiVersion,
+ force,
+ provider,
+ overview,
+ promptSupport,
+ modelDefinitionSupport);
+ return indexer.aggregate(outputPath);
+ }
+ }
}
diff --git a/src/test/java/net/ladenthin/maven/llamacpp/aiindex/indexer/ProjectIndexerTest.java b/src/test/java/net/ladenthin/maven/llamacpp/aiindex/indexer/ProjectIndexerTest.java
index 12e7208..f62b71f 100644
--- a/src/test/java/net/ladenthin/maven/llamacpp/aiindex/indexer/ProjectIndexerTest.java
+++ b/src/test/java/net/ladenthin/maven/llamacpp/aiindex/indexer/ProjectIndexerTest.java
@@ -14,10 +14,16 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
+import java.util.Collections;
+import net.ladenthin.maven.llamacpp.aiindex.CommonTestFixtures;
+import net.ladenthin.maven.llamacpp.aiindex.config.AiFieldGenerationConfig;
import net.ladenthin.maven.llamacpp.aiindex.document.AiMdDocument;
import net.ladenthin.maven.llamacpp.aiindex.document.AiMdDocumentCodec;
import net.ladenthin.maven.llamacpp.aiindex.document.AiMdHeader;
import net.ladenthin.maven.llamacpp.aiindex.document.AiMdHeaderCodec;
+import net.ladenthin.maven.llamacpp.aiindex.prompt.AiPromptDefinition;
+import net.ladenthin.maven.llamacpp.aiindex.prompt.AiPromptSupport;
+import net.ladenthin.maven.llamacpp.aiindex.provider.MockAiGenerationProvider;
import org.apache.maven.plugin.logging.SystemStreamLog;
import org.junit.jupiter.api.Test;
@@ -180,6 +186,124 @@ public void aggregate_rootPackage_isListedAsDot() throws Exception {
}
//
+ //
+ @Test
+ public void aggregate_overviewEnabled_writesOverviewSectionAboveTheListing() throws Exception {
+ // arrange
+ final Path outputRoot = Files.createTempDirectory("ai-index-test").resolve("ai");
+ writePackageFile(
+ outputRoot.resolve("main/java/com/example/package.ai.md"),
+ "main/java/com/example",
+ "> Example package.\n");
+
+ // act
+ final int written = overviewIndexer(false).aggregate(outputRoot);
+
+ // assert: an #### Overview section with the generated text precedes the deterministic listing
+ assertThat(written, is(equalTo(1)));
+ final AiMdDocument document = documentCodec.read(outputRoot.resolve(AiMdHeaderCodec.PROJECT_AI_MD_FILENAME));
+ final String body = document.body();
+ assertThat(body, containsString(OVERVIEW_HEADING));
+ assertThat(body, containsString("Mock summary for project.ai.md"));
+ assertThat(body, containsString("#### Packages"));
+ assertThat(body, containsString("- main/java/com/example — Example package."));
+ assertThat(body.indexOf(OVERVIEW_HEADING) < body.indexOf("#### Packages"), is(true));
+ }
+
+ @Test
+ public void aggregate_overviewDisabled_hasNoOverviewSection() throws Exception {
+ // arrange
+ final Path outputRoot = Files.createTempDirectory("ai-index-test").resolve("ai");
+ writePackageFile(
+ outputRoot.resolve("main/java/com/example/package.ai.md"),
+ "main/java/com/example",
+ "> Example package.\n");
+
+ // act: the deterministic constructor performs no AI call
+ new ProjectIndexer(new SystemStreamLog(), PROJECT_TITLE, "1.0.0", "0.0.0", false).aggregate(outputRoot);
+
+ // assert
+ final AiMdDocument document = documentCodec.read(outputRoot.resolve(AiMdHeaderCodec.PROJECT_AI_MD_FILENAME));
+ assertThat(document.body(), not(containsString(OVERVIEW_HEADING)));
+ }
+
+ @Test
+ public void aggregate_overviewEnabled_unchangedProjectIsNotReInferred() throws Exception {
+ // arrange
+ final Path outputRoot = Files.createTempDirectory("ai-index-test").resolve("ai");
+ writePackageFile(
+ outputRoot.resolve("main/java/com/example/package.ai.md"),
+ "main/java/com/example",
+ "> Example package.\n");
+
+ // first run writes the overview
+ assertThat(overviewIndexer(false).aggregate(outputRoot), is(equalTo(1)));
+
+ // act: a second run over the same packages and same overview config must detect no change
+ // (the checksum folds in the generation signature but not the AI output)
+ final int second = overviewIndexer(false).aggregate(outputRoot);
+
+ // assert
+ assertThat(second, is(equalTo(0)));
+ }
+
+ @Test
+ public void aggregate_enablingOverview_rewritesPreviouslyDeterministicIndex() throws Exception {
+ // arrange: a deterministic project index already exists
+ final Path outputRoot = Files.createTempDirectory("ai-index-test").resolve("ai");
+ writePackageFile(
+ outputRoot.resolve("main/java/com/example/package.ai.md"),
+ "main/java/com/example",
+ "> Example package.\n");
+ assertThat(
+ new ProjectIndexer(new SystemStreamLog(), PROJECT_TITLE, "1.0.0", "0.0.0", false).aggregate(outputRoot),
+ is(equalTo(1)));
+
+ // act: enabling the overview changes the generation signature in the checksum -> regenerate
+ final int rewritten = overviewIndexer(false).aggregate(outputRoot);
+
+ // assert
+ assertThat(rewritten, is(equalTo(1)));
+ final AiMdDocument document = documentCodec.read(outputRoot.resolve(AiMdHeaderCodec.PROJECT_AI_MD_FILENAME));
+ assertThat(document.body(), containsString(OVERVIEW_HEADING));
+ }
+ //
+
+ /**
+ * Markdown heading the overview section emits; duplicated here from the (private) constant in
+ * {@code ProjectIndexer} so the tests assert on the literal contract rather than the symbol.
+ */
+ private static final String OVERVIEW_HEADING = "#### Overview";
+
+ /**
+ * Builds a {@link ProjectIndexer} with the AI overview enabled, backed by the deterministic
+ * {@link MockAiGenerationProvider} (which returns {@code "Mock summary for project.ai.md"}).
+ *
+ * @param force the force flag to pass through
+ * @return an overview-enabled indexer
+ */
+ private ProjectIndexer overviewIndexer(final boolean force) {
+ final AiFieldGenerationConfig overview = new AiFieldGenerationConfig();
+ overview.setPromptKey("project-body");
+ overview.setAiDefinitionKey(CommonTestFixtures.AI_DEFINITION_KEY_DEFAULT);
+
+ final AiPromptDefinition prompt = new AiPromptDefinition();
+ prompt.setKey("project-body");
+ prompt.setTemplate("Write a project overview.\nProject: %s\nLeads:\n%s\n");
+ final AiPromptSupport promptSupport = new AiPromptSupport(Collections.singletonList(prompt));
+
+ return new ProjectIndexer(
+ new SystemStreamLog(),
+ PROJECT_TITLE,
+ "1.0.0",
+ "0.0.0",
+ force,
+ new MockAiGenerationProvider(),
+ overview,
+ promptSupport,
+ CommonTestFixtures.createDefaultAiModelDefinitionSupport());
+ }
+
private void writePackageFile(final Path file, final String title, final String body) throws IOException {
Files.createDirectories(file.getParent());
final AiMdHeader header = new AiMdHeader(
From bbc7c35dbc6bb61d4eb3501a3b6863bac9525d78 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 26 Jun 2026 10:14:26 +0000
Subject: [PATCH 11/13] feat: independently switchable file/package/project
phases
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Make each of the three index phases (generate, aggregate-packages,
aggregate-project) toggleable on its own, so any subset can run — nothing,
all three, or e.g. file + project only.
Generalized symmetrically in the class hierarchy:
- AbstractAiIndexMojo owns the global `skip` (aiIndex.skip — skip every phase),
an abstract isPhaseSkipped() seam, and shouldSkip() = skip || isPhaseSkipped().
- Each concrete mojo adds exactly one skip @Parameter plus a one-line
isPhaseSkipped() override, and calls shouldSkip() at the top of execute():
- aiIndex.generate.skip (GenerateMojo)
- aiIndex.aggregatePackages.skip (AggregatePackagesMojo)
- aiIndex.aggregateProject.skip (AggregateProjectMojo)
The global aiIndex.skip stays as the master switch (backward compatible).
MojoPhaseSkipTest covers the base shouldSkip() truth table and each goal's
independent phase flag (incl. global skip overriding an off phase flag).
Docs (README, CLAUDE.md) document the per-phase switches.
Gates: 174 tests green; SpotBugs 0; full PIT 200/200 (100%).
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01RvxJko6xiCx83LxKgj92mH
---
CLAUDE.md | 14 +-
README.md | 9 +-
.../aiindex/mojo/AbstractAiIndexMojo.java | 27 +++-
.../aiindex/mojo/AggregatePackagesMojo.java | 14 +-
.../aiindex/mojo/AggregateProjectMojo.java | 14 +-
.../llamacpp/aiindex/mojo/GenerateMojo.java | 14 +-
.../aiindex/mojo/MojoPhaseSkipTest.java | 122 ++++++++++++++++++
7 files changed, 208 insertions(+), 6 deletions(-)
create mode 100644 src/test/java/net/ladenthin/maven/llamacpp/aiindex/mojo/MojoPhaseSkipTest.java
diff --git a/CLAUDE.md b/CLAUDE.md
index 4a64859..2b5fec9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -146,6 +146,15 @@ the full bodies). Incrementality is preserved by folding the overview generation
(`promptKey:aiDefinitionKey`) — not the AI output — into the `c` checksum, so an unchanged project
is never re-inferred but enabling/switching the overview, or a package change, rebuilds it.
+**Each phase is independently switchable.** Every goal is a separate Maven execution, and each can be
+toggled on/off on its own via a phase-specific skip flag (`aiIndex.generate.skip`,
+`aiIndex.aggregatePackages.skip`, `aiIndex.aggregateProject.skip`), with `aiIndex.skip` as a global
+"skip all" switch — so you can run nothing, all three, or any subset (e.g. file + project only). The
+mechanism is generalized symmetrically: `AbstractAiIndexMojo` owns the global `skip`, the abstract
+`isPhaseSkipped()` seam, and `shouldSkip() = skip || isPhaseSkipped()`; each concrete mojo adds exactly
+one `skip` `@Parameter` plus a one-line `isPhaseSkipped()` override and calls `shouldSkip()` at
+the top of `execute()`. Covered by `MojoPhaseSkipTest`.
+
### Key Components
| Class | Role |
@@ -236,7 +245,10 @@ header block, so a `- F:` line in the body is never read as a link.
| Parameter | Property | Default | Description |
|---|---|---|---|
| `outputDirectory` | `aiIndex.outputDirectory` | `${basedir}/src/site/ai` | Where `.ai.md` files are written |
-| `skip` | `aiIndex.skip` | `false` | Skip all execution |
+| `skip` | `aiIndex.skip` | `false` | Global switch: skip **every** phase |
+| `skipGenerate` | `aiIndex.generate.skip` | `false` | Skip only the `generate` (file) phase |
+| `skipAggregatePackages` | `aiIndex.aggregatePackages.skip` | `false` | Skip only the `aggregate-packages` phase |
+| `skipAggregateProject` | `aiIndex.aggregateProject.skip` | `false` | Skip only the `aggregate-project` phase |
| `force` | `aiIndex.force` | `false` | Regenerate even if summary exists |
| `subtrees` | `aiIndex.subtrees` | *(all)* | Limit to specific source subdirectories |
| `fileExtensions` | `aiIndex.fileExtensions` | `.java` | File extensions to index |
diff --git a/README.md b/README.md
index 09d8388..06873f1 100644
--- a/README.md
+++ b/README.md
@@ -289,7 +289,14 @@ Run-level parameters (set in ``):
`*` stays within one path segment, `**` spans directories, `?` is a single character.
- `generationProvider` — AI backend: `mock` (default) or `llamacpp-jni`
- `force` — regenerate even when a body already exists (default: `false`)
-- `skip` — skip the goal entirely (default: `false`)
+- `skip` — global switch: skip **every** phase (default: `false`)
+- Per-phase switches — turn any of the three phases on/off independently (each default `false`):
+ - `aiIndex.generate.skip` — skip the file-indexing phase (`generate`)
+ - `aiIndex.aggregatePackages.skip` — skip the package-aggregation phase (`aggregate-packages`)
+ - `aiIndex.aggregateProject.skip` — skip the project-index phase (`aggregate-project`)
+
+ So `-DaiIndex.aggregatePackages.skip=true` runs file + project only, `-DaiIndex.skip=true` runs
+ nothing, and the defaults run all three.
- `aiDefinitions` / `promptDefinitions` — named models / prompt templates, referenced by key
- `fieldGenerations` — per goal: which `promptKey` runs with which `aiDefinitionKey` (**required**)
diff --git a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/AbstractAiIndexMojo.java b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/AbstractAiIndexMojo.java
index 9543784..cd3630a 100644
--- a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/AbstractAiIndexMojo.java
+++ b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/AbstractAiIndexMojo.java
@@ -59,7 +59,11 @@ protected AbstractAiIndexMojo() {
@Parameter(property = "aiIndex.outputDirectory", defaultValue = "${project.basedir}/src/site/ai")
protected File outputDirectory;
- /** When {@code true}, the goal skips all processing and returns immediately. */
+ /**
+ * Global skip switch: when {@code true}, every AI index goal (generate,
+ * aggregate-packages, aggregate-project) skips and returns immediately. To toggle a single
+ * phase instead, use that goal's own {@code skip} flag (see {@link #isPhaseSkipped()}).
+ */
@Parameter(property = "aiIndex.skip", defaultValue = "false")
protected boolean skip;
@@ -146,10 +150,31 @@ protected AbstractAiIndexMojo() {
*/
protected abstract int getLlamaThreads();
+ /**
+ * Returns whether this individual goal (phase) is disabled by its own phase-specific skip flag,
+ * independent of the global {@link #skip}. Each concrete goal declares its own
+ * {@code skip} {@code @Parameter} and returns it here, so the three phases (generate,
+ * aggregate-packages, aggregate-project) can be switched on and off independently.
+ *
+ * @return {@code true} if this phase's own skip flag is set
+ */
+ protected abstract boolean isPhaseSkipped();
+
// -------------------------------------------------------------------------
// Shared utility methods
// -------------------------------------------------------------------------
+ /**
+ * Returns whether this goal should skip execution: either the global {@link #skip} (which skips
+ * every phase) or this phase's own {@link #isPhaseSkipped()} flag. Centralised here so every goal
+ * applies the same rule identically.
+ *
+ * @return {@code true} if the goal must not run
+ */
+ protected boolean shouldSkip() {
+ return skip || isPhaseSkipped();
+ }
+
/**
* Resolves the configured {@link #subtrees} strings against {@code basePath},
* filtering out any paths that do not exist on disk.
diff --git a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/AggregatePackagesMojo.java b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/AggregatePackagesMojo.java
index ca6864d..0283544 100644
--- a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/AggregatePackagesMojo.java
+++ b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/AggregatePackagesMojo.java
@@ -34,6 +34,13 @@ public AggregatePackagesMojo() {
// no-op
}
+ /**
+ * Phase switch for the {@code aggregate-packages} phase: when {@code true}, this phase is skipped
+ * independently of the other phases. The global {@link #skip} still skips every phase.
+ */
+ @Parameter(property = "aiIndex.aggregatePackages.skip", defaultValue = "false")
+ private boolean skipAggregatePackages;
+
@Parameter(defaultValue = "${project.version}", readonly = true)
private String pluginVersion;
@@ -58,9 +65,14 @@ protected int getLlamaThreads() {
return llamaThreads;
}
+ @Override
+ protected boolean isPhaseSkipped() {
+ return skipAggregatePackages;
+ }
+
@Override
public void execute() throws MojoExecutionException {
- if (skip) {
+ if (shouldSkip()) {
getLog().info("AI package aggregation skipped.");
return;
}
diff --git a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/AggregateProjectMojo.java b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/AggregateProjectMojo.java
index ea76363..08a4226 100644
--- a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/AggregateProjectMojo.java
+++ b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/AggregateProjectMojo.java
@@ -56,6 +56,13 @@ public AggregateProjectMojo() {
*/
private static final String DEFAULT_PROJECT_TITLE = "project";
+ /**
+ * Phase switch for the {@code aggregate-project} phase: when {@code true}, this phase is skipped
+ * independently of the other phases. The global {@link #skip} still skips every phase.
+ */
+ @Parameter(property = "aiIndex.aggregateProject.skip", defaultValue = "false")
+ private boolean skipAggregateProject;
+
/** Plugin version recorded in the project index header. */
@Parameter(defaultValue = "${project.version}", readonly = true)
private String pluginVersion;
@@ -91,9 +98,14 @@ protected int getLlamaThreads() {
return llamaThreads;
}
+ @Override
+ protected boolean isPhaseSkipped() {
+ return skipAggregateProject;
+ }
+
@Override
public void execute() throws MojoExecutionException {
- if (skip) {
+ if (shouldSkip()) {
getLog().info("AI project index aggregation skipped.");
return;
}
diff --git a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/GenerateMojo.java b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/GenerateMojo.java
index 2b47c44..de75d01 100644
--- a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/GenerateMojo.java
+++ b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/GenerateMojo.java
@@ -40,6 +40,13 @@ public GenerateMojo() {
*/
private static final String DEFAULT_FILE_EXTENSION = ".java";
+ /**
+ * Phase switch for the {@code generate} (file indexing) phase: when {@code true}, this phase is
+ * skipped independently of the other phases. The global {@link #skip} still skips every phase.
+ */
+ @Parameter(property = "aiIndex.generate.skip", defaultValue = "false")
+ private boolean skipGenerate;
+
@Parameter(defaultValue = "${project.version}", readonly = true)
private String pluginVersion;
@@ -80,9 +87,14 @@ protected int getLlamaThreads() {
return llamaThreads;
}
+ @Override
+ protected boolean isPhaseSkipped() {
+ return skipGenerate;
+ }
+
@Override
public void execute() throws MojoExecutionException {
- if (skip) {
+ if (shouldSkip()) {
getLog().info("AI index generation skipped.");
return;
}
diff --git a/src/test/java/net/ladenthin/maven/llamacpp/aiindex/mojo/MojoPhaseSkipTest.java b/src/test/java/net/ladenthin/maven/llamacpp/aiindex/mojo/MojoPhaseSkipTest.java
new file mode 100644
index 0000000..c397b04
--- /dev/null
+++ b/src/test/java/net/ladenthin/maven/llamacpp/aiindex/mojo/MojoPhaseSkipTest.java
@@ -0,0 +1,122 @@
+// SPDX-FileCopyrightText: 2026 Bernard Ladenthin
+//
+// SPDX-License-Identifier: Apache-2.0
+package net.ladenthin.maven.llamacpp.aiindex.mojo;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import java.lang.reflect.Field;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Verifies the per-phase skip mechanism: the generalized {@code shouldSkip()} rule in
+ * {@link AbstractAiIndexMojo} and each concrete goal's independent phase flag.
+ */
+public class MojoPhaseSkipTest {
+
+ //
+ @Test
+ public void shouldSkip_isGlobalSkipOrPhaseSkip() {
+ // neither flag -> run
+ assertThat(mojo(false, false).shouldSkip(), is(false));
+ // global skip alone -> skip every phase
+ assertThat(mojo(true, false).shouldSkip(), is(true));
+ // phase skip alone -> skip just this phase
+ assertThat(mojo(false, true).shouldSkip(), is(true));
+ // both -> skip
+ assertThat(mojo(true, true).shouldSkip(), is(true));
+ }
+
+ private static AbstractAiIndexMojo mojo(final boolean globalSkip, final boolean phaseSkip) {
+ final AbstractAiIndexMojo mojo = new AbstractAiIndexMojo() {
+ @Override
+ protected int getLlamaContextSize() {
+ return 0;
+ }
+
+ @Override
+ protected int getLlamaThreads() {
+ return 0;
+ }
+
+ @Override
+ protected boolean isPhaseSkipped() {
+ return phaseSkip;
+ }
+
+ @Override
+ public void execute() throws MojoExecutionException {
+ // no-op
+ }
+ };
+ mojo.skip = globalSkip;
+ return mojo;
+ }
+ //
+
+ //
+ @Test
+ public void generateMojo_phaseFlagTogglesIndependently() throws Exception {
+ final GenerateMojo mojo = new GenerateMojo();
+ assertThat(mojo.isPhaseSkipped(), is(false));
+ setBooleanField(mojo, "skipGenerate", true);
+ assertThat(mojo.isPhaseSkipped(), is(true));
+ assertThat(mojo.shouldSkip(), is(true));
+ }
+
+ @Test
+ public void aggregatePackagesMojo_phaseFlagTogglesIndependently() throws Exception {
+ final AggregatePackagesMojo mojo = new AggregatePackagesMojo();
+ assertThat(mojo.isPhaseSkipped(), is(false));
+ setBooleanField(mojo, "skipAggregatePackages", true);
+ assertThat(mojo.isPhaseSkipped(), is(true));
+ assertThat(mojo.shouldSkip(), is(true));
+ }
+
+ @Test
+ public void aggregateProjectMojo_phaseFlagTogglesIndependently() throws Exception {
+ final AggregateProjectMojo mojo = new AggregateProjectMojo();
+ assertThat(mojo.isPhaseSkipped(), is(false));
+ setBooleanField(mojo, "skipAggregateProject", true);
+ assertThat(mojo.isPhaseSkipped(), is(true));
+ assertThat(mojo.shouldSkip(), is(true));
+ }
+
+ @Test
+ public void globalSkipSkipsAPhaseEvenWhenItsPhaseFlagIsOff() throws Exception {
+ final GenerateMojo mojo = new GenerateMojo();
+ setBooleanField(mojo, "skip", true);
+ assertThat(mojo.isPhaseSkipped(), is(false));
+ assertThat(mojo.shouldSkip(), is(true));
+ }
+ //
+
+ /**
+ * Sets a {@code boolean} field by name on {@code target}, walking up the class hierarchy so a
+ * field declared on a superclass (e.g. the global {@code skip} on {@link AbstractAiIndexMojo}) is
+ * found. Mirrors how Maven's plugin framework injects {@code @Parameter} fields via reflection.
+ *
+ * @param target the object whose field to set
+ * @param fieldName the field name
+ * @param value the boolean value to set
+ * @throws NoSuchFieldException if no such field exists anywhere in the hierarchy
+ * @throws IllegalAccessException if the field cannot be set
+ */
+ private static void setBooleanField(final Object target, final String fieldName, final boolean value)
+ throws NoSuchFieldException, IllegalAccessException {
+ Class> type = target.getClass();
+ while (type != null) {
+ try {
+ final Field field = type.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.setBoolean(target, value);
+ return;
+ } catch (NoSuchFieldException ignored) {
+ type = type.getSuperclass();
+ }
+ }
+ throw new NoSuchFieldException(fieldName);
+ }
+}
From 79f708e2ecc1535d9f85e380547c34b7dedae99d Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 26 Jun 2026 10:21:25 +0000
Subject: [PATCH 12/13] refactor: name per-phase skips after the index levels
(file/package/project)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Rename the three per-phase skip flags from the goal-derived (and asymmetric)
names to the index levels — the x node types (file/package/project) the rest of
the codebase already uses. Uniform, parallel, and Maven-idiomatic (ns.thing.skip,
like maven.test.skip). No backward compatibility kept (the flags are new).
aiIndex.generate.skip -> aiIndex.file.skip (field skipFile)
aiIndex.aggregatePackages.skip -> aiIndex.package.skip (field skipPackage)
aiIndex.aggregateProject.skip -> aiIndex.project.skip (field skipProject)
Global aiIndex.skip (skip every phase) unchanged. Renamed synchronously across
the three mojos, MojoPhaseSkipTest, README, and CLAUDE.md.
Gates: 174 tests green; SpotBugs 0; spotless clean.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01RvxJko6xiCx83LxKgj92mH
---
CLAUDE.md | 10 +++++-----
README.md | 11 ++++++-----
.../llamacpp/aiindex/mojo/AggregatePackagesMojo.java | 10 +++++-----
.../llamacpp/aiindex/mojo/AggregateProjectMojo.java | 10 +++++-----
.../maven/llamacpp/aiindex/mojo/GenerateMojo.java | 10 +++++-----
.../llamacpp/aiindex/mojo/MojoPhaseSkipTest.java | 6 +++---
6 files changed, 29 insertions(+), 28 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 2b5fec9..d626f5a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -147,8 +147,8 @@ the full bodies). Incrementality is preserved by folding the overview generation
is never re-inferred but enabling/switching the overview, or a package change, rebuilds it.
**Each phase is independently switchable.** Every goal is a separate Maven execution, and each can be
-toggled on/off on its own via a phase-specific skip flag (`aiIndex.generate.skip`,
-`aiIndex.aggregatePackages.skip`, `aiIndex.aggregateProject.skip`), with `aiIndex.skip` as a global
+toggled on/off on its own via a phase-specific skip flag named after the index level (`aiIndex.file.skip`,
+`aiIndex.package.skip`, `aiIndex.project.skip` — the `x` node types), with `aiIndex.skip` as a global
"skip all" switch — so you can run nothing, all three, or any subset (e.g. file + project only). The
mechanism is generalized symmetrically: `AbstractAiIndexMojo` owns the global `skip`, the abstract
`isPhaseSkipped()` seam, and `shouldSkip() = skip || isPhaseSkipped()`; each concrete mojo adds exactly
@@ -246,9 +246,9 @@ header block, so a `- F:` line in the body is never read as a link.
|---|---|---|---|
| `outputDirectory` | `aiIndex.outputDirectory` | `${basedir}/src/site/ai` | Where `.ai.md` files are written |
| `skip` | `aiIndex.skip` | `false` | Global switch: skip **every** phase |
-| `skipGenerate` | `aiIndex.generate.skip` | `false` | Skip only the `generate` (file) phase |
-| `skipAggregatePackages` | `aiIndex.aggregatePackages.skip` | `false` | Skip only the `aggregate-packages` phase |
-| `skipAggregateProject` | `aiIndex.aggregateProject.skip` | `false` | Skip only the `aggregate-project` phase |
+| `skipFile` | `aiIndex.file.skip` | `false` | Skip only the **file** phase (`generate` goal) |
+| `skipPackage` | `aiIndex.package.skip` | `false` | Skip only the **package** phase (`aggregate-packages` goal) |
+| `skipProject` | `aiIndex.project.skip` | `false` | Skip only the **project** phase (`aggregate-project` goal) |
| `force` | `aiIndex.force` | `false` | Regenerate even if summary exists |
| `subtrees` | `aiIndex.subtrees` | *(all)* | Limit to specific source subdirectories |
| `fileExtensions` | `aiIndex.fileExtensions` | `.java` | File extensions to index |
diff --git a/README.md b/README.md
index 06873f1..9584184 100644
--- a/README.md
+++ b/README.md
@@ -290,12 +290,13 @@ Run-level parameters (set in ``):
- `generationProvider` — AI backend: `mock` (default) or `llamacpp-jni`
- `force` — regenerate even when a body already exists (default: `false`)
- `skip` — global switch: skip **every** phase (default: `false`)
-- Per-phase switches — turn any of the three phases on/off independently (each default `false`):
- - `aiIndex.generate.skip` — skip the file-indexing phase (`generate`)
- - `aiIndex.aggregatePackages.skip` — skip the package-aggregation phase (`aggregate-packages`)
- - `aiIndex.aggregateProject.skip` — skip the project-index phase (`aggregate-project`)
+- Per-phase switches — turn any of the three phases on/off independently (each default `false`).
+ Named after the three index levels (`file` / `package` / `project`, the `x` node types):
+ - `aiIndex.file.skip` — skip the **file** phase (the `generate` goal)
+ - `aiIndex.package.skip` — skip the **package** phase (the `aggregate-packages` goal)
+ - `aiIndex.project.skip` — skip the **project** phase (the `aggregate-project` goal)
- So `-DaiIndex.aggregatePackages.skip=true` runs file + project only, `-DaiIndex.skip=true` runs
+ So `-DaiIndex.package.skip=true` runs file + project only, `-DaiIndex.skip=true` runs
nothing, and the defaults run all three.
- `aiDefinitions` / `promptDefinitions` — named models / prompt templates, referenced by key
- `fieldGenerations` — per goal: which `promptKey` runs with which `aiDefinitionKey` (**required**)
diff --git a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/AggregatePackagesMojo.java b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/AggregatePackagesMojo.java
index 0283544..fcee5d8 100644
--- a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/AggregatePackagesMojo.java
+++ b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/AggregatePackagesMojo.java
@@ -35,11 +35,11 @@ public AggregatePackagesMojo() {
}
/**
- * Phase switch for the {@code aggregate-packages} phase: when {@code true}, this phase is skipped
- * independently of the other phases. The global {@link #skip} still skips every phase.
+ * Phase switch for the package phase (the {@code aggregate-packages} goal): when
+ * {@code true}, only this phase is skipped. The global {@link #skip} still skips every phase.
*/
- @Parameter(property = "aiIndex.aggregatePackages.skip", defaultValue = "false")
- private boolean skipAggregatePackages;
+ @Parameter(property = "aiIndex.package.skip", defaultValue = "false")
+ private boolean skipPackage;
@Parameter(defaultValue = "${project.version}", readonly = true)
private String pluginVersion;
@@ -67,7 +67,7 @@ protected int getLlamaThreads() {
@Override
protected boolean isPhaseSkipped() {
- return skipAggregatePackages;
+ return skipPackage;
}
@Override
diff --git a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/AggregateProjectMojo.java b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/AggregateProjectMojo.java
index 08a4226..c7df68a 100644
--- a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/AggregateProjectMojo.java
+++ b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/AggregateProjectMojo.java
@@ -57,11 +57,11 @@ public AggregateProjectMojo() {
private static final String DEFAULT_PROJECT_TITLE = "project";
/**
- * Phase switch for the {@code aggregate-project} phase: when {@code true}, this phase is skipped
- * independently of the other phases. The global {@link #skip} still skips every phase.
+ * Phase switch for the project phase (the {@code aggregate-project} goal): when
+ * {@code true}, only this phase is skipped. The global {@link #skip} still skips every phase.
*/
- @Parameter(property = "aiIndex.aggregateProject.skip", defaultValue = "false")
- private boolean skipAggregateProject;
+ @Parameter(property = "aiIndex.project.skip", defaultValue = "false")
+ private boolean skipProject;
/** Plugin version recorded in the project index header. */
@Parameter(defaultValue = "${project.version}", readonly = true)
@@ -100,7 +100,7 @@ protected int getLlamaThreads() {
@Override
protected boolean isPhaseSkipped() {
- return skipAggregateProject;
+ return skipProject;
}
@Override
diff --git a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/GenerateMojo.java b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/GenerateMojo.java
index de75d01..148c36a 100644
--- a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/GenerateMojo.java
+++ b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/GenerateMojo.java
@@ -41,11 +41,11 @@ public GenerateMojo() {
private static final String DEFAULT_FILE_EXTENSION = ".java";
/**
- * Phase switch for the {@code generate} (file indexing) phase: when {@code true}, this phase is
- * skipped independently of the other phases. The global {@link #skip} still skips every phase.
+ * Phase switch for the file phase (the {@code generate} goal): when {@code true},
+ * only this phase is skipped. The global {@link #skip} still skips every phase.
*/
- @Parameter(property = "aiIndex.generate.skip", defaultValue = "false")
- private boolean skipGenerate;
+ @Parameter(property = "aiIndex.file.skip", defaultValue = "false")
+ private boolean skipFile;
@Parameter(defaultValue = "${project.version}", readonly = true)
private String pluginVersion;
@@ -89,7 +89,7 @@ protected int getLlamaThreads() {
@Override
protected boolean isPhaseSkipped() {
- return skipGenerate;
+ return skipFile;
}
@Override
diff --git a/src/test/java/net/ladenthin/maven/llamacpp/aiindex/mojo/MojoPhaseSkipTest.java b/src/test/java/net/ladenthin/maven/llamacpp/aiindex/mojo/MojoPhaseSkipTest.java
index c397b04..a7d35f1 100644
--- a/src/test/java/net/ladenthin/maven/llamacpp/aiindex/mojo/MojoPhaseSkipTest.java
+++ b/src/test/java/net/ladenthin/maven/llamacpp/aiindex/mojo/MojoPhaseSkipTest.java
@@ -61,7 +61,7 @@ public void execute() throws MojoExecutionException {
public void generateMojo_phaseFlagTogglesIndependently() throws Exception {
final GenerateMojo mojo = new GenerateMojo();
assertThat(mojo.isPhaseSkipped(), is(false));
- setBooleanField(mojo, "skipGenerate", true);
+ setBooleanField(mojo, "skipFile", true);
assertThat(mojo.isPhaseSkipped(), is(true));
assertThat(mojo.shouldSkip(), is(true));
}
@@ -70,7 +70,7 @@ public void generateMojo_phaseFlagTogglesIndependently() throws Exception {
public void aggregatePackagesMojo_phaseFlagTogglesIndependently() throws Exception {
final AggregatePackagesMojo mojo = new AggregatePackagesMojo();
assertThat(mojo.isPhaseSkipped(), is(false));
- setBooleanField(mojo, "skipAggregatePackages", true);
+ setBooleanField(mojo, "skipPackage", true);
assertThat(mojo.isPhaseSkipped(), is(true));
assertThat(mojo.shouldSkip(), is(true));
}
@@ -79,7 +79,7 @@ public void aggregatePackagesMojo_phaseFlagTogglesIndependently() throws Excepti
public void aggregateProjectMojo_phaseFlagTogglesIndependently() throws Exception {
final AggregateProjectMojo mojo = new AggregateProjectMojo();
assertThat(mojo.isPhaseSkipped(), is(false));
- setBooleanField(mojo, "skipAggregateProject", true);
+ setBooleanField(mojo, "skipProject", true);
assertThat(mojo.isPhaseSkipped(), is(true));
assertThat(mojo.shouldSkip(), is(true));
}
From 42f3adc63bda05d91aa13d29042b436fd6034eba Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 26 Jun 2026 11:20:17 +0000
Subject: [PATCH 13/13] docs: sync TODO.md and README.md with the three-level
index changes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Refresh both docs against the actual codebase.
TODO.md:
- PIT line corrected: 24 target classes / 200 mutations (was 20 / 142), and drop
the stale "AiPathSupport / AiGenerationProviderFactory are excluded" claim —
both are PIT targets now. Note the new targets (AiMdLeadExtractor,
AiFieldGenerationSelector, AiSourceExcludeFilter); codecs + prep stay the open
optional expansion.
- Add a "Done" entry for the three-level index: project index, header F
navigation, language prompts, exclude globs, optional AI overview, per-phase
skips.
README.md:
- Fix a real quickstart bug: the aggregate-project execution referenced
project-body but the abbreviated never defined it — a
copy-paste would fail with "Missing prompt template for key: project-body".
project-body is now defined.
- PIT badge 1 class -> 24 classes.
- Features: per-language prompts, optional project overview, per-phase toggles,
exclude globs.
- %s placeholder note now covers the project overview (per-package leads).
All numbers verified against pom.xml / a full PIT run (200/200) / the selftest
profile (5 prompt keys).
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01RvxJko6xiCx83LxKgj92mH
---
README.md | 28 +++++++++++++++++++++-------
TODO.md | 31 ++++++++++++++++++++++++++++++-
2 files changed, 51 insertions(+), 8 deletions(-)
diff --git a/README.md b/README.md
index 9584184..410c594 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@
[](https://coveralls.io/github/bernardladenthin/llamacpp-ai-index-maven-plugin?branch=main)
[](https://codecov.io/gh/bernardladenthin/llamacpp-ai-index-maven-plugin)
[](https://codecov.io/gh/bernardladenthin/llamacpp-ai-index-maven-plugin)
-[-brightgreen)](https://github.com/bernardladenthin/llamacpp-ai-index-maven-plugin/actions/workflows/publish.yml)
+[-brightgreen)](https://github.com/bernardladenthin/llamacpp-ai-index-maven-plugin/actions/workflows/publish.yml)
**Quality:**
[](https://sonarcloud.io/dashboard?id=bernardladenthin_llamacpp-ai-index-maven-plugin)
@@ -76,10 +76,13 @@ using the assigned ID:
A Maven plugin for generating hierarchical, AI-readable documentation of source code projects using local llama.cpp-compatible models.
It creates structured `.ai.md` files per source file and aggregates them into package-level summaries for fast semantic navigation and retrieval.
## Features
-- Generate AI summaries for Java source files
+- Generate AI summaries for source files
+- Per-language prompts — Java, SQL schema, and a generic fallback — selected by file extension
- Weave searchable type, API and domain names into every summary
- Aggregate summaries at package level
-- Build a single project index (one line + link per package) for top-down navigation
+- Build a single project index (one line + link per package) for top-down navigation, optionally with a one-call AI `#### Overview`
+- Run any phase independently — toggle file / package / project on or off
+- Exclude trivial or generated files with glob patterns
- Uses local models via llama.cpp (no cloud dependency)
- Incremental updates (skips unchanged files)
- Optimized for AI-assisted code understanding
@@ -146,7 +149,8 @@ The plugin is configured from three building blocks, declared on the plugin insi
1. **``** — define each GGUF model once (path + sampling parameters), each with a ``.
2. **``** — define each prompt template once, each with a ``. A template takes
- two `%s` placeholders: the file/package name and the source (or, for packages, the child summaries).
+ two `%s` placeholders: the file/package name and the source (for packages, the child summaries; for
+ the optional project overview, the per-package leads).
3. **``** — per goal, map one `` to one ``. This is
**required**: a goal with no field generation fails fast. The `generate` goal may list several
field generations and give each an optional `` filter so the per-file prompt is
@@ -179,9 +183,10 @@ The plugin is configured from three building blocks, declared on the plugin insi
-
+
file-body-java
@@ -208,6 +213,15 @@ Source:
Package: %s
File summaries:
+%s]]>
+
+
+
+ project-body
+
diff --git a/TODO.md b/TODO.md
index 65e8017..6f3fdb0 100644
--- a/TODO.md
+++ b/TODO.md
@@ -15,7 +15,7 @@ cross-cutting initiative.
- **SpotBugs `effort=Max` + `threshold=Low`** — ✅ **enforced at the gate** (`0bddf2a`). `pom.xml` `Max` + `Low`; `spotbugs:check` is part of `mvn verify` and fails on any unsuppressed finding. The full clearing chain is recorded in [`../workspace/crossrepostatus.md`](../workspace/crossrepostatus.md) under "SpotBugs Max+Low". `spotbugs-exclude.xml` carries narrow `` blocks with rationale: Lombok-USBR, HelpMojo auto-gen family, Maven `@Parameter` SPP, identity-IMC, prompt-template `FORMAT_STRING`, fb-contrib flow-coarseness sites, NPE→`MojoExecutionException` bridge.
-- **Mutation-testing threshold enforcement (PIT)** — `pom.xml` wires `100`. **Scope expanded 2026-06-07** from the single `AiCompletionParser` target to an explicit **20-class** list (config / document / prompt / provider / support value + support classes; 142 mutations, all killed — `document.AiMdLeadExtractor` is the latest addition, landed with the project-index feature, 4/4 killed). Still open (optional): `document.AiMdDocumentCodec` / `AiMdHeaderCodec` / `prompt.AiPromptPreparationSupport` (need careful codec fixtures). `support.AiPathSupport` and `provider.AiGenerationProviderFactory` are excluded — equivalent / native-dependent mutants (see crossrepostatus "Deliberate non-parity").
+- **Mutation-testing threshold enforcement (PIT)** — `pom.xml` wires `100`. The explicit `` list now covers **24 classes** (config / document / prompt / provider / support value + support classes; **200 mutations, all killed**). Latest additions landed with the three-level index work: `document.AiMdLeadExtractor` (project index), `config.AiFieldGenerationSelector` (extension-based prompt selection), and `support.AiSourceExcludeFilter` (exclude globs). Still open (optional): `document.AiMdDocumentCodec` / `AiMdHeaderCodec` / `prompt.AiPromptPreparationSupport` (need careful codec fixtures). The orchestration layers (`indexer.*`, `mojo.*`) and the JNI provider stay out of PIT — they need a Maven/native context rather than pure-unit mutation (see crossrepostatus "Deliberate non-parity").
- **Additional ArchUnit rules to consider** — the full **`layeredArchitecture()`** rule and **per-module banned-imports** (`jniConfinedToProvider`, `mavenMojoAnnotationsConfinedToMojo`, `foundationIsMavenFree`) are now DONE (see "Done" below). No further ArchUnit rules outstanding.
@@ -25,6 +25,35 @@ cross-cutting initiative.
## Done (kept for history)
+### Three-level hierarchical index + per-phase switches
+
+The project index (third level) and its surrounding navigation / prompt / scaling work:
+
+- **Project index (`aggregate-project` goal / `ProjectIndexer`)** — harvests the one-line blockquote
+ lead of every `package.ai.md` (`AiMdLeadExtractor`) into a single, always-loadable `project.ai.md`
+ table of contents. Deterministic listing, no model. Node type `x: project`, `PROJECT_AI_MD_FILENAME`.
+- **Header `F` child-link navigation (level 2 → 1)** — `AiMdHeader` carries a repeatable `F` markdown
+ link list (children for a package, packages for the project), so navigation stays in the
+ machine-parseable header while the body stays free-form. `AiMdDocumentCodec` parses only the header
+ block, so a `- F:` line in the body is never read as a link.
+- **Language-specific file prompts** — `AiFieldGenerationConfig` gained `fileExtensions`;
+ `AiFieldGenerationSelector` picks the per-file prompt by extension (`.java` / `.sql` / fallback) while
+ one model stays loaded. Backward compatible: a single extension-less entry is the universal fallback.
+- **Exclude globs** — `AiSourceExcludeFilter` (a pure, cross-platform glob matcher: `*` within a
+ segment, `**` across, `?` one char) + the `excludes` parameter (`aiIndex.excludes`) skip
+ trivial/generated sources during the file walk.
+- **Optional AI project overview** — opt-in `#### Overview` paragraph synthesised from the package
+ leads (never the full bodies), gated incrementally by folding the generation signature
+ (`promptKey:aiDefinitionKey`) — not the AI output — into the `c` checksum, so an unchanged project is
+ never re-inferred but enabling/switching the overview, or a package change, rebuilds it.
+- **Independently switchable phases** — `AbstractAiIndexMojo` owns the global `skip` (`aiIndex.skip`),
+ the abstract `isPhaseSkipped()` seam and `shouldSkip() = skip || isPhaseSkipped()`; each goal adds one
+ `skip` `@Parameter` named after the index level (`aiIndex.file.skip` / `aiIndex.package.skip` /
+ `aiIndex.project.skip`). Covered by `MojoPhaseSkipTest`.
+
+Gates: 174 tests green (1 JNI integration test skipped without the native lib); SpotBugs `Max`+`Low`
+clean; full PIT 200/200 (100%); javadoc clean.
+
### Layered package restructure (flat plugin package → layered hierarchy)
The 34 classes that sat flat in `net.ladenthin.maven.llamacpp.aiindex` were split