This commit is contained in:
Ingo Schnabel
2026-09-23 15:31:45 +02:00
parent 7e0d75cc5e
commit 234ea76911
21 changed files with 841 additions and 505 deletions

View File

@@ -4,4 +4,4 @@
server.url=http://localhost:8787 server.url=http://localhost:8787
# Stamped by manage-ac.sh (stamp_cli_version) from ac-code-server's agenticcode.version # Stamped by manage-ac.sh (stamp_cli_version) from ac-code-server's agenticcode.version
# at build time. "dev" means this jar wasn't built via manage-ac.sh. # at build time. "dev" means this jar wasn't built via manage-ac.sh.
version=333 version=334

View File

@@ -732,7 +732,7 @@ public class AnalysisResource {
@PathParam("function") String function, @PathParam("function") String function,
@QueryParam("sourceFile") @Nullable String sourceFile) { @QueryParam("sourceFile") @Nullable String sourceFile) {
return withIngestedModule(project, name, sourceFile, resolvedName -> graphRepository.functionCallers(project, resolvedName, function, anySource(sourceFile)) return withIngestedModule(project, name, sourceFile, resolvedName -> graphRepository.functionCallers(project, resolvedName, function, anySource(sourceFile))
.map(resp -> Response.ok(resp).build())); .map(resp -> callRefResponse(resp, null)));
} }
@GET @GET
@@ -759,9 +759,18 @@ public class AnalysisResource {
@QueryParam("sourceFile") @Nullable String sourceFile) { @QueryParam("sourceFile") @Nullable String sourceFile) {
return withIngestedModule(project, name, sourceFile, resolvedName -> graphRepository.callees(project, resolvedName, scope, effectiveLimit(limit), effectiveOffset(offset), return withIngestedModule(project, name, sourceFile, resolvedName -> graphRepository.callees(project, resolvedName, scope, effectiveLimit(limit), effectiveOffset(offset),
resolveInterfaces != null && resolveInterfaces) resolveInterfaces != null && resolveInterfaces)
.map(resp -> namesOnly(fields) .map(resp -> callRefResponse(resp, fields)));
? ok(callRefNames(resp)) }
: Response.ok(resp).build()));
/**
* Item 181: callers/callees page at {@code limit=50} by default; the response says so in the
* item-131 headers (and the body's {@code total}/{@code truncated}), for the names-only shape too.
*/
private Response callRefResponse(CallRefResponse resp, @Nullable String fields) {
Response.ResponseBuilder builder = namesOnly(fields)
? Response.fromResponse(ok(callRefNames(resp)))
: Response.ok(resp);
return builder.header(TOTAL_COUNT, resp.total()).header(TRUNCATED, resp.truncated()).build();
} }
/** /**
@@ -1337,7 +1346,7 @@ public class AnalysisResource {
return withModule(project, name, sourceFile, resolvedName -> fanoutWarm(project, return withModule(project, name, sourceFile, resolvedName -> fanoutWarm(project,
() -> graphRepository.callers(project, resolvedName, scope, effectiveLimit(limit), effectiveOffset(offset)), () -> graphRepository.callers(project, resolvedName, scope, effectiveLimit(limit), effectiveOffset(offset)),
CallRefResponse::sourceFiles, CallRefResponse::sourceFiles,
resp -> namesOnly(fields) ? ok(callRefNames(resp)) : Response.ok(resp).build())); resp -> callRefResponse(resp, fields)));
} }
@GET @GET

View File

@@ -3,7 +3,7 @@ quarkus.http.port=8787
# AgenticCode's own release counter (not the Maven project version) — bump this by hand for each # AgenticCode's own release counter (not the Maven project version) — bump this by hand for each
# release. Single source of truth for the startup log line, GET /api/version, and the OpenAPI # release. Single source of truth for the startup log line, GET /api/version, and the OpenAPI
# info version (referenced below via property expression, not duplicated). # info version (referenced below via property expression, not duplicated).
agenticcode.version=333 agenticcode.version=334
# OpenAPI / Swagger UI (item 48) — the generated spec is the contract the web-UI TS client # OpenAPI / Swagger UI (item 48) — the generated spec is the contract the web-UI TS client
# is generated against. Served at /q/openapi (yaml/json); Swagger UI at /q/swagger-ui in dev. # is generated against. Served at /q/openapi (yaml/json); Swagger UI at /q/swagger-ui in dev.
mp.openapi.extensions.smallrye.info.title=AgenticCode API mp.openapi.extensions.smallrye.info.title=AgenticCode API

View File

@@ -0,0 +1,83 @@
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.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.equalTo;
/**
* Item 181: {@code callees}/{@code callers} page at 50 by default. A module calling 60 others used to
* get 50 rows and nothing saying so; now the headers and the body's {@code total}/{@code truncated}
* tell a cut fan-out from a complete one.
*/
@QuarkusTest
class CallRefTruncationIT {
private static final String PROJECT = "item181-truncation";
private static final int FANOUT = 60;
@TempDir
static Path root;
@BeforeAll
static void ingest() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
StringBuilder hub = new StringBuilder("DEFINE DATA\n LOCAL\n 01 #A (A8)\nEND-DEFINE\n");
for (int i = 0; i < FANOUT; i++) {
String callee = "CALLEE%02d".formatted(i);
hub.append("CALLNAT '").append(callee).append("' #A\n");
write(callee + ".nat", "DEFINE DATA\n PARAMETER\n 01 #P (A8)\nEND-DEFINE\nEND\n");
}
hub.append("END\n");
write("HUB.nat", hub.toString());
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
.when().post("/api/projects/" + PROJECT).then().statusCode(201);
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true").then().statusCode(200).body("failed", empty());
}
private static void write(String fileName, String content) {
try {
Files.writeString(root.resolve(fileName), content);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
@Test
void theDefaultPageSaysItIsCut() {
given().when().get("/api/projects/" + PROJECT + "/modules/HUB/callees").then().statusCode(200)
.header("X-AC-Total-Count", equalTo(String.valueOf(FANOUT)))
.header("X-AC-Truncated", equalTo("true"))
.body("items.size()", equalTo(50))
.body("total", equalTo(FANOUT))
.body("truncated", equalTo(true));
given().when().get("/api/projects/" + PROJECT + "/modules/HUB/callees?fields=name").then().statusCode(200)
.header("X-AC-Truncated", equalTo("true"));
}
@Test
void aCompletePageSaysItIsComplete() {
given().when().get("/api/projects/" + PROJECT + "/modules/HUB/callees?limit=1000").then().statusCode(200)
.header("X-AC-Total-Count", equalTo(String.valueOf(FANOUT)))
.header("X-AC-Truncated", equalTo("false"))
.body("items.size()", equalTo(FANOUT))
.body("truncated", equalTo(false));
given().when().get("/api/projects/" + PROJECT + "/modules/HUB/callees?offset=50").then().statusCode(200)
.header("X-AC-Truncated", equalTo("false"))
.body("items.size()", equalTo(10));
given().when().get("/api/projects/" + PROJECT + "/modules/CALLEE07/callers").then().statusCode(200)
.header("X-AC-Total-Count", equalTo("1"))
.header("X-AC-Truncated", equalTo("false"));
}
}

View File

@@ -0,0 +1,86 @@
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.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
/**
* Item 203: a caller that invokes an interface at two lines gets one synthetic (CHA) call edge to the
* implementation per line, each carrying the called method — so {@code callees} lists both real lines
* (not one picked at random) and {@code functions/{impl-method}/callers} sees the call made through the
* interface.
*/
@QuarkusTest
class InheritanceCallSitesIT {
private static final String PROJECT = "item203-inheritance-sites";
@TempDir
static Path root;
@BeforeAll
static void ingest() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
Path pkg = root.resolve("src/main/java/com/example");
write(pkg, "Repo.java", "package com.example;\npublic interface Repo { void save(); void load(); }\n");
write(pkg, "RepoImpl.java", "package com.example;\npublic class RepoImpl implements Repo {\n public void save() {}\n public void load() {}\n}\n");
write(pkg, "Service.java", """
package com.example;
public class Service {
private Repo repo;
public void store() {
repo.save();
}
public void read() {
repo.load();
}
}
""");
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "java", null, null))
.when().post("/api/projects/" + PROJECT).then().statusCode(201);
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true").then().statusCode(200).body("failed", empty());
}
private static void write(Path dir, String fileName, String content) {
try {
Files.createDirectories(dir);
Files.writeString(dir.resolve(fileName), content);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
@Test
void everyInterfaceCallSiteReachesTheImplementationWithItsLine() {
given().when().get("/api/projects/" + PROJECT + "/modules/com.example.Service/callees").then().statusCode(200)
.body("items.find { it.name == 'com.example.RepoImpl' }.sites.lineNo", containsInAnyOrder(5, 8))
.body("items.find { it.name == 'com.example.Repo' }.sites.lineNo", containsInAnyOrder(5, 8));
}
@Test
void implementationMethodCallersIncludeCallsThroughTheInterface() {
given().when().get("/api/projects/" + PROJECT + "/modules/com.example.RepoImpl/functions/save/callers").then().statusCode(200)
.body("items.name", contains("store"))
.body("items[0].sites.lineNo", contains(5));
given().when().get("/api/projects/" + PROJECT + "/modules/com.example.RepoImpl/functions/load/callers").then().statusCode(200)
.body("items.name", contains("read"));
}
@Test
void aSecondRefreshDoesNotMultiplyTheSyntheticEdges() {
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true").then().statusCode(200);
given().when().get("/api/projects/" + PROJECT + "/modules/com.example.Service/callees").then().statusCode(200)
.body("items.find { it.name == 'com.example.RepoImpl' }.sites.size()", equalTo(2));
}
}

View File

@@ -82,7 +82,7 @@ class StylesIT {
export function Page() { export function Page() {
const theme = useTheme() const theme = useTheme()
return ( return (
<Panel sx={{ mt: 2, color: PRIMARY, '&:hover': { background: theme.palette.background.paper } }}> <Panel sx={{ mt: 2, color: PRIMARY, borderColor: PRIMARY, '&:hover': { background: theme.palette.background.paper } }}>
<Box style={{ color: appTheme.palette.primary.main, height: '17px' }} borderColor={theme.palette.grey['200']}>x</Box> <Box style={{ color: appTheme.palette.primary.main, height: '17px' }} borderColor={theme.palette.grey['200']}>x</Box>
<Box sx={{ width: '100%' }} /> <Box sx={{ width: '100%' }} />
</Panel> </Panel>
@@ -165,8 +165,10 @@ class StylesIT {
.body("[0].function", equalTo("Page")) .body("[0].function", equalTo("Page"))
.body("[0].context", equalTo("borderColor")) .body("[0].context", equalTo("borderColor"))
.body("[0].styleKind", nullValue()); .body("[0].styleKind", nullValue());
// item 199: two reads of one token on one line are one usage listing both keys
get("/theme/PRIMARY/usages").then().statusCode(200) get("/theme/PRIMARY/usages").then().statusCode(200)
.body("[0].styleKind", equalTo("sx")).body("[0].property", equalTo("color")).body("[0].element", equalTo("Panel")); .body("size()", equalTo(1))
.body("[0].styleKind", equalTo("sx")).body("[0].property", equalTo("color,borderColor")).body("[0].element", equalTo("Panel"));
get("/theme/nope/usages").then().statusCode(404).body("code", equalTo("TOKEN_NOT_FOUND")); get("/theme/nope/usages").then().statusCode(404).body("code", equalTo("TOKEN_NOT_FOUND"));
} }
@@ -182,7 +184,7 @@ class StylesIT {
.body("find { it.styleKind == 'styled' }.tokens", containsInAnyOrder("spacing", "palette.primary.dark")) .body("find { it.styleKind == 'styled' }.tokens", containsInAnyOrder("spacing", "palette.primary.dark"))
.body("find { it.styleKind == 'style' }.literals", equalTo("17px")) .body("find { it.styleKind == 'style' }.literals", equalTo("17px"))
.body("find { it.styleKind == 'style' }.tokens", contains("palette.primary.main")) .body("find { it.styleKind == 'style' }.tokens", contains("palette.primary.main"))
.body("find { it.name == 'Page.sx@8:12' }.properties", equalTo("mt,color,&:hover.background")) .body("find { it.name == 'Page.sx@8:12' }.properties", equalTo("mt,color,borderColor,&:hover.background"))
.body("find { it.name == 'body@1' }.styleKind", equalTo("css")) .body("find { it.name == 'body@1' }.styleKind", equalTo("css"))
.body("find { it.name == 'body@1' }.literals", equalTo("#333333")) .body("find { it.name == 'body@1' }.literals", equalTo("#333333"))
.body("find { it.name == '@font-face@5' }.properties", equalTo("font-family,src")); .body("find { it.name == '@font-face@5' }.properties", equalTo("font-family,src"));

View File

@@ -6,6 +6,17 @@ import java.util.List;
* Wrapper returned by the {@code /callers} and {@code /callees} endpoints: a deduplicated * Wrapper returned by the {@code /callers} and {@code /callees} endpoints: a deduplicated
* {@code sourceFiles} index plus aggregated call references that use integer indices into * {@code sourceFiles} index plus aggregated call references that use integer indices into
* that list instead of repeating the full path on every entry. * that list instead of repeating the full path on every entry.
*
* @param total item 181: how many references exist before paging
* @param truncated item 181: {@code true} when {@code items} is a page that stops before {@code total}
* — the default {@code limit=50} used to cut a fan-out silently
*/ */
public record CallRefResponse(List<String> sourceFiles, List<AggregatedCallRef> items) { public record CallRefResponse(List<String> sourceFiles, List<AggregatedCallRef> items, int total, boolean truncated) {
/**
* An unpaged response: everything is in {@code items}.
*/
public CallRefResponse(List<String> sourceFiles, List<AggregatedCallRef> items) {
this(sourceFiles, items, items.size(), false);
}
} }

View File

@@ -853,8 +853,9 @@ public final class CypherQueries {
* through an {@code IMPLEMENTS}/{@code EXTENDS} chain (Class-Hierarchy-Analysis over-approximation). * through an {@code IMPLEMENTS}/{@code EXTENDS} chain (Class-Hierarchy-Analysis over-approximation).
* *
* <p>Synthetic edges are tagged {@code resolvedVia: 'INHERITANCE'} so they {@code MERGE} * <p>Synthetic edges are tagged {@code resolvedVia: 'INHERITANCE'} so they {@code MERGE}
* idempotently and stay disjoint from directly-parsed {@code CALLS} edges; the call-site * idempotently and stay disjoint from directly-parsed {@code CALLS} edges; since item 203 there is
* {@code lineNo} is copied from the originating interface/base call. Runs after placeholder * one per originating interface/base call site ({@code lineNo}, {@code originFile}), carrying its
* {@code callKind}, {@code calleeMethod} and {@code callerFn}. Runs after placeholder
* resolution and dataflow so both the {@code CALLS} and {@code IMPLEMENTS}/{@code EXTENDS} edges * resolution and dataflow so both the {@code CALLS} and {@code IMPLEMENTS}/{@code EXTENDS} edges
* already point at real modules, and dataflow is not routed through the synthetic edges. * already point at real modules, and dataflow is not routed through the synthetic edges.
* *
@@ -871,8 +872,14 @@ public final class CypherQueries {
AND ( ()-[:IMPLEMENTS]->(base) OR ()-[:EXTENDS]->(base) ) AND ( ()-[:IMPLEMENTS]->(base) OR ()-[:EXTENDS]->(base) )
MATCH (impl:AstNode {type: 'MODULE', project: $project})-[:IMPLEMENTS|EXTENDS*1..]->(base) MATCH (impl:AstNode {type: 'MODULE', project: $project})-[:IMPLEMENTS|EXTENDS*1..]->(base)
WHERE impl <> caller AND impl.sourceFile <> "" WHERE impl <> caller AND impl.sourceFile <> ""
MERGE (caller)-[s:CALLS {resolvedVia: 'INHERITANCE'}]->(impl) // Item 203: one synthetic edge per originating call site, keyed like the parsed edge it
ON CREATE SET s.lineNo = r.lineNo, s.callKind = r.callKind // derives from. Keyed on the pair alone, the line was whichever base call the MERGE met
// first (95 in one run, 97 in the next for the same pair) and calleeMethod/callerFn were
// lost, so function-level callers could not see a call made through the interface.
// `args` is deliberately not copied: dataflow is not routed through synthetic edges.
MERGE (caller)-[s:CALLS {resolvedVia: 'INHERITANCE', lineNo: r.lineNo,
originFile: coalesce(r.originFile, caller.sourceFile)}]->(impl)
SET s.callKind = r.callKind, s.calleeMethod = r.calleeMethod, s.callerFn = r.callerFn
"""; """;
/** /**

View File

@@ -1377,7 +1377,9 @@ public class GraphRepository {
.toList(); .toList();
reindexed.add(new AggregatedCallRef(item.name(), item.type(), newIdx, item.edgeKind(), sites, item.unresolved())); reindexed.add(new AggregatedCallRef(item.name(), item.type(), newIdx, item.edgeKind(), sites, item.unresolved()));
} }
return new CallRefResponse(new ArrayList<>(fileIndex.keySet()), reindexed); int total = resp.items().size();
return new CallRefResponse(new ArrayList<>(fileIndex.keySet()), reindexed, total,
Math.max(offset, 0) + reindexed.size() < total);
} }
/** /**

View File

@@ -263,50 +263,460 @@ public final class JavaParser implements LanguageParser {
* {@code L} suffix); references to another constant in the same class (bare {@code NAME} or * {@code L} suffix); references to another constant in the same class (bare {@code NAME} or
* {@code ThisClass.NAME}) are resolved transitively. * {@code ThisClass.NAME}) are resolved transitively.
*/ */
private static Map<String, String> collectConstants(TypeDeclaration<?> type) { private static Map<String, String> collectConstants(TypeDeclaration<?> type, Set<String> templates) {
String className = type.getNameAsString(); String className = type.getNameAsString();
Map<String, Expression> initializers = new LinkedHashMap<>(); Map<String, Expression> initializers = new LinkedHashMap<>();
Set<String> stringTyped = new HashSet<>();
for (FieldDeclaration field : type.getFields()) { for (FieldDeclaration field : type.getFields()) {
if (!(field.isStatic() && field.isFinal())) { if (!(field.isStatic() && field.isFinal())) {
continue; continue;
} }
for (VariableDeclarator variable : field.getVariables()) { for (VariableDeclarator variable : field.getVariables()) {
variable.getInitializer().ifPresent(init -> initializers.put(variable.getNameAsString(), init)); variable.getInitializer().ifPresent(init -> initializers.put(variable.getNameAsString(), init));
if (variable.getTypeAsString().equals("String")) {
stringTyped.add(variable.getNameAsString());
}
} }
} }
Map<String, String> resolved = new HashMap<>(); ConstantScope scope = new ConstantScope(className, initializers, stringTyped, new HashMap<>(), templates);
for (String name : initializers.keySet()) { for (String name : initializers.keySet()) {
resolveConstant(name, className, initializers, resolved, new HashSet<>()); resolveConstant(name, scope, new HashSet<>());
} }
return resolved; return scope.resolved();
}
@Nullable
private static String resolveConstant(String name, ConstantScope scope, Set<String> visiting) {
if (scope.resolved().containsKey(name)) {
return scope.resolved().get(name);
}
Expression init = scope.initializers().get(name);
if (init == null || !visiting.add(name)) {
return null; // unknown, or cycle
}
String referenced = referencedConstantName(init, scope.className());
@Nullable String value;
if (referenced != null) {
value = resolveConstant(referenced, scope, visiting);
if (scope.templates().contains(referenced)) {
scope.templates().add(name);
}
} else if (scope.stringTyped().contains(name)) {
value = foldString(name, init, scope, visiting);
} else {
value = literalValue(init);
}
visiting.remove(name);
if (value != null) {
scope.resolved().put(name, value);
}
return value;
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Constant value resolution (item 16) // Constant value resolution (item 16)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/**
* Item 139: the value of a {@code String} constant built from text blocks, literals, same-class
* constants and {@code +} — {@code CypherQueries}-style query text used to be invisible to
* {@code search/value} because only a single plain literal was resolved. {@code "...".formatted(...)}
* yields its template (with the {@code %s} placeholders) and marks {@code name} a template. Anything
* not resolvable (a method call, another class's constant) makes the whole value {@code null}:
* nothing is guessed.
*/
@Nullable @Nullable
private static String resolveConstant(String name, String className, Map<String, Expression> initializers, private static String foldString(String name, Expression expr, ConstantScope scope, Set<String> visiting) {
Map<String, String> resolved, Set<String> visiting) { if (expr instanceof EnclosedExpr enclosed) {
if (resolved.containsKey(name)) { return foldString(name, enclosed.getInner(), scope, visiting);
return resolved.get(name);
} }
Expression init = initializers.get(name); if (expr instanceof TextBlockLiteralExpr text) {
if (init == null || !visiting.add(name)) { return text.asString();
return null; // unknown, or cycle
} }
String referenced = referencedConstantName(init, className); String referenced = referencedConstantName(expr, scope.className());
String value;
if (referenced != null) { if (referenced != null) {
value = resolveConstant(referenced, className, initializers, resolved, visiting); @Nullable String value = resolveConstant(referenced, scope, visiting);
} else { if (value != null && scope.templates().contains(referenced)) {
value = literalValue(init); scope.templates().add(name);
}
return value;
} }
visiting.remove(name); if (expr instanceof BinaryExpr binary && binary.getOperator() == BinaryExpr.Operator.PLUS) {
if (value != null) { @Nullable String left = foldString(name, binary.getLeft(), scope, visiting);
resolved.put(name, value); @Nullable String right = left == null ? null : foldString(name, binary.getRight(), scope, visiting);
return left == null || right == null ? null : left + right;
} }
return value; if (expr instanceof MethodCallExpr call && call.getNameAsString().equals("formatted")
&& call.getScope().isPresent()) {
@Nullable String template = foldString(name, call.getScope().get(), scope, visiting);
if (template != null) {
scope.templates().add(name);
}
return template;
}
return literalValue(expr);
}
@Override
public ParseResult parse(String sourceFile, String content) {
List<AstNode> nodes = new ArrayList<>();
List<AstEdge> edges = new ArrayList<>();
com.github.javaparser.ParseResult<CompilationUnit> parsed =
new com.github.javaparser.JavaParser(PARSER_CONFIGURATION).parse(content);
CompilationUnit unit = parsed.getResult()
.orElseThrow(() -> new ParseProblemException(parsed.getProblems()));
TypeResolver types = new TypeResolver(unit);
for (TypeDeclaration<?> type : unit.findAll(TypeDeclaration.class)) {
TypeFacts facts = facts(type);
String className = type.getNameAsString();
// Item 117: the fully-qualified name IS the module's identity — a simple name does not
// identify a class (nested @Nested classes, Builder, WorkingStorage: 8% of one real
// codebase's modules were only ambiguously addressable). `simpleName` stays for display,
// and the API still accepts it as the convenient short form.
// Local and anonymous classes have no FQN and keep the simple name.
String fqn = type.getFullyQualifiedName().orElse(className);
Map<String, String> moduleProps = new HashMap<>();
moduleProps.put("simpleName", className);
moduleProps.put("fqn", fqn);
// J3: distinguish interfaces from classes (interface -> implementation resolution).
moduleProps.put("isInterface", String.valueOf(facts.isInterface()));
// Item 1 / item 119: persisted module sub-kind for API filtering ("list all interfaces").
// CLASS | INTERFACE | ENUM | RECORD | ANNOTATION.
moduleProps.put("moduleKind", facts.kind());
// The JPA/Panache heuristics below are class/interface notions — a record is never a
// Panache repository, and generalizing them would apply guesswork to types the assumption
// was never written for.
@Nullable String repositoryEntity = null;
if (type instanceof ClassOrInterfaceDeclaration cls) {
// J1: tag repository classes with their managed entity so the enrichment step can map
// a repository method call to the entity's DB_TABLE.
repositoryEntity = repositoryEntityType(cls);
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(cls);
if (panacheParam != null) {
moduleProps.put("panacheEntityTypeParam", panacheParam);
moduleProps.put("typeParams", String.join(",", ownTypeParamNames(cls)));
}
// J1b: a repository-named interface with no generic entity argument anywhere in sight
// (e.g. a project-specific IRiskRepository) — guess the entity from a method return type.
if (repositoryEntity == null && panacheParam == null
&& cls.isInterface() && isRepositoryReceiverName(className)) {
repositoryEntity = fallbackRepositoryEntity(cls);
if (repositoryEntity != null) {
moduleProps.put("repositoryEntity", repositoryEntity);
}
}
}
type.getJavadoc().ifPresent(jd -> {
String firstLine = jd.getDescription().toText().lines()
.map(String::strip).filter(s -> !s.isEmpty()).findFirst().orElse("");
if (!firstLine.isEmpty()) {
moduleProps.put("description", firstLine);
}
});
// Collected before the module props below, because item 130's class-level @Path may be
// written as a constant reference (@Path(PurPaths.PARTNER)) and must resolve the same way
// a @Column name does.
Set<String> templateConstants = new HashSet<>();
Map<String, String> constants = collectConstants(type, templateConstants);
// Item 29: generic annotation capture for search_annotation, independent of any
// annotation's specific interpretation above (@Entity/@Query/repository base types/...).
@Nullable String typeAnnotations = annotationNames(type);
if (typeAnnotations != null) {
moduleProps.put("annotations", typeAnnotations);
}
// Item 130: the class-level @Path, so "which endpoint path reaches this handler" is
// answerable from the graph. Annotations are otherwise stored by name only, and a JAX-RS
// path lives half on the class and half on the method — composing it needed the source.
@Nullable String typePath = restPath(type, className, constants);
if (typePath != null) {
moduleProps.put("restPath", typePath);
}
AstNode typeNode = node(NodeType.MODULE, fqn, sourceFile,
type.getBegin().map(p -> p.line).orElse(1),
type.getEnd().map(p -> p.line).orElse(1),
null, null, moduleProps);
nodes.add(typeNode);
// Dedupe placeholder MODULE nodes for referenced classes (extends/implements targets
// and cross-class CALLS targets) by name, so repeated references don't create
// duplicate nodes that collide on the (type, name, sourceFile, project) merge key.
Map<String, AstNode> referencedModules = new HashMap<>();
for (ClassOrInterfaceType extended : facts.extended()) {
AstNode superType = referencedModule(referencedModules, nodes, types.resolve(supertypeName(extended, className)));
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 : facts.implemented()) {
AstNode interfaceType = referencedModule(referencedModules, nodes, types.resolve(supertypeName(implemented, className)));
edges.add(edge(EdgeType.IMPLEMENTS, typeNode.id(), interfaceType.id(), typeNode.startLine()));
}
// Pass 1: collect resolved constant values (needed before resolving the entity table name).
// JPA entity: resolve the table name and link the class to its DB_TABLE. A Panache
// active-record entity (extends PanacheEntity[Base]) is an entity even without @Entity.
boolean isEntity = annotation(type, "Entity").isPresent()
|| (type instanceof ClassOrInterfaceDeclaration cls && extendsPanacheEntity(cls));
boolean isMappedSuperclass = annotation(type, "MappedSuperclass").isPresent();
if (isEntity && !isMappedSuperclass) {
String tableName = resolveTableName(type, className, constants);
AstNode tableNode = node(NodeType.DB_TABLE, tableName, "", 1, 1, null, null);
nodes.add(tableNode);
edges.add(edge(EdgeType.MAPS_TO, typeNode.id(), tableNode.id(), typeNode.startLine()));
}
// Pass 2: emit field/constant nodes (with JPA column metadata where present).
// fieldsByName maps a field/constant name to its node, for READS/WRITES resolution;
// fieldTypes maps a field name to its declared type, for cross-class call resolution.
// Item 116a: fieldTypes starts from the enclosing classes' fields, so a nested class
// resolves a call on a field it inherits lexically (the @Nested/JUnit 5 layout). Only
// fieldTypes is widened — fieldsByName stays this type's own fields, since a field node
// belongs to the class that declares it.
Map<String, AstNode> fieldsByName = new HashMap<>();
Map<String, String> fieldTypes = enclosingFieldTypes(type);
for (FieldDeclaration field : type.getFields()) {
boolean isConstant = field.isStatic() && field.isFinal();
@Nullable AnnotationExpr column = annotation(field, "Column").orElse(null);
@Nullable String fieldAnnotations = annotationNames(field);
for (VariableDeclarator variable : field.getVariables()) {
String dataType = variable.getTypeAsString();
fieldTypes.put(variable.getNameAsString(), dataType);
int startLine = field.getBegin().map(p -> p.line).orElse(1);
int endLine = field.getEnd().map(p -> p.line).orElse(1);
AstNode fieldNode;
if (column != null) {
Map<String, String> props = columnProperties(field, column, variable, className, constants);
if (fieldAnnotations != null) {
props.put("annotations", fieldAnnotations);
}
fieldNode = node(NodeType.FIELD, variable.getNameAsString(), sourceFile,
startLine, endLine, dataType, null, props);
} else {
@Nullable String value = isConstant ? constants.get(variable.getNameAsString()) : null;
Map<String, String> fieldProps = new LinkedHashMap<>();
if (fieldAnnotations != null) {
fieldProps.put("annotations", fieldAnnotations);
}
if (value != null && templateConstants.contains(variable.getNameAsString())) {
fieldProps.put("valueKind", "template"); // item 139: `"...".formatted(...)`
}
fieldNode = node(isConstant ? NodeType.CONSTANT : NodeType.FIELD,
variable.getNameAsString(), sourceFile, startLine, endLine, dataType, value,
fieldProps.isEmpty() ? null : fieldProps);
}
nodes.add(fieldNode);
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), fieldNode.id(), fieldNode.startLine()));
fieldsByName.put(variable.getNameAsString(), fieldNode);
}
}
// Item 119: the state a record, an enum or an annotation type carries is not declared as a
// FieldDeclaration, so the loop above sees none of it. Without these three the types would
// be modules with an empty body — "analysed, nothing found" for a record DTO's components,
// which is the failure mode item 114 is about.
for (RecordDeclaration record : type instanceof RecordDeclaration r ? List.of(r) : List.<RecordDeclaration>of()) {
for (Parameter component : record.getParameters()) {
int line = component.getBegin().map(p -> p.line).orElse(typeNode.startLine());
AstNode componentNode = node(NodeType.FIELD, component.getNameAsString(), sourceFile,
line, line, component.getTypeAsString(), null,
Map.of("recordComponent", "true"));
nodes.add(componentNode);
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), componentNode.id(), line));
fieldsByName.put(component.getNameAsString(), componentNode);
fieldTypes.put(component.getNameAsString(), component.getTypeAsString());
}
}
for (EnumDeclaration enumeration : type instanceof EnumDeclaration e ? List.of(e) : List.<EnumDeclaration>of()) {
for (EnumConstantDeclaration constant : enumeration.getEntries()) {
int line = constant.getBegin().map(p -> p.line).orElse(typeNode.startLine());
// The constant's own class body (a per-constant override) is not modelled as a
// separate module; its methods would need an identity no source-level name gives them.
AstNode constantNode = node(NodeType.CONSTANT, constant.getNameAsString(), sourceFile,
line, line, className, null, Map.of("enumConstant", "true"));
nodes.add(constantNode);
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), constantNode.id(), line));
fieldsByName.put(constant.getNameAsString(), constantNode);
}
}
for (AnnotationMemberDeclaration member : type.getMembers().stream()
.filter(AnnotationMemberDeclaration.class::isInstance)
.map(AnnotationMemberDeclaration.class::cast).toList()) {
int line = member.getBegin().map(p -> p.line).orElse(typeNode.startLine());
Map<String, String> memberProps = new HashMap<>();
memberProps.put("annotationMember", "true");
member.getDefaultValue().ifPresent(v -> memberProps.put("defaultValue", v.toString()));
// A FIELD, not a FUNCTION: the question asked of an annotation type is which attributes
// it carries, not which methods it declares.
AstNode memberNode = node(NodeType.FIELD, member.getNameAsString(), sourceFile,
line, line, member.getType().asString(), null, memberProps);
nodes.add(memberNode);
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), memberNode.id(), line));
fieldsByName.put(member.getNameAsString(), memberNode);
}
// Functions: methods + constructors (constructors are FUNCTION nodes named after the class).
Map<String, AstNode> methods = new HashMap<>();
List<CallableDeclaration<?>> callables = new ArrayList<>();
List<AstNode> callableNodes = new ArrayList<>();
for (MethodDeclaration method : type.getMethods()) {
@Nullable String methodAnnotations = annotationNames(method);
Map<String, String> methodProps = new HashMap<>();
if (methodAnnotations != null) {
methodProps.put("annotations", methodAnnotations);
}
// Item 130: the method half of a JAX-RS endpoint — its own @Path (often absent, which
// means "the class path itself") and the HTTP verb annotation.
@Nullable String methodPath = restPath(method, className, constants);
if (methodPath != null) {
methodProps.put("restPath", methodPath);
}
@Nullable String httpMethod = httpMethod(method);
if (httpMethod != null) {
methodProps.put("httpMethod", httpMethod);
}
// Item 33: modifier-derived kind, so an agent can ask "what must a subclass
// implement/not override" without reading the base class source by hand.
methodProps.put("kind", method.isAbstract() ? "abstract" : method.isFinal() ? "final" : "overridable");
AstNode methodNode = node(NodeType.FUNCTION, method.getNameAsString(), sourceFile,
method.getBegin().map(p -> p.line).orElse(1),
method.getEnd().map(p -> p.line).orElse(1),
method.getTypeAsString(), null, methodProps);
methods.put(method.getNameAsString(), methodNode);
nodes.add(methodNode);
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), methodNode.id(), methodNode.startLine()));
callables.add(method);
callableNodes.add(methodNode);
// J1b: a @Query-annotated repository method is abstract (no call sites to scan), so
// its JPQL/native-SQL DB access is captured here, at the declaration.
addQueryDbAccessCandidate(method, methodNode, repositoryEntity, className, constants, sourceFile, nodes, edges);
}
for (ConstructorDeclaration ctor : type.getConstructors()) {
@Nullable String ctorAnnotations = annotationNames(ctor);
AstNode ctorNode = node(NodeType.FUNCTION, className, sourceFile,
ctor.getBegin().map(p -> p.line).orElse(1),
ctor.getEnd().map(p -> p.line).orElse(1),
className, null,
ctorAnnotations != null ? Map.of("annotations", ctorAnnotations) : null);
nodes.add(ctorNode);
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), ctorNode.id(), ctorNode.startLine()));
callables.add(ctor);
callableNodes.add(ctorNode);
}
// Parameters, field READS/WRITES, and intra-class CALLS per callable.
for (int ci = 0; ci < callables.size(); ci++) {
CallableDeclaration<?> callable = callables.get(ci);
AstNode callableNode = callableNodes.get(ci);
Set<String> shadowed = new HashSet<>();
// declaredTypes: field types plus this callable's parameter and local types, for
// resolving a typed receiver (svc.method()) to its class.
Map<String, String> declaredTypes = new HashMap<>(fieldTypes);
int paramPosition = 0;
for (Parameter parameter : callable.getParameters()) {
shadowed.add(parameter.getNameAsString());
declaredTypes.put(parameter.getNameAsString(), parameter.getTypeAsString());
AstNode paramNode = node(NodeType.VARIABLE, parameter.getNameAsString(), sourceFile,
parameter.getBegin().map(p -> p.line).orElse(callableNode.startLine()),
parameter.getEnd().map(p -> p.line).orElse(callableNode.startLine()),
parameter.getTypeAsString(), null, Map.of("paramPosition", String.valueOf(paramPosition++)));
nodes.add(paramNode);
edges.add(edge(EdgeType.CONTAINS, callableNode.id(), paramNode.id(), paramNode.startLine()));
}
callable.findAll(VariableDeclarationExpr.class).forEach(vde ->
vde.getVariables().forEach(v -> {
shadowed.add(v.getNameAsString());
declaredTypes.put(v.getNameAsString(), v.getTypeAsString());
}));
// Every *nested* parameter too — lambda and catch parameters. They are bound names
// like any local, but they are not VariableDeclarationExpr and not the callable's own
// parameter list, so both loops above miss them. Names only: an implicit lambda
// parameter has no usable declared type (see isProbableFieldReceiver).
callable.findAll(Parameter.class).forEach(p -> shadowed.add(p.getNameAsString()));
addFieldAccessEdges(callable, callableNode, fieldsByName, shadowed, edges);
for (MethodCallExpr call : callable.findAll(MethodCallExpr.class)) {
int callLine = call.getBegin().map(p -> p.line).orElse(callableNode.startLine());
@Nullable Expression scope = call.getScope().orElse(null);
if (scope == null || scope.isThisExpr()) {
// Intra-class call.
AstNode target = methods.get(call.getNameAsString());
if (target != null) {
edges.add(edge(EdgeType.CALLS, callableNode.id(), target.id(), callLine, null,
callProps(call, CallKind.METHOD_CALL)));
}
} else {
// Cross-class call: a class-level (MODULE->MODULE) CALLS edge, so callers/
// callees/call-tree (which traverse from the module) span files uniformly
// with Natural's module-level CALLNAT.
@Nullable String simpleTarget = resolveReceiverClass(scope, declaredTypes);
@Nullable String targetClass = simpleTarget == null ? null : types.resolve(simpleTarget);
// The DB-access candidate keeps the receiver as written (see extendsTypeArgs).
if (targetClass == null && isProbableFieldReceiver(scope, declaredTypes, shadowed)) {
// Item 116b: the receiver names a field this class does not declare — almost
// always one inherited from a supertype, which lives in another file the
// parser never sees. Record the receiver's *name* against a placeholder so
// the enrichment stage, which does know the EXTENDS chain, can resolve it.
// Dropping it here is what left a service with 185 tests reporting no callers.
AstNode marker = referencedModule(referencedModules, nodes,
UNRESOLVED_FIELD_RECEIVER_PREFIX + scope.asNameExpr().getNameAsString());
Map<String, String> props = callProps(call, CallKind.METHOD_CALL);
props.put("calleeMethod", call.getNameAsString());
props.put("callerFn", callableNode.name());
props.put(UNRESOLVED_FIELD_RECEIVER, scope.asNameExpr().getNameAsString());
edges.add(edge(EdgeType.CALLS, typeNode.id(), marker.id(), callLine, null, props));
}
if (targetClass != null) {
AstNode mod = referencedModule(referencedModules, nodes, targetClass);
// J5: carry the invoked method + enclosing function so enrichment can map
// this cross-class call's arguments to the callee method's parameters.
Map<String, String> props = callProps(call, CallKind.METHOD_CALL);
props.put("calleeMethod", call.getNameAsString());
props.put("callerFn", callableNode.name());
edges.add(edge(EdgeType.CALLS, typeNode.id(), mod.id(), callLine, null, props));
}
// J1: a persistence-shaped call on this receiver becomes a DB_ACCESS candidate.
boolean staticReceiver = scope.isNameExpr()
&& !declaredTypes.containsKey(scope.asNameExpr().getNameAsString());
addDbAccessCandidate(call, simpleTarget, staticReceiver, declaredTypes, sourceFile,
callableNode, nodes, edges);
}
}
// Constructor calls (new Foo(...)) -> class-level CALLS to the instantiated class.
for (ObjectCreationExpr creation : callable.findAll(ObjectCreationExpr.class)) {
int callLine = creation.getBegin().map(p -> p.line).orElse(callableNode.startLine());
AstNode mod = referencedModule(referencedModules, nodes, types.resolve(creation.getType().getNameAsString()));
edges.add(edge(EdgeType.CALLS, typeNode.id(), mod.id(), callLine, null,
Map.of("callKind", CallKind.CONSTRUCTOR.name())));
}
}
// J2: CDI injection + class-literal wiring edges.
addWiringEdges(type, typeNode, referencedModules, types, nodes, edges);
// Item 128: the reference index — imports, declared type positions, annotation usages.
addReferenceEdges(unit, type, typeNode, referencedModules, types, nodes, edges);
}
// Item 141: comments are file-level, so this runs once after the type loop — inside it, a file
// with nested types would emit every comment once per type.
addCommentNodes(unit, sourceFile, nodes, edges);
return new ParseResult(nodes, edges);
} }
/** /**
@@ -1190,358 +1600,12 @@ public final class JavaParser implements LanguageParser {
// DI + class-literal wiring edges (item J2) // DI + class-literal wiring edges (item J2)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@Override /**
public ParseResult parse(String sourceFile, String content) { * The per-class state of constant resolution. {@code templates} collects the names whose value is
List<AstNode> nodes = new ArrayList<>(); * a {@code .formatted(...)} template rather than the final string (item 139).
List<AstEdge> edges = new ArrayList<>(); */
private record ConstantScope(String className, Map<String, Expression> initializers, Set<String> stringTyped,
com.github.javaparser.ParseResult<CompilationUnit> parsed = Map<String, String> resolved, Set<String> templates) {
new com.github.javaparser.JavaParser(PARSER_CONFIGURATION).parse(content);
CompilationUnit unit = parsed.getResult()
.orElseThrow(() -> new ParseProblemException(parsed.getProblems()));
TypeResolver types = new TypeResolver(unit);
for (TypeDeclaration<?> type : unit.findAll(TypeDeclaration.class)) {
TypeFacts facts = facts(type);
String className = type.getNameAsString();
// Item 117: the fully-qualified name IS the module's identity — a simple name does not
// identify a class (nested @Nested classes, Builder, WorkingStorage: 8% of one real
// codebase's modules were only ambiguously addressable). `simpleName` stays for display,
// and the API still accepts it as the convenient short form.
// Local and anonymous classes have no FQN and keep the simple name.
String fqn = type.getFullyQualifiedName().orElse(className);
Map<String, String> moduleProps = new HashMap<>();
moduleProps.put("simpleName", className);
moduleProps.put("fqn", fqn);
// J3: distinguish interfaces from classes (interface -> implementation resolution).
moduleProps.put("isInterface", String.valueOf(facts.isInterface()));
// Item 1 / item 119: persisted module sub-kind for API filtering ("list all interfaces").
// CLASS | INTERFACE | ENUM | RECORD | ANNOTATION.
moduleProps.put("moduleKind", facts.kind());
// The JPA/Panache heuristics below are class/interface notions — a record is never a
// Panache repository, and generalizing them would apply guesswork to types the assumption
// was never written for.
@Nullable String repositoryEntity = null;
if (type instanceof ClassOrInterfaceDeclaration cls) {
// J1: tag repository classes with their managed entity so the enrichment step can map
// a repository method call to the entity's DB_TABLE.
repositoryEntity = repositoryEntityType(cls);
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(cls);
if (panacheParam != null) {
moduleProps.put("panacheEntityTypeParam", panacheParam);
moduleProps.put("typeParams", String.join(",", ownTypeParamNames(cls)));
}
// J1b: a repository-named interface with no generic entity argument anywhere in sight
// (e.g. a project-specific IRiskRepository) — guess the entity from a method return type.
if (repositoryEntity == null && panacheParam == null
&& cls.isInterface() && isRepositoryReceiverName(className)) {
repositoryEntity = fallbackRepositoryEntity(cls);
if (repositoryEntity != null) {
moduleProps.put("repositoryEntity", repositoryEntity);
}
}
}
type.getJavadoc().ifPresent(jd -> {
String firstLine = jd.getDescription().toText().lines()
.map(String::strip).filter(s -> !s.isEmpty()).findFirst().orElse("");
if (!firstLine.isEmpty()) {
moduleProps.put("description", firstLine);
}
});
// Collected before the module props below, because item 130's class-level @Path may be
// written as a constant reference (@Path(PurPaths.PARTNER)) and must resolve the same way
// a @Column name does.
Map<String, String> constants = collectConstants(type);
// Item 29: generic annotation capture for search_annotation, independent of any
// annotation's specific interpretation above (@Entity/@Query/repository base types/...).
@Nullable String typeAnnotations = annotationNames(type);
if (typeAnnotations != null) {
moduleProps.put("annotations", typeAnnotations);
}
// Item 130: the class-level @Path, so "which endpoint path reaches this handler" is
// answerable from the graph. Annotations are otherwise stored by name only, and a JAX-RS
// path lives half on the class and half on the method — composing it needed the source.
@Nullable String typePath = restPath(type, className, constants);
if (typePath != null) {
moduleProps.put("restPath", typePath);
}
AstNode typeNode = node(NodeType.MODULE, fqn, sourceFile,
type.getBegin().map(p -> p.line).orElse(1),
type.getEnd().map(p -> p.line).orElse(1),
null, null, moduleProps);
nodes.add(typeNode);
// Dedupe placeholder MODULE nodes for referenced classes (extends/implements targets
// and cross-class CALLS targets) by name, so repeated references don't create
// duplicate nodes that collide on the (type, name, sourceFile, project) merge key.
Map<String, AstNode> referencedModules = new HashMap<>();
for (ClassOrInterfaceType extended : facts.extended()) {
AstNode superType = referencedModule(referencedModules, nodes, types.resolve(supertypeName(extended, className)));
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 : facts.implemented()) {
AstNode interfaceType = referencedModule(referencedModules, nodes, types.resolve(supertypeName(implemented, className)));
edges.add(edge(EdgeType.IMPLEMENTS, typeNode.id(), interfaceType.id(), typeNode.startLine()));
}
// Pass 1: collect resolved constant values (needed before resolving the entity table name).
// JPA entity: resolve the table name and link the class to its DB_TABLE. A Panache
// active-record entity (extends PanacheEntity[Base]) is an entity even without @Entity.
boolean isEntity = annotation(type, "Entity").isPresent()
|| (type instanceof ClassOrInterfaceDeclaration cls && extendsPanacheEntity(cls));
boolean isMappedSuperclass = annotation(type, "MappedSuperclass").isPresent();
if (isEntity && !isMappedSuperclass) {
String tableName = resolveTableName(type, className, constants);
AstNode tableNode = node(NodeType.DB_TABLE, tableName, "", 1, 1, null, null);
nodes.add(tableNode);
edges.add(edge(EdgeType.MAPS_TO, typeNode.id(), tableNode.id(), typeNode.startLine()));
}
// Pass 2: emit field/constant nodes (with JPA column metadata where present).
// fieldsByName maps a field/constant name to its node, for READS/WRITES resolution;
// fieldTypes maps a field name to its declared type, for cross-class call resolution.
// Item 116a: fieldTypes starts from the enclosing classes' fields, so a nested class
// resolves a call on a field it inherits lexically (the @Nested/JUnit 5 layout). Only
// fieldTypes is widened — fieldsByName stays this type's own fields, since a field node
// belongs to the class that declares it.
Map<String, AstNode> fieldsByName = new HashMap<>();
Map<String, String> fieldTypes = enclosingFieldTypes(type);
for (FieldDeclaration field : type.getFields()) {
boolean isConstant = field.isStatic() && field.isFinal();
@Nullable AnnotationExpr column = annotation(field, "Column").orElse(null);
@Nullable String fieldAnnotations = annotationNames(field);
for (VariableDeclarator variable : field.getVariables()) {
String dataType = variable.getTypeAsString();
fieldTypes.put(variable.getNameAsString(), dataType);
int startLine = field.getBegin().map(p -> p.line).orElse(1);
int endLine = field.getEnd().map(p -> p.line).orElse(1);
AstNode fieldNode;
if (column != null) {
Map<String, String> props = columnProperties(field, column, variable, className, constants);
if (fieldAnnotations != null) {
props.put("annotations", fieldAnnotations);
}
fieldNode = node(NodeType.FIELD, variable.getNameAsString(), sourceFile,
startLine, endLine, dataType, null, props);
} else {
@Nullable String value = isConstant ? constants.get(variable.getNameAsString()) : null;
fieldNode = node(isConstant ? NodeType.CONSTANT : NodeType.FIELD,
variable.getNameAsString(), sourceFile, startLine, endLine, dataType, value,
fieldAnnotations != null ? Map.of("annotations", fieldAnnotations) : null);
}
nodes.add(fieldNode);
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), fieldNode.id(), fieldNode.startLine()));
fieldsByName.put(variable.getNameAsString(), fieldNode);
}
}
// Item 119: the state a record, an enum or an annotation type carries is not declared as a
// FieldDeclaration, so the loop above sees none of it. Without these three the types would
// be modules with an empty body — "analysed, nothing found" for a record DTO's components,
// which is the failure mode item 114 is about.
for (RecordDeclaration record : type instanceof RecordDeclaration r ? List.of(r) : List.<RecordDeclaration>of()) {
for (Parameter component : record.getParameters()) {
int line = component.getBegin().map(p -> p.line).orElse(typeNode.startLine());
AstNode componentNode = node(NodeType.FIELD, component.getNameAsString(), sourceFile,
line, line, component.getTypeAsString(), null,
Map.of("recordComponent", "true"));
nodes.add(componentNode);
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), componentNode.id(), line));
fieldsByName.put(component.getNameAsString(), componentNode);
fieldTypes.put(component.getNameAsString(), component.getTypeAsString());
}
}
for (EnumDeclaration enumeration : type instanceof EnumDeclaration e ? List.of(e) : List.<EnumDeclaration>of()) {
for (EnumConstantDeclaration constant : enumeration.getEntries()) {
int line = constant.getBegin().map(p -> p.line).orElse(typeNode.startLine());
// The constant's own class body (a per-constant override) is not modelled as a
// separate module; its methods would need an identity no source-level name gives them.
AstNode constantNode = node(NodeType.CONSTANT, constant.getNameAsString(), sourceFile,
line, line, className, null, Map.of("enumConstant", "true"));
nodes.add(constantNode);
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), constantNode.id(), line));
fieldsByName.put(constant.getNameAsString(), constantNode);
}
}
for (AnnotationMemberDeclaration member : type.getMembers().stream()
.filter(AnnotationMemberDeclaration.class::isInstance)
.map(AnnotationMemberDeclaration.class::cast).toList()) {
int line = member.getBegin().map(p -> p.line).orElse(typeNode.startLine());
Map<String, String> memberProps = new HashMap<>();
memberProps.put("annotationMember", "true");
member.getDefaultValue().ifPresent(v -> memberProps.put("defaultValue", v.toString()));
// A FIELD, not a FUNCTION: the question asked of an annotation type is which attributes
// it carries, not which methods it declares.
AstNode memberNode = node(NodeType.FIELD, member.getNameAsString(), sourceFile,
line, line, member.getType().asString(), null, memberProps);
nodes.add(memberNode);
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), memberNode.id(), line));
fieldsByName.put(member.getNameAsString(), memberNode);
}
// Functions: methods + constructors (constructors are FUNCTION nodes named after the class).
Map<String, AstNode> methods = new HashMap<>();
List<CallableDeclaration<?>> callables = new ArrayList<>();
List<AstNode> callableNodes = new ArrayList<>();
for (MethodDeclaration method : type.getMethods()) {
@Nullable String methodAnnotations = annotationNames(method);
Map<String, String> methodProps = new HashMap<>();
if (methodAnnotations != null) {
methodProps.put("annotations", methodAnnotations);
}
// Item 130: the method half of a JAX-RS endpoint — its own @Path (often absent, which
// means "the class path itself") and the HTTP verb annotation.
@Nullable String methodPath = restPath(method, className, constants);
if (methodPath != null) {
methodProps.put("restPath", methodPath);
}
@Nullable String httpMethod = httpMethod(method);
if (httpMethod != null) {
methodProps.put("httpMethod", httpMethod);
}
// Item 33: modifier-derived kind, so an agent can ask "what must a subclass
// implement/not override" without reading the base class source by hand.
methodProps.put("kind", method.isAbstract() ? "abstract" : method.isFinal() ? "final" : "overridable");
AstNode methodNode = node(NodeType.FUNCTION, method.getNameAsString(), sourceFile,
method.getBegin().map(p -> p.line).orElse(1),
method.getEnd().map(p -> p.line).orElse(1),
method.getTypeAsString(), null, methodProps);
methods.put(method.getNameAsString(), methodNode);
nodes.add(methodNode);
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), methodNode.id(), methodNode.startLine()));
callables.add(method);
callableNodes.add(methodNode);
// J1b: a @Query-annotated repository method is abstract (no call sites to scan), so
// its JPQL/native-SQL DB access is captured here, at the declaration.
addQueryDbAccessCandidate(method, methodNode, repositoryEntity, className, constants, sourceFile, nodes, edges);
}
for (ConstructorDeclaration ctor : type.getConstructors()) {
@Nullable String ctorAnnotations = annotationNames(ctor);
AstNode ctorNode = node(NodeType.FUNCTION, className, sourceFile,
ctor.getBegin().map(p -> p.line).orElse(1),
ctor.getEnd().map(p -> p.line).orElse(1),
className, null,
ctorAnnotations != null ? Map.of("annotations", ctorAnnotations) : null);
nodes.add(ctorNode);
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), ctorNode.id(), ctorNode.startLine()));
callables.add(ctor);
callableNodes.add(ctorNode);
}
// Parameters, field READS/WRITES, and intra-class CALLS per callable.
for (int ci = 0; ci < callables.size(); ci++) {
CallableDeclaration<?> callable = callables.get(ci);
AstNode callableNode = callableNodes.get(ci);
Set<String> shadowed = new HashSet<>();
// declaredTypes: field types plus this callable's parameter and local types, for
// resolving a typed receiver (svc.method()) to its class.
Map<String, String> declaredTypes = new HashMap<>(fieldTypes);
int paramPosition = 0;
for (Parameter parameter : callable.getParameters()) {
shadowed.add(parameter.getNameAsString());
declaredTypes.put(parameter.getNameAsString(), parameter.getTypeAsString());
AstNode paramNode = node(NodeType.VARIABLE, parameter.getNameAsString(), sourceFile,
parameter.getBegin().map(p -> p.line).orElse(callableNode.startLine()),
parameter.getEnd().map(p -> p.line).orElse(callableNode.startLine()),
parameter.getTypeAsString(), null, Map.of("paramPosition", String.valueOf(paramPosition++)));
nodes.add(paramNode);
edges.add(edge(EdgeType.CONTAINS, callableNode.id(), paramNode.id(), paramNode.startLine()));
}
callable.findAll(VariableDeclarationExpr.class).forEach(vde ->
vde.getVariables().forEach(v -> {
shadowed.add(v.getNameAsString());
declaredTypes.put(v.getNameAsString(), v.getTypeAsString());
}));
// Every *nested* parameter too — lambda and catch parameters. They are bound names
// like any local, but they are not VariableDeclarationExpr and not the callable's own
// parameter list, so both loops above miss them. Names only: an implicit lambda
// parameter has no usable declared type (see isProbableFieldReceiver).
callable.findAll(Parameter.class).forEach(p -> shadowed.add(p.getNameAsString()));
addFieldAccessEdges(callable, callableNode, fieldsByName, shadowed, edges);
for (MethodCallExpr call : callable.findAll(MethodCallExpr.class)) {
int callLine = call.getBegin().map(p -> p.line).orElse(callableNode.startLine());
@Nullable Expression scope = call.getScope().orElse(null);
if (scope == null || scope.isThisExpr()) {
// Intra-class call.
AstNode target = methods.get(call.getNameAsString());
if (target != null) {
edges.add(edge(EdgeType.CALLS, callableNode.id(), target.id(), callLine, null,
callProps(call, CallKind.METHOD_CALL)));
}
} else {
// Cross-class call: a class-level (MODULE->MODULE) CALLS edge, so callers/
// callees/call-tree (which traverse from the module) span files uniformly
// with Natural's module-level CALLNAT.
@Nullable String simpleTarget = resolveReceiverClass(scope, declaredTypes);
@Nullable String targetClass = simpleTarget == null ? null : types.resolve(simpleTarget);
// The DB-access candidate keeps the receiver as written (see extendsTypeArgs).
if (targetClass == null && isProbableFieldReceiver(scope, declaredTypes, shadowed)) {
// Item 116b: the receiver names a field this class does not declare — almost
// always one inherited from a supertype, which lives in another file the
// parser never sees. Record the receiver's *name* against a placeholder so
// the enrichment stage, which does know the EXTENDS chain, can resolve it.
// Dropping it here is what left a service with 185 tests reporting no callers.
AstNode marker = referencedModule(referencedModules, nodes,
UNRESOLVED_FIELD_RECEIVER_PREFIX + scope.asNameExpr().getNameAsString());
Map<String, String> props = callProps(call, CallKind.METHOD_CALL);
props.put("calleeMethod", call.getNameAsString());
props.put("callerFn", callableNode.name());
props.put(UNRESOLVED_FIELD_RECEIVER, scope.asNameExpr().getNameAsString());
edges.add(edge(EdgeType.CALLS, typeNode.id(), marker.id(), callLine, null, props));
}
if (targetClass != null) {
AstNode mod = referencedModule(referencedModules, nodes, targetClass);
// J5: carry the invoked method + enclosing function so enrichment can map
// this cross-class call's arguments to the callee method's parameters.
Map<String, String> props = callProps(call, CallKind.METHOD_CALL);
props.put("calleeMethod", call.getNameAsString());
props.put("callerFn", callableNode.name());
edges.add(edge(EdgeType.CALLS, typeNode.id(), mod.id(), callLine, null, props));
}
// J1: a persistence-shaped call on this receiver becomes a DB_ACCESS candidate.
boolean staticReceiver = scope.isNameExpr()
&& !declaredTypes.containsKey(scope.asNameExpr().getNameAsString());
addDbAccessCandidate(call, simpleTarget, staticReceiver, declaredTypes, sourceFile,
callableNode, nodes, edges);
}
}
// Constructor calls (new Foo(...)) -> class-level CALLS to the instantiated class.
for (ObjectCreationExpr creation : callable.findAll(ObjectCreationExpr.class)) {
int callLine = creation.getBegin().map(p -> p.line).orElse(callableNode.startLine());
AstNode mod = referencedModule(referencedModules, nodes, types.resolve(creation.getType().getNameAsString()));
edges.add(edge(EdgeType.CALLS, typeNode.id(), mod.id(), callLine, null,
Map.of("callKind", CallKind.CONSTRUCTOR.name())));
}
}
// J2: CDI injection + class-literal wiring edges.
addWiringEdges(type, typeNode, referencedModules, types, nodes, edges);
// Item 128: the reference index — imports, declared type positions, annotation usages.
addReferenceEdges(unit, type, typeNode, referencedModules, types, nodes, edges);
}
// Item 141: comments are file-level, so this runs once after the type loop — inside it, a file
// with nested types would emit every comment once per type.
addCommentNodes(unit, sourceFile, nodes, edges);
return new ParseResult(nodes, edges);
} }
/** /**

View File

@@ -496,4 +496,35 @@ class JavaParserTest {
assertEquals("ServiceEndpoint: partner.update.partnercs.Update\n\nUpmsObject: PartnerCs", assertEquals("ServiceEndpoint: partner.update.partnercs.Update\n\nUpmsObject: PartnerCs",
commentAt(result, 2).value()); commentAt(result, 2).value());
} }
@Test
void stringConstantsFoldTextBlocksConcatenationsAndTemplates() {
// Item 139: CypherQueries-style constants used to have no value, so search/value never found them.
String content = """
package q;
public class Queries {
private static final String ROW = "n.name AS name";
public static final String CORE = \"""
MATCH (n:AstNode {project: $project})
\""";
public static final String LIST = CORE + "RETURN " + ROW;
public static final String KEYED = \"""
MERGE (a)-[r:%s]->(b)
\""".formatted("CALLS");
public static final String ALIAS = KEYED;
public static final String OPAQUE = CORE + helper();
public static final int COUNT = 1 + 2;
static String helper() { return "x"; }
}
""";
LanguageParser.ParseResult result = parser.parse("Queries.java", content);
assertEquals("MATCH (n:AstNode {project: $project})\n", findNode(result, NodeType.CONSTANT, "CORE").value());
assertEquals("MATCH (n:AstNode {project: $project})\nRETURN n.name AS name", findNode(result, NodeType.CONSTANT, "LIST").value());
AstNode keyed = findNode(result, NodeType.CONSTANT, "KEYED");
assertEquals("MERGE (a)-[r:%s]->(b)\n", keyed.value());
assertEquals("template", java.util.Objects.requireNonNull(keyed.properties()).get("valueKind"));
assertEquals("template", java.util.Objects.requireNonNull(findNode(result, NodeType.CONSTANT, "ALIAS").properties()).get("valueKind"));
assertNull(findNode(result, NodeType.CONSTANT, "OPAQUE").value(), "a method call is not folded - nothing is guessed");
assertNull(findNode(result, NodeType.CONSTANT, "COUNT").value(), "only String constants are concatenated");
}
} }

View File

@@ -286,7 +286,6 @@ public final class CopycodePreprocessor {
} }
private static String stripInlineComment(String s) { private static String stripInlineComment(String s) {
int comment = s.indexOf("/*"); return NaturalLines.stripInlineComment(s); // item 152: the shared, quote-aware rule
return comment >= 0 ? s.substring(0, comment) : s;
} }
} }

View File

@@ -22,7 +22,7 @@ public final class NaturalLineCounter implements LineCounter {
if (trimmed.charAt(0) == '*') { if (trimmed.charAt(0) == '*') {
return false; return false;
} }
int inline = line.indexOf("/*"); int inline = NaturalLines.commentStart(line); // item 152: quote-aware
String code = inline >= 0 ? line.substring(0, inline) : line; String code = inline >= 0 ? line.substring(0, inline) : line;
return !code.isBlank(); return !code.isBlank();
} }

View File

@@ -47,14 +47,38 @@ final class NaturalLines {
} }
/** /**
* @return {@code s} truncated at the first {@code /*}, which starts a Natural comment running to * @return {@code s} truncated at the {@code /*} that starts a Natural comment running to end of
* end of line. * line, or {@code s} unchanged if it has none (see {@link #commentStart}).
*/ */
static String stripInlineComment(String s) { static String stripInlineComment(String s) {
int comment = s.indexOf("/*"); int comment = commentStart(s);
return comment >= 0 ? s.substring(0, comment) : s; return comment >= 0 ? s.substring(0, comment) : s;
} }
/**
* Item 152: the index of the {@code /*} that starts a trailing comment, or -1 — quote-aware, so
* {@code MOVE 'A/*B' TO #X} and {@code MOVE "A/*B" TO #X} hold no comment. Natural delimits a
* literal with {@code '} or {@code "}; the other character is plain text inside it, and a doubled
* delimiter toggles twice, which is the same as not toggling. A literal still open at end of line
* means "no comment" — the conservative reading, which truncates nothing.
*/
static int commentStart(String line) {
char quote = 0;
for (int i = 0; i < line.length() - 1; i++) {
char c = line.charAt(i);
if (quote != 0) {
if (c == quote) {
quote = 0;
}
} else if (c == '\'' || c == '"') {
quote = c;
} else if (c == '/' && line.charAt(i + 1) == '*') {
return i;
}
}
return -1;
}
/** /**
* Advances {@code matcher} to the first match whose start lies outside a quoted string literal, * Advances {@code matcher} to the first match whose start lies outside a quoted string literal,
* leaving the matcher positioned on it so {@code group()}/{@code end()} apply to that match. * leaving the matcher positioned on it so {@code group()}/{@code end()} apply to that match.

View File

@@ -1121,7 +1121,7 @@ public final class NaturalParser implements LanguageParser {
blockStart = -1; blockStart = -1;
blockKind = null; blockKind = null;
} }
int inline = inlineCommentStart(line); int inline = NaturalLines.commentStart(line);
if (inline >= 0) { if (inline >= 0) {
emitComment(sourceFile, i + 1, i + 1, line.substring(inline + 2), emitComment(sourceFile, i + 1, i + 1, line.substring(inline + 2),
CommentProperties.KIND_NATURAL_INLINE, targets, nodes, edges); CommentProperties.KIND_NATURAL_INLINE, targets, nodes, edges);
@@ -1177,26 +1177,6 @@ public final class NaturalParser implements LanguageParser {
return stripped.substring(i).stripLeading(); return stripped.substring(i).stripLeading();
} }
/**
* @return the index of the {@code /*} that starts a trailing comment on a code line, or -1.
*
* <p>Quote-aware, unlike {@link NaturalLines#stripInlineComment}: {@code MOVE 'A/*B' TO #X} holds
* no comment. That the strip path is not quote-aware is a separate, pre-existing precision bug —
* there it merely truncates a line, here it would manufacture a comment node out of a literal.
*/
private static int inlineCommentStart(String line) {
boolean inQuote = false;
for (int i = 0; i < line.length() - 1; i++) {
char c = line.charAt(i);
if (c == '\'') {
inQuote = !inQuote; // a doubled '' toggles twice, which is the same as not toggling
} else if (!inQuote && c == '/' && line.charAt(i + 1) == '*') {
return i;
}
}
return -1;
}
private ParseResult parseModule(String sourceFile, String[] lines) { private ParseResult parseModule(String sourceFile, String[] lines) {
List<AstNode> nodes = new ArrayList<>(); List<AstNode> nodes = new ArrayList<>();
List<AstEdge> edges = new ArrayList<>(); List<AstEdge> edges = new ArrayList<>();

View File

@@ -0,0 +1,49 @@
package com.agenticcode.parsernatural;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Item 152: the trailing-comment rule every Natural scan path shares is quote-aware.
*/
class NaturalLinesCommentTest {
@Test
void aSlashStarInsideALiteralIsNotAComment() {
assertEquals("MOVE 'A/*B' TO #X", NaturalLines.stripInlineComment("MOVE 'A/*B' TO #X"));
assertEquals("MOVE \"A/*B\" TO #X", NaturalLines.stripInlineComment("MOVE \"A/*B\" TO #X"));
assertEquals("MOVE 'A/*B' TO #X ", NaturalLines.stripInlineComment("MOVE 'A/*B' TO #X /* real one"));
}
@Test
void theOtherDelimiterIsPlainTextInsideALiteral() {
assertEquals("MOVE 'it\"s /* x' TO #X", NaturalLines.stripInlineComment("MOVE 'it\"s /* x' TO #X"));
assertEquals("MOVE 'it''s' TO #X ", NaturalLines.stripInlineComment("MOVE 'it''s' TO #X /* doubled quote"));
}
@Test
void plainCommentsAndOpenLiteralsBehave() {
assertEquals("CALLNAT 'MOD' ", NaturalLines.stripInlineComment("CALLNAT 'MOD' /* comment"));
assertEquals("", NaturalLines.stripInlineComment("/* whole line"));
assertEquals(-1, NaturalLines.commentStart("MOVE 'unterminated /* TO #X"), "an open literal truncates nothing");
assertEquals(-1, NaturalLines.commentStart("no comment here"));
}
@Test
void theParserKeepsTheStatementBehindAQuotedSlashStar() throws Exception {
String src = """
DEFINE DATA
LOCAL
01 #X (A10)
END-DEFINE
MOVE 'A/*B' TO #X
CALLNAT 'TARGETQ' #X
END
""";
var result = new NaturalParser().parse("QUOTED.nat", src);
var writes = result.edges().stream()
.filter(e -> e.type() == com.agenticcode.parsercore.ast.model.EdgeType.WRITES && e.lineNo() == 5).count();
assertEquals(1, writes, "MOVE 'A/*B' TO #X still writes #X");
}
}

View File

@@ -318,8 +318,9 @@ class TypeScriptParserTest {
assertEquals("17px", Objects.requireNonNull(sx.properties()).get("literals")); assertEquals("17px", Objects.requireNonNull(sx.properties()).get("literals"));
assertTrue(r.edges().stream().anyMatch(e -> e.type() == EdgeType.CONTAINS && e.sourceId().equals(drawer.id()) && e.targetId().equals(sx.id()))); assertTrue(r.edges().stream().anyMatch(e -> e.type() == EdgeType.CONTAINS && e.sourceId().equals(drawer.id()) && e.targetId().equals(sx.id())));
List<AstEdge> refs = r.edges().stream().filter(e -> e.type() == EdgeType.REFERENCES && e.sourceId().equals(sx.id())).toList(); List<AstEdge> refs = r.edges().stream().filter(e -> e.type() == EdgeType.REFERENCES && e.sourceId().equals(sx.id())).toList();
// item 199: PRIMARY feeds color and borderColor on one line -> one edge, both keys // the fixture puts color and borderColor on separate lines (IDE reformat), so two edges; the
assertEquals(List.of("theme.PRIMARY[color,borderColor]", "theme.palette.background.paper[&:hover.background]"), // item-199 same-line grouping is pinned by StylesIT, whose inline fixture no formatter touches
assertEquals(List.of("theme.PRIMARY[color]", "theme.PRIMARY[borderColor]", "theme.palette.background.paper[&:hover.background]"),
refs.stream().map(e -> byId(r, e.targetId()).name() + "[" + prop(e, "property") + "]").toList()); refs.stream().map(e -> byId(r, e.targetId()).name() + "[" + prop(e, "property") + "]").toList());
AstNode ph = byId(r, refs.get(0).targetId()); AstNode ph = byId(r, refs.get(0).targetId());
assertEquals("", ph.sourceFile(), "the theme lives in another file: a placeholder"); assertEquals("", ph.sourceFile(), "the theme lives in another file: a placeholder");

View File

@@ -1240,7 +1240,7 @@
"resolved": null, "resolved": null,
"package": null, "package": null,
"line": 1, "line": 1,
"names": "{ createTheme }" "names": "{createTheme}"
} }
], ],
"declarations": [ "declarations": [
@@ -1431,7 +1431,7 @@
"kind": "component", "kind": "component",
"exported": true, "exported": true,
"startLine": 5, "startLine": 5,
"endLine": 13, "endLine": 19,
"members": [] "members": []
} }
], ],
@@ -1516,17 +1516,17 @@
{ {
"token": "PRIMARY", "token": "PRIMARY",
"property": "color", "property": "color",
"line": 9 "line": 10
}, },
{ {
"token": "PRIMARY", "token": "PRIMARY",
"property": "borderColor", "property": "borderColor",
"line": 9 "line": 11
}, },
{ {
"token": "palette.background.paper", "token": "palette.background.paper",
"property": "&:hover.background", "property": "&:hover.background",
"line": 9 "line": 13
} }
] ]
} }
@@ -1536,7 +1536,7 @@
"fromDecl": "HistorieDrawer", "fromDecl": "HistorieDrawer",
"token": "palette.primary.main", "token": "palette.primary.main",
"context": "borderColor", "context": "borderColor",
"line": 9 "line": 15
} }
], ],
"themeTokens": [] "themeTokens": []

View File

@@ -83,11 +83,12 @@ reengineered"* — which it answered for services reengineered months earlier.
Three routes now reach comment text. Pick one before concluding "not present": Three routes now reach comment text. Pick one before concluding "not present":
| Question | Call | | Question | Call |
|---|---| |---------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| "What does this module's header/change log say?" | `GET /modules/{name}/comments` (`ac comments <module>`) — blocks with the declaration each documents | | "What does this module's header/change log say?" | `GET /modules/{name}/comments` (`ac comments <module>`) — blocks with the declaration each documents |
| "Does this string appear anywhere, code **or** comment?" | `GET /search/value?value=…&includeComments=true` (`ac search-value --include-comments`) — comment hits carry `kind: "COMMENT"` | | "Where is this query / SQL / JSON text defined?" (Java) | `GET /search/value?value=…&contains=true` — since item 139 a `static final String` built from text blocks, literals, same-class constants and `+` carries its full text as `value`; a `"…".formatted(...)` constant carries its template (with `%s`) and `valueKind: template`. Anything built from a method call or another class's constant stays unresolved (no value) |
| "…and in text the parsers do not model at all, or in a module that is not deeply ingested?" | `GET /search/source?regex=…` — raw grep over the files on disk | | "Does this string appear anywhere, code **or** comment?" | `GET /search/value?value=…&includeComments=true` (`ac search-value --include-comments`) — comment hits carry `kind: "COMMENT"` |
| "…and in text the parsers do not model at all, or in a module that is not deeply ingested?" | `GET /search/source?regex=…` — raw grep over the files on disk |
``` ```
GET /pur/search/value?value=WPARTX0S&contains=true → [] (code only) GET /pur/search/value?value=WPARTX0S&contains=true → [] (code only)
@@ -175,6 +176,16 @@ or `/context` when you want an overview rather than an enumeration.
as they stand, and a `limit` that ever acquired a default would reintroduce exactly the silent as they stand, and a `limit` that ever acquired a default would reintroduce exactly the silent
truncation item 131 removed.)* truncation item 131 removed.)*
## `callers` / `callees` say when they are cut (item 181)
`callers`, `callees` and `functions/{fn}/callers` now send `X-AC-Total-Count` and `X-AC-Truncated`
like the search endpoints, and their body carries `total` and `truncated` next to `sourceFiles` /
`items` (also for `fields=name`; `ac callers` / `ac callees` print the usual "truncated" warning).
The default page is still 50: `upms/modules/DPARTFN0/callees` answers 50 of 58 with
`X-AC-Truncated: true` — before, the 8 missing callees (among them the module that writes the
partner) looked like an inconsistency with `digest`. **Read `truncated` before concluding "X does not
call Y"**; ask with `limit=1000` or narrow with `scope=external`.
## Truncation is now visible on the search endpoints (item 131) ## Truncation is now visible on the search endpoints (item 131)
`search/identifier`, `search/value`, `search/annotation`, `search/references` and `search/identifier`, `search/value`, `search/annotation`, `search/references` and
@@ -1180,7 +1191,13 @@ a dependency whose stem exists in several workspaces (`index`) is reported as a
`modules/{generated module}/callers` names the calling slices/components (module level), and the `modules/{generated module}/callers` names the calling slices/components (module level), and the
member call `api.saveBroker.post(...)` is retargeted from the generic `PostMethod.post` signature to member call `api.saveBroker.post(...)` is retargeted from the generic `PostMethod.post` signature to
the endpoint function, so the module-to-module `CALLS` edge carries `calleeMethod = the endpoint function, so the module-to-module `CALLS` edge carries `calleeMethod =
AgstammControllerEndpoint.saveBroker` and `callerFn = <thunk>`. Since item 197 AgstammControllerEndpoint.saveBroker` and `callerFn = <thunk>`. Since item 203 the synthetic
class-hierarchy edges (`resolvedVia: INHERITANCE`, caller → each implementation of the called
interface/base) exist once per originating call site with its real `lineNo`, `originFile`,
`calleeMethod` and `callerFn` — before, one edge per pair took whichever call line the merge met first.
So `callees` lists every real line for an implementation, and `functions/{impl-method}/callers` also
names callers that go through the interface (`RepoImpl.save` ← `Service.store` via `Repo.save`).
Since item 197
`functions/{fn}/callers` joins these module-to-module edges back to the calling function, so `functions/{fn}/callers` joins these module-to-module edges back to the calling function, so
`purfe/modules/pur-ui/src/generated/endpoints/functions/GeneralAgreementUiControllerEndpoint.createNew/callers` `purfe/modules/pur-ui/src/generated/endpoints/functions/GeneralAgreementUiControllerEndpoint.createNew/callers`
names the thunk in `generalAgreementSlice`, and on the Java side names the thunk in `generalAgreementSlice`, and on the Java side

View File

@@ -5505,6 +5505,53 @@ reproduced the bug.)*
75 reading tokens), `palette.primary.dark` read 26 times; no placeholder left except the undeclared 75 reading tokens), `palette.primary.dark` read 26 times; no placeholder left except the undeclared
tokens. The item-195 inherited-field fix is confirmed on the same run (its 8 placeholders are gone). tokens. The item-195 inherited-field fix is confirmed on the same run (its 8 placeholders are gone).
## Call-site-true CHA edges, visible truncation, quote-aware Natural comments, folded Java constants — items 203, 181, 152, 139 (2026-09-23)
- [x] **203. Synthetic inheritance `CALLS` edges carry an arbitrary line**
`LINK_CALLS_TO_IMPLEMENTATIONS` merged one `CALLS {resolvedVia: 'INHERITANCE'}` edge per
caller/implementation pair and set `lineNo` `ON CREATE` from whichever base call it met first, so the
line changed between runs (`SearchResultLogic` → `ResultRepository`: 95 in one graph, 97 in the next)
and `calleeMethod`/`callerFn` were missing. It now merges one edge per originating call site
(`lineNo`, `originFile`) and copies `callKind`, `calleeMethod`, `callerFn` (not `args`: dataflow stays
off synthetic edges). Consequences: `callees` lists every real line for an implementation, and
`functions/{impl-method}/callers` names callers that go through the interface. The roadmap's second
claim — "never reaped" — was wrong: `delete-synthetic-inheritance-edges` rebuilds them every finalize;
the 75 stale ones seen in `pur_alt` derived from stale parsed base calls (item 198's territory).
Cost, measured before the change: `pur` 6 197 → 12 893 synthetic edges, `app` 6 038 → 45 036 (about
+15 % of `app`'s edges); `ac` unchanged (63). Test: `InheritanceCallSitesIT`.
- [x] **181. `callees` / `callers` truncate silently at 50 rows**
`CallRefResponse` gained `total` and `truncated` (a two-argument constructor keeps every existing
construction site), `paginateCallRef` fills them, and `callers`, `callees` and
`functions/{fn}/callers` send `X-AC-Total-Count` / `X-AC-Truncated` — also for `fields=name`. The CLI's
generic truncation warning therefore fires for `ac callers`/`ac callees` without a CLI change. The
default page stays 50. Test: `CallRefTruncationIT` (a 60-callee hub: 50 + `truncated`, `limit=1000`
complete, `offset=50` the last 10, callers of a leaf complete).
- [x] **152. `NaturalLines.stripInlineComment` is not quote-aware**
`NaturalLines.commentStart` is the one quote-aware rule (`'…'` and `"…"`, the other delimiter plain
text inside, a literal open at end of line truncates nothing). `stripInlineComment`, the parser's
comment-node detection (its private `inlineCommentStart` is gone), `CopycodePreprocessor` (its private
copy now delegates) and `NaturalLineCounter` all use it. Measured on the `upms` sources: the cut moves
on 45 lines in 5 files — `USIX020P.nat` (37 lines like `'LOCAL USING USIX020A /* hashmap…' /`, which
the old rule cut into an apparent `LOCAL USING`), `W-DEB-N0.nat` (3), and 5 export-format data-area
comment continuations with an apostrophe (`on't modify* /* …`), which yield no field either way.
Tests: `NaturalLinesCommentTest`.
- [x] **139. `search/value` cannot find string content that is not part of a single-line expression**
Decided: fold constants (no new node kind). A `String` constant's value is now folded from text
blocks, literals, same-class constants, parentheses and `+`; `"…".formatted(...)` yields its template
and marks the node `valueKind: template` (an alias of a template inherits the mark). Anything not
resolvable — a method call, another class's constant — leaves the value `null`; non-`String`
constants keep the old literal-only rule. This makes `CypherQueries` (and any SQL/JSON/Cypher held
that way) findable with `search/value?contains=true`. Still out of reach: multi-line literals that are
call arguments (`"MISSING_VALUE"` in a wrapped call) — they are not constants. Test:
`JavaParserTest.stringConstantsFoldTextBlocksConcatenationsAndTemplates`.
## Project rename — item 202 (2026-09-23) ## Project rename — item 202 (2026-09-23)
- [x] **202. A project cannot be renamed** - [x] **202. A project cannot be renamed**

View File

@@ -106,40 +106,6 @@ Item **141** — the one that made the API return a *wrong* answer rather than a
## Known bugs ## Known bugs
- [ ] **203. Synthetic inheritance `CALLS` edges carry an arbitrary line and are never reaped**
(found 2026-09-23 comparing a fresh `pur` ingest against the 2026-09-11 one)
**Symptom.** `LINK_CALLS_TO_IMPLEMENTATIONS` merges one `CALLS {resolvedVia: 'INHERITANCE'}` edge
per caller/implementation pair and sets `lineNo` `ON CREATE` from whichever originating base call
the match meets first. With several call sites the line is arbitrary and changes between runs:
`SearchResultLogic` -> `ResultRepository` was line 95 in one graph and 97 in the other, both real
calls. `callers`/`callees` sites for such an edge therefore point at one call chosen at random.
The edge also carries no `ingestGen`, so the item-198 reap never removes it when the base call is
gone; only a project recreate does (`pur`: 75 stale ones next to 235 new ones in unchanged files).
**Fix.** Key the synthetic edge on the originating call (`lineNo` + `originFile`, one edge per base
call site, like the parsed edge it derives from) or aggregate all originating lines into a list;
and delete the project's (or the scoped modules') inheritance edges before re-deriving them in the
finalize, as `DELETE_CALLS_MODULE` does for `CALLS_MODULE`.
- [ ] **152. `NaturalLines.stripInlineComment` is not quote-aware — a `/*` inside a string literal
truncates the statement** (split out of item 141 on 2026-08-28, pre-existing)
Every Natural scan path strips a trailing comment with `line.indexOf("/*")`, ignoring string
literals: `MOVE 'A/*B' TO #X` is cut to `MOVE 'A`, and whatever the statement said after the
literal is invisible to the parser. Call sites: `NaturalLines.stripInlineComment` (shared by
`NaturalParser` and `NaturalCoarseScanner`) plus the private copy in `CopycodePreprocessor`.
Found while implementing item 141, which needed the opposite direction — deciding whether a `/*`
*starts* a comment node — and therefore has its own quote-aware scanner
(`NaturalParser.inlineCommentStart`). It was deliberately not folded back into the strip path in the
same change: there the bug merely truncates a line, and swapping the rule underneath every Natural
scan is a behaviour change across the whole corpus that deserves its own measurement (how many lines
actually contain a quoted `/*`, and what starts resolving once they stop being truncated).
Fix shape: move the quote-aware scan into `NaturalLines`, use it from all three call sites, delete
the `CopycodePreprocessor` copy, and report the ingest delta on `upms` before and after.
- [ ] **73. A `NONE`/`ANY` branch is reported under its enclosing guard alone — the condition is a - [ ] **73. A `NONE`/`ANY` branch is reported under its enclosing guard alone — the condition is a
negation no guard chain can express** (found 2026-07-17 while fixing item 72; **item 72 does not fix negation no guard chain can express** (found 2026-07-17 while fixing item 72; **item 72 does not fix
this**). `NaturalParser`'s `VALUE_RESET` clears a `DECIDE`'s active value on `NONE`/`ANY`, so an this**). `NaturalParser`'s `VALUE_RESET` clears a `DECIDE`'s active value on `NONE`/`ANY`, so an
@@ -232,48 +198,6 @@ wrong answer, found by the 2026-07-17 `VMULTMN4` audit.)*
the edge count of `CONTAINS` a reference count rather than a containment count, which is not what the the edge count of `CONTAINS` a reference count rather than a containment count, which is not what the
schema doc claims it is.)* schema doc claims it is.)*
- [ ] **139. `search/value` cannot find string content that is not part of a single-line expression —
Java text blocks are entirely invisible** (found 2026-08-23 while measuring item 138; scoped
2026-08-27). Looking for the Cypher source of a query, `GET /api/projects/ac/search/value?value=MATCH
(n:AstNode&contains=true` returns `[]`, although that text occurs **19 times** in
`ac-neo4j-store/.../CypherQueries.java`. `ownerModule` likewise returns `[]`. The fallback was grep.
**What the index actually holds.** Every hit comes back as `kind: "NODE"` with `value` set to the
*source text of a single-line expression* (`properties.store(out, "AgenticCode CLI configuration")`).
String literals are therefore findable only incidentally, when they happen to sit inside such an
expression on one line. Two classes are missing:
- **Text blocks** (`"""…"""` assigned to a `static final String`) — the entire `CypherQueries` class,
and by extension any embedded SQL, Cypher, JSON or HTML held the same way.
- **Multi-line expressions** — `"MISSING_VALUE"` at `AnalysisResource.java:838` is a plain call
argument and still returns `[]`, because the call is wrapped across lines.
**Why it matters here.** AgenticCode's own purpose is making unfamiliar code searchable; embedded query
text is exactly what an agent asks about ("where is this table read?", "which query builds this
projection?"), and today that question can only be answered with grep. Not yet decided: whether to index
literal *content* as its own value kind (`kind: "LITERAL"`) or to widen expression capture to multiple
lines. The first is the more useful shape but adds nodes; the second is cheaper and fixes only half.
Neither the node cost nor the Natural side (does the same gap exist for long `MOVE`/`COMPRESS` text?)
has been measured.
- [ ] **181. `callees` / `callers` truncate silently at 50 rows — no header, no body flag** (found
2026-09-17, `PartnerCopy` Java↔Natural verification)
```
GET /upms/modules/DPARTFN0/callees → 50 items (17 MODULE), no X-AC-Truncated header
GET /upms/modules/DPARTFN0/callees?limit=1000 → 58 items (25 MODULE)
GET /upms/modules/DPARTFN0/callees?scope=external → complete
```
The unscoped default drops `YPARTBN0`, `YPARTGNH`, `YPARTMN0`, `YPARTMNH`, `YPHONBNH`, `YPHONMNH`,
`ZINCLGET`, `ZINERR01` — among them the module that actually writes the partner. `digest` lists them,
and `callers` of `YPHONMNH` does include `DPARTFN0`, so the analysing agent reported it as an
*inconsistency* between endpoints; it is the 50-row page, and nothing in the response says so.
Item 131 added `X-AC-Total-Count` / `X-AC-Truncated` to the search endpoints (item 135 to two more);
`callees`/`callers` return an object with `sourceFiles`/`items` and carry neither the headers nor a
`truncated` field. A wrong answer, not a missing one: the caller cannot tell a complete fan-out from a
cut one. Either send the headers (and a `truncated` field, as `call-tree` does since item 67) or
return all rows when `limit` is absent, as `db-accesses` does.
## Agent API gaps ## Agent API gaps
- [ ] **108. `dispatch-table` only understands the `DECIDE` dispatcher, not the dispatch-*table* idiom — - [ ] **108. `dispatch-table` only understands the `DECIDE` dispatcher, not the dispatch-*table* idiom —