Roadmap
This commit is contained in:
@@ -4,4 +4,4 @@
|
||||
server.url=http://localhost:8787
|
||||
# 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.
|
||||
version=333
|
||||
version=334
|
||||
|
||||
@@ -732,7 +732,7 @@ public class AnalysisResource {
|
||||
@PathParam("function") String function,
|
||||
@QueryParam("sourceFile") @Nullable String 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
|
||||
@@ -759,9 +759,18 @@ public class AnalysisResource {
|
||||
@QueryParam("sourceFile") @Nullable String sourceFile) {
|
||||
return withIngestedModule(project, name, sourceFile, resolvedName -> graphRepository.callees(project, resolvedName, scope, effectiveLimit(limit), effectiveOffset(offset),
|
||||
resolveInterfaces != null && resolveInterfaces)
|
||||
.map(resp -> namesOnly(fields)
|
||||
? ok(callRefNames(resp))
|
||||
: Response.ok(resp).build()));
|
||||
.map(resp -> callRefResponse(resp, fields)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
() -> graphRepository.callers(project, resolvedName, scope, effectiveLimit(limit), effectiveOffset(offset)),
|
||||
CallRefResponse::sourceFiles,
|
||||
resp -> namesOnly(fields) ? ok(callRefNames(resp)) : Response.ok(resp).build()));
|
||||
resp -> callRefResponse(resp, fields)));
|
||||
}
|
||||
|
||||
@GET
|
||||
|
||||
@@ -3,7 +3,7 @@ quarkus.http.port=8787
|
||||
# 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
|
||||
# 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
|
||||
# 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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,7 @@ class StylesIT {
|
||||
export function Page() {
|
||||
const theme = useTheme()
|
||||
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 sx={{ width: '100%' }} />
|
||||
</Panel>
|
||||
@@ -165,8 +165,10 @@ class StylesIT {
|
||||
.body("[0].function", equalTo("Page"))
|
||||
.body("[0].context", equalTo("borderColor"))
|
||||
.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)
|
||||
.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"));
|
||||
}
|
||||
|
||||
@@ -182,7 +184,7 @@ class StylesIT {
|
||||
.body("find { it.styleKind == 'styled' }.tokens", containsInAnyOrder("spacing", "palette.primary.dark"))
|
||||
.body("find { it.styleKind == 'style' }.literals", equalTo("17px"))
|
||||
.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' }.literals", equalTo("#333333"))
|
||||
.body("find { it.name == '@font-face@5' }.properties", equalTo("font-family,src"));
|
||||
|
||||
@@ -6,6 +6,17 @@ import java.util.List;
|
||||
* Wrapper returned by the {@code /callers} and {@code /callees} endpoints: a deduplicated
|
||||
* {@code sourceFiles} index plus aggregated call references that use integer indices into
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -853,8 +853,9 @@ public final class CypherQueries {
|
||||
* through an {@code IMPLEMENTS}/{@code EXTENDS} chain (Class-Hierarchy-Analysis over-approximation).
|
||||
*
|
||||
* <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
|
||||
* {@code lineNo} is copied from the originating interface/base call. Runs after placeholder
|
||||
* idempotently and stay disjoint from directly-parsed {@code CALLS} edges; since item 203 there is
|
||||
* 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
|
||||
* 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) )
|
||||
MATCH (impl:AstNode {type: 'MODULE', project: $project})-[:IMPLEMENTS|EXTENDS*1..]->(base)
|
||||
WHERE impl <> caller AND impl.sourceFile <> ""
|
||||
MERGE (caller)-[s:CALLS {resolvedVia: 'INHERITANCE'}]->(impl)
|
||||
ON CREATE SET s.lineNo = r.lineNo, s.callKind = r.callKind
|
||||
// Item 203: one synthetic edge per originating call site, keyed like the parsed edge it
|
||||
// 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
|
||||
""";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1377,7 +1377,9 @@ public class GraphRepository {
|
||||
.toList();
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 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();
|
||||
Map<String, Expression> initializers = new LinkedHashMap<>();
|
||||
Set<String> stringTyped = new HashSet<>();
|
||||
for (FieldDeclaration field : type.getFields()) {
|
||||
if (!(field.isStatic() && field.isFinal())) {
|
||||
continue;
|
||||
}
|
||||
for (VariableDeclarator variable : field.getVariables()) {
|
||||
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()) {
|
||||
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)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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
|
||||
private static String resolveConstant(String name, String className, Map<String, Expression> initializers,
|
||||
Map<String, String> resolved, Set<String> visiting) {
|
||||
if (resolved.containsKey(name)) {
|
||||
return resolved.get(name);
|
||||
private static String foldString(String name, Expression expr, ConstantScope scope, Set<String> visiting) {
|
||||
if (expr instanceof EnclosedExpr enclosed) {
|
||||
return foldString(name, enclosed.getInner(), scope, visiting);
|
||||
}
|
||||
Expression init = initializers.get(name);
|
||||
if (init == null || !visiting.add(name)) {
|
||||
return null; // unknown, or cycle
|
||||
if (expr instanceof TextBlockLiteralExpr text) {
|
||||
return text.asString();
|
||||
}
|
||||
String referenced = referencedConstantName(init, className);
|
||||
String value;
|
||||
String referenced = referencedConstantName(expr, scope.className());
|
||||
if (referenced != null) {
|
||||
value = resolveConstant(referenced, className, initializers, resolved, visiting);
|
||||
} else {
|
||||
value = literalValue(init);
|
||||
@Nullable String value = resolveConstant(referenced, scope, visiting);
|
||||
if (value != null && scope.templates().contains(referenced)) {
|
||||
scope.templates().add(name);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
visiting.remove(name);
|
||||
if (value != null) {
|
||||
resolved.put(name, value);
|
||||
if (expr instanceof BinaryExpr binary && binary.getOperator() == BinaryExpr.Operator.PLUS) {
|
||||
@Nullable String left = foldString(name, binary.getLeft(), scope, visiting);
|
||||
@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)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@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.
|
||||
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);
|
||||
/**
|
||||
* The per-class state of constant resolution. {@code templates} collects the names whose value is
|
||||
* a {@code .formatted(...)} template rather than the final string (item 139).
|
||||
*/
|
||||
private record ConstantScope(String className, Map<String, Expression> initializers, Set<String> stringTyped,
|
||||
Map<String, String> resolved, Set<String> templates) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -496,4 +496,35 @@ class JavaParserTest {
|
||||
assertEquals("ServiceEndpoint: partner.update.partnercs.Update\n\nUpmsObject: PartnerCs",
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +286,6 @@ public final class CopycodePreprocessor {
|
||||
}
|
||||
|
||||
private static String stripInlineComment(String s) {
|
||||
int comment = s.indexOf("/*");
|
||||
return comment >= 0 ? s.substring(0, comment) : s;
|
||||
return NaturalLines.stripInlineComment(s); // item 152: the shared, quote-aware rule
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ public final class NaturalLineCounter implements LineCounter {
|
||||
if (trimmed.charAt(0) == '*') {
|
||||
return false;
|
||||
}
|
||||
int inline = line.indexOf("/*");
|
||||
int inline = NaturalLines.commentStart(line); // item 152: quote-aware
|
||||
String code = inline >= 0 ? line.substring(0, inline) : line;
|
||||
return !code.isBlank();
|
||||
}
|
||||
|
||||
@@ -47,14 +47,38 @@ final class NaturalLines {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {@code s} truncated at the first {@code /*}, which starts a Natural comment running to
|
||||
* end of line.
|
||||
* @return {@code s} truncated at the {@code /*} that starts a Natural comment running to end of
|
||||
* line, or {@code s} unchanged if it has none (see {@link #commentStart}).
|
||||
*/
|
||||
static String stripInlineComment(String s) {
|
||||
int comment = s.indexOf("/*");
|
||||
int comment = commentStart(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,
|
||||
* leaving the matcher positioned on it so {@code group()}/{@code end()} apply to that match.
|
||||
|
||||
@@ -1121,7 +1121,7 @@ public final class NaturalParser implements LanguageParser {
|
||||
blockStart = -1;
|
||||
blockKind = null;
|
||||
}
|
||||
int inline = inlineCommentStart(line);
|
||||
int inline = NaturalLines.commentStart(line);
|
||||
if (inline >= 0) {
|
||||
emitComment(sourceFile, i + 1, i + 1, line.substring(inline + 2),
|
||||
CommentProperties.KIND_NATURAL_INLINE, targets, nodes, edges);
|
||||
@@ -1177,26 +1177,6 @@ public final class NaturalParser implements LanguageParser {
|
||||
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) {
|
||||
List<AstNode> nodes = new ArrayList<>();
|
||||
List<AstEdge> edges = new ArrayList<>();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -318,8 +318,9 @@ class TypeScriptParserTest {
|
||||
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())));
|
||||
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
|
||||
assertEquals(List.of("theme.PRIMARY[color,borderColor]", "theme.palette.background.paper[&:hover.background]"),
|
||||
// the fixture puts color and borderColor on separate lines (IDE reformat), so two edges; the
|
||||
// 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());
|
||||
AstNode ph = byId(r, refs.get(0).targetId());
|
||||
assertEquals("", ph.sourceFile(), "the theme lives in another file: a placeholder");
|
||||
|
||||
@@ -1240,7 +1240,7 @@
|
||||
"resolved": null,
|
||||
"package": null,
|
||||
"line": 1,
|
||||
"names": "{ createTheme }"
|
||||
"names": "{createTheme}"
|
||||
}
|
||||
],
|
||||
"declarations": [
|
||||
@@ -1431,7 +1431,7 @@
|
||||
"kind": "component",
|
||||
"exported": true,
|
||||
"startLine": 5,
|
||||
"endLine": 13,
|
||||
"endLine": 19,
|
||||
"members": []
|
||||
}
|
||||
],
|
||||
@@ -1516,17 +1516,17 @@
|
||||
{
|
||||
"token": "PRIMARY",
|
||||
"property": "color",
|
||||
"line": 9
|
||||
"line": 10
|
||||
},
|
||||
{
|
||||
"token": "PRIMARY",
|
||||
"property": "borderColor",
|
||||
"line": 9
|
||||
"line": 11
|
||||
},
|
||||
{
|
||||
"token": "palette.background.paper",
|
||||
"property": "&:hover.background",
|
||||
"line": 9
|
||||
"line": 13
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1536,7 +1536,7 @@
|
||||
"fromDecl": "HistorieDrawer",
|
||||
"token": "palette.primary.main",
|
||||
"context": "borderColor",
|
||||
"line": 9
|
||||
"line": 15
|
||||
}
|
||||
],
|
||||
"themeTokens": []
|
||||
|
||||
@@ -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":
|
||||
|
||||
| Question | Call |
|
||||
|---|---|
|
||||
| "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"` |
|
||||
| "…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 |
|
||||
| Question | Call |
|
||||
|---------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| "What does this module's header/change log say?" | `GET /modules/{name}/comments` (`ac comments <module>`) — blocks with the declaration each documents |
|
||||
| "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) |
|
||||
| "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)
|
||||
@@ -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
|
||||
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)
|
||||
|
||||
`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
|
||||
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 =
|
||||
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
|
||||
`purfe/modules/pur-ui/src/generated/endpoints/functions/GeneralAgreementUiControllerEndpoint.createNew/callers`
|
||||
names the thunk in `generalAgreementSlice`, and on the Java side
|
||||
|
||||
@@ -5505,6 +5505,53 @@ reproduced the bug.)*
|
||||
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).
|
||||
|
||||
## 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)
|
||||
|
||||
- [x] **202. A project cannot be renamed**
|
||||
|
||||
@@ -106,40 +106,6 @@ Item **141** — the one that made the API return a *wrong* answer rather than a
|
||||
|
||||
## 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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
|
||||
- [ ] **108. `dispatch-table` only understands the `DECIDE` dispatcher, not the dispatch-*table* idiom —
|
||||
|
||||
Reference in New Issue
Block a user