New features

This commit is contained in:
Ingo Schnabel
2026-08-19 10:55:03 +03:00
parent 29269b83c3
commit 56e20aee3b
15 changed files with 635 additions and 53 deletions

View File

@@ -43,6 +43,7 @@ import java.util.concurrent.Callable;
DbTableColumnsCommand.class,
EntityColumnsCommand.class,
SearchIdentifierCommand.class,
SearchReferencesCommand.class,
ModulesCommand.class,
LocCommand.class,
ModuleDataStructuresCommand.class,

View File

@@ -0,0 +1,43 @@
package com.agenticcode.cli;
import org.jspecify.annotations.Nullable;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
/**
* Item 128: every place a type is mentioned — imports, declared type positions, annotation usages,
* calls, inheritance and wiring — not just its callers. This is what scopes a rename honestly:
* {@code callers} sees calls alone, so a file that only imports or declares the type was invisible.
*/
@Command(name = "references", mixinStandardHelpOptions = true,
description = "Find every reference site of a type (imports, type positions, annotations, calls, inheritance)")
final class SearchReferencesCommand extends AbstractProjectCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Type identity (FQN) or short name")
String name;
@Option(names = "--kind",
description = "Narrow to one kind: CALL, IMPORT, TYPE, ANNOTATION, EXTENDS, IMPLEMENTS, INJECTS, CLASS_LITERAL, INCLUDE")
@Nullable String kind;
@Option(names = "--limit", description = "Max items to return")
int limit = -1;
@Option(names = "--offset", description = "Items to skip")
int offset = -1;
@Override
public Integer call() throws Exception {
try {
String path = appendQuery(projectPath() + "/search/references", "name", name);
path = appendQuery(path, "kind", kind);
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
return printResponse(apiClient().get(path));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -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=226
version=230

View File

@@ -162,6 +162,14 @@ public class AnalysisResource {
IngestSummary run(ProjectInfo project) throws IOException;
}
/**
* Item 128: the reference kinds {@code /search/references} can report. Rejecting anything else with
* {@code 400} rather than answering {@code []} — an empty list for a misspelt kind reads as "this
* name is referenced nowhere", which is the failure this endpoint exists to remove.
*/
private static final Set<String> REFERENCE_KINDS = Set.of("CALL", "IMPORT", "TYPE", "ANNOTATION",
"EXTENDS", "IMPLEMENTS", "INJECTS", "CLASS_LITERAL", "INCLUDE");
/**
* Default page size for paginated list endpoints, per the documented API design principles.
*/
@@ -726,6 +734,34 @@ public class AnalysisResource {
});
}
@GET
@Path("/search/references")
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = ReferenceSite.class)))
@APIResponse(responseCode = "400", description = "Missing 'name', or unknown 'kind'.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> searchReferences(@PathParam("project") String project,
@Parameter(description = "Type identity or short name to find references to.")
@QueryParam("name") @Nullable String name,
@Parameter(description = "Narrow to one kind: CALL, IMPORT, TYPE, ANNOTATION, EXTENDS, IMPLEMENTS, INJECTS, CLASS_LITERAL, INCLUDE.")
@QueryParam("kind") @Nullable String kind,
@QueryParam("limit") @Nullable Integer limit,
@QueryParam("offset") @Nullable Integer offset) {
if (name == null || name.isBlank()) {
return Uni.createFrom().item(ProjectResource.error(Response.Status.BAD_REQUEST, "MISSING_NAME",
"Query parameter 'name' is required"));
}
@Nullable String upperKind = kind == null ? null : kind.toUpperCase(Locale.ROOT);
if (upperKind != null && !REFERENCE_KINDS.contains(upperKind)) {
return Uni.createFrom().item(ProjectResource.error(Response.Status.BAD_REQUEST, "INVALID_KIND",
"Unknown reference kind '" + kind + "'; expected one of " + REFERENCE_KINDS));
}
return withFanoutWarm(project,
() -> graphRepository.searchReferences(project, name, upperKind,
effectiveLimit(limit), effectiveOffset(offset)),
sites -> sites.stream().map(ReferenceSite::sourceFile).filter(sf -> !sf.isEmpty()).distinct().toList(),
sites -> ok(sites));
}
@GET
@Path("/search/value")
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = ValueMatch.class)))

View File

@@ -1,52 +1,15 @@
package com.agenticcode.codeserver.service;
import java.util.Set;
import com.agenticcode.parsercore.ast.model.ExternalTypeNames;
/**
* Allowlist-by-exclusion of JDK/stdlib and common-framework type names (item J6). A by-name ingest
* follows every referenced type; JDK/framework types (e.g. {@code List}, {@code String},
* {@code Optional}, {@code EntityManager}) never resolve to a file in the project, so without this
* filter they dominate the {@code unresolved} list and needlessly inflate the dependency fan-out.
*
* <p>Matched by uppercased simple name (dependency refs are uppercased). This is a deliberate
* heuristic: a project class deliberately named like a JDK type would also be skipped — acceptable
* and vanishingly rare, especially for Natural modules (8-char codes).
* Item J6 filter for the by-name ingest fan-out. The name list itself lives in
* {@link ExternalTypeNames} (item 128) so the parsers apply the identical exclusion when emitting
* reference edges — two copies of this list would drift, and the drift would show up as placeholder
* nodes appearing and disappearing between ingests.
*/
final class ExternalTypes {
private static final Set<String> NAMES = Set.of(
// java.lang
"OBJECT", "STRING", "CHARSEQUENCE", "INTEGER", "LONG", "DOUBLE", "FLOAT", "BOOLEAN", "BYTE",
"SHORT", "CHARACTER", "NUMBER", "STRINGBUILDER", "STRINGBUFFER", "THREAD", "RUNNABLE",
"EXCEPTION", "RUNTIMEEXCEPTION", "ILLEGALARGUMENTEXCEPTION", "ILLEGALSTATEEXCEPTION",
"THROWABLE", "ERROR", "CLASS", "ENUM", "ITERABLE", "COMPARABLE", "CLONEABLE", "VOID", "MATH",
"SYSTEM", "AUTOCLOSEABLE",
// java.util
"LIST", "ARRAYLIST", "LINKEDLIST", "MAP", "HASHMAP", "LINKEDHASHMAP", "TREEMAP",
"CONCURRENTHASHMAP", "SORTEDMAP", "NAVIGABLEMAP", "SET", "HASHSET", "LINKEDHASHSET", "TREESET",
"SORTEDSET", "COLLECTION", "COLLECTIONS", "OPTIONAL", "OPTIONALINT", "OPTIONALLONG", "ITERATOR",
"QUEUE", "DEQUE", "ARRAYDEQUE", "STACK", "VECTOR", "COMPARATOR", "ARRAYS", "OBJECTS", "UUID",
"DATE", "CALENDAR", "LOCALE", "RANDOM", "SCANNER", "PROPERTIES", "ENUMSET", "ENUMMAP", "BITSET",
// java.util.stream / function
"STREAM", "INTSTREAM", "LONGSTREAM", "DOUBLESTREAM", "COLLECTORS", "FUNCTION", "BIFUNCTION",
"CONSUMER", "BICONSUMER", "SUPPLIER", "PREDICATE", "BIPREDICATE", "UNARYOPERATOR", "BINARYOPERATOR",
// java.time
"LOCALDATE", "LOCALDATETIME", "LOCALTIME", "INSTANT", "DURATION", "PERIOD", "ZONEDDATETIME",
"OFFSETDATETIME", "ZONEID", "DAYOFWEEK", "MONTH", "YEAR", "CHRONOUNIT",
// java.io / nio
"FILE", "PATH", "PATHS", "FILES", "INPUTSTREAM", "OUTPUTSTREAM", "READER", "WRITER",
"BUFFEREDREADER", "IOEXCEPTION", "UNCHECKEDIOEXCEPTION",
// java.math
"BIGDECIMAL", "BIGINTEGER",
// java.util.concurrent / atomic
"ATOMICINTEGER", "ATOMICLONG", "ATOMICBOOLEAN", "ATOMICREFERENCE", "COMPLETABLEFUTURE", "FUTURE",
"EXECUTOR", "EXECUTORSERVICE", "EXECUTORS", "TIMEUNIT", "COUNTDOWNLATCH",
// logging
"LOGGER", "LOGGERFACTORY", "LOG",
// common frameworks: CDI / JPA / Quarkus / JAX-RS reactive
"ENTITYMANAGER", "SESSION", "STATELESSSESSION", "INSTANCE", "EVENT", "PROVIDER", "TYPELITERAL",
"UNI", "MULTI", "RESPONSE", "PANACHEQUERY", "PANACHEENTITY", "PANACHEENTITYBASE");
private ExternalTypes() {
}
@@ -54,6 +17,6 @@ final class ExternalTypes {
* True if {@code upperName} (an uppercased simple type name) is a JDK/stdlib/framework type.
*/
static boolean isExternal(String upperName) {
return NAMES.contains(upperName);
return ExternalTypeNames.isExternal(upperName);
}
}

View File

@@ -3,7 +3,7 @@ quarkus.http.port=8787
# AgenticCode's own release counter (not the Maven project version) — bump this by hand for each
# 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=226
agenticcode.version=230
# 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

View File

@@ -0,0 +1,172 @@
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 128: every reference site of a type, not just its callers.
*
* <p>The fixture is built so each reference kind occurs in exactly one place: {@code Consumer}
* imports {@code Target}, declares a field of it, takes it as a parameter, returns it, is annotated
* with {@code Marker}, and calls a method on it. {@code Sub} extends it. A rename of {@code Target}
* must find all of those — {@code callers} finds only the call.
*/
@QuarkusTest
class SearchReferencesIT {
private static final String PROJECT = "search-references-project";
@TempDir
static Path root;
@BeforeAll
static void ingestFixtures() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
Path pkg = root.resolve("src/main/java/com/example/refs");
write(pkg, "Target.java", """
package com.example.refs;
public class Target {
public String describe() {
return "target";
}
}
""");
write(pkg, "Marker.java", """
package com.example.refs;
public @interface Marker {
}
""");
write(root.resolve("src/main/java/com/example/other"), "Consumer.java", """
package com.example.other;
import com.example.refs.Marker;
import com.example.refs.Target;
@Marker
public class Consumer {
private Target field;
public Target handle(Target incoming) {
return incoming;
}
public String use() {
return field.describe();
}
}
""");
write(root.resolve("src/main/java/com/example/other"), "Sub.java", """
package com.example.other;
import com.example.refs.Target;
public class Sub extends Target {
}
""");
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);
}
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);
}
}
private static io.restassured.response.Response references(String name, @org.jspecify.annotations.Nullable String kindOrNull) {
var request = given().queryParam("name", name).queryParam("limit", 200);
if (kindOrNull != null) {
request = request.queryParam("kind", kindOrNull);
}
return request.when().get("/api/projects/" + PROJECT + "/search/references");
}
@Test
void theImportOfATypeIsAReferenceSite() {
references("Target", "IMPORT").then()
.statusCode(200)
.body("sourceFile", hasItems(containsString("Consumer.java"), containsString("Sub.java")));
}
@Test
void aDeclaredFieldParameterAndReturnTypeAreReferenceSites() {
references("Target", "TYPE").then()
.statusCode(200)
.body("sourceFile", everyItem(containsString("Consumer.java")))
.body("size()", greaterThanOrEqualTo(2));
}
@Test
void anAnnotationUsageIsAReferenceSite() {
references("Marker", "ANNOTATION").then()
.statusCode(200)
.body("sourceFile", hasItem(containsString("Consumer.java")));
}
@Test
void inheritanceIsAReferenceSite() {
references("Target", "EXTENDS").then()
.statusCode(200)
.body("sourceFile", hasItem(containsString("Sub.java")));
}
@Test
void everyKindComesBackTogetherWhenNoKindIsGiven() {
references("Target", null).then()
.statusCode(200)
.body("kind", hasItems("IMPORT", "TYPE", "EXTENDS"))
.body("target", everyItem(equalTo("com.example.refs.Target")));
}
@Test
void theFullyQualifiedNameFindsTheSameSites() {
int viaShortName = references("Target", null).jsonPath().getList("$").size();
references("com.example.refs.Target", null).then()
.statusCode(200)
.body("size()", equalTo(viaShortName));
}
@Test
void eachSiteCarriesAUsableFileAndLine() {
references("Target", "IMPORT").then()
.statusCode(200)
.body("lineNo", everyItem(greaterThan(0)))
.body("inModule", everyItem(notNullValue()));
}
@Test
void anUnknownKindIsRejectedRatherThanAnsweredEmpty() {
references("Target", "NONSENSE").then()
.statusCode(400)
.body("code", equalTo("INVALID_KIND"));
}
@Test
void aMissingNameIsRejected() {
given().when().get("/api/projects/" + PROJECT + "/search/references")
.then()
.statusCode(400)
.body("code", equalTo("MISSING_NAME"));
}
}

View File

@@ -1444,7 +1444,9 @@ public final class CypherQueries {
*/
public static final List<EdgeType> RESOLVABLE_EDGE_TYPES =
List.of(EdgeType.CALLS, EdgeType.INCLUDES, EdgeType.USES_TYPE, EdgeType.EXTENDS, EdgeType.IMPLEMENTS,
EdgeType.INJECTS, EdgeType.REFERENCES);
// Item 128: a mention's target is a placeholder until enrichment resolves it to the
// real module, exactly like a call's — otherwise every import would dangle.
EdgeType.INJECTS, EdgeType.REFERENCES, EdgeType.MENTIONS);
/**
* Scoped variant of {@link #LINK_ARGS_TO_PARAMS_JAVA}: callers in {@code $names} only.
@@ -2396,6 +2398,48 @@ public final class CypherQueries {
// $module is a hard filter (return only that module's nodes); $priorityModule instead only
// pins that module's matches to the front so they survive a caller's limit/paginate truncation
// when a name recurs across many modules. ORDER BY makes the page deterministic (it was not before).
/**
* Item 128: <b>every reference site of a name</b> — not just its callers. Unions the edge kinds
* that mean "this file mentions that type": {@code CALLS} (call sites), {@code EXTENDS}/
* {@code IMPLEMENTS} (inheritance), {@code INJECTS} (CDI wiring), {@code INCLUDES} (Natural
* copycode) and {@code REFERENCES}, whose {@code refKind} distinguishes an {@code IMPORT}, a
* declared {@code TYPE} position, an {@code ANNOTATION} usage and the pre-existing class-literal
* edges (no {@code refKind}, reported as {@code CLASS_LITERAL}).
*
* <p>This is what makes "scope a rename" answerable. {@code callers} only sees calls, so a file
* that imports a class, declares a field of it or names it in an annotation was invisible — and
* the rename that missed it looked complete.
*
* <p>The target is matched by identity <em>or</em> short name (item 117/125), so both forms work.
* {@code $kind} narrows to one reference kind; {@code $scanCap} bounds the row set exactly as in
* {@link #SEARCH_IDENTIFIER}.
*/
public static final String SEARCH_REFERENCES = """
MATCH (t:AstNode {project: $project})
WHERE (t.name = $name OR t.simpleName = $name) AND t.type IN ['MODULE', 'DATA_STRUCTURE']
MATCH (s:AstNode {project: $project})-[r]->(t)
WHERE type(r) IN ['CALLS', 'EXTENDS', 'IMPLEMENTS', 'INJECTS', 'REFERENCES', 'INCLUDES', 'MENTIONS']
AND s.sourceFile <> ''
WITH s, r, t, CASE type(r)
WHEN 'CALLS' THEN 'CALL'
WHEN 'INCLUDES' THEN 'INCLUDE'
WHEN 'REFERENCES' THEN coalesce(r.refKind, 'CLASS_LITERAL')
WHEN 'MENTIONS' THEN coalesce(r.refKind, 'TYPE')
ELSE type(r)
END AS kind
WHERE $kind IS NULL OR kind = $kind
// One hop only: a reference is anchored either at the module itself or at a function
// inside it. A variable-length CONTAINS walk here would scan the whole containment tree
// for every row, and buys nothing this data model can use.
OPTIONAL MATCH (owner:AstNode {project: $project, type: 'MODULE'})-[:CONTAINS]->(s)
WITH s, r, t, kind,
CASE WHEN s.type = 'MODULE' THEN s.name ELSE owner.name END AS inModule
RETURN DISTINCT s.sourceFile AS sourceFile, coalesce(r.lineNo, s.startLine) AS lineNo,
kind AS kind, inModule AS inModule, t.name AS target
ORDER BY sourceFile ASC, lineNo ASC, kind ASC
LIMIT $scanCap
""";
public static final String SEARCH_IDENTIFIER = """
MATCH (n:AstNode {project: $project})
// Item 125: a Java type declaration's identity is its FQN (item 117), so matching n.name

View File

@@ -1379,6 +1379,30 @@ public class GraphRepository {
nullableString(record, "userExitDir"), toProjectIngestInfo(record));
}
/**
* Item 128: every reference site of {@code name} — imports, declared type positions, annotation
* usages, calls, inheritance and wiring — with a {@code kind} discriminator per row.
*
* @param kind optional filter on that discriminator ({@code IMPORT}, {@code TYPE}, {@code CALL}, …)
*/
public Uni<List<ReferenceSite>> searchReferences(String project, String name, @Nullable String kind,
int limit, int offset) {
int scanCap = limit > 0 ? (int) Math.min((long) Math.max(offset, 0) + limit, Integer.MAX_VALUE)
: Integer.MAX_VALUE;
Map<String, @Nullable Object> params = new HashMap<>();
params.put("project", project);
params.put("name", name);
params.put("kind", kind);
params.put("scanCap", scanCap);
return read(CypherQueries.SEARCH_REFERENCES, params, record -> new ReferenceSite(
record.get("sourceFile").asString(),
record.get("lineNo").asInt(),
record.get("kind").asString(),
record.get("inModule").isNull() ? null : record.get("inModule").asString(),
record.get("target").asString()))
.map(list -> paginate(list, limit, offset));
}
/**
* Item 129: {@code sourceFile -> sourceHash} for the whole project, the input to a
* {@code changedOnly} refresh's skip decision. A file missing from this map has no stored hash and

View File

@@ -0,0 +1,21 @@
package com.agenticcode.neo4jstore.graph;
import org.jspecify.annotations.Nullable;
/**
* Item 128: one place a name is mentioned, returned by {@link CypherQueries#SEARCH_REFERENCES}.
*
* @param sourceFile the referencing file, relative to the project root
* @param lineNo the line the reference sits on
* @param kind how it is referenced — {@code CALL}, {@code IMPORT}, {@code TYPE} (a declared
* field/parameter/return type), {@code ANNOTATION}, {@code EXTENDS},
* {@code IMPLEMENTS}, {@code INJECTS}, {@code CLASS_LITERAL} ({@code X.class}), or
* {@code INCLUDE} (Natural copycode)
* @param inModule the module the reference sits in, or {@code null} when the referencing node has
* no containing module
* @param target the referenced module's identity (the FQN for Java), so a short-name query shows
* what it actually resolved to
*/
public record ReferenceSite(String sourceFile, int lineNo, String kind, @Nullable String inModule,
String target) {
}

View File

@@ -40,5 +40,17 @@ public enum EdgeType {
* enrichment run, because the stale-node sweep only deletes nodes and would let an edge outlive the
* call it came from.
*/
CALLS_MODULE
CALLS_MODULE,
/**
* Item 128: a plain <b>mention</b> of a type — an {@code import}, a declared field/parameter/return
* type, or an annotation usage — carrying a {@code refKind} property saying which.
*
* <p>Deliberately <em>not</em> folded into {@link #REFERENCES}. The call-graph traversals
* ({@code callers}, {@code callees}, {@code call-tree}, {@code ego-graph}) follow {@code REFERENCES}
* as wiring, so reusing it made an {@code import} show up as a caller — a regression caught by
* {@code javaCrossClassCallGraphSpansFiles}, which saw {@code edgeKind: REFERENCES} where a method
* call belonged. A separate type keeps "mentions this" out of "calls this" by construction, rather
* than by remembering to filter it in every query that already exists.
*/
MENTIONS
}

View File

@@ -0,0 +1,67 @@
package com.agenticcode.parsercore.ast.model;
import java.util.Locale;
import java.util.Set;
/**
* JDK/stdlib and common-framework type names (item J6), in {@code ac-parser-core} so that both the
* parsers and the server-side ingest apply the <b>same</b> exclusion.
*
* <p>Two callers with one reason. A by-name ingest follows every referenced type, and JDK/framework
* types never resolve to a file in the project — without this they dominate the {@code unresolved}
* list and inflate the dependency fan-out. Item 128's reference index has the sharper version of the
* same problem: an edge per mention of {@code List} or {@code Logger} would mint a placeholder node
* that is created, persisted and swept again on <em>every</em> ingest.
*
* <p>Matched by uppercased simple name (a qualified name is reduced to its last segment first). A
* deliberate heuristic: a project class named like a JDK type is also skipped — acceptable, and
* vanishingly rare.
*/
public final class ExternalTypeNames {
private static final Set<String> NAMES = Set.of(
// java.lang
"OBJECT", "STRING", "CHARSEQUENCE", "INTEGER", "LONG", "DOUBLE", "FLOAT", "BOOLEAN", "BYTE",
"SHORT", "CHARACTER", "NUMBER", "STRINGBUILDER", "STRINGBUFFER", "THREAD", "RUNNABLE",
"EXCEPTION", "RUNTIMEEXCEPTION", "ILLEGALARGUMENTEXCEPTION", "ILLEGALSTATEEXCEPTION",
"THROWABLE", "ERROR", "CLASS", "ENUM", "ITERABLE", "COMPARABLE", "CLONEABLE", "VOID", "MATH",
"SYSTEM", "AUTOCLOSEABLE",
// java.util
"LIST", "ARRAYLIST", "LINKEDLIST", "MAP", "HASHMAP", "LINKEDHASHMAP", "TREEMAP",
"CONCURRENTHASHMAP", "SORTEDMAP", "NAVIGABLEMAP", "SET", "HASHSET", "LINKEDHASHSET", "TREESET",
"SORTEDSET", "COLLECTION", "COLLECTIONS", "OPTIONAL", "OPTIONALINT", "OPTIONALLONG", "ITERATOR",
"QUEUE", "DEQUE", "ARRAYDEQUE", "STACK", "VECTOR", "COMPARATOR", "ARRAYS", "OBJECTS", "UUID",
"DATE", "CALENDAR", "LOCALE", "RANDOM", "SCANNER", "PROPERTIES", "ENUMSET", "ENUMMAP", "BITSET",
// java.util.stream / function
"STREAM", "INTSTREAM", "LONGSTREAM", "DOUBLESTREAM", "COLLECTORS", "FUNCTION", "BIFUNCTION",
"CONSUMER", "BICONSUMER", "SUPPLIER", "PREDICATE", "BIPREDICATE", "UNARYOPERATOR", "BINARYOPERATOR",
// java.time
"LOCALDATE", "LOCALDATETIME", "LOCALTIME", "INSTANT", "DURATION", "PERIOD", "ZONEDDATETIME",
"OFFSETDATETIME", "ZONEID", "DAYOFWEEK", "MONTH", "YEAR", "CHRONOUNIT",
// java.io / nio
"FILE", "PATH", "PATHS", "FILES", "INPUTSTREAM", "OUTPUTSTREAM", "READER", "WRITER",
"BUFFEREDREADER", "IOEXCEPTION", "UNCHECKEDIOEXCEPTION",
// java.math
"BIGDECIMAL", "BIGINTEGER",
// java.util.concurrent / atomic
"ATOMICINTEGER", "ATOMICLONG", "ATOMICBOOLEAN", "ATOMICREFERENCE", "COMPLETABLEFUTURE", "FUTURE",
"EXECUTOR", "EXECUTORSERVICE", "EXECUTORS", "TIMEUNIT", "COUNTDOWNLATCH",
// logging
"LOGGER", "LOGGERFACTORY", "LOG",
// common frameworks: CDI / JPA / Quarkus / JAX-RS reactive
"ENTITYMANAGER", "SESSION", "STATELESSSESSION", "INSTANCE", "EVENT", "PROVIDER", "TYPELITERAL",
"UNI", "MULTI", "RESPONSE", "PANACHEQUERY", "PANACHEENTITY", "PANACHEENTITYBASE");
private ExternalTypeNames() {
}
/**
* @return whether {@code name} — a simple or fully-qualified type name, in any case — is a
* JDK/stdlib/framework type rather than something this project declares.
*/
public static boolean isExternal(String name) {
int lastDot = name.lastIndexOf('.');
String simple = lastDot >= 0 ? name.substring(lastDot + 1) : name;
return NAMES.contains(simple.toUpperCase(Locale.ROOT));
}
}

View File

@@ -855,6 +855,141 @@ public final class JavaParser implements LanguageParser {
}
}
/**
* Item 128: emits the <b>reference sites</b> a call graph does not see — {@code import} statements,
* declared type positions (field, parameter, return) and annotation usages — as {@code REFERENCES}
* edges carrying a {@code kind} property ({@code IMPORT}/{@code TYPE}/{@code ANNOTATION}). Together
* with the existing {@code CALLS}/{@code EXTENDS}/{@code IMPLEMENTS}/{@code INJECTS} edges this is
* what {@code /search/references} answers from: "every place this name is mentioned", which is the
* honest basis for scoping a rename.
*
* <p><b>Deliberately bounded.</b> Local-variable types and generic type arguments are not indexed:
* they multiply the edge count for far less value than the positions above. The practical
* consequence is worth stating plainly — a reference within the <em>same package</em> has no import,
* so for a rename inside one package this index rests on the declared-type positions alone.
*
* <p>Static and asterisk imports are skipped (as {@link TypeResolver} skips them): neither names a
* single type unambiguously, and guessing which segment is the type would put wrong lines in a
* result that exists to be trusted.
*/
private static void addReferenceEdges(CompilationUnit unit, TypeDeclaration<?> type, AstNode typeNode,
Map<String, AstNode> referencedModules, TypeResolver types,
List<AstNode> nodes, List<AstEdge> edges) {
// Imports belong to the file, not to each type in it, so they are emitted once — from the
// first top-level type. Emitting them per nested class would multiply identical rows.
// getTypes() is the file's top-level declarations; the first of them owns the import block.
if (type.isTopLevelType() && !unit.getTypes().isEmpty() && unit.getTypes().get(0) == type) {
String ownPackage = unit.getPackageDeclaration().map(pd -> pd.getNameAsString()).orElse("");
for (ImportDeclaration imp : unit.getImports()) {
if (imp.isAsterisk() || imp.isStatic()) {
continue;
}
String qualified = imp.getNameAsString();
if (!isProjectType(qualified, ownPackage)) {
continue;
}
int line = imp.getBegin().map(pos -> pos.line).orElse(typeNode.startLine());
reference(qualified, "IMPORT", line, typeNode, referencedModules, nodes, edges);
}
}
for (FieldDeclaration field : type.getFields()) {
int line = field.getBegin().map(pos -> pos.line).orElse(typeNode.startLine());
reference(types.resolve(baseTypeName(field.getElementType().asString())), "TYPE", line,
typeNode, referencedModules, nodes, edges);
addAnnotationReferences(field.getAnnotations(), typeNode, referencedModules, types, nodes, edges);
}
for (CallableDeclaration<?> callable : callables(type)) {
int line = callable.getBegin().map(pos -> pos.line).orElse(typeNode.startLine());
if (callable instanceof MethodDeclaration method) {
reference(types.resolve(baseTypeName(method.getType().asString())), "TYPE", line,
typeNode, referencedModules, nodes, edges);
}
for (Parameter parameter : callable.getParameters()) {
int parameterLine = parameter.getBegin().map(pos -> pos.line).orElse(line);
reference(types.resolve(baseTypeName(parameter.getType().asString())), "TYPE", parameterLine,
typeNode, referencedModules, nodes, edges);
}
addAnnotationReferences(callable.getAnnotations(), typeNode, referencedModules, types, nodes, edges);
}
addAnnotationReferences(type.getAnnotations(), typeNode, referencedModules, types, nodes, edges);
}
private static List<CallableDeclaration<?>> callables(TypeDeclaration<?> type) {
List<CallableDeclaration<?>> callables = new ArrayList<>(type.getMethods());
callables.addAll(type.getConstructors());
return callables;
}
private static void addAnnotationReferences(List<AnnotationExpr> annotations, AstNode typeNode,
Map<String, AstNode> referencedModules, TypeResolver types,
List<AstNode> nodes, List<AstEdge> edges) {
for (AnnotationExpr annotation : annotations) {
int line = annotation.getBegin().map(pos -> pos.line).orElse(typeNode.startLine());
reference(types.resolve(annotation.getNameAsString()), "ANNOTATION", line, typeNode,
referencedModules, nodes, edges);
}
}
/**
* Emits one {@code REFERENCES} edge, skipping the JDK/framework names that would otherwise mint a
* placeholder node per mention — created, persisted and swept again on every single ingest.
*/
private static void reference(String target, String kind, int line, AstNode typeNode,
Map<String, AstNode> referencedModules, List<AstNode> nodes,
List<AstEdge> edges) {
if (target.isEmpty() || ExternalTypeNames.isExternal(target)) {
return;
}
AstNode targetNode = referencedModule(referencedModules, nodes, target);
// MENTIONS, not REFERENCES: the call-graph traversals follow REFERENCES as wiring, and an
// import is not a call. See EdgeType.MENTIONS.
edges.add(edge(EdgeType.MENTIONS, typeNode.id(), targetNode.id(), line, null,
Map.of("refKind", kind)));
}
/**
* @return {@code type} without array brackets and generic arguments — {@code List<Foo>[]} yields
* {@code List}. The type <em>argument</em> is deliberately dropped rather than indexed as a second
* reference; see {@link #addReferenceEdges}.
*/
private static String baseTypeName(String type) {
String base = type;
int generic = base.indexOf('<');
if (generic >= 0) {
base = base.substring(0, generic);
}
int bracket = base.indexOf('[');
if (bracket >= 0) {
base = base.substring(0, bracket);
}
return base.trim();
}
/**
* @return whether {@code qualified} (an import's FQN) plausibly names a type <em>inside this
* project</em>, judged by sharing the first two package segments with the importing file's own
* package ({@code com.uniqagroup.…} importing {@code com.uniqagroup.…}).
*
* <p>A heuristic, chosen over a hardcoded list of external package prefixes because it adapts to
* whatever organisation a project belongs to. It errs toward <em>excluding</em>: an internal type
* under a differently-rooted package is missed, which costs a row in the reference index —
* whereas including everything would mint a placeholder node for every {@code java.util} and
* framework import, on every ingest, only for the finalize sweep to delete them again.
*/
private static boolean isProjectType(String qualified, String ownPackage) {
String prefix = firstTwoSegments(ownPackage);
return !prefix.isEmpty() && qualified.startsWith(prefix + ".");
}
private static String firstTwoSegments(String packageName) {
int first = packageName.indexOf('.');
if (first < 0) {
return packageName;
}
int second = packageName.indexOf('.', first + 1);
return second < 0 ? packageName : packageName.substring(0, second);
}
private static boolean hasCdiScope(TypeDeclaration<?> type) {
return type.getAnnotations().stream().anyMatch(a -> CDI_SCOPES.contains(a.getNameAsString()));
}
@@ -1332,6 +1467,8 @@ public final class JavaParser implements LanguageParser {
// 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);
}
return new ParseResult(nodes, edges);

View File

@@ -69,6 +69,45 @@ numbers — re-ingest (refresh) the project to update the graph. Line ranges you
read directly off disk are of course always current; this only guards the API's
own slicing. Copycode/INCLUDE slices are raw pre-expansion file text.
## Every reference site of a name (item 128)
`GET /api/projects/{p}/search/references?name=&kind=&limit=&offset=` (CLI `ac references <name>`)
returns `{sourceFile, lineNo, kind, inModule, target}` per **mention** of a type — not just per call:
| `kind` | Where it comes from |
|-------------------------|--------------------------------------------|
| `CALL` | a call site (`CALLS`) |
| `IMPORT` | an `import` of the type |
| `TYPE` | a declared field / parameter / return type |
| `ANNOTATION` | the type used as an annotation |
| `EXTENDS`, `IMPLEMENTS` | inheritance |
| `INJECTS` | CDI wiring |
| `CLASS_LITERAL` | `X.class` in argument position |
| `INCLUDE` | Natural copycode inclusion |
Use it to **scope a rename**. `callers` sees calls alone, so a file that only imports the class,
declares a field of it, or names it in an annotation was invisible — and the rename that missed it
looked complete. The `name` may be the identity (FQN) or the short form; `target` echoes what it
resolved to. An unknown `kind` is `400 INVALID_KIND`, never an empty list.
**Known limits, by design:**
* **Local-variable types and generic type arguments are not indexed** — `List<Target> x` records
`List`, not `Target`. They multiply edge volume for much less value than the positions above.
* **Same-package references have no import**, so within one package the index rests on declared-type
positions alone.
* **Imports are only indexed when they look project-internal** (they share the first two package
segments with the importing file). Otherwise every `java.util`/framework import would mint a
placeholder node on every ingest, just for the finalize sweep to delete it again.
* **Natural has no import or type-position concept.** It contributes `CALL`, `INCLUDE` and inheritance
kinds only; this is not parity with Java and should not be read as such.
* Reference edges are written **at parse time**, so they only exist for files re-parsed since this
landed — a project needs a `refresh` before the index is complete.
* Mentions use their own `MENTIONS` edge type, kept out of `CALLS`/`REFERENCES` deliberately: the
call-graph traversals (`callers`, `callees`, `call-tree`, `ego-graph`) follow `REFERENCES` as
wiring, so folding imports into it made an `import` surface as a **caller**. `/search/references` is
the only endpoint that reads `MENTIONS`; the call graph is unchanged.
## Refreshing only what changed (item 129)
`POST /api/projects/{p}/refresh` (CLI `ac refresh`) has two ways to avoid re-walking a whole root:

View File

@@ -43,7 +43,7 @@ cost. All probes below were run against a freshly refreshed graph and are reprod
Items 125 and 127 were the two said to make the API return a *wrong* answer rather than a missing
one, which is why they led the list. **125 is fixed (2026-08-18); 127 was retracted the same day —
its probe was not ambiguous, so the answer had been correct all along (see the retraction below).**
**126 and 129 are fixed (2026-08-18).** That leaves **128 and 130** open.
**126 and 129 are fixed (2026-08-18), 128 on 2026-08-19.** That leaves **130** open.
- [x] **125. `search/identifier` did not match a type declaration's short name — and silently ignored `contains`** (
fixed 2026-08-18)
@@ -135,7 +135,7 @@ its probe was not ambiguous, so the answer had been correct all along (see the r
be checked from the API"* — was real, and is what **item 125** now answers:
`GET /pur/search/identifier?name=Builder&type=MODULE` lists every module sharing a short name.
- [ ] **128. No endpoint for "every reference site of this symbol"**
- [x] **128. No endpoint for "every reference site of this symbol"** (fixed 2026-08-19)
**Symptom.** `callers` gives module-level call edges with line sites. There is no way to ask for
*all* occurrences of a name — import, type position, field type, annotation argument, test
@@ -144,10 +144,33 @@ its probe was not ambiguous, so the answer had been correct all along (see the r
(`PartnerControllerTest`, `AbstractUPMSServiceAcceptanceTest`) were only found because the agent
already knew they existed.
**Fix.** `/search/references?name=` returning `file:line` per site with a kind discriminator
(`CALL`, `IMPORT`, `TYPE`, `ANNOTATION`, `EXTENDS`, …). This is also the honest basis for
"a utility for this already exists, reuse it" — today that answer rests on an index that does
not see type declarations at all (item 125).
**Delivered.** `GET /search/references?name=&kind=` (CLI `ac references`) returns
`{sourceFile, lineNo, kind, inModule, target}` per mention, unioning `CALL`, `IMPORT`, `TYPE`
(declared field/parameter/return), `ANNOTATION`, `EXTENDS`, `IMPLEMENTS`, `INJECTS`,
`CLASS_LITERAL` and Natural's `INCLUDE`. The name may be the identity or the short form (item 125);
an unknown `kind` is `400 INVALID_KIND`, never an empty list. `SearchReferencesIT`, 9 tests.
The Java parser now emits the mention edges it never had — imports, declared type positions and
annotation usages — measured on `pur` as ~26.6k imports and ~13.4k declared-type positions against
~194k existing edges, so roughly +20%.
**Two decisions worth keeping:**
* **A new `MENTIONS` edge type, not `REFERENCES`.** Reusing `REFERENCES` regressed the call graph —
`callers`/`callees`/`call-tree`/`ego-graph` follow it as wiring, so an `import` surfaced as a
*caller* (`javaCrossClassCallGraphSpansFiles` caught it: `edgeKind: REFERENCES` where a method
call belonged). A separate type keeps "mentions" out of "calls" by construction rather than by
remembering to filter it in every existing query.
* **Imports are filtered to project-internal ones** (sharing the importing file's first two package
segments), and the JDK/framework name list moved to `ac-parser-core` (`ExternalTypeNames`) so the
parser and the ingest apply the same exclusion. Otherwise every `java.util`/framework import mints
a placeholder node that is created, persisted and swept again on *every* ingest.
**Known gaps, stated rather than hidden:** local-variable types and generic type arguments are not
indexed (`List<Target>` records `List`); same-package references have no import, so within one
package the index rests on declared-type positions; Natural has no import or type-position concept
and contributes call/include/inheritance kinds only. And the index is only as complete as the last
re-parse — existing graphs need a `refresh` before it is populated.
- [x] **129. Refresh was all-or-nothing** (fixed 2026-08-18)