New features
This commit is contained in:
@@ -43,6 +43,7 @@ import java.util.concurrent.Callable;
|
||||
DbTableColumnsCommand.class,
|
||||
EntityColumnsCommand.class,
|
||||
SearchIdentifierCommand.class,
|
||||
SearchReferencesCommand.class,
|
||||
ModulesCommand.class,
|
||||
LocCommand.class,
|
||||
ModuleDataStructuresCommand.class,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user