From 32f5cf9f4426b2854f66551985e78e8580366029 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 07:44:12 +0000 Subject: [PATCH 01/13] feat: add third-level project index (aggregate-project goal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the top of the three-level hierarchical index: a single project.ai.md that lists every package on one line — its one-sentence lead plus a relative link to its package.ai.md — so an agent reads one compact, always-loadable table of contents and navigates project -> package -> file -> raw, instead of scanning thousands of file summaries. The step is fully deterministic: it harvests the blockquote lead each package summary already begins with and never calls the AI model, so it costs only I/O and scales to projects with hundreds of packages (no embeddings/vector DB). Components (mirrors the existing two levels, one tier up): - document.AiMdLeadExtractor — pure, tested extraction of the first blockquote lead (fallback: first non-blank line). Added to the PIT 100% target list. - indexer.ProjectIndexer — walks the output tree, harvests leads + links, writes project.ai.md (node type "project"). Incremental via c = CRC32(body) + AiMdHeaderSupport.shouldWrite, matching the file/package contract. - mojo.AggregateProjectMojo — goal ai-index:aggregate-project. Deterministic, so it extends AbstractMojo directly (no provider/model/prompt params). - document.AiMdHeaderCodec — NODE_TYPE_PROJECT + PROJECT_AI_MD_FILENAME. - indexer.PackageIndexer.isAiMdContentFile now excludes project.ai.md so a re-run of aggregate-packages never folds the project index back into the output-root package summary/checksum (regression test added). pom.xml: PIT target list += AiMdLeadExtractor; self-test profile gains an ai-aggregate-project execution bound to prepare-package (after aggregate-packages). spotbugs-exclude.xml: AggregateProjectMojo added to the existing Maven @Parameter SPP_FIELD_COULD_BE_STATIC suppression. Tests: 120 green (ProjectIndexer 6, AiMdLeadExtractor 10, PackageIndexer +1); PIT AiMdLeadExtractor 4/4 (100%); SpotBugs Max+Low 0; Spotless + Javadoc clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RvxJko6xiCx83LxKgj92mH --- pom.xml | 15 + spotbugs-exclude.xml | 12 +- .../aiindex/document/AiMdHeaderCodec.java | 16 ++ .../aiindex/document/AiMdLeadExtractor.java | 59 ++++ .../aiindex/indexer/PackageIndexer.java | 6 +- .../aiindex/indexer/ProjectIndexer.java | 266 ++++++++++++++++++ .../aiindex/mojo/AggregateProjectMojo.java | 111 ++++++++ .../document/AiMdLeadExtractorTest.java | 114 ++++++++ .../aiindex/indexer/PackageIndexerTest.java | 54 ++++ .../aiindex/indexer/ProjectIndexerTest.java | 191 +++++++++++++ 10 files changed, 838 insertions(+), 6 deletions(-) create mode 100644 src/main/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdLeadExtractor.java create mode 100644 src/main/java/net/ladenthin/maven/llamacpp/aiindex/indexer/ProjectIndexer.java create mode 100644 src/main/java/net/ladenthin/maven/llamacpp/aiindex/mojo/AggregateProjectMojo.java create mode 100644 src/test/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdLeadExtractorTest.java create mode 100644 src/test/java/net/ladenthin/maven/llamacpp/aiindex/indexer/ProjectIndexerTest.java diff --git a/pom.xml b/pom.xml index 54f8476..f7dafb1 100644 --- a/pom.xml +++ b/pom.xml @@ -682,6 +682,7 @@ SPDX-License-Identifier: Apache-2.0 net.ladenthin.maven.llamacpp.aiindex.document.AiMdDocument net.ladenthin.maven.llamacpp.aiindex.document.AiMdHeader net.ladenthin.maven.llamacpp.aiindex.document.AiMdHeaderSupport + net.ladenthin.maven.llamacpp.aiindex.document.AiMdLeadExtractor net.ladenthin.maven.llamacpp.aiindex.prompt.AiPreparedPrompt net.ladenthin.maven.llamacpp.aiindex.prompt.AiPromptDefinition net.ladenthin.maven.llamacpp.aiindex.prompt.AiPromptSupport @@ -1345,6 +1346,20 @@ File summaries: + + + + ai-aggregate-project + prepare-package + + aggregate-project + + diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml index a37fd20..2a71331 100644 --- a/spotbugs-exclude.xml +++ b/spotbugs-exclude.xml @@ -51,11 +51,12 @@ SPDX-License-Identifier: Apache-2.0 + + ai-aggregate-project + prepare-package + aggregate-project + ``` diff --git a/TODO.md b/TODO.md index 7bab3cc..65e8017 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 **19-class** list (config / document / prompt / provider / support value + support classes; 138 mutations, all 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`. **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"). - **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. From 21695baf8c3fdeec2e645d699c40d0d695e7e24a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 08:44:26 +0000 Subject: [PATCH 03/13] feat(B1): carry deterministic child links in the .ai.md header (level 2->1 navigation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each package.ai.md header now lists its children as a repeatable `- F:` markdown link line — one per child .ai.md (files and sub-packages) — so an agent can jump package -> file. The links live in the header (deterministic, machine-parseable) rather than the body, keeping the AI body free-form, per the project's "header = metadata, body = AI" principle. - AiMdHeader: new `children` field. A 9-arg canonical constructor takes the links; the existing 8-arg constructor delegates with an empty list, so all 15 existing call sites stay unchanged. children() returns an unmodifiable, defensively-copied list. The list is loosened to Collection at the parameter (fb-contrib OCP). - AiMdHeaderCodec: FIELD_KEY_F; write() appends one `- F:` line per child; read() collects repeated `- F:` values into children (scalar keys unchanged). - AiMdDocumentCodec.read: now isolates the header block and parses only those lines, so a `- F:` line in the body can never be mistaken for a child link. - PackageIndexer: collectChildLinks() builds the deterministic links and passes them to the header. shouldWrite is unchanged — child-set changes already move `c`. Incrementality unchanged (children follow the same child set the `c` checksum covers). Tests: 130 green (+ AiMdHeaderTest 5, codec F round-trip 3, body-not-a-child 1, PackageIndexer header-links 1); PIT AiMdHeader 9/9 (100%); SpotBugs 0; Spotless + Javadoc clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RvxJko6xiCx83LxKgj92mH --- .../aiindex/document/AiMdDocumentCodec.java | 7 +- .../llamacpp/aiindex/document/AiMdHeader.java | 78 +++++++++++++--- .../aiindex/document/AiMdHeaderCodec.java | 43 +++++++-- .../aiindex/indexer/PackageIndexer.java | 54 ++++++++++- .../document/AiMdDocumentCodecTest.java | 24 +++++ .../aiindex/document/AiMdHeaderCodecTest.java | 71 ++++++++++++++ .../aiindex/document/AiMdHeaderTest.java | 93 +++++++++++++++++++ .../aiindex/indexer/PackageIndexerTest.java | 38 ++++++++ 8 files changed, 385 insertions(+), 23 deletions(-) create mode 100644 src/test/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdHeaderTest.java diff --git a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdDocumentCodec.java b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdDocumentCodec.java index 9cd305c..ab49d47 100644 --- a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdDocumentCodec.java +++ b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdDocumentCodec.java @@ -7,6 +7,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import lombok.ToString; import net.ladenthin.maven.llamacpp.aiindex.support.Java8CompatibilityHelper; @@ -50,8 +51,7 @@ public AiMdDocument read(final Path file) throws IOException { * @return parsed document */ AiMdDocument read(final List lines) { - final AiMdHeader header = new AiMdHeaderCodec().read(lines); - + final List headerLines = new ArrayList<>(); final StringBuilder body = new StringBuilder(); boolean bodyStarted = false; boolean headerFinished = false; @@ -60,6 +60,7 @@ AiMdDocument read(final List lines) { if (!headerFinished) { if (line.startsWith(AiMdHeaderCodec.HEADER_TITLE_PREFIX) || line.startsWith(AiMdHeaderCodec.HEADER_FIELD_PREFIX)) { + headerLines.add(line); continue; } @@ -81,6 +82,8 @@ AiMdDocument read(final List lines) { body.append(line).append('\n'); } + // Parse only the header block — a "- F:" line in the body must never become a child link. + final AiMdHeader header = new AiMdHeaderCodec().read(headerLines); return new AiMdDocument(header, body.toString()); } diff --git a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdHeader.java b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdHeader.java index aba1616..e6e2574 100644 --- a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdHeader.java +++ b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdHeader.java @@ -3,6 +3,10 @@ // SPDX-License-Identifier: Apache-2.0 package net.ladenthin.maven.llamacpp.aiindex.document; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; import java.util.Objects; import lombok.EqualsAndHashCode; import lombok.ToString; @@ -35,7 +39,11 @@ *
  • t: Timestamp when this {@code .ai.md} document was last generated.
  • *
  • g: Version of the template/generator implementation that produced this document.
  • *
  • a: Version of the AI summarization logic or AI output schema applied to this document.
  • - *
  • x: Node type, for example {@code file} or {@code package}.
  • + *
  • x: Node type, for example {@code file}, {@code package}, or {@code project}.
  • + *
  • children: Deterministic child links (markdown), carried in the header so navigation + * stays machine-parseable and the AI body stays free-form. For a {@code package}: one link per + * child {@code .ai.md} (files and sub-packages). For a {@code project}: one link per package. + * Empty for a leaf {@code file}.
  • * * *

    Important invariants:

    @@ -49,8 +57,8 @@ * *

    Record-shaped value type marked {@link net.ladenthin.maven.llamacpp.aiindex.support.ConvertToRecord} for the future Java 17+ * migration. Accessors are record-style ({@code title()}, {@code h()}, ...). {@code equals}, - * {@code hashCode} and {@code toString} are generated by Lombok across all eight fields; - * full value semantics apply.

    + * {@code hashCode} and {@code toString} are generated by Lombok across all fields (including + * {@code children}); full value semantics apply.

    */ @ConvertToRecord @ToString @@ -64,20 +72,32 @@ public final class AiMdHeader { private final String g; private final String a; private final String x; + private final List children; /** - * Creates a new {@link AiMdHeader}. + * Creates a new {@link AiMdHeader} with an explicit child-link list. * - * @param title display title (typically the file name or package path) - * @param h header format version - * @param c source-state checksum - * @param d source-state timestamp - * @param t generation timestamp of this {@code .ai.md} - * @param g generator/template version - * @param a AI summarisation logic / output schema version - * @param x node type, for example {@code file} or {@code package} + * @param title display title (typically the file name or package path) + * @param h header format version + * @param c source-state checksum + * @param d source-state timestamp + * @param t generation timestamp of this {@code .ai.md} + * @param g generator/template version + * @param a AI summarisation logic / output schema version + * @param x node type, for example {@code file}, {@code package}, or {@code project} + * @param children deterministic child links (markdown), e.g. {@code [Foo.java](Foo.java.ai.md)}; + * empty for leaf {@code file} nodes. Defensively copied; the stored list is unmodifiable. */ - public AiMdHeader(String title, String h, String c, String d, String t, String g, String a, String x) { + public AiMdHeader( + String title, + String h, + String c, + String d, + String t, + String g, + String a, + String x, + Collection children) { Objects.requireNonNull(title, "title"); Objects.requireNonNull(h, "h"); Objects.requireNonNull(c, "c"); @@ -94,6 +114,25 @@ public AiMdHeader(String title, String h, String c, String d, String t, String g this.g = g; this.a = a; this.x = x; + // No separate requireNonNull(children): new ArrayList<>(children) already throws NPE on a + // null list, so an explicit check would be a redundant, equivalent (un-killable) mutant. + this.children = Collections.unmodifiableList(new ArrayList<>(children)); + } + + /** + * Creates a new {@link AiMdHeader} with no child links (the common case for {@code file} nodes). + * + * @param title display title (typically the file name or package path) + * @param h header format version + * @param c source-state checksum + * @param d source-state timestamp + * @param t generation timestamp of this {@code .ai.md} + * @param g generator/template version + * @param a AI summarisation logic / output schema version + * @param x node type, for example {@code file}, {@code package}, or {@code project} + */ + public AiMdHeader(String title, String h, String c, String d, String t, String g, String a, String x) { + this(title, h, c, d, t, g, a, x, Collections.emptyList()); } /** @@ -160,11 +199,22 @@ public String a() { } /** - * Returns the node type ({@code file} or {@code package}). + * Returns the node type ({@code file}, {@code package}, or {@code project}). * * @return node type */ public String x() { return x; } + + /** + * Returns the deterministic child links carried in the header — markdown links to each child + * {@code .ai.md} (files and sub-packages) for a {@code package} node, or to each package for a + * {@code project} node. Empty for leaf {@code file} nodes. The returned list is unmodifiable. + * + * @return unmodifiable list of child-link markdown strings; never {@code null}, possibly empty + */ + public List children() { + return children; + } } diff --git a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdHeaderCodec.java b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdHeaderCodec.java index 2ae9791..783c399 100644 --- a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdHeaderCodec.java +++ b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdHeaderCodec.java @@ -7,6 +7,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -55,6 +56,20 @@ public AiMdHeaderCodec() { /** Field key for the node type ({@code x}). */ public static final String FIELD_KEY_X = "X"; + /** + * Field key for a child-link line ({@code F}). Unlike the scalar keys this one may appear + * multiple times — once per child — and the values are collected into {@link AiMdHeader#children()}. + */ + public static final String FIELD_KEY_F = "F"; + + /** + * Full line prefix for a child-link header line ({@code "- F: "}). Built from + * {@link #HEADER_FIELD_PREFIX} + {@link #FIELD_KEY_F} so the {@code write} side stays in sync with + * the {@code read} side's key match, and so the single-character {@link #FIELD_KEY_F} is never + * appended on its own (SpotBugs UCPM). + */ + private static final String CHILD_FIELD_LINE_PREFIX = HEADER_FIELD_PREFIX + FIELD_KEY_F + ": "; + /** * Current metadata header format version written into every AI document. * @@ -122,12 +137,18 @@ public AiMdHeaderCodec() { /** * Parses an {@link AiMdHeader} from the given header lines. * + *

    The single-letter scalar keys ({@code H}/{@code C}/{@code D}/{@code T}/{@code G}/{@code A}/{@code X}) + * map to one value each; the repeatable {@link #FIELD_KEY_F} key is collected, in encounter order, into + * {@link AiMdHeader#children()}. Callers must pass header lines only (see {@link AiMdDocumentCodec}), + * so a {@code - F:} line in the document body is never mistaken for a child link.

    + * * @param lines lines that include the title and key-value field lines - * @return parsed header (missing fields default to the empty string) + * @return parsed header (missing scalar fields default to the empty string) */ public AiMdHeader read(final List lines) { String title = null; final Map values = new HashMap<>(lines.size()); + final List children = new ArrayList<>(); for (String line : lines) { if (line.startsWith(HEADER_TITLE_PREFIX)) { @@ -147,7 +168,11 @@ public AiMdHeader read(final List lines) { final String key = line.substring(HEADER_FIELD_PREFIX.length(), colonIndex).trim(); final String value = line.substring(colonIndex + 1).trim(); - values.put(key, value); + if (FIELD_KEY_F.equals(key)) { + children.add(value); + } else { + values.put(key, value); + } } return new AiMdHeader( @@ -158,17 +183,19 @@ public AiMdHeader read(final List lines) { valueOrEmpty(values, FIELD_KEY_T), valueOrEmpty(values, FIELD_KEY_G), valueOrEmpty(values, FIELD_KEY_A), - valueOrEmpty(values, FIELD_KEY_X)); + valueOrEmpty(values, FIELD_KEY_X), + children); } /** - * Renders the given header to its serialised string form. + * Renders the given header to its serialised string form: the scalar fields followed by one + * {@link #FIELD_KEY_F} line per {@link AiMdHeader#children() child link}. * * @param header header to serialise * @return serialised header text */ public String write(final AiMdHeader header) { - return compatibilityHelper.formatted( + final StringBuilder builder = new StringBuilder(compatibilityHelper.formatted( "### %s\n" + "- H: %s\n" + "- C: %s\n" + "- D: %s\n" @@ -176,7 +203,11 @@ public String write(final AiMdHeader header) { + "- G: %s\n" + "- A: %s\n" + "- X: %s\n", - header.title(), header.h(), header.c(), header.d(), header.t(), header.g(), header.a(), header.x()); + header.title(), header.h(), header.c(), header.d(), header.t(), header.g(), header.a(), header.x())); + for (final String child : header.children()) { + builder.append(CHILD_FIELD_LINE_PREFIX).append(child).append('\n'); + } + return builder.toString(); } private String valueOrEmpty(final Map values, final String key) { diff --git a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/indexer/PackageIndexer.java b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/indexer/PackageIndexer.java index bf7df21..ad870d7 100644 --- a/src/main/java/net/ladenthin/maven/llamacpp/aiindex/indexer/PackageIndexer.java +++ b/src/main/java/net/ladenthin/maven/llamacpp/aiindex/indexer/PackageIndexer.java @@ -254,6 +254,7 @@ private void writePackageFile(final Path directory) throws IOException { } final List contents = collectContents(directory); + final List childLinks = collectChildLinks(directory); final Path packageFile = directory.resolve(AiMdHeaderCodec.PACKAGE_AI_MD_FILENAME); final String nodeName = outputRoot.relativize(directory).toString().replace('\\', '/'); @@ -270,7 +271,8 @@ private void writePackageFile(final Path directory) throws IOException { generatedAt, pluginVersion, aiVersion, - AiMdHeaderCodec.NODE_TYPE_PACKAGE); + AiMdHeaderCodec.NODE_TYPE_PACKAGE, + childLinks); if (!headerSupport.shouldWrite(force, packageFile, baseHeader)) { log.info("Unchanged package AI index file: " + packageFile); @@ -320,6 +322,56 @@ private List collectContents(final Path directory) throws IOException { return entries; } + /** + * Collects the deterministic child links for {@code directory}'s header {@code F} list: one + * markdown link per child {@code .ai.md} (the source name pointing at its index file) and per + * sub-package (the directory name pointing at its {@code package.ai.md}), in ascending name order. + * These links live in the header so the package→file navigation stays machine-parseable and + * the AI body stays free-form. + * + * @param directory the package directory whose children are linked + * @return ordered list of markdown child links; empty when the package has no children + * @throws IOException if the directory cannot be listed + */ + private List collectChildLinks(final Path directory) throws IOException { + final List links = new ArrayList<>(); + + try (Stream stream = Files.list(directory)) { + for (Path path : compatibilityHelper.toList(stream.sorted(BY_FILE_NAME))) { + final Path fileNamePath = path.getFileName(); + if (fileNamePath == null) { + continue; + } + final String name = fileNamePath.toString(); + + if (Files.isDirectory(path)) { + if (hasPackageAiMdFile(path)) { + final String label = name + CHILD_DIRECTORY_SUFFIX; + links.add(markdownLink(label, label + AiMdHeaderCodec.PACKAGE_AI_MD_FILENAME)); + } + continue; + } + + if (isAiMdContentFile(name)) { + links.add(markdownLink(toChildDisplayName(name), name)); + } + } + } + + return links; + } + + /** + * Formats a markdown link {@code [label](target)}. + * + * @param label link label + * @param target link target (relative path) + * @return the markdown link string + */ + private String markdownLink(final String label, final String target) { + return "[" + label + "](" + target + ")"; + } + /** * Builds the source text passed to the AI model for a package summary. * diff --git a/src/test/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdDocumentCodecTest.java b/src/test/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdDocumentCodecTest.java index e94dc47..222b2ce 100644 --- a/src/test/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdDocumentCodecTest.java +++ b/src/test/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdDocumentCodecTest.java @@ -212,5 +212,29 @@ public void read_documentWithEmptyBody_parsesCorrectly() { // assert assertThat(document.body().trim().isEmpty(), is(true)); } + + @Test + public void read_bodyLineLookingLikeChildLink_isNotParsedAsChild() { + // arrange: a "- F:" line in the BODY must not be mistaken for a header child link + final List lines = Arrays.asList( + "### Example.java", + "- H: 1.0", + "- C: ABC123", + "- D: 2026-03-15T18:33:40Z", + "- T: 2026-03-15T18:34:26Z", + "- G: 0.1.0-SNAPSHOT", + "- A: 0.0.0", + "- X: file", + "", + "---", + "- F: this looks like a child link but is body text"); + + // act + final AiMdDocument document = documentCodec.read(lines); + + // assert: the header has no children, and the line stayed in the body + assertThat(document.header().children().isEmpty(), is(true)); + assertThat(document.body(), containsString("- F: this looks like a child link but is body text")); + } // } diff --git a/src/test/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdHeaderCodecTest.java b/src/test/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdHeaderCodecTest.java index 14d3ab4..bcd06b7 100644 --- a/src/test/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdHeaderCodecTest.java +++ b/src/test/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdHeaderCodecTest.java @@ -3,6 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 package net.ladenthin.maven.llamacpp.aiindex.document; +import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; @@ -66,4 +67,74 @@ public void write_packageNodeHeader_decodedFieldsMatchOriginal() { assertThat(decoded.x(), is(equalTo(AiMdHeaderCodec.NODE_TYPE_PACKAGE))); } // + + // + @Test + public void write_headerWithChildren_emitsOneChildLinkLinePerChild() { + // arrange + final AiMdHeader header = new AiMdHeader( + "main/java/com/example", + AiMdHeaderCodec.HEADER_VERSION_1_0, + "9863444A", + "2026-03-15T18:33:50Z", + "2026-03-15T18:34:26Z", + "1.0.0", + "0.0.0", + AiMdHeaderCodec.NODE_TYPE_PACKAGE, + Arrays.asList("[Foo.java](Foo.java.ai.md)", "[sub/](sub/package.ai.md)")); + + // act + final String encoded = headerCodec.write(header); + + // assert + assertThat(encoded, containsString("- F: [Foo.java](Foo.java.ai.md)\n")); + assertThat(encoded, containsString("- F: [sub/](sub/package.ai.md)\n")); + } + + @Test + public void read_childLinkLines_collectedIntoChildrenInOrder() { + // arrange + final List lines = Arrays.asList( + "### main/java/com/example", + "- H: 1.0", + "- C: 9863444A", + "- D: 2026-03-15T18:33:50Z", + "- T: 2026-03-15T18:34:26Z", + "- G: 1.0.0", + "- A: 0.0.0", + "- X: package", + "- F: [Foo.java](Foo.java.ai.md)", + "- F: [sub/](sub/package.ai.md)"); + + // act + final AiMdHeader decoded = headerCodec.read(lines); + + // assert + assertThat( + decoded.children(), + is(equalTo(Arrays.asList("[Foo.java](Foo.java.ai.md)", "[sub/](sub/package.ai.md)")))); + } + + @Test + public void write_read_headerWithChildren_roundtripsToEqualHeader() { + // arrange + final AiMdHeader original = new AiMdHeader( + "main/java/com/example", + AiMdHeaderCodec.HEADER_VERSION_1_0, + "9863444A", + "2026-03-15T18:33:50Z", + "2026-03-15T18:34:26Z", + "1.0.0", + "0.0.0", + AiMdHeaderCodec.NODE_TYPE_PACKAGE, + Arrays.asList("[Foo.java](Foo.java.ai.md)", "[sub/](sub/package.ai.md)")); + + // act + final String encoded = headerCodec.write(original); + final AiMdHeader decoded = headerCodec.read(Arrays.asList(encoded.split("\\R"))); + + // assert + assertThat(decoded, is(equalTo(original))); + } + // } diff --git a/src/test/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdHeaderTest.java b/src/test/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdHeaderTest.java new file mode 100644 index 0000000..6fd6bf7 --- /dev/null +++ b/src/test/java/net/ladenthin/maven/llamacpp/aiindex/document/AiMdHeaderTest.java @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: Apache-2.0 +package net.ladenthin.maven.llamacpp.aiindex.document; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.is; +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 AiMdHeaderTest { + + private static AiMdHeader headerWithChildren(final List children) { + return new AiMdHeader( + "main/java/com/example", + AiMdHeaderCodec.HEADER_VERSION_1_0, + "AAAAAAAA", + "2026-03-16T00:00:00Z", + "2026-03-16T00:00:10Z", + "1.0.0", + "0.0.0", + AiMdHeaderCodec.NODE_TYPE_PACKAGE, + children); + } + + // + @Test + public void canonicalConstructor_storesChildrenInOrder() { + // act + final AiMdHeader header = + headerWithChildren(Arrays.asList("[Foo.java](Foo.java.ai.md)", "[sub/](sub/package.ai.md)")); + + // assert + assertThat( + header.children(), + is(equalTo(Arrays.asList("[Foo.java](Foo.java.ai.md)", "[sub/](sub/package.ai.md)")))); + } + + @Test + public void canonicalConstructor_nullChildren_throws() { + // act + assert + assertThrows(NullPointerException.class, () -> headerWithChildren(null)); + } + + @Test + public void canonicalConstructor_defensivelyCopiesSourceList() { + // arrange: a mutable source list passed into the header + final List source = new ArrayList<>(); + source.add("[Foo.java](Foo.java.ai.md)"); + final AiMdHeader header = headerWithChildren(source); + + // act: mutating the source after construction must not affect the header + source.add("[Bar.java](Bar.java.ai.md)"); + + // assert + assertThat(header.children(), is(equalTo(Arrays.asList("[Foo.java](Foo.java.ai.md)")))); + } + + @Test + public void children_returnedListIsUnmodifiable() { + // arrange + final AiMdHeader header = headerWithChildren(Arrays.asList("[Foo.java](Foo.java.ai.md)")); + + // act + assert + assertThrows( + UnsupportedOperationException.class, () -> header.children().add("[Bar.java](Bar.java.ai.md)")); + } + // + + // + @Test + public void convenienceConstructor_hasEmptyChildren() { + // act: the 8-arg constructor used by file nodes + final AiMdHeader header = new AiMdHeader( + "Foo.java", + AiMdHeaderCodec.HEADER_VERSION_1_0, + "AAAAAAAA", + "2026-03-16T00:00:00Z", + "2026-03-16T00:00:10Z", + "1.0.0", + "0.0.0", + AiMdHeaderCodec.NODE_TYPE_FILE); + + // assert + assertThat(header.children().isEmpty(), is(true)); + } + // +} diff --git a/src/test/java/net/ladenthin/maven/llamacpp/aiindex/indexer/PackageIndexerTest.java b/src/test/java/net/ladenthin/maven/llamacpp/aiindex/indexer/PackageIndexerTest.java index e3f6fc3..d80e8e7 100644 --- a/src/test/java/net/ladenthin/maven/llamacpp/aiindex/indexer/PackageIndexerTest.java +++ b/src/test/java/net/ladenthin/maven/llamacpp/aiindex/indexer/PackageIndexerTest.java @@ -13,6 +13,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -229,6 +230,43 @@ public void aggregate_projectAiMdInOutputRoot_isNotEmbeddedAsChild() throws Exce } // + // + @Test + public void aggregate_packageHeader_listsChildLinksInHeader() throws Exception { + // arrange + final Path temp = Files.createTempDirectory("ai-index-test"); + final Path outputRoot = temp.resolve("ai"); + final Path packageDirectory = outputRoot.resolve("main/java/com/example"); + Files.createDirectories(packageDirectory); + writeChildFile(packageDirectory.resolve("Foo.java.ai.md"), "Foo.java", "Foo summary."); + writeChildFile(packageDirectory.resolve("Bar.java.ai.md"), "Bar.java", "Bar summary."); + + final AiPromptSupport promptSupport = new AiPromptSupport(CommonTestFixtures.createPackagePromptDefinitions()); + final PackageIndexer indexer = new PackageIndexer( + new SystemStreamLog(), + temp, + outputRoot, + "1.0.0", + "0.0.0", + Collections.emptyList(), + false, + new MockAiGenerationProvider(), + CommonTestFixtures.createPackageFieldGenerations(), + promptSupport, + CommonTestFixtures.createDefaultAiModelDefinitionSupport()); + + // act + indexer.aggregate(outputRoot); + + // assert: the example package header carries one deterministic child link per file, + // in ascending name order, pointing at each child .ai.md. + final AiMdDocument document = documentCodec.read(packageDirectory.resolve("package.ai.md")); + assertThat( + document.header().children(), + is(equalTo(Arrays.asList("[Bar.java](Bar.java.ai.md)", "[Foo.java](Foo.java.ai.md)")))); + } + // + private void writeChildFile(final Path file, final String title, final String body) throws IOException { final AiMdHeader header = new AiMdHeader( title, From 2d1d1098d86541ef7719e43d1d2ebae1fb90de1c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 08:45:35 +0000 Subject: [PATCH 04/13] feat(B3): sharpen the lead prompts to domain terms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file-body / package-body prompts (ai-index-selftest profile) now ask for a lead in DOMAIN/business terms — the functional purpose a developer would search for ("Calculates VAT for invoices") rather than class mechanics ("Service class with three methods"). The lead is the entry signal the project index harvests, so a domain-oriented sentence makes top-down navigation and keyword-free search materially better. Prompt-only change; no code or tests affected. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RvxJko6xiCx83LxKgj92mH --- pom.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index f7dafb1..b6384dc 100644 --- a/pom.xml +++ b/pom.xml @@ -1221,8 +1221,8 @@ SPDX-License-Identifier: Apache-2.0 You are a multi-language code indexer. Produce a dense, structured markdown summary of ONE source file (Java, C/C++, OpenCL, SQL, or similar) for a searchable code index. Prioritize core logic and data flow. Avoid filler words. Write in English. ## Output: start with the lead, then the sections below. -Begin with a single blockquote line — one sentence capturing what this file does: -> +Begin with a single blockquote line — one sentence capturing what this file does in DOMAIN terms: the business/functional purpose a developer would search for (what it accomplishes), not how it is named or structured. Prefer the words a human would search ("Calculates VAT for invoices") over class mechanics ("Service class with three methods"): +> Then output these sections as markdown. Omit any section that does not apply. Use '####' for section labels. Never use '###' or higher. @@ -1271,8 +1271,8 @@ Source: You are a multi-language code indexer. You receive the already-generated per-file summaries of all files in ONE package or directory (NOT raw source code). Produce one dense, structured markdown summary of the whole package for a searchable code index. Synthesize and group — do not just concatenate the file summaries. Avoid filler. Write in English. ## Output: start with the lead, then the sections below. -Begin with a single blockquote line — one sentence capturing what this package does as a whole: -> +Begin with a single blockquote line — one sentence capturing what this package does as a whole in DOMAIN terms: the business/functional responsibility a developer would search for, not how it is named or layered: +> Then output these sections as markdown. Omit any that do not apply. Use '####' for labels. Never '###' or higher. From 0685890d7f6198351e5c906c9accefd0c880e9bd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 08:52:22 +0000 Subject: [PATCH 05/13] feat(B1b): mirror the header child-link list on the project index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit project.ai.md now matches the package level: the clickable package links move into the header F list (uniform, machine-parseable navigation across file -> package -> project), and the body keeps one compact line per package — its display path plus its harvested lead, no inline link. The path doubles as the anchor mapping a body line to its header link, so the body stays half the size at hundreds of packages. - ProjectIndexer: builds the package links into the header (9-arg AiMdHeader); buildBody drops the inline [..](..) and emits "- ". The body checksum still covers link changes (link is derived from the in-body path). - ProjectIndexerTest: link assertions move to header().children(); body assertions check path + lead. 130 tests green; SpotBugs 0; Spotless + Javadoc clean. Co-Authored-By: Claude Opus 4.8 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 + + + + 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