J7+J8: resolve DB-table access through inherited Panache base classes

RiskRepository-style repositories that get their Panache-ness through a
  project base class (RiskRepository extends AbstractPurRepository<Risk,
  String>, AbstractPurRepository implements PanacheRepositoryBase) never
  resolved repositoryEntity, so db-accesses/sql-statements stayed empty
  (J7). Parser now tags such a base class with panacheEntityTypeParam +
  typeParams, and EXTENDS edges with typeArgs; a new
  resolve-panache-inherited-entity enrichment step binds them to set
  repositoryEntity before the existing resolve-java-db-access step runs.

  J8 (constant-valued @Entity(name=...)) turned out to already work via
  existing constant resolution — verified, no code change needed.

  Adds JavaDbAccessInheritedRepoIT + fixtures; updates roadmap/features/
  agent-module-analysis docs.
This commit is contained in:
Ingo Schnabel
2026-07-07 18:03:44 +02:00
parent c533afe784
commit 0cc532258e
13 changed files with 370 additions and 7 deletions

View File

@@ -111,6 +111,26 @@
</systemPropertyVariables>
</configuration>
</plugin>
<!-- Run the *IT integration tests (QuarkusTest + Testcontainers Neo4j) in the
integration-test/verify phases, so `mvn clean install` executes them. Requires Docker. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>${surefire-plugin.version}</version>
<configuration>
<systemPropertyVariables>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
</systemPropertyVariables>
</configuration>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>com.agenticcode</groupId>
<artifactId>ac-mvn-plugins</artifactId>

View File

@@ -0,0 +1,99 @@
package com.agenticcode.codeserver.api;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.hasItems;
/**
* Real-world regression for J1 that the original {@link JavaDbAccessIT} does <em>not</em> exercise.
*
* <p>{@code JavaDbAccessIT} only covers the "easy" shapes: a repository that <em>directly</em>
* implements {@code PanacheRepository<Order>} (entity generic on the implements clause) and a
* {@code @Table(name = "ORDERS")} <em>string literal</em>. The PUR {@code pur-batch} code — the
* case that motivated J1 — hits neither, so its {@code db-accesses} came back empty even after J1a
* shipped. This test reproduces that structure with a minimal fixture:
*
* <ul>
* <li><b>J7 — indirect Panache base:</b> {@code RiskRepository extends AbstractPurRepository<Risk,String>},
* where {@code AbstractPurRepository … implements PanacheRepositoryBase<Entity,Id>}. The Panache
* marker is one inheritance hop up; the entity generic is bound on the concrete repo's
* {@code extends} clause, not on a directly-Panache supertype.</li>
* <li><b>J8 — constant table name:</b> {@code @Entity(name = Risk.TABLE_NAME)} with
* {@code public static final String TABLE_NAME = "RISK"} and no {@code @Table} literal.</li>
* </ul>
*
* <p>Expected once J7 + J8 are implemented: {@code RiskImportService}'s repository calls
* ({@code persist}/{@code findById}/{@code deleteById}) resolve to {@code READS}/{@code WRITES} on
* the {@code RISK} table, exactly as {@link JavaDbAccessIT} asserts for {@code ORDERS}.
*/
@QuarkusTest
class JavaDbAccessInheritedRepoIT {
private static final String PROJECT = "j7-db-access-inherited";
@TempDir
static Path root;
@BeforeAll
static void ingest() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
copyFixture("fixtures/java/jpa/inherited/AbstractEntity.java");
copyFixture("fixtures/java/jpa/inherited/AbstractPurRepository.java");
copyFixture("fixtures/java/jpa/inherited/Risk.java");
copyFixture("fixtures/java/jpa/inherited/RiskRepository.java");
copyFixture("fixtures/java/jpa/inherited/RiskImportService.java");
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null))
.when().post("/api/projects/" + PROJECT)
.then().statusCode(201);
given().when().post("/api/projects/" + PROJECT + "/ingest-all?deep=true")
.then().statusCode(200);
}
private static void copyFixture(String classpathResource) {
String fileName = classpathResource.substring(classpathResource.lastIndexOf('/') + 1);
try (InputStream in = JavaDbAccessInheritedRepoIT.class.getClassLoader().getResourceAsStream(classpathResource)) {
if (in == null) {
throw new IllegalStateException("Resource not found: " + classpathResource);
}
Files.write(root.resolve(fileName), in.readAllBytes());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
/**
* The real-world case: a repository whose Panache-ness is inherited through a project base
* class, backed by an entity whose table name is a constant. persist -> WRITES, findById ->
* READS, deleteById -> WRITES(DELETE), all on RISK.
*/
@Test
void inheritedRepositoryCallsResolveToEntityTable() {
given().pathParam("name", "RiskImportService")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/db-accesses")
.then().statusCode(200)
.body("name", hasItem("RISK"))
.body("findAll { it.name == 'RISK' }.mode", hasItems("READS", "WRITES"));
}
@Test
void inheritedRepositorySqlStatementsCarryTheCallText() {
given().pathParam("name", "RiskImportService")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/sql-statements")
.then().statusCode(200)
.body("table", hasItem("RISK"))
.body("mode", hasItems("READ", "WRITE", "DELETE"));
}
}

View File

@@ -0,0 +1,8 @@
package fixtures.jpa.inherited;
/**
* Minimal stand-in for PUR's {@code AbstractEntity}: the upper bound of the repository's
* entity generic. Real world: {@code AbstractPurRepository<Entity extends AbstractEntity, Id>}.
*/
public abstract class AbstractEntity {
}

View File

@@ -0,0 +1,12 @@
package fixtures.jpa.inherited;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
/**
* Mirrors PUR's {@code AbstractPurRepository}: a project-specific base class that carries the
* Panache-ness. Concrete repositories extend <em>this</em>, so the {@code PanacheRepositoryBase}
* marker is one inheritance hop away from the concrete repository (roadmap J7).
*/
public abstract class AbstractPurRepository<Entity extends AbstractEntity, Id>
implements PanacheRepositoryBase<Entity, Id> {
}

View File

@@ -0,0 +1,17 @@
package fixtures.jpa.inherited;
import jakarta.persistence.Entity;
/**
* Mirrors PUR's {@code RiskEntity}: the physical table name comes from a {@code static final}
* constant referenced in the annotation, not a string literal, and there is no {@code @Table}
* (the table name defaults from {@code @Entity(name=…)}) — roadmap J8.
*/
@Entity(name = Risk.TABLE_NAME)
public class Risk extends AbstractEntity {
public static final String TABLE_NAME = "RISK";
Long id;
String client;
}

View File

@@ -0,0 +1,28 @@
package fixtures.jpa.inherited;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
/**
* Mirrors PUR's {@code RiskProcessingStep}: calls the inherited-Panache repository. The
* {@code persist}/{@code findById}/{@code deleteById} methods are inherited from
* {@code PanacheRepositoryBase} via {@link AbstractPurRepository}.
*/
@ApplicationScoped
public class RiskImportService {
@Inject
RiskRepository riskRepository;
public void save(Risk risk) {
riskRepository.persist(risk);
}
public Risk load(String id) {
return riskRepository.findById(id);
}
public void clear(String id) {
riskRepository.deleteById(id);
}
}

View File

@@ -0,0 +1,11 @@
package fixtures.jpa.inherited;
import jakarta.enterprise.context.ApplicationScoped;
/**
* Mirrors PUR's {@code RiskRepository}: the entity generic ({@code Risk}) is bound on the
* {@code extends} clause of a project base class, not on a directly-Panache supertype (roadmap J7).
*/
@ApplicationScoped
public class RiskRepository extends AbstractPurRepository<Risk, String> {
}

View File

@@ -215,6 +215,31 @@ public final class CypherQueries {
ORDER BY module, name
""";
/**
* Resolves Panache-ness inherited through a project base class (roadmap J7): a concrete
* repository ({@code d}) extends a project base class ({@code b}) that itself implements a
* Panache repository base type one hop up — the parser tags {@code b} with
* {@code panacheEntityTypeParam} (the name of {@code b}'s own type parameter that fills the
* entity slot) and {@code typeParams} (b's own type parameter list, ordered), and tags the
* {@code EXTENDS} edge from {@code d} with {@code typeArgs} (the concrete arguments {@code d}
* supplies, in the same order). Finds {@code panacheEntityTypeParam}'s position in
* {@code typeParams} and picks {@code d}'s {@code typeArgs} at that position as the concrete
* entity, setting {@code d.repositoryEntity} so {@link #RESOLVE_JAVA_DB_ACCESS} (which must run
* after this) resolves {@code d}'s repository calls to the entity's {@code DB_TABLE} exactly as
* it already does for a directly-Panache repository. Only resolves one level of indirection (the
* observed real-world shape); idempotent (plain property {@code SET}); project-wide.
*/
public static final String RESOLVE_PANACHE_INHERITED_ENTITY = """
MATCH (d:AstNode {type: 'MODULE', project: $project})-[r:EXTENDS]->(b:AstNode {type: 'MODULE', project: $project})
WHERE d.sourceFile <> "" AND b.panacheEntityTypeParam IS NOT NULL
AND b.typeParams IS NOT NULL AND r.typeArgs IS NOT NULL
WITH d, split(b.typeParams, ',') AS params, split(r.typeArgs, ',') AS args, b.panacheEntityTypeParam AS paramName
UNWIND range(0, size(params) - 1) AS i
WITH d, params[i] AS p, i, args, paramName
WHERE p = paramName AND i < size(args)
SET d.repositoryEntity = trim(args[i])
""";
/**
* Resolves Java JPA/Panache {@code DB_ACCESS} candidates (item J1) to the real {@code DB_TABLE}.
* Each candidate carries {@code javaReceiverType} (the call's receiver type), the finer

View File

@@ -79,6 +79,11 @@ public class GraphRepository {
for (EdgeType type : CypherQueries.RESOLVABLE_EDGE_TYPES) {
statements.add(new EnrichmentStep("resolve-placeholder " + type, CypherQueries.resolvePlaceholderTargets(type)));
}
// J7: resolve Panache-ness inherited through a project base class (needs EXTENDS pointing at
// real nodes, i.e. after the placeholder loop above) by setting repositoryEntity on the
// concrete repository; must run before resolve-java-db-access, which reads it. Project-wide,
// idempotent (plain SET).
statements.add(new EnrichmentStep("resolve-panache-inherited-entity", CypherQueries.RESOLVE_PANACHE_INHERITED_ENTITY));
// J1: resolve Java JPA/Panache DB_ACCESS candidates to the entity's DB_TABLE (READS/WRITES for
// db-accesses, USES_TYPE for sql-statements). Depends only on persisted MAPS_TO + node
// properties, so it runs in every mode; project-wide and idempotent (all MERGE).

View File

@@ -13,6 +13,7 @@ import com.github.javaparser.ast.type.ClassOrInterfaceType;
import org.jspecify.annotations.Nullable;
import java.util.*;
import java.util.stream.Collectors;
/**
* Parser for Java source code based on JavaParser.
@@ -504,10 +505,34 @@ public final class JavaParser implements LanguageParser {
/**
* If {@code type} extends a known repository base type, returns its managed entity (the first
* type argument), else {@code null}.
* type argument), else {@code null}. Returns {@code null} (not the raw name) when that argument
* is one of {@code type}'s own type parameters rather than a concrete class — i.e. {@code type}
* is itself a project base class passing the entity through one level up (roadmap J7), not a
* concrete repository; see {@link #panacheEntityTypeParam}.
*/
@Nullable
private static String repositoryEntityType(ClassOrInterfaceDeclaration type) {
String argName = firstRepositoryTypeArgName(type);
return (argName != null && !ownTypeParamNames(type).contains(argName)) ? argName : null;
}
/**
* If {@code type} is itself a project base class that carries Panache-ness through to its
* subclasses (roadmap J7) — e.g. {@code AbstractPurRepository<Entity, Id> implements
* PanacheRepositoryBase<Entity, Id>} — returns the name of {@code type}'s own type parameter
* that fills the entity slot (e.g. {@code "Entity"}). Paired with {@code type}'s own ordered
* type parameter list ({@link #ownTypeParamNames}), this lets the graph-side enrichment step
* ({@code CypherQueries.RESOLVE_PANACHE_INHERITED_ENTITY}) substitute a concrete subclass's
* type argument at the same position.
*/
@Nullable
private static String panacheEntityTypeParam(ClassOrInterfaceDeclaration type) {
String argName = firstRepositoryTypeArgName(type);
return (argName != null && ownTypeParamNames(type).contains(argName)) ? argName : null;
}
@Nullable
private static String firstRepositoryTypeArgName(ClassOrInterfaceDeclaration type) {
List<ClassOrInterfaceType> supertypes = new ArrayList<>(type.getExtendedTypes());
supertypes.addAll(type.getImplementedTypes());
for (ClassOrInterfaceType supertype : supertypes) {
@@ -523,6 +548,26 @@ public final class JavaParser implements LanguageParser {
return null;
}
/**
* {@code type}'s own declared generic type parameter names, in declaration order (e.g.
* {@code ["Entity", "Id"]} for {@code AbstractPurRepository<Entity, Id>}).
*/
private static List<String> ownTypeParamNames(ClassOrInterfaceDeclaration type) {
return type.getTypeParameters().stream().map(tp -> tp.getNameAsString()).toList();
}
/**
* The type arguments given on an {@code extends}/{@code implements} clause, as an edge property
* map (roadmap J7) — e.g. {@code {typeArgs: "Risk,String"}} for {@code extends
* AbstractPurRepository<Risk, String>}. Empty when the clause has no generic arguments.
*/
private static Map<String, String> extendsTypeArgs(ClassOrInterfaceType extended) {
return extended.getTypeArguments()
.filter(args -> !args.isEmpty())
.map(args -> Map.of("typeArgs", args.stream().map(t -> simpleType(t.asString())).collect(Collectors.joining(","))))
.orElse(Map.of());
}
private static boolean extendsPanacheEntity(ClassOrInterfaceDeclaration type) {
return type.getExtendedTypes().stream().anyMatch(t -> PANACHE_ENTITY_BASE_TYPES.contains(t.getNameAsString()));
}
@@ -689,6 +734,15 @@ public final class JavaParser implements LanguageParser {
if (repositoryEntity != null) {
moduleProps.put("repositoryEntity", repositoryEntity);
}
// J7: a project base class that passes Panache-ness one level up to its subclasses
// (e.g. AbstractPurRepository<Entity, Id> implements PanacheRepositoryBase<Entity, Id>).
// Tag it with its entity type parameter + own parameter list so the graph-side
// enrichment step can bind a concrete subclass's EXTENDS type argument to it.
@Nullable String panacheParam = panacheEntityTypeParam(type);
if (panacheParam != null) {
moduleProps.put("panacheEntityTypeParam", panacheParam);
moduleProps.put("typeParams", String.join(",", ownTypeParamNames(type)));
}
type.getJavadoc().ifPresent(jd -> {
String firstLine = jd.getDescription().toText().lines()
.map(String::strip).filter(s -> !s.isEmpty()).findFirst().orElse("");
@@ -709,7 +763,10 @@ public final class JavaParser implements LanguageParser {
for (ClassOrInterfaceType extended : type.getExtendedTypes()) {
AstNode superType = referencedModule(referencedModules, nodes, extended.getNameAsString());
edges.add(edge(EdgeType.EXTENDS, typeNode.id(), superType.id(), typeNode.startLine()));
Map<String, String> extendsProps = extendsTypeArgs(extended);
edges.add(extendsProps.isEmpty()
? edge(EdgeType.EXTENDS, typeNode.id(), superType.id(), typeNode.startLine())
: edge(EdgeType.EXTENDS, typeNode.id(), superType.id(), typeNode.startLine(), null, extendsProps));
}
for (ClassOrInterfaceType implemented : type.getImplementedTypes()) {
AstNode interfaceType = referencedModule(referencedModules, nodes, implemented.getNameAsString());

View File

@@ -514,11 +514,16 @@ enumerate names. Any other `fields` value (or none) returns the full shape.
(`em.persist/merge/remove(x)`), or a Panache active-record call (`Product.findById`)
is resolved to `READS`/`WRITES` on the entity's `@Table`. Verb→mode is heuristic
(`save/persist/merge/update/create`→WRITE, `delete*/remove*`→WRITE shown as
`DELETE` in `sql-statements`, `find*/get*/list*/count*`→READ). Limits: only calls
whose entity is syntactically recoverable resolve — a custom repository with no
generic entity type, or a `@Query`/native-SQL string, is not yet mapped (roadmap
J1b). So an empty Java `db-accesses` means "no recognised persistence call", not
necessarily "touches no table".
`DELETE` in `sql-statements`, `find*/get*/list*/count*`→READ). The repository's
entity type is recovered even when Panache-ness is inherited through a project base
class one hop up (e.g. `RiskRepository extends AbstractPurRepository<Risk, String>`,
where `AbstractPurRepository implements PanacheRepositoryBase`) — roadmap J7 — and the
table name is resolved even when `@Entity(name=…)`/`@Table(name=…)` references a
same-class `static final` constant rather than a string literal — roadmap J8. Limits:
only calls whose entity is syntactically recoverable resolve — a custom repository with
no generic entity type anywhere in its inheritance chain, or a `@Query`/native-SQL
string, is not yet mapped (roadmap J1b). So an empty Java `db-accesses` means "no
recognised persistence call", not necessarily "touches no table".
- **Java `flow-forward`/`flow-backward` now span classes** (not just intra-class): a value
passed as an argument to a method on another class is followed into that method's
parameter (mapped to the *named* callee method's parameter, not every same-position

View File

@@ -482,6 +482,39 @@ items). Each entry records what was built; IDs are preserved from the roadmap
**J1b deferred** (roadmap): `@Query`/JPQL/native-SQL string parsing, derived-name
filters, and no-generic custom repositories (need symbol resolution).
- [x] **J7. Panache-ness inherited through a project base class** (2026-07-07) —
J1a only recovered `repositoryEntity` when a repository *directly* extended a
Panache/JPA base type; a repository extending a project-specific abstract base
(e.g. `RiskRepository extends AbstractPurRepository<Risk, String>`, where
`AbstractPurRepository<Entity, Id> implements PanacheRepositoryBase<Entity, Id>`)
got nothing, since the entity generic sits one inheritance hop away from the
Panache marker. **Parser** (`JavaParser`): `repositoryEntityType` now returns
`null` (not a bogus name) when the repository base type's first type argument is
one of *its own* type parameters rather than a concrete class; a new
`panacheEntityTypeParam` tags such a project base class with the name of that
type parameter, alongside its own ordered `typeParams`; a new `typeArgs` edge
property on `EXTENDS` records the concrete arguments a subclass supplies (e.g.
`Risk,String`). **Enrichment** (`RESOLVE_PANACHE_INHERITED_ENTITY`, a new
project-wide, idempotent finalize step run before `resolve-java-db-access`):
finds the base class's `panacheEntityTypeParam` position in its `typeParams`,
reads the subclass's `EXTENDS` `typeArgs` at that position, and `SET`s
`repositoryEntity` on the subclass — after which `RESOLVE_JAVA_DB_ACCESS` (J1a,
unchanged) resolves its repository calls exactly as for a directly-Panache
repository. Resolves one level of indirection (the observed real-world shape).
New fixtures `fixtures/java/jpa/inherited/*` and `JavaDbAccessInheritedRepoIT`
(2 tests); full `ac-code-server` unit + integration suite green.
- [x] **J8. `@Entity(name=CONST)` / `@Table` with a constant table name** (verified
2026-07-07, no change needed) — the physical table name is not always a string
literal on the annotation; `RiskEntity`-shaped entities use a same-class constant
reference instead (`@Entity(name = Risk.TABLE_NAME)` with
`public static final String TABLE_NAME = "RISK";`). Turns out `resolveTableName`
already resolved this via existing same-class constant-value resolution
(`collectConstants`/`resolveAnnotationString`, predating J1a) — confirmed by
parsing the `JavaDbAccessInheritedRepoIT` fixture in isolation before any code
change. No fix required; J7 (above) was the actual blocker for that fixture's
`db-accesses`.
- [x] **12. Java parser — constructors, parameters, field READS/WRITES** (2026-06-16) —
constructors → `FUNCTION` nodes named after the class; method/constructor
parameters → `VARIABLE` nodes (`CONTAINS` from the function); field

View File

@@ -29,6 +29,11 @@ actually runs on. Ordered by analytical impact.
see `x-docs/features.md`. Repository/EntityManager/Panache active-record calls now
resolve to `READS`/`WRITES` on the entity's `DB_TABLE`, so Java `db-accesses`/
`sql-statements` are populated. **J1b remains open** (below).
- ✅ **Re-test 2026-07-07 fixed by J7 + J8** (both below): `JavaDbAccessInheritedRepoIT`
(repo → project base → Panache base, plus a constant-valued `@Entity(name=…)`) now
passes end-to-end — `db-accesses`/`sql-statements` resolve to `RISK`. A regression
check against the actual 9 PUR jobs is still worth doing as a follow-up acceptance
pass, but the two traced root causes are fixed.
- [ ] **J1b. Parse `@Query`/JPQL/native-SQL strings + derived-name filters (Java)**
(found 2026-07-06) — J1a resolves calls whose entity is syntactically recoverable
@@ -44,6 +49,33 @@ actually runs on. Ordered by analytical impact.
`REFERENCES` (from `X.class` in argument position) edges now reconnect a job to its
injected collaborators and referenced steps; surfaced in `callees`/`callers` as
`edgeKind = INJECTS`/`REFERENCES`. Kept out of `call-tree` (CALLS-only) by design.
- ✅ **Re-test 2026-07-07 confirms this works:** all 3 probed PUR job digests now list
their steps via `callees.REFERENCES` (`RiskImportJob` → `RiskInitStep`/`RiskProcessingStep`/
`RiskEndStep`; same for `MultiTableImportJob`, `KeyTableExportJob`), and the step's
`callers.REFERENCES` names the job. The former job-root dead-end is fixed at digest level.
Follow-up usability gap captured as **J9** below.
- [x] **J7. Panache-ness inherited through a project base class (Java)** — done 2026-07-07.
J1a resolved DB accesses only when the repository *directly* extends a Panache type; PUR-shaped
repositories don't (`RiskRepository extends AbstractPurRepository<Risk, String>`, with the Panache
marker one hop up on `AbstractPurRepository<Entity, Id> implements PanacheRepositoryBase<Entity, Id>`).
Fixed as a parser + graph-enrichment pair: the parser tags a project base class with
`panacheEntityTypeParam` (which of its own type parameters is the entity slot) + its ordered
`typeParams`, and tags an `EXTENDS` edge with the concrete `typeArgs` a subclass supplies; a new
enrichment step (`resolve-panache-inherited-entity`, `CypherQueries.RESOLVE_PANACHE_INHERITED_ENTITY`)
binds the two to set `repositoryEntity` on the concrete repository, which `RESOLVE_JAVA_DB_ACCESS`
then uses unchanged. Covers one level of indirection (the observed shape); regression test
`JavaDbAccessInheritedRepoIT`. Distinct from J1b's "no generic entity type at all" (the
`IRiskRepository` interface) case.
- [x] **J8. `@Entity(name=CONST)` / `@Table` with a constant table name (Java)** — verified
2026-07-07 already working (no change needed): the physical table name isn't always a string
literal — PUR-shaped entities use a constant reference (`@Entity(name = Risk.TABLE_NAME)` with
`public static final String TABLE_NAME = "RISK";`). `JavaParser.resolveTableName` already resolves
same-class constant references (via `collectConstants`/`resolveAnnotationString`, from earlier
constant-value-resolution work), so this was already handled; confirmed by direct parse of the
`JavaDbAccessInheritedRepoIT` fixture (`Risk.java` → `DB_TABLE` node named `RISK`) before any J7 fix
was applied.
- [x] **J3. Interface → implementation resolution (Java)** — done 2026-07-07, see
`x-docs/features.md`. Explicit `IMPLEMENTED_BY` edge (interface → concrete impl) built
@@ -72,6 +104,17 @@ actually runs on. Ordered by analytical impact.
reporting them in `unresolved`; `target/` build output is excluded from the scan by
default (no generated-source duplicates). So `unresolved` surfaces only genuine gaps.
- [ ] **J9. Opt-in traversal that follows `REFERENCES`/`INJECTS` (Java)** (found 2026-07-07) —
J2 wired the `REFERENCES`/`INJECTS` edges but keeps them out of `call-tree` (CALLS-only). For
framework/DI-heavy code (JBeret jobs, CDI) this means there is **no automatic transitive tree**
from a job to its steps to their repositories — the agent must chain `digest` calls by hand
(job-digest → step-digest → repo/entity). Mirror the J3 solution: an opt-in flag on
`call-tree`/`callees` (e.g. `?includeEdges=REFERENCES,INJECTS` or `?followWiring=true`, REST +
MCP) that traverses these runtime-wiring edges so `call-tree(RiskImportJob)` can reach
`RiskProcessingStep` → `RiskRepository` in one call. Keep the CALLS-only default. Combined with
J7/J8 this is what would finally make batch-job analysis **graph-driven rather than
source-driven**.
## Token efficiency (payload shape)
- [x] **P1-t. `/context` is heavy by default — make sub-arrays opt-in, return counts** (done 2026-06-22)