From 234ea7691195dff756f91bdc978e3abfa3fa5bbd Mon Sep 17 00:00:00 2001 From: Ingo Schnabel Date: Wed, 23 Sep 2026 15:31:45 +0200 Subject: [PATCH] Roadmap --- .../src/main/resources/agenticcode.properties | 2 +- .../codeserver/api/AnalysisResource.java | 19 +- .../src/main/resources/application.properties | 2 +- .../codeserver/api/CallRefTruncationIT.java | 83 ++ .../api/InheritanceCallSitesIT.java | 86 ++ .../agenticcode/codeserver/api/StylesIT.java | 8 +- .../neo4jstore/graph/CallRefResponse.java | 13 +- .../neo4jstore/graph/CypherQueries.java | 15 +- .../neo4jstore/graph/GraphRepository.java | 4 +- .../agenticcode/parserjava/JavaParser.java | 808 ++++++++++-------- .../parserjava/JavaParserTest.java | 31 + .../parsernatural/CopycodePreprocessor.java | 3 +- .../parsernatural/NaturalLineCounter.java | 2 +- .../parsernatural/NaturalLines.java | 30 +- .../parsernatural/NaturalParser.java | 22 +- .../NaturalLinesCommentTest.java | 49 ++ .../TypeScriptParserTest.java | 5 +- .../typescript/facts-pur-r-vstamm.json | 12 +- x-docs/agent-api-usage-ac-implementation.md | 29 +- x-docs/features.md | 47 + x-docs/roadmap.md | 76 -- 21 files changed, 841 insertions(+), 505 deletions(-) create mode 100644 ac-code-server/src/test/java/com/agenticcode/codeserver/api/CallRefTruncationIT.java create mode 100644 ac-code-server/src/test/java/com/agenticcode/codeserver/api/InheritanceCallSitesIT.java create mode 100644 ac-parser-natural/src/test/java/com/agenticcode/parsernatural/NaturalLinesCommentTest.java diff --git a/ac-cli/src/main/resources/agenticcode.properties b/ac-cli/src/main/resources/agenticcode.properties index 4edee73..7cc12f6 100644 --- a/ac-cli/src/main/resources/agenticcode.properties +++ b/ac-cli/src/main/resources/agenticcode.properties @@ -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 diff --git a/ac-code-server/src/main/java/com/agenticcode/codeserver/api/AnalysisResource.java b/ac-code-server/src/main/java/com/agenticcode/codeserver/api/AnalysisResource.java index be79498..a3642d9 100644 --- a/ac-code-server/src/main/java/com/agenticcode/codeserver/api/AnalysisResource.java +++ b/ac-code-server/src/main/java/com/agenticcode/codeserver/api/AnalysisResource.java @@ -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 diff --git a/ac-code-server/src/main/resources/application.properties b/ac-code-server/src/main/resources/application.properties index 7c6e090..8bfefa5 100644 --- a/ac-code-server/src/main/resources/application.properties +++ b/ac-code-server/src/main/resources/application.properties @@ -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 diff --git a/ac-code-server/src/test/java/com/agenticcode/codeserver/api/CallRefTruncationIT.java b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/CallRefTruncationIT.java new file mode 100644 index 0000000..16d7f12 --- /dev/null +++ b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/CallRefTruncationIT.java @@ -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")); + } +} diff --git a/ac-code-server/src/test/java/com/agenticcode/codeserver/api/InheritanceCallSitesIT.java b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/InheritanceCallSitesIT.java new file mode 100644 index 0000000..adedf2a --- /dev/null +++ b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/InheritanceCallSitesIT.java @@ -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)); + } +} diff --git a/ac-code-server/src/test/java/com/agenticcode/codeserver/api/StylesIT.java b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/StylesIT.java index 05042c8..95d4b74 100644 --- a/ac-code-server/src/test/java/com/agenticcode/codeserver/api/StylesIT.java +++ b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/StylesIT.java @@ -82,7 +82,7 @@ class StylesIT { export function Page() { const theme = useTheme() return ( - + x @@ -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")); diff --git a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/CallRefResponse.java b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/CallRefResponse.java index ee668dc..20c94fb 100644 --- a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/CallRefResponse.java +++ b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/CallRefResponse.java @@ -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 sourceFiles, List items) { +public record CallRefResponse(List sourceFiles, List items, int total, boolean truncated) { + + /** + * An unpaged response: everything is in {@code items}. + */ + public CallRefResponse(List sourceFiles, List items) { + this(sourceFiles, items, items.size(), false); + } } diff --git a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/CypherQueries.java b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/CypherQueries.java index 5ba4bfa..35563cd 100644 --- a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/CypherQueries.java +++ b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/CypherQueries.java @@ -853,8 +853,9 @@ public final class CypherQueries { * through an {@code IMPLEMENTS}/{@code EXTENDS} chain (Class-Hierarchy-Analysis over-approximation). * *

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 """; /** diff --git a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/GraphRepository.java b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/GraphRepository.java index a4dd922..fb5f7bb 100644 --- a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/GraphRepository.java +++ b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/GraphRepository.java @@ -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); } /** diff --git a/ac-parser-java/src/main/java/com/agenticcode/parserjava/JavaParser.java b/ac-parser-java/src/main/java/com/agenticcode/parserjava/JavaParser.java index 6bb62c8..e71ab21 100644 --- a/ac-parser-java/src/main/java/com/agenticcode/parserjava/JavaParser.java +++ b/ac-parser-java/src/main/java/com/agenticcode/parserjava/JavaParser.java @@ -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 collectConstants(TypeDeclaration type) { + private static Map collectConstants(TypeDeclaration type, Set templates) { String className = type.getNameAsString(); Map initializers = new LinkedHashMap<>(); + Set 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 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 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 initializers, - Map resolved, Set visiting) { - if (resolved.containsKey(name)) { - return resolved.get(name); + private static String foldString(String name, Expression expr, ConstantScope scope, Set 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 nodes = new ArrayList<>(); + List edges = new ArrayList<>(); + + com.github.javaparser.ParseResult 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 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 implements PanacheRepositoryBase). + // 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 templateConstants = new HashSet<>(); + Map 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 referencedModules = new HashMap<>(); + + for (ClassOrInterfaceType extended : facts.extended()) { + AstNode superType = referencedModule(referencedModules, nodes, types.resolve(supertypeName(extended, className))); + Map 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 fieldsByName = new HashMap<>(); + Map 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 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 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.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.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 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 methods = new HashMap<>(); + List> callables = new ArrayList<>(); + List callableNodes = new ArrayList<>(); + for (MethodDeclaration method : type.getMethods()) { + @Nullable String methodAnnotations = annotationNames(method); + Map 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 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 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 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 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 nodes = new ArrayList<>(); - List edges = new ArrayList<>(); - - com.github.javaparser.ParseResult 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 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 implements PanacheRepositoryBase). - // 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 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 referencedModules = new HashMap<>(); - - for (ClassOrInterfaceType extended : facts.extended()) { - AstNode superType = referencedModule(referencedModules, nodes, types.resolve(supertypeName(extended, className))); - Map 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 fieldsByName = new HashMap<>(); - Map 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 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.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.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 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 methods = new HashMap<>(); - List> callables = new ArrayList<>(); - List callableNodes = new ArrayList<>(); - for (MethodDeclaration method : type.getMethods()) { - @Nullable String methodAnnotations = annotationNames(method); - Map 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 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 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 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 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 initializers, Set stringTyped, + Map resolved, Set templates) { } /** diff --git a/ac-parser-java/src/test/java/com/agenticcode/parserjava/JavaParserTest.java b/ac-parser-java/src/test/java/com/agenticcode/parserjava/JavaParserTest.java index 4ab2c85..186a3ce 100644 --- a/ac-parser-java/src/test/java/com/agenticcode/parserjava/JavaParserTest.java +++ b/ac-parser-java/src/test/java/com/agenticcode/parserjava/JavaParserTest.java @@ -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"); + } } diff --git a/ac-parser-natural/src/main/java/com/agenticcode/parsernatural/CopycodePreprocessor.java b/ac-parser-natural/src/main/java/com/agenticcode/parsernatural/CopycodePreprocessor.java index aea69c8..2720657 100644 --- a/ac-parser-natural/src/main/java/com/agenticcode/parsernatural/CopycodePreprocessor.java +++ b/ac-parser-natural/src/main/java/com/agenticcode/parsernatural/CopycodePreprocessor.java @@ -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 } } diff --git a/ac-parser-natural/src/main/java/com/agenticcode/parsernatural/NaturalLineCounter.java b/ac-parser-natural/src/main/java/com/agenticcode/parsernatural/NaturalLineCounter.java index a924b7c..0793918 100644 --- a/ac-parser-natural/src/main/java/com/agenticcode/parsernatural/NaturalLineCounter.java +++ b/ac-parser-natural/src/main/java/com/agenticcode/parsernatural/NaturalLineCounter.java @@ -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(); } diff --git a/ac-parser-natural/src/main/java/com/agenticcode/parsernatural/NaturalLines.java b/ac-parser-natural/src/main/java/com/agenticcode/parsernatural/NaturalLines.java index 8df4421..af1c9a1 100644 --- a/ac-parser-natural/src/main/java/com/agenticcode/parsernatural/NaturalLines.java +++ b/ac-parser-natural/src/main/java/com/agenticcode/parsernatural/NaturalLines.java @@ -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. diff --git a/ac-parser-natural/src/main/java/com/agenticcode/parsernatural/NaturalParser.java b/ac-parser-natural/src/main/java/com/agenticcode/parsernatural/NaturalParser.java index 31a0c60..bfba526 100644 --- a/ac-parser-natural/src/main/java/com/agenticcode/parsernatural/NaturalParser.java +++ b/ac-parser-natural/src/main/java/com/agenticcode/parsernatural/NaturalParser.java @@ -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. - * - *

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 nodes = new ArrayList<>(); List edges = new ArrayList<>(); diff --git a/ac-parser-natural/src/test/java/com/agenticcode/parsernatural/NaturalLinesCommentTest.java b/ac-parser-natural/src/test/java/com/agenticcode/parsernatural/NaturalLinesCommentTest.java new file mode 100644 index 0000000..90303ac --- /dev/null +++ b/ac-parser-natural/src/test/java/com/agenticcode/parsernatural/NaturalLinesCommentTest.java @@ -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"); + } +} diff --git a/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptParserTest.java b/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptParserTest.java index 336664b..bcc5a67 100644 --- a/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptParserTest.java +++ b/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptParserTest.java @@ -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 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"); diff --git a/ac-parser-typescript/src/test/resources/fixtures/typescript/facts-pur-r-vstamm.json b/ac-parser-typescript/src/test/resources/fixtures/typescript/facts-pur-r-vstamm.json index 8ded847..6e0edd1 100644 --- a/ac-parser-typescript/src/test/resources/fixtures/typescript/facts-pur-r-vstamm.json +++ b/ac-parser-typescript/src/test/resources/fixtures/typescript/facts-pur-r-vstamm.json @@ -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": [] diff --git a/x-docs/agent-api-usage-ac-implementation.md b/x-docs/agent-api-usage-ac-implementation.md index 66bdf04..b435ec2 100644 --- a/x-docs/agent-api-usage-ac-implementation.md +++ b/x-docs/agent-api-usage-ac-implementation.md @@ -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 `) — 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 `) — 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 = `. Since item 197 +AgstammControllerEndpoint.saveBroker` and `callerFn = `. 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 diff --git a/x-docs/features.md b/x-docs/features.md index 68d9d7b..bfae868 100644 --- a/x-docs/features.md +++ b/x-docs/features.md @@ -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** diff --git a/x-docs/roadmap.md b/x-docs/roadmap.md index eefca39..72e35c1 100644 --- a/x-docs/roadmap.md +++ b/x-docs/roadmap.md @@ -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 —