Compare commits
19 Commits
e2e8448b85
...
c3eaa0f55e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3eaa0f55e | ||
|
|
039ff1e176 | ||
|
|
2732d69bd1 | ||
|
|
8bfef47e1e | ||
|
|
bd86a01e92 | ||
|
|
7adf7e54e4 | ||
|
|
4b8a441566 | ||
|
|
5091cba820 | ||
|
|
ec924971a9 | ||
|
|
56e20aee3b | ||
|
|
29269b83c3 | ||
|
|
907286dabb | ||
|
|
f5ca2584f3 | ||
|
|
0e54cc1589 | ||
|
|
3309afe0e1 | ||
|
|
cf5d5ea821 | ||
|
|
b6007b2096 | ||
|
|
97081718fd | ||
|
|
93589c2eb9 |
@@ -1,5 +1,7 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import picocli.CommandLine.Option;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
@@ -56,6 +58,21 @@ abstract class AbstractApiCommand implements Callable<Integer> {
|
||||
return path + (path.contains("?") ? "&" : "?") + key + "=" + encode(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 131: says so when the server cut the answer. Written to <b>stderr</b> so piping the body
|
||||
* into {@code jq} stays clean, and printed at all because the alternative — a capped list that
|
||||
* looks like the whole set — is what made a real audit report 17 missing annotations that were
|
||||
* never missing.
|
||||
*/
|
||||
private static void warnIfTruncated(ApiClient.ApiResponse response) {
|
||||
if (!"true".equalsIgnoreCase(String.valueOf(response.header("X-AC-Truncated")))) {
|
||||
return;
|
||||
}
|
||||
@Nullable String total = response.header("X-AC-Total-Count");
|
||||
System.err.println("note: this answer is truncated" + (total == null ? "" : " (" + total + " rows match)")
|
||||
+ " — re-run with a larger --limit, page with --offset, or use --count-only");
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints the response body and returns an exit code derived from the HTTP status.
|
||||
*/
|
||||
@@ -64,6 +81,7 @@ abstract class AbstractApiCommand implements Callable<Integer> {
|
||||
if (!pretty.isBlank()) {
|
||||
System.out.println(pretty);
|
||||
}
|
||||
warnIfTruncated(response);
|
||||
if (!response.isSuccess()) {
|
||||
System.err.println("HTTP " + response.statusCode());
|
||||
return 1;
|
||||
|
||||
@@ -43,10 +43,13 @@ import java.util.concurrent.Callable;
|
||||
DbTableColumnsCommand.class,
|
||||
EntityColumnsCommand.class,
|
||||
SearchIdentifierCommand.class,
|
||||
SearchReferencesCommand.class,
|
||||
RestEndpointsCommand.class,
|
||||
ModulesCommand.class,
|
||||
LocCommand.class,
|
||||
ModuleDataStructuresCommand.class,
|
||||
PayloadCommand.class,
|
||||
CommentsCommand.class,
|
||||
DispatchTableCommand.class,
|
||||
DynamicCallsCommand.class,
|
||||
DigestCommand.class,
|
||||
|
||||
@@ -78,13 +78,26 @@ public final class ApiClient {
|
||||
|
||||
private ApiResponse send(HttpRequest request) throws IOException, InterruptedException {
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
return new ApiResponse(response.statusCode(), response.body());
|
||||
return new ApiResponse(response.statusCode(), response.body(), response.headers());
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of an API call.
|
||||
*/
|
||||
public record ApiResponse(int statusCode, String body) {
|
||||
/**
|
||||
* Item 131: the response now carries its headers, not just the body. Without them a truncated
|
||||
* answer looked complete on the command line — the same silent cut the item is about, moved one
|
||||
* layer out.
|
||||
*/
|
||||
public record ApiResponse(int statusCode, String body, java.net.http.HttpHeaders headers) {
|
||||
|
||||
/**
|
||||
* @return a header's value, or {@code null} when the server did not send it
|
||||
*/
|
||||
public @org.jspecify.annotations.Nullable String header(String name) {
|
||||
return headers.firstValue(name).orElse(null);
|
||||
}
|
||||
|
||||
|
||||
public boolean isSuccess() {
|
||||
return statusCode >= 200 && statusCode < 300;
|
||||
|
||||
@@ -12,17 +12,18 @@ import java.util.Objects;
|
||||
import java.util.concurrent.Callable;
|
||||
|
||||
/**
|
||||
* Groups project management subcommands: create, update, delete, recreate, list.
|
||||
* Groups project management subcommands: create, update, delete, recreate, show, list.
|
||||
*/
|
||||
@Command(
|
||||
name = "project",
|
||||
mixinStandardHelpOptions = true,
|
||||
description = "Create, update, delete, recreate or list projects",
|
||||
description = "Create, update, delete, recreate, show or list projects",
|
||||
subcommands = {
|
||||
ProjectCommand.CreateCommand.class,
|
||||
ProjectCommand.UpdateCommand.class,
|
||||
ProjectCommand.DeleteCommand.class,
|
||||
ProjectCommand.RecreateCommand.class,
|
||||
ProjectCommand.ShowCommand.class,
|
||||
ProjectCommand.ListCommand.class
|
||||
}
|
||||
)
|
||||
@@ -193,6 +194,20 @@ final class ProjectCommand implements Callable<Integer> {
|
||||
}
|
||||
}
|
||||
|
||||
@Command(name = "show", mixinStandardHelpOptions = true,
|
||||
description = "Show one project's config and its last whole-root ingest (ingestedAt, mode, file counts)")
|
||||
static final class ShowCommand extends AbstractApiCommand {
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
@Parameters(index = "0", description = "Project name")
|
||||
String name;
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
return printResponse(apiClient().get("/api/projects/" + encode(name)));
|
||||
}
|
||||
}
|
||||
|
||||
@Command(name = "list", mixinStandardHelpOptions = true, description = "List all projects")
|
||||
static final class ListCommand extends AbstractApiCommand {
|
||||
|
||||
|
||||
@@ -40,6 +40,16 @@ final class RefreshCommand extends AbstractProjectCommand {
|
||||
@Option(names = "--neighborhood", description = "Module refresh only: also deep-ingest the module's transitive callers (not just callees/data areas)")
|
||||
boolean neighborhood;
|
||||
|
||||
@Option(names = "--paths", split = ",",
|
||||
description = "Whole-project refresh only: re-ingest just these relative source paths (repeatable or comma-separated) "
|
||||
+ "instead of the whole root. Paths matching no file come back under 'unresolved'")
|
||||
List<String> paths = List.of();
|
||||
|
||||
@Option(names = "--changed-only",
|
||||
description = "Whole-project refresh only: re-parse only files whose content differs from the graph's stored hash. "
|
||||
+ "Enrichment still runs in full; a changed Natural copycode re-parses everything (its text is inlined at parse time)")
|
||||
boolean changedOnly;
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
if (name != null && !name.isBlank()) {
|
||||
@@ -60,6 +70,12 @@ final class RefreshCommand extends AbstractProjectCommand {
|
||||
return printResponse(apiClient().post(path));
|
||||
}
|
||||
String path = projectPath() + "/refresh" + (deep ? "?deep=true" : "");
|
||||
if (!paths.isEmpty()) {
|
||||
path = appendQuery(path, "paths", String.join(",", paths));
|
||||
}
|
||||
if (changedOnly) {
|
||||
path = appendQuery(path, "changedOnly", "true");
|
||||
}
|
||||
return printResponse(apiClient().post(path));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Option;
|
||||
|
||||
/**
|
||||
* Item 130: lists a project's REST endpoints — composed path, HTTP verb, declaring class and handler
|
||||
* method. Answers "which code runs for this URL" directly, instead of composing the class-level and
|
||||
* method-level {@code @Path} by hand from two annotation searches.
|
||||
*/
|
||||
@Command(name = "rest-endpoints", mixinStandardHelpOptions = true,
|
||||
description = "List the project's REST endpoints (path, HTTP method, declaring class, handler)")
|
||||
final class RestEndpointsCommand extends AbstractProjectCommand {
|
||||
|
||||
@Option(names = "--module", description = "Only the endpoints declared by this class (identity or short name)")
|
||||
@Nullable String module;
|
||||
|
||||
@Option(names = "--count-only", description = "Print only how many rows match, instead of the rows themselves")
|
||||
boolean countOnly;
|
||||
|
||||
@Option(names = "--limit", description = "Max items to return (default: all)")
|
||||
int limit = -1;
|
||||
|
||||
@Option(names = "--offset", description = "Items to skip")
|
||||
int offset = -1;
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
try {
|
||||
String path = appendQuery(projectPath() + "/rest-endpoints", "module", module);
|
||||
if (countOnly) {
|
||||
path = appendQuery(path, "countOnly", "true");
|
||||
}
|
||||
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
|
||||
return printResponse(apiClient().get(path));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,9 @@ final class SearchAnnotationCommand extends AbstractProjectCommand {
|
||||
@Option(names = "--type", description = "Filter by node type (MODULE, FUNCTION, VARIABLE, DATA_STRUCTURE, DB_TABLE)")
|
||||
@Nullable String type;
|
||||
|
||||
@Option(names = "--count-only", description = "Print only how many rows match, instead of the rows themselves")
|
||||
boolean countOnly;
|
||||
|
||||
@Option(names = "--limit", description = "Max items to return")
|
||||
int limit = -1;
|
||||
|
||||
@@ -32,6 +35,9 @@ final class SearchAnnotationCommand extends AbstractProjectCommand {
|
||||
if (type != null) {
|
||||
path += "&type=" + encode(type);
|
||||
}
|
||||
if (countOnly) {
|
||||
path = appendQuery(path, "countOnly", "true");
|
||||
}
|
||||
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
|
||||
return printResponse(apiClient().get(path));
|
||||
} catch (IllegalStateException e) {
|
||||
|
||||
@@ -12,9 +12,12 @@ import picocli.CommandLine.Parameters;
|
||||
@Command(name = "search-identifier", mixinStandardHelpOptions = true, description = "Search for an identifier across all modules, or list all identifiers")
|
||||
final class SearchIdentifierCommand extends AbstractProjectCommand {
|
||||
|
||||
@Parameters(index = "0", arity = "0..1", description = "Identifier name; a leading Natural sigil (# & +) is ignored (omit to list all identifiers)")
|
||||
@Parameters(index = "0", arity = "0..1", description = "Identifier name — a Java type's short or fully-qualified form; a leading Natural sigil (# & +) is ignored (omit to list all identifiers)")
|
||||
@Nullable String name;
|
||||
|
||||
@Option(names = "--contains", description = "Match names that contain the given name (case-insensitive) instead of equalling it; requires a name")
|
||||
boolean contains;
|
||||
|
||||
@Option(names = "--type", description = "Node type filter (MODULE, FUNCTION, VARIABLE, DATA_STRUCTURE, DB_TABLE)")
|
||||
@Nullable String type;
|
||||
|
||||
@@ -27,6 +30,9 @@ final class SearchIdentifierCommand extends AbstractProjectCommand {
|
||||
@Option(names = "--source-file", description = "Scope to this exact source file (relative path)")
|
||||
@Nullable String sourceFile;
|
||||
|
||||
@Option(names = "--count-only", description = "Print only how many rows match, instead of the rows themselves")
|
||||
boolean countOnly;
|
||||
|
||||
@Option(names = "--limit", description = "Max items to return")
|
||||
int limit = -1;
|
||||
|
||||
@@ -42,6 +48,12 @@ final class SearchIdentifierCommand extends AbstractProjectCommand {
|
||||
path = appendQuery(path, "module", module);
|
||||
path = appendQuery(path, "priorityModule", priorityModule);
|
||||
path = appendQuery(path, "sourceFile", sourceFile);
|
||||
if (contains) {
|
||||
path = appendQuery(path, "contains", "true");
|
||||
}
|
||||
if (countOnly) {
|
||||
path = appendQuery(path, "countOnly", "true");
|
||||
}
|
||||
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
|
||||
return printResponse(apiClient().get(path));
|
||||
} catch (IllegalStateException e) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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 = "--count-only", description = "Print only how many rows match, instead of the rows themselves")
|
||||
boolean countOnly;
|
||||
|
||||
@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);
|
||||
if (countOnly) {
|
||||
path = appendQuery(path, "countOnly", "true");
|
||||
}
|
||||
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
|
||||
return printResponse(apiClient().get(path));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,12 @@ final class SearchValueCommand extends AbstractProjectCommand {
|
||||
@Option(names = "--contains", description = "Match values that contain the given string, not just exact matches")
|
||||
boolean contains;
|
||||
|
||||
@Option(names = "--include-comments", description = "Also search comment blocks (item 141); their hits come back as kind=COMMENT")
|
||||
boolean includeComments;
|
||||
|
||||
@Option(names = "--count-only", description = "Print only how many rows match, instead of the rows themselves")
|
||||
boolean countOnly;
|
||||
|
||||
@Option(names = "--limit", description = "Max items to return")
|
||||
int limit = -1;
|
||||
|
||||
@@ -30,6 +36,12 @@ final class SearchValueCommand extends AbstractProjectCommand {
|
||||
if (contains) {
|
||||
path += "&contains=true";
|
||||
}
|
||||
if (includeComments) {
|
||||
path = appendQuery(path, "includeComments", "true");
|
||||
}
|
||||
if (countOnly) {
|
||||
path = appendQuery(path, "countOnly", "true");
|
||||
}
|
||||
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
|
||||
return printResponse(apiClient().get(path));
|
||||
} catch (IllegalStateException e) {
|
||||
|
||||
@@ -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=198
|
||||
version=265
|
||||
|
||||
@@ -162,6 +162,57 @@ public class AnalysisResource {
|
||||
IngestSummary run(ProjectInfo project) throws IOException;
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 131: how many rows match in total, and whether this page leaves some out. Headers rather
|
||||
* than body fields for the same reason as item 130's scope echo: these endpoints answer with a
|
||||
* bare JSON array, and turning that into an object would break the web UI's generated client, the
|
||||
* CLI printers and every agent that indexes {@code [0]}.
|
||||
*
|
||||
* <p>Silence here is what did the damage: {@code search/annotation?name=Immutable} returned 50 of
|
||||
* 95 rows with no total, no flag and no {@code Link}/{@code X-Total-Count} header, and a real
|
||||
* audit read that page as the whole set — concluding 17 entities had lost the annotation when
|
||||
* none had.
|
||||
*/
|
||||
static final String TOTAL_COUNT = "X-AC-Total-Count";
|
||||
static final String TRUNCATED = "X-AC-Truncated";
|
||||
/**
|
||||
* 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");
|
||||
|
||||
/**
|
||||
* Item 131: {@code ?countOnly=true} — a completeness question ("how many classes carry
|
||||
* {@code @Immutable}?") is a counting question, and answering it with rows costs ~68 tokens each
|
||||
* for information the caller then throws away.
|
||||
*/
|
||||
private static Response countOnly(Page<?> page) {
|
||||
return Response.ok(Map.of("count", page.total()))
|
||||
.header(TOTAL_COUNT, page.total())
|
||||
.header(TRUNCATED, false)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static boolean isCountOnly(@Nullable Boolean countOnly) {
|
||||
return Boolean.TRUE.equals(countOnly);
|
||||
}
|
||||
|
||||
/**
|
||||
* A count-only request must not be capped to a page, or the total it reports is the page size.
|
||||
*/
|
||||
private static int countAwareLimit(@Nullable Boolean countOnly, @Nullable Integer limit) {
|
||||
return isCountOnly(countOnly) ? 1 : effectiveLimit(limit);
|
||||
}
|
||||
|
||||
private Response paged(Page<?> page) {
|
||||
return Response.ok(page.rows())
|
||||
.header(TOTAL_COUNT, page.total())
|
||||
.header(TRUNCATED, page.truncated())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Default page size for paginated list endpoints, per the documented API design principles.
|
||||
*/
|
||||
@@ -363,9 +414,28 @@ public class AnalysisResource {
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = IngestSummary.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Response refresh(@PathParam("project") String project,
|
||||
@QueryParam("deep") @Nullable Boolean deep) {
|
||||
@QueryParam("deep") @Nullable Boolean deep,
|
||||
@Parameter(description = "Item 129: comma-separated relative source paths to re-ingest instead of the whole root — "
|
||||
+ "the fast loop after editing a few files. Always deep for the named files and their dependencies. "
|
||||
+ "Requested paths that match no file under the root come back in 'unresolved' rather than being dropped. "
|
||||
+ "Does not run the deleted-file sweep and does not move the project's ingestedAt, both of which need a whole-root walk.")
|
||||
@QueryParam("paths") @Nullable String paths,
|
||||
@Parameter(description = "Item 129: re-parse only files whose content differs from the graph's stored hash. "
|
||||
+ "Opt-in: enrichment still runs in full (so this cuts parse time only), duplicate detection sees just the "
|
||||
+ "changed files, user-exit LoC is re-stamped only on re-parsed files, and a changed Natural copycode "
|
||||
+ "disables the skipping for that run because copycode text is inlined at parse time.")
|
||||
@QueryParam("changedOnly") @Nullable Boolean changedOnly) {
|
||||
boolean deepIngest = deep != null && deep;
|
||||
return withResolvedRoot(project, info -> projectIngestService.refreshProject(info, deepIngest));
|
||||
if (paths != null && !paths.isBlank()) {
|
||||
List<String> requested = Arrays.stream(paths.split(",")).map(String::trim).filter(p -> !p.isEmpty()).toList();
|
||||
if (requested.isEmpty()) {
|
||||
return ProjectResource.error(Response.Status.BAD_REQUEST, "MISSING_PATHS",
|
||||
"Query parameter 'paths' was given but contained no path");
|
||||
}
|
||||
return withResolvedRoot(project, info -> projectIngestService.refreshPaths(info, requested));
|
||||
}
|
||||
return withResolvedRoot(project, info ->
|
||||
projectIngestService.refreshProject(info, deepIngest, Boolean.TRUE.equals(changedOnly)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -688,6 +758,33 @@ public class AnalysisResource {
|
||||
: Response.ok(resp).build()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 141: the module's comment blocks, each with the declaration it documents.
|
||||
*
|
||||
* <p>Deep-gated on purpose. Comments are produced by the full parse, not by the Tier-1 coarse
|
||||
* scan, so a {@code CALL_GRAPH}-depth module has none in the graph - and answering {@code []}
|
||||
* there would be exactly the confident false negative this item exists to remove ("no comments"
|
||||
* is indistinguishable from "not analysed"). The module is deep-ingested on demand first, and if
|
||||
* it still is not {@code FULL} the caller gets {@code 409 NOT_DEEPLY_INGESTED} rather than a
|
||||
* misleading empty list.
|
||||
*/
|
||||
@GET
|
||||
@Path("/modules/{name}/comments")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = CommentBlock.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project or module not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "409", description = "Ambiguous module name (AMBIGUOUS_NAME), an unresolved placeholder (NOT_INGESTED), or a module that is not deeply ingested (NOT_DEEPLY_INGESTED).", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
public Uni<Response> moduleComments(@PathParam("project") String project, @PathParam("name") String name,
|
||||
@Parameter(description = "One comment kind: LINE|BLOCK|JAVADOC (Java), NATURAL_BANNER|NATURAL_INLINE|SAG (Natural). Omitted, every kind but the machine-written SAG directives is returned.")
|
||||
@QueryParam("kind") @Nullable String kind,
|
||||
@QueryParam("limit") @Nullable Integer limit, @QueryParam("offset") @Nullable Integer offset,
|
||||
@QueryParam("sourceFile") @Nullable String sourceFile) {
|
||||
return withDeeplyIngestedModule(project, name, sourceFile, resolvedName ->
|
||||
graphRepository.moduleComments(project, resolvedName, anySource(sourceFile),
|
||||
kind == null || kind.isBlank() ? null : kind.toUpperCase(Locale.ROOT),
|
||||
uncappedLimit(limit), effectiveOffset(offset))
|
||||
.map(this::ok));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/modules/{name}/db-accesses")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = DbAccess.class)))
|
||||
@@ -707,19 +804,72 @@ public class AnalysisResource {
|
||||
});
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/rest-endpoints")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = RestEndpoint.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> restEndpoints(@PathParam("project") String project,
|
||||
@Parameter(description = "Narrow to the endpoints declared by one class (identity or short name).")
|
||||
@QueryParam("module") @Nullable String module,
|
||||
@Parameter(description = "Item 135: report only how many endpoints match, without the rows.")
|
||||
@QueryParam("countOnly") @Nullable Boolean countOnly,
|
||||
@QueryParam("limit") @Nullable Integer limit,
|
||||
@QueryParam("offset") @Nullable Integer offset) {
|
||||
int effLimit = isCountOnly(countOnly) ? 1 : uncappedLimit(limit);
|
||||
return withProject(project, () -> graphRepository.restEndpointsPage(project, module,
|
||||
effLimit, effectiveOffset(offset))
|
||||
.map(page -> isCountOnly(countOnly) ? countOnly(page) : paged(page)));
|
||||
}
|
||||
|
||||
@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,
|
||||
@Parameter(description = "Item 135: report only how many reference sites match, without the rows.")
|
||||
@QueryParam("countOnly") @Nullable Boolean countOnly,
|
||||
@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.searchReferencesPage(project, name, upperKind,
|
||||
countAwareLimit(countOnly, limit), effectiveOffset(offset)),
|
||||
page -> page.rows().stream().map(ReferenceSite::sourceFile)
|
||||
.filter(sf -> !sf.isEmpty()).distinct().toList(),
|
||||
page -> isCountOnly(countOnly) ? countOnly(page) : paged(page));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/search/value")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = ValueMatch.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> searchValue(@PathParam("project") String project, @QueryParam("value") @Nullable String value,
|
||||
@QueryParam("contains") @Nullable Boolean contains,
|
||||
@Parameter(description = "Item 141: also search comment blocks. Their hits come back as kind=COMMENT. Off by default - a comment is not the same evidence as a literal in code, and folding it in silently would move every existing completeness count.")
|
||||
@QueryParam("includeComments") @Nullable Boolean includeComments,
|
||||
@Parameter(description = "Return only {\"count\": n} instead of the rows (item 131).")
|
||||
@QueryParam("countOnly") @Nullable Boolean countOnly,
|
||||
@QueryParam("limit") @Nullable Integer limit, @QueryParam("offset") @Nullable Integer offset) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return Uni.createFrom().item(ProjectResource.error(Response.Status.BAD_REQUEST, "MISSING_VALUE",
|
||||
"Query parameter 'value' is required"));
|
||||
}
|
||||
return withProject(project, () -> graphRepository.searchByValue(project, value, Boolean.TRUE.equals(contains),
|
||||
effectiveLimit(limit), effectiveOffset(offset)).map(this::ok));
|
||||
return withProject(project, () -> graphRepository.searchByValuePage(project, value, Boolean.TRUE.equals(contains),
|
||||
Boolean.TRUE.equals(includeComments), countAwareLimit(countOnly, limit), effectiveOffset(offset))
|
||||
.map(page -> isCountOnly(countOnly) ? countOnly(page) : paged(page)));
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -728,6 +878,8 @@ public class AnalysisResource {
|
||||
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> searchAnnotation(@PathParam("project") String project, @QueryParam("name") @Nullable String name,
|
||||
@QueryParam("type") @Nullable String type,
|
||||
@Parameter(description = "Return only {\"count\": n} instead of the rows (item 131).")
|
||||
@QueryParam("countOnly") @Nullable Boolean countOnly,
|
||||
@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",
|
||||
@@ -741,8 +893,9 @@ public class AnalysisResource {
|
||||
"Unknown node type '" + type + "'"));
|
||||
}
|
||||
}
|
||||
return withProject(project, () -> graphRepository.searchAnnotation(project, name,
|
||||
type != null ? type.toUpperCase() : null, effectiveLimit(limit), effectiveOffset(offset)).map(this::ok));
|
||||
return withProject(project, () -> graphRepository.searchAnnotationPage(project, name,
|
||||
type != null ? type.toUpperCase() : null, countAwareLimit(countOnly, limit), effectiveOffset(offset))
|
||||
.map(page -> isCountOnly(countOnly) ? countOnly(page) : paged(page)));
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -771,6 +924,7 @@ public class AnalysisResource {
|
||||
@GET
|
||||
@Path("/search/identifier")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = IdentifierMatch.class)))
|
||||
@APIResponse(responseCode = "400", description = "Unknown node 'type', or 'contains' without a 'name'.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> searchIdentifier(@PathParam("project") String project, @QueryParam("name") @Nullable String name,
|
||||
@QueryParam("type") @Nullable String type,
|
||||
@@ -780,8 +934,19 @@ public class AnalysisResource {
|
||||
@QueryParam("module") @Nullable String module,
|
||||
@Parameter(description = "Do not filter, but pin this module's matches to the front so its local declaration survives the limit when a name recurs across many modules.")
|
||||
@QueryParam("priorityModule") @Nullable String priorityModule,
|
||||
@Parameter(description = "Match names that contain 'name' (case-insensitive) instead of equalling it, as on /search/value. Matches a Java type's fully-qualified name as well as its short form, so a package fragment also hits. Requires a non-blank 'name'.")
|
||||
@QueryParam("contains") @Nullable Boolean contains,
|
||||
@Parameter(description = "Return only {\"count\": n} instead of the rows (item 131).")
|
||||
@QueryParam("countOnly") @Nullable Boolean countOnly,
|
||||
@QueryParam("limit") @Nullable Integer limit, @QueryParam("offset") @Nullable Integer offset,
|
||||
@QueryParam("fields") @Nullable String fields) {
|
||||
// Item 125: a substring search for nothing is a full node dump, and was previously the
|
||||
// silent outcome — the parameter did not exist, so JAX-RS dropped it and the caller read the
|
||||
// resulting [] as "no such identifier".
|
||||
if (Boolean.TRUE.equals(contains) && (name == null || name.isBlank())) {
|
||||
return Uni.createFrom().item(ProjectResource.error(Response.Status.BAD_REQUEST, "MISSING_NAME",
|
||||
"Query parameter 'name' is required when 'contains' is true"));
|
||||
}
|
||||
if (type != null) {
|
||||
try {
|
||||
NodeType.valueOf(type.toUpperCase());
|
||||
@@ -791,10 +956,14 @@ public class AnalysisResource {
|
||||
}
|
||||
}
|
||||
return withFanoutWarm(project,
|
||||
() -> graphRepository.searchIdentifier(project, name,
|
||||
type != null ? type.toUpperCase() : null, sourceFile, module, priorityModule, effectiveLimit(limit), effectiveOffset(offset)),
|
||||
matches -> matches.stream().map(IdentifierMatch::sourceFile).filter(sf -> !sf.isEmpty()).distinct().toList(),
|
||||
matches -> namesOnly(fields) ? ok(identifierNames(matches)) : ok(matches));
|
||||
() -> graphRepository.searchIdentifierPage(project, name,
|
||||
type != null ? type.toUpperCase() : null, sourceFile, module, priorityModule,
|
||||
Boolean.TRUE.equals(contains), countAwareLimit(countOnly, limit), effectiveOffset(offset)),
|
||||
page -> page.rows().stream().map(IdentifierMatch::sourceFile).filter(sf -> !sf.isEmpty()).distinct().toList(),
|
||||
page -> isCountOnly(countOnly) ? countOnly(page)
|
||||
: namesOnly(fields) ? Response.ok(identifierNames(page.rows()))
|
||||
.header(TOTAL_COUNT, page.total()).header(TRUNCATED, page.truncated()).build()
|
||||
: paged(page));
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -1106,6 +1275,22 @@ public class AnalysisResource {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 141: {@link #withIngestedModule} plus the deep tier - for endpoints whose data only exists
|
||||
* after a full parse. Deep-ingests the module on demand (as the fan-out warm does), then refuses
|
||||
* with the {@code 409 DeepIngestRequired} hint if it still is not {@code FULL}, so an empty answer
|
||||
* never masquerades as "analysed, nothing found".
|
||||
*/
|
||||
private Uni<Response> withDeeplyIngestedModule(String project, String name, @Nullable String sourceFile,
|
||||
Function<String, Uni<Response>> action) {
|
||||
return withIngestedModule(project, name, sourceFile, resolvedName ->
|
||||
deepIngestCoordinator.ensureDeep(project, resolvedName)
|
||||
.flatMap(ignored -> graphRepository.moduleIngestState(project, resolvedName, anySource(sourceFile))
|
||||
.flatMap(state -> state.isFull()
|
||||
? action.apply(resolvedName)
|
||||
: Uni.createFrom().item(deepIngestRequired(project, resolvedName, state)))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up {@code sourceFile}'s stored content hash (item 41), then reads its {@code [startLine,
|
||||
* endLine]} slice with a stale check (see {@link #withSnippet}).
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.ext.ExceptionMapper;
|
||||
import jakarta.ws.rs.ext.Provider;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Item 136: makes an <em>unexpected</em> failure look like every other error this API returns —
|
||||
* {@code { "error": ..., "code": "INTERNAL_ERROR", "details": {} }} — instead of Quarkus's default
|
||||
* plain-text error page.
|
||||
*
|
||||
* <p>Why it matters: the API's whole contract is "errors are structured JSON with a {@code code} to
|
||||
* branch on". A {@code Uncoercible: Cannot coerce NULL to Java int} escaping the identifier search
|
||||
* broke that promise at exactly the moment a client most needs a machine-readable answer — it got an
|
||||
* HTML-ish body with no {@code code} at all, and no way to tell a server fault from a bad request.
|
||||
*
|
||||
* <p><b>Pass-through is load-bearing.</b> JAX-RS picks the most specific mapper for an exception, and
|
||||
* {@code Throwable} is the least specific one there is: without the {@link WebApplicationException}
|
||||
* branch below, this mapper would also swallow every {@code 404}/{@code 405}/{@code 415} the runtime
|
||||
* raises and the deliberate statuses built by {@link ProjectResource#error}, turning correct answers
|
||||
* into {@code 500}s across the board.
|
||||
*
|
||||
* <p>The error id in the message is the correlation handle: the full stack trace goes to the server
|
||||
* log under the same id, and never into the response — a client has no use for it and a stack trace
|
||||
* is not something to hand out.
|
||||
*/
|
||||
@Provider
|
||||
public class ApiExceptionMapper implements ExceptionMapper<Throwable> {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(ApiExceptionMapper.class);
|
||||
|
||||
@Override
|
||||
public Response toResponse(Throwable exception) {
|
||||
// A deliberate status (404 PROJECT_NOT_FOUND, 400 MISSING_NAME, 409 STALE_SOURCE, and the
|
||||
// runtime's own routing failures) already carries its response. Hand it back untouched.
|
||||
if (exception instanceof WebApplicationException webApplicationException) {
|
||||
return webApplicationException.getResponse();
|
||||
}
|
||||
String errorId = java.util.UUID.randomUUID().toString();
|
||||
LOG.errorf(exception, "Unhandled failure, error id %s", errorId);
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.type(MediaType.APPLICATION_JSON)
|
||||
.entity(ErrorResponse.of("INTERNAL_ERROR",
|
||||
"Unexpected server error (error id " + errorId + "); see the server log for details",
|
||||
Map.of("errorId", errorId)))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,23 @@ public class ProjectResource {
|
||||
return graphRepository.listProjects();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{project}")
|
||||
@Operation(summary = "One project's config and last whole-root ingest",
|
||||
description = "Item 126: the project's configuration plus what its last whole-root ingest did "
|
||||
+ "(ingestedAt, mode, filesExamined/Persisted/Failed, serverVersion). 'ingest' is null when "
|
||||
+ "no whole-root ingest has been recorded, which is not the same as one that found nothing.")
|
||||
@APIResponse(responseCode = "200", description = "The project.")
|
||||
@APIResponse(responseCode = "404", description = "Project not found.",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> get(@PathParam("project") String project) {
|
||||
return graphRepository.getProject(project)
|
||||
.map(info -> info == null
|
||||
? error(Response.Status.NOT_FOUND, "PROJECT_NOT_FOUND",
|
||||
"Project '" + project + "' does not exist")
|
||||
: Response.ok(info).build());
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/{project}")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import com.agenticcode.codeserver.service.ProjectMetadataCache;
|
||||
import com.agenticcode.neo4jstore.graph.ProjectInfo;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.container.ContainerRequestContext;
|
||||
import jakarta.ws.rs.container.ContainerResponseContext;
|
||||
import jakarta.ws.rs.container.ContainerResponseFilter;
|
||||
import jakarta.ws.rs.ext.Provider;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Item 130: stamps every project-scoped response with the facts that decide how to read an
|
||||
* <em>empty</em> one.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code X-AC-Exclude-Dirs} — the directories the ingest skipped. "No callers" means "none
|
||||
* outside tests" in a project excluding {@code test} and "none at all" in one that does not,
|
||||
* and nothing in the body said which.</li>
|
||||
* <li>{@code X-AC-Ingested-At} — when the graph was last walked (item 126), so a stale answer is
|
||||
* visible at the point of use rather than only on the project listing.</li>
|
||||
* <li>{@code X-AC-Ingest-Incomplete} — a whole-root pass is running or never finished (item 129).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>Headers, not body fields, and deliberately so.</b> Most endpoints answer with a bare JSON
|
||||
* array ({@code db-accesses}, {@code functions}, {@code search/identifier}, …); adding a field there
|
||||
* means restructuring array → object, which breaks the web UI's generated client, the CLI printers
|
||||
* and every agent that indexes {@code [0]}. A header costs no shape change and covers every endpoint
|
||||
* at once. The trade-off is real: an agent reading only the JSON body will not see these.
|
||||
*/
|
||||
@Provider
|
||||
public class ProjectScopeHeaderFilter implements ContainerResponseFilter {
|
||||
|
||||
static final String EXCLUDE_DIRS = "X-AC-Exclude-Dirs";
|
||||
static final String INGESTED_AT = "X-AC-Ingested-At";
|
||||
static final String INCOMPLETE = "X-AC-Ingest-Incomplete";
|
||||
|
||||
@Inject
|
||||
ProjectMetadataCache projects;
|
||||
|
||||
@Override
|
||||
public void filter(ContainerRequestContext request, ContainerResponseContext response) {
|
||||
@Nullable String project = request.getUriInfo().getPathParameters().getFirst("project");
|
||||
if (project == null || project.isBlank()) {
|
||||
return;
|
||||
}
|
||||
@Nullable ProjectInfo info = projects.get(project);
|
||||
if (info == null) {
|
||||
return;
|
||||
}
|
||||
List<String> excludeDirs = info.excludeDirs();
|
||||
response.getHeaders().putSingle(EXCLUDE_DIRS, excludeDirs.isEmpty() ? "(none)" : String.join(",", excludeDirs));
|
||||
if (info.ingest() != null) {
|
||||
response.getHeaders().putSingle(INGESTED_AT, info.ingest().ingestedAt());
|
||||
response.getHeaders().putSingle(INCOMPLETE, Boolean.toString(info.ingest().incomplete()));
|
||||
} else {
|
||||
// Item 126's "never recorded" state, carried through honestly rather than as a false "false".
|
||||
response.getHeaders().putSingle(INCOMPLETE, "unknown");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
package com.agenticcode.codeserver.service;
|
||||
|
||||
import com.agenticcode.neo4jstore.graph.EnrichmentLevel;
|
||||
import com.agenticcode.neo4jstore.graph.GraphRepository;
|
||||
import com.agenticcode.neo4jstore.graph.IngestDepth;
|
||||
import com.agenticcode.neo4jstore.graph.ModuleIngestState;
|
||||
import com.agenticcode.neo4jstore.graph.*;
|
||||
import com.agenticcode.parsercore.ast.model.LocMetrics;
|
||||
import com.agenticcode.parsercore.ast.model.NodeType;
|
||||
import com.agenticcode.parsercore.ast.spi.CoarseScanner;
|
||||
@@ -160,6 +157,30 @@ public class AstIngestService {
|
||||
return graphRepository.distinctSourceFiles(project);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 126: records what the last <b>whole-root</b> ingest of {@code project} did, so an agent can
|
||||
* date an answer and see how complete the graph is without crawling the file system.
|
||||
*/
|
||||
public Uni<Void> recordProjectIngest(String project, ProjectIngestInfo ingest) {
|
||||
return graphRepository.recordProjectIngest(project, ingest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 129: marks a whole-root ingest as in flight, so one that never finishes leaves the graph
|
||||
* visibly half-updated rather than looking clean.
|
||||
*/
|
||||
public Uni<Void> markProjectIngestStarted(String project, String mode, String startedAt) {
|
||||
return graphRepository.markProjectIngestStarted(project, mode, startedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 129: every ingested file's stored content hash, the input to a {@code changedOnly} refresh's
|
||||
* skip decision.
|
||||
*/
|
||||
public Uni<java.util.Map<String, String>> sourceHashes(String project) {
|
||||
return graphRepository.sourceHashes(project);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 43: deletes every node of {@code project} belonging to one of {@code sourceFiles} — the
|
||||
* deleted-file orphan sweep run after a whole-project refresh.
|
||||
|
||||
@@ -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,6 +3,7 @@ package com.agenticcode.codeserver.service;
|
||||
import com.agenticcode.neo4jstore.graph.EnrichmentLevel;
|
||||
import com.agenticcode.neo4jstore.graph.IngestDepth;
|
||||
import com.agenticcode.neo4jstore.graph.ProjectInfo;
|
||||
import com.agenticcode.neo4jstore.graph.ProjectIngestInfo;
|
||||
import com.agenticcode.parsercore.ast.model.*;
|
||||
import com.agenticcode.parsercore.ast.spi.LanguageParser.ParseResult;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
@@ -13,6 +14,8 @@ import org.jspecify.annotations.Nullable;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -43,14 +46,24 @@ public class ProjectIngestService {
|
||||
private final int maxDeepDepth;
|
||||
private final int defaultDeepNodes;
|
||||
private final boolean autoInvalidateEnabled;
|
||||
// Item 126: stamped onto the project shell with every whole-root ingest, so a graph can be
|
||||
// attributed to the server release that wrote it.
|
||||
private final VersionInfo versionInfo;
|
||||
// Item 130: dropped whenever the project shell changes, so the scope/staleness headers cannot
|
||||
// report "clean" about a graph whose refresh has just started.
|
||||
private final ProjectMetadataCache projectMetadata;
|
||||
|
||||
public ProjectIngestService(AstIngestService astIngestService,
|
||||
VersionInfo versionInfo,
|
||||
ProjectMetadataCache projectMetadata,
|
||||
@ConfigProperty(name = "agenticcode.ingest.batch-size", defaultValue = "200") int batchSize,
|
||||
@ConfigProperty(name = "agenticcode.deep-ingest.default-depth", defaultValue = "5") int defaultDeepDepth,
|
||||
@ConfigProperty(name = "agenticcode.deep-ingest.max-depth", defaultValue = "20") int maxDeepDepth,
|
||||
@ConfigProperty(name = "agenticcode.deep-ingest.default-nodes", defaultValue = "300") int defaultDeepNodes,
|
||||
@ConfigProperty(name = "agenticcode.auto-invalidate.enabled", defaultValue = "true") boolean autoInvalidateEnabled) {
|
||||
this.astIngestService = astIngestService;
|
||||
this.versionInfo = versionInfo;
|
||||
this.projectMetadata = projectMetadata;
|
||||
this.batchSize = Math.max(1, batchSize);
|
||||
this.maxDeepDepth = Math.max(1, maxDeepDepth);
|
||||
this.defaultDeepDepth = Math.min(Math.max(1, defaultDeepDepth), this.maxDeepDepth);
|
||||
@@ -281,6 +294,18 @@ public class ProjectIngestService {
|
||||
return new IngestSummary.Truncation(reason, depthLimit, nodeLimit, hint);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {@code file}'s content hash, or {@code ""} when it cannot be read — which never equals a
|
||||
* stored hash, so an unreadable file is re-parsed (and fails loudly there) rather than skipped.
|
||||
*/
|
||||
private static String hashOf(Path file) {
|
||||
try {
|
||||
return SourceHash.of(SourceFiles.read(file));
|
||||
} catch (IOException e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the entire project root and ingests every ingestible file. {@code deep} selects the
|
||||
* enrichment level: {@code deep=false} (default) runs the fast call-graph pass (placeholder
|
||||
@@ -291,7 +316,7 @@ public class ProjectIngestService {
|
||||
* {@link IngestDepth#FULL}).
|
||||
*/
|
||||
public IngestSummary ingestAll(ProjectInfo project, boolean deep) throws IOException {
|
||||
return ingestRoot(project, deep ? EnrichmentLevel.FULL : EnrichmentLevel.CALL_GRAPH, false);
|
||||
return ingestRoot(project, deep ? EnrichmentLevel.FULL : EnrichmentLevel.CALL_GRAPH, false, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -304,7 +329,7 @@ public class ProjectIngestService {
|
||||
* resolve; field-level dataflow is deferred to the on-demand deep ingest.
|
||||
*/
|
||||
public IngestSummary scanTier1(ProjectInfo project) throws IOException {
|
||||
return ingestRoot(project, EnrichmentLevel.CALL_GRAPH, true);
|
||||
return ingestRoot(project, EnrichmentLevel.CALL_GRAPH, true, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,7 +339,7 @@ public class ProjectIngestService {
|
||||
* caller to deep-ingest a program first. Intended as the fast first pass after project creation.
|
||||
*/
|
||||
public IngestSummary ingestCallGraph(ProjectInfo project) throws IOException {
|
||||
return ingestRoot(project, EnrichmentLevel.CALL_GRAPH, false);
|
||||
return ingestRoot(project, EnrichmentLevel.CALL_GRAPH, false, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -327,8 +352,32 @@ public class ProjectIngestService {
|
||||
* wipe.)
|
||||
*/
|
||||
public IngestSummary refreshProject(ProjectInfo project, boolean deep) throws IOException {
|
||||
return refreshProject(project, deep, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 129: {@code changedOnly} re-parses only files whose content hash differs from the graph's
|
||||
* (plus files with no stored hash). <b>Opt-in, and deliberately not the default</b> — three
|
||||
* whole-walk behaviours are reduced in this mode:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>Natural copycodes are inlined at parse time</b>, so a module whose {@code .cpy} changed
|
||||
* parses differently while its own hash is unchanged. A changed copycode therefore
|
||||
* <b>disables skipping for the whole run</b> (logged) rather than silently keeping stale
|
||||
* expansions — the one case where "incremental" would corrupt the graph outright.</li>
|
||||
* <li><b>Duplicate identity detection</b> groups the files it parsed; with a subset it can only
|
||||
* confirm duplicates among changed files. Existing markers are never cleared (the query only
|
||||
* MERGEs), so this loses discovery, not recorded facts.</li>
|
||||
* <li><b>User-exit LoC annotation</b> (item 47) is re-stamped only on files that were re-parsed:
|
||||
* a changed user-exit twin does not refresh an unchanged generated module's metrics.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Enrichment is project-wide and still runs in full, so this cuts parse+persist time only.
|
||||
*/
|
||||
public IngestSummary refreshProject(ProjectInfo project, boolean deep, boolean changedOnly) throws IOException {
|
||||
long startedAt = System.nanoTime();
|
||||
IngestSummary summary = deep ? ingestAll(project, true) : ingestCallGraph(project);
|
||||
IngestSummary summary = ingestRoot(project, deep ? EnrichmentLevel.FULL : EnrichmentLevel.CALL_GRAPH,
|
||||
false, changedOnly);
|
||||
sweepDeletedFileOrphans(project);
|
||||
LOG.infof("Refresh finished: project='%s', mode=%s, files=%d, modules=%d, failed=%d, %d s",
|
||||
project.name(), deep ? "deep" : "call-graph", summary.examinedFiles().size(),
|
||||
@@ -372,17 +421,69 @@ public class ProjectIngestService {
|
||||
return ingestModule(project, moduleName, maxDepth, maxNodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 129: re-ingests <b>only the named files</b> (relative paths), for the common case of a code
|
||||
* change touching a handful of files where a whole-root refresh re-examines thousands.
|
||||
*
|
||||
* <p>Deep by construction: it runs the same BFS deep ingest the fan-out warm uses, so the named
|
||||
* files and their dependencies land {@code FULL}. Two things it deliberately does <b>not</b> do,
|
||||
* because they are only meaningful for a whole-root walk: the deleted-file sweep (item 43 —
|
||||
* nothing here says which files disappeared) and the project-shell ingest stamp (item 126 — this
|
||||
* walks a fraction of the tree, and moving {@code ingestedAt} would advertise the project as
|
||||
* freshly walked).
|
||||
*
|
||||
* <p>Paths that match no file under the root are returned in the summary's {@code unresolved} list
|
||||
* rather than dropped: "I ingested 2 of your 3 files" must be visible, or a typo'd path reads as a
|
||||
* successful refresh.
|
||||
*/
|
||||
public IngestSummary refreshPaths(ProjectInfo project, Collection<String> sourceFiles) throws IOException {
|
||||
Path root = Path.of(project.root()).toAbsolutePath().normalize();
|
||||
List<String> requested = sourceFiles.stream()
|
||||
.map(String::trim)
|
||||
.filter(sf -> !sf.isEmpty())
|
||||
.distinct()
|
||||
.toList();
|
||||
// Same guard as the source endpoints: these paths are client-supplied, so a '..' or an
|
||||
// absolute path must not reach outside the project root.
|
||||
List<String> unresolved = requested.stream()
|
||||
.filter(sf -> !root.resolve(sf).normalize().startsWith(root)
|
||||
|| !Files.isRegularFile(root.resolve(sf).normalize())
|
||||
// A path that exists but is not an ingestible source file (pom.xml, a README)
|
||||
// must be reported, not accepted: it was silently listed as examined while
|
||||
// nothing about it could ever be ingested, which is precisely the "I ingested
|
||||
// 2 of your 3 files" invisibility this list exists to prevent.
|
||||
|| SourceFiles.classify(root.resolve(sf).normalize()) == null)
|
||||
.toList();
|
||||
List<String> ingestable = requested.stream().filter(sf -> !unresolved.contains(sf)).toList();
|
||||
if (ingestable.isEmpty()) {
|
||||
return new IngestSummary(0, unresolved, List.of(), List.of(), List.of(), null);
|
||||
}
|
||||
IngestSummary summary = ingestFiles(project, ingestable, null);
|
||||
LOG.infof("Targeted refresh of '%s': %d file(s) requested, %d ingested, %d unresolved",
|
||||
project.name(), requested.size(), summary.ingested(), unresolved.size());
|
||||
return new IngestSummary(summary.ingested(), unresolved, summary.duplicates(), summary.failed(),
|
||||
ingestable, summary.truncation());
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared whole-root ingest. {@code level} selects how far enrichment goes, and the
|
||||
* {@link IngestDepth} the ingested modules are tagged with ({@code FULL} only when field-level
|
||||
* resolution ran, else {@code CALL_GRAPH}).
|
||||
*/
|
||||
private IngestSummary ingestRoot(ProjectInfo project, EnrichmentLevel level, boolean coarse) throws IOException {
|
||||
private IngestSummary ingestRoot(ProjectInfo project, EnrichmentLevel level, boolean coarse,
|
||||
boolean changedOnly) throws IOException {
|
||||
long startedAt = System.nanoTime();
|
||||
// Item 129: mark the pass in flight before touching anything. If it never reaches the record
|
||||
// call at the end — crash, container stop, an aborted deep refresh — the marker stays set and
|
||||
// every later answer can say the graph is half-updated instead of looking clean.
|
||||
markIngestStarted(project, coarse ? "tier1" : level.name().toLowerCase(Locale.ROOT));
|
||||
Path root = Path.of(project.root());
|
||||
List<String> excludeDirs = ingestExcludeDirs(project);
|
||||
List<Candidate> candidates = walk(root, excludeDirs);
|
||||
List<Candidate> allCandidates = walk(root, excludeDirs);
|
||||
CopycodeLibrary copycodes = CopycodeLibrary.scan(root, excludeDirs);
|
||||
// Item 129: opt-in skip of files whose content is byte-identical to what the graph holds.
|
||||
List<Candidate> candidates = changedOnly ? changedCandidates(project, root, excludeDirs, allCandidates)
|
||||
: allCandidates;
|
||||
Map<String, LocMetrics> userExit = UserExitMetrics.scan(root, project.userExitDir(), project.excludeDirs(), astIngestService);
|
||||
|
||||
List<String> examinedFiles = candidates.stream()
|
||||
@@ -479,14 +580,128 @@ public class ProjectIngestService {
|
||||
astIngestService.markIngestDepth(project.name(),
|
||||
level.resolveFields() ? IngestDepth.FULL : IngestDepth.CALL_GRAPH, null).await().indefinitely();
|
||||
}
|
||||
String mode = coarse ? "tier1" : level.name().toLowerCase(Locale.ROOT);
|
||||
long durationSeconds = TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - startedAt);
|
||||
LOG.infof("Project ingest finished: project='%s', mode=%s, files=%d, persisted=%d, failed=%d, "
|
||||
+ "duplicates=%d, %d s",
|
||||
project.name(), coarse ? "tier1" : level.name().toLowerCase(Locale.ROOT), examinedFiles.size(),
|
||||
ingested, failed.size(), duplicates.size(),
|
||||
TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - startedAt));
|
||||
project.name(), mode, examinedFiles.size(),
|
||||
ingested, failed.size(), duplicates.size(), durationSeconds);
|
||||
// Item 126: this is the only place that stamps the project shell, and it is reached only by the
|
||||
// three whole-root passes (Tier-1 scan, call-graph refresh, deep refresh). By-name and fan-out
|
||||
// ingests deliberately do not come through here — they walk a fraction of the tree, and moving
|
||||
// ingestedAt for them would report the project as freshly walked when one module was deepened.
|
||||
// Item 129: filesExamined counts what this pass actually walked. In changedOnly mode that is
|
||||
// the changed subset, and the log line above says so — a smaller number here is the point, not
|
||||
// a sign of a short walk.
|
||||
recordIngest(project, mode, examinedFiles.size(), ingested, failed, durationSeconds);
|
||||
return new IngestSummary(ingested, List.of(), duplicates, failed, examinedFiles, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 129: the subset of {@code candidates} whose on-disk content differs from the hash stored in
|
||||
* the graph (or that has no stored hash at all — a new file, or one ingested before hashes existed).
|
||||
*
|
||||
* <p>Returns <b>every</b> candidate — i.e. skips nothing — when a Natural copycode has changed.
|
||||
* Copycode text is inlined into the including module at parse time, so those modules parse
|
||||
* differently while their own hashes are unchanged; skipping them would leave stale expansions in
|
||||
* the graph with nothing to indicate it. Reading the copycodes' own hashes is not enough to know
|
||||
* <em>which</em> modules include them at this point in the walk, and guessing wrong is silent
|
||||
* corruption, so the whole optimisation stands down for that run.
|
||||
*/
|
||||
private List<Candidate> changedCandidates(ProjectInfo project, Path root, List<String> excludeDirs,
|
||||
List<Candidate> candidates) {
|
||||
Map<String, String> stored = astIngestService.sourceHashes(project.name()).await().indefinitely();
|
||||
// Only Natural inlines copycodes at parse time, so only Natural needs the stand-down. Running
|
||||
// the check for a Java project was actively harmful: `ac` carries .cpy files as Natural *test
|
||||
// fixtures* that its Java walk never ingests, so they had no stored hash, counted as changed,
|
||||
// and disabled skipping entirely — 487 of 509 unchanged files re-parsed. An unknown language
|
||||
// (a legacy project) keeps the conservative behaviour.
|
||||
if (!"java".equalsIgnoreCase(project.language() == null ? "" : project.language())
|
||||
&& copycodeChanged(root, excludeDirs, stored)) {
|
||||
LOG.infof("changedOnly refresh of '%s': a copycode changed, so every file is re-parsed "
|
||||
+ "(copycode text is inlined at parse time; skipping would keep stale expansions)",
|
||||
project.name());
|
||||
return candidates;
|
||||
}
|
||||
List<Candidate> changed = new ArrayList<>();
|
||||
for (Candidate candidate : candidates) {
|
||||
String relative = relativeSourceFile(root, candidate.file());
|
||||
@Nullable String known = stored.get(relative);
|
||||
if (known == null || !known.equals(hashOf(candidate.file()))) {
|
||||
changed.add(candidate);
|
||||
}
|
||||
}
|
||||
LOG.infof("changedOnly refresh of '%s': %d of %d files changed since the last ingest",
|
||||
project.name(), changed.size(), candidates.size());
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return whether any {@code .cpy} member's content differs from the hash the graph holds for it.
|
||||
* A copycode with no stored hash counts as changed: it may never have been ingested, and assuming
|
||||
* otherwise is the unsafe direction.
|
||||
*/
|
||||
private boolean copycodeChanged(Path root, List<String> excludeDirs, Map<String, String> stored) {
|
||||
try (Stream<Path> stream = Files.walk(root)) {
|
||||
return stream.filter(Files::isRegularFile)
|
||||
.filter(f -> f.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".cpy"))
|
||||
.filter(f -> !SourceFiles.isExcluded(root, f, excludeDirs))
|
||||
.anyMatch(f -> {
|
||||
@Nullable String known = stored.get(relativeSourceFile(root, f));
|
||||
return known == null || !known.equals(hashOf(f));
|
||||
});
|
||||
} catch (IOException e) {
|
||||
LOG.warnf("Could not scan copycodes under '%s' for changes (%s); re-parsing everything",
|
||||
root, e.toString());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 129: stamps the in-flight marker. Never fatal — a project whose bookkeeping cannot be written
|
||||
* must still be ingestable; the cost of failing here is only that an interruption would go unmarked.
|
||||
*/
|
||||
private void markIngestStarted(ProjectInfo project, String mode) {
|
||||
try {
|
||||
astIngestService.markProjectIngestStarted(project.name(), mode,
|
||||
Instant.now().truncatedTo(ChronoUnit.SECONDS).toString()).await().indefinitely();
|
||||
projectMetadata.invalidate(project.name());
|
||||
} catch (RuntimeException e) {
|
||||
LOG.warnf(e, "Could not mark ingest start for project '%s'; an interrupted run will not be flagged",
|
||||
project.name());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 126: writes the whole-root ingest's outcome onto the {@code (:Project)} shell. Failure
|
||||
* <em>paths</em> are capped at {@link ProjectIngestInfo#MAX_FAILURES} while the count stays exact,
|
||||
* so a truncated list can never be read as "these were all of them".
|
||||
*
|
||||
* <p>Never fatal: the graph is already written, and losing the bookkeeping must not turn a
|
||||
* successful ingest into a failed request. A warning is logged instead — the missing metadata then
|
||||
* shows up as {@code ingest: null}, which reads as "not recorded" rather than as a false fact.
|
||||
*/
|
||||
private void recordIngest(ProjectInfo project, String mode, int filesExamined, int filesPersisted,
|
||||
List<IngestSummary.Failure> failed, long durationSeconds) {
|
||||
List<String> failurePaths = failed.stream()
|
||||
.map(f -> relativeSourceFile(Path.of(project.root()), Path.of(f.path())))
|
||||
.limit(ProjectIngestInfo.MAX_FAILURES)
|
||||
.toList();
|
||||
ProjectIngestInfo ingest = new ProjectIngestInfo(
|
||||
Instant.now().truncatedTo(ChronoUnit.SECONDS).toString(), mode,
|
||||
filesExamined, filesPersisted, failed.size(), failurePaths,
|
||||
failed.size() > failurePaths.size(), durationSeconds, versionInfo.version(),
|
||||
// Reaching here means the pass completed; the write clears the in-flight marker.
|
||||
false, null);
|
||||
try {
|
||||
astIngestService.recordProjectIngest(project.name(), ingest).await().indefinitely();
|
||||
projectMetadata.invalidate(project.name());
|
||||
} catch (RuntimeException e) {
|
||||
LOG.warnf(e, "Could not record ingest metadata for project '%s'; it will report ingest=null",
|
||||
project.name());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingests {@code moduleName} and its transitive dependencies using the configured default depth
|
||||
* and node budget. See {@link #ingestModule(ProjectInfo, String, Integer, Integer)}.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.agenticcode.codeserver.service;
|
||||
|
||||
import com.agenticcode.neo4jstore.graph.GraphRepository;
|
||||
import com.agenticcode.neo4jstore.graph.ProjectInfo;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Item 130: a short-lived cache of the {@code (:Project)} shell, so the scope/staleness response
|
||||
* headers cost no database round trip per request.
|
||||
*
|
||||
* <p>Without it the header filter would add a Neo4j read to <em>every</em> call — unacceptable for
|
||||
* endpoints that answer in tens of milliseconds. The TTL is short, and the ingest path
|
||||
* {@link #invalidate(String) invalidates} explicitly rather than waiting it out: a stale
|
||||
* {@code ingestedAt} is a cosmetic lag, but a stale {@code incomplete=false} while a refresh is
|
||||
* running would point exactly the wrong way — it would say "clean" about a half-updated graph.
|
||||
*
|
||||
* <p>A read failure yields {@code null} (no headers) rather than an error: this is decoration on
|
||||
* someone else's answer, and it must never turn a good response into a failed one.
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class ProjectMetadataCache {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(ProjectMetadataCache.class);
|
||||
|
||||
/**
|
||||
* How long a cached shell may be reused. Short enough that a missed invalidation self-corrects
|
||||
* within one human-noticeable moment.
|
||||
*/
|
||||
private static final long TTL_MILLIS = 10_000;
|
||||
|
||||
private final GraphRepository graphRepository;
|
||||
private final Map<String, Entry> cache = new ConcurrentHashMap<>();
|
||||
|
||||
public ProjectMetadataCache(GraphRepository graphRepository) {
|
||||
this.graphRepository = graphRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the project's shell, or {@code null} if it does not exist or could not be read
|
||||
*/
|
||||
public @Nullable ProjectInfo get(String project) {
|
||||
Entry cached = cache.get(project);
|
||||
long now = System.currentTimeMillis();
|
||||
if (cached != null && now - cached.readAt() < TTL_MILLIS) {
|
||||
return cached.info();
|
||||
}
|
||||
try {
|
||||
@Nullable ProjectInfo info = graphRepository.getProject(project).await().indefinitely();
|
||||
cache.put(project, new Entry(info, now));
|
||||
return info;
|
||||
} catch (RuntimeException e) {
|
||||
LOG.debugf(e, "Could not read project '%s' for response headers", project);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the cached shell — called when an ingest changes it, so the in-flight marker is visible
|
||||
* immediately rather than up to {@link #TTL_MILLIS} late.
|
||||
*/
|
||||
public void invalidate(String project) {
|
||||
cache.remove(project);
|
||||
}
|
||||
|
||||
private record Entry(@Nullable ProjectInfo info, long readAt) {
|
||||
}
|
||||
}
|
||||
@@ -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=198
|
||||
agenticcode.version=265
|
||||
# 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,52 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Item 136: an unexpected failure must still leave the server as structured JSON, and a deliberate
|
||||
* one must pass through untouched.
|
||||
*
|
||||
* <p>The pass-through half is the one worth testing hardest: {@code Throwable} is the least specific
|
||||
* exception type there is, so without it this mapper would convert every {@code 404}/{@code 400} the
|
||||
* API answers today into a {@code 500}.
|
||||
*/
|
||||
class ApiExceptionMapperTest {
|
||||
|
||||
private final ApiExceptionMapper mapper = new ApiExceptionMapper();
|
||||
|
||||
@Test
|
||||
void anUnexpectedFailureBecomesAStructuredInternalError() {
|
||||
Response response = mapper.toResponse(new IllegalStateException("boom"));
|
||||
|
||||
assertEquals(500, response.getStatus());
|
||||
ErrorResponse body = (ErrorResponse) response.getEntity();
|
||||
assertEquals("INTERNAL_ERROR", body.code());
|
||||
assertTrue(body.details().containsKey("errorId"), "the correlation id belongs in details");
|
||||
assertFalse(body.error().contains("boom"),
|
||||
"the exception message may name internals and must not be echoed to the client");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDeliberateNotFoundIsHandedBackUnchanged() {
|
||||
Response built = ProjectResource.error(Response.Status.NOT_FOUND, "PROJECT_NOT_FOUND", "no such project");
|
||||
|
||||
Response response = mapper.toResponse(new WebApplicationException(built));
|
||||
|
||||
assertEquals(404, response.getStatus());
|
||||
assertEquals("PROJECT_NOT_FOUND", ((ErrorResponse) response.getEntity()).code());
|
||||
}
|
||||
|
||||
/**
|
||||
* The runtime's own routing failures are {@code WebApplicationException}s too and must keep their
|
||||
* status — an unknown path stays a 404, not a 500.
|
||||
*/
|
||||
@Test
|
||||
void aRoutingFailureKeepsItsStatus() {
|
||||
assertEquals(404, mapper.toResponse(new NotFoundException()).getStatus());
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@ class CoalescingIT {
|
||||
assertTrue(state.isFull(), "the module is FULL after concurrent deep ingests");
|
||||
assertEquals(IngestStatus.INGESTED.name(), state.status());
|
||||
|
||||
List<IdentifierMatch> realModules = graphRepository.searchIdentifier(PROJECT, MODULE, "MODULE", null, null, null, 50, 0)
|
||||
List<IdentifierMatch> realModules = graphRepository.searchIdentifier(PROJECT, MODULE, "MODULE", null, null, null, false, 50, 0)
|
||||
.await().indefinitely().stream()
|
||||
.filter(m -> !m.sourceFile().isEmpty())
|
||||
.toList();
|
||||
|
||||
@@ -44,6 +44,8 @@ class CopycodeExpansionIT {
|
||||
copyFixture("fixtures/natural/copycode/MYLDA.lda");
|
||||
copyFixture("fixtures/natural/copycode/QUALCOPY.cpy");
|
||||
copyFixture("fixtures/natural/copycode/QUALHOST.nat");
|
||||
copyFixture("fixtures/natural/copycode/BROWSECPY.cpy");
|
||||
copyFixture("fixtures/natural/copycode/BROWSEHOST.nat");
|
||||
|
||||
given().contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
|
||||
@@ -171,6 +173,42 @@ class CopycodeExpansionIT {
|
||||
+ "Full response was: " + body.getList("$"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Items 120/121/123 in one fixture, because in the corpus they occur in one statement.
|
||||
*
|
||||
* <p>{@code BROWSEHOST} pulls in a browse copycode whose {@code CALLNAT} target exists only after
|
||||
* substitution. Three separate defects each broke it on their own:
|
||||
* <ul>
|
||||
* <li><b>120</b> — the arguments run onto line 12, and only line 11 was read, so {@code &2&}
|
||||
* survived substitution verbatim and the call was dropped;</li>
|
||||
* <li><b>121</b> — {@code '''AGNT-CHG-CMP-SP'''} is <em>one</em> literal, but split into three
|
||||
* arguments, shifting every later position by one;</li>
|
||||
* <li><b>123</b> — the target is written {@code '"YAGCHBN0"'}, and the {@code CALLNAT} pattern
|
||||
* accepted only {@code '} as a string delimiter.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Measured over {@code upms}: fixing 120 alone recovers <b>0</b> edges — its own motivating
|
||||
* example is a 123 case. All three together recover 2672 copycode-derived call pairs (+49%), 0
|
||||
* lost. That is why the fixture asserts the call surfaces rather than asserting three mechanisms.
|
||||
*/
|
||||
@Test
|
||||
void aBrowseIncludeWithMultiLineEscapedArgumentsResolvesItsCall() {
|
||||
JsonPath body = given().pathParam("name", "BROWSEHOST")
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callees")
|
||||
.then().statusCode(200)
|
||||
.body("items.name", hasItem("YAGCHBN0"))
|
||||
// Item 121's phantom: the sort key bound to &2& and became a MODULE node of its own,
|
||||
// so the endpoint reported a call that does not exist while hiding the one that does.
|
||||
.body("items.name", not(hasItem("AGNT-CHG-CMP-SP")))
|
||||
.extract().jsonPath();
|
||||
|
||||
Map<String, Object> site = body.getMap("items.find { it.name == 'YAGCHBN0' }.sites[0]");
|
||||
assertEquals("BROWSECPY", site.get("viaCopycode"));
|
||||
assertEquals(8, site.get("lineNo"), "the CALLNAT is on line 8 of BROWSECPY.cpy");
|
||||
assertEquals(11, site.get("includedAt"),
|
||||
"the INCLUDE is on line 11 of BROWSEHOST.nat — line 12 is its argument continuation");
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 70: placeholder resolution must not throw the copycode provenance away.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.RestAssured;
|
||||
import jakarta.inject.Inject;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.neo4j.driver.Driver;
|
||||
import org.neo4j.driver.Session;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Item 75-B: a copycode-resident node belongs to the module that includes it, not to the copycode.
|
||||
*
|
||||
* <p>{@code CopycodePreprocessor} splices a {@code .cpy} body into every including module before
|
||||
* parsing, and the nodes it produces used to be MERGEd on {@code (type, name, sourceFile)} — so all
|
||||
* includers shared <em>one</em> node. Two consequences, both of which this test pins:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>{@code CONTAINS} became cyclic (items 75/75-C).</b> A copycode may open a block it does
|
||||
* not close (the {@code END-FOR} lives in the includer — {@code YFRAMBC0.cpy} in {@code upms}
|
||||
* does exactly this), so one node collected the nesting context of every expansion. Measured on
|
||||
* {@code upms}, every cycle pairs a copycode node with statements of a <em>single</em> host that
|
||||
* includes it repeatedly ({@code JX0031N0.nat} includes {@code YFRAMBC0} 16 times), which is why
|
||||
* identity has to be per <em>expansion site</em> ({@code <hostFile>#<includePath>}) and not per
|
||||
* module. {@code HOSTB} in this fixture reproduces that shape with two nested sites.</li>
|
||||
* <li><b>The stale-edge reaps could not run on it.</b> Items 124/86/106 key on the source node's
|
||||
* file; with a shared node, reaping during one module's refresh would delete edges the other
|
||||
* includers contributed and never re-create them. The fixture's second half asserts the
|
||||
* opposite property now holds: refreshing one host leaves the other host's copies alone.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@QuarkusTest
|
||||
class CopycodeNodeOwnershipIT {
|
||||
|
||||
private static final String PROJECT = "item75b-copycode-ownership";
|
||||
private static final String CPY = "SHAREDBLK.cpy";
|
||||
|
||||
@TempDir
|
||||
static Path root;
|
||||
|
||||
@Inject
|
||||
Driver driver;
|
||||
|
||||
@BeforeAll
|
||||
static void createProject() {
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
// Opens a FOR it never closes: the END-FOR is supplied by each including module. This is the
|
||||
// shape that made the shared node cyclic.
|
||||
write(CPY, """
|
||||
FOR #I = 1 TO 10
|
||||
CALLNAT 'SHAREDSUB' #I
|
||||
""");
|
||||
write("HOSTA.nat", """
|
||||
DEFINE DATA
|
||||
LOCAL
|
||||
1 #I (I2)
|
||||
END-DEFINE
|
||||
*
|
||||
INCLUDE SHAREDBLK
|
||||
END-FOR
|
||||
*
|
||||
END
|
||||
""");
|
||||
// Item 75-C: the same copycode included TWICE, at different nesting depths. Both expansions
|
||||
// used to map onto one node (same file, same line), so that node was contained by the IF of
|
||||
// the second site and contained the IF itself — the CONTAINS 2-cycle of item 75.
|
||||
write("HOSTB.nat", """
|
||||
DEFINE DATA
|
||||
LOCAL
|
||||
1 #I (I2)
|
||||
END-DEFINE
|
||||
*
|
||||
INCLUDE SHAREDBLK
|
||||
IF #I = 2
|
||||
INCLUDE SHAREDBLK
|
||||
END-FOR
|
||||
END-IF
|
||||
END-FOR
|
||||
*
|
||||
END
|
||||
""");
|
||||
write("SHAREDSUB.nat", """
|
||||
DEFINE DATA
|
||||
PARAMETER
|
||||
1 #I (I2)
|
||||
END-DEFINE
|
||||
*
|
||||
END
|
||||
""");
|
||||
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);
|
||||
}
|
||||
|
||||
private static void write(String fileName, String content) {
|
||||
try {
|
||||
Files.write(root.resolve(fileName), content.getBytes(StandardCharsets.UTF_8));
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private long count(String cypher) {
|
||||
try (Session session = driver.session()) {
|
||||
return session.run(cypher, Map.of("p", PROJECT)).single().get("c").asLong();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards against a vacuous pass: if the copycode were not expanded at all, every assertion below
|
||||
* would hold trivially.
|
||||
*/
|
||||
@Test
|
||||
void theCopycodeIsActuallyExpandedIntoBothHosts() {
|
||||
List<String> owners;
|
||||
try (Session session = driver.session()) {
|
||||
owners = session.run("""
|
||||
MATCH (n:AstNode {project: $p})
|
||||
WHERE n.sourceFile ENDS WITH '.cpy'
|
||||
RETURN DISTINCT n.ownerModule AS owner ORDER BY owner
|
||||
""", Map.of("p", PROJECT))
|
||||
.list(r -> r.get("owner").asString());
|
||||
}
|
||||
assertEquals(3, owners.size(),
|
||||
"one expansion site for HOSTA and two for HOSTB, each with its own identity: " + owners);
|
||||
assertTrue(owners.stream().allMatch(o -> o.startsWith("HOSTA.nat#") || o.startsWith("HOSTB.nat#")),
|
||||
"an owner is <hostFile>#<includePath>: " + owners);
|
||||
}
|
||||
|
||||
/**
|
||||
* The point of the item: no node is shared between the two includers.
|
||||
*/
|
||||
@Test
|
||||
void eachIncluderGetsItsOwnCopyOfTheCopycodeNodes() {
|
||||
long shared = count("""
|
||||
MATCH (n:AstNode {project: $p})
|
||||
WHERE n.sourceFile ENDS WITH '.cpy' AND n.ownerModule = ''
|
||||
RETURN count(n) AS c
|
||||
""");
|
||||
assertEquals(0, shared, "a copycode-resident node must carry the including module as its owner");
|
||||
}
|
||||
|
||||
/**
|
||||
* A MODULE declared inside a copycode must stay shared — every module lookup binds
|
||||
* {@code (project, name, sourceFile)} and never {@code ownerModule}, so an owned MODULE node
|
||||
* would be invisible to them. Asserted on the whole project because the fixture has no such
|
||||
* module; the invariant is what matters, and it must hold for every node of that type.
|
||||
*/
|
||||
@Test
|
||||
void moduleAndTableNodesAreNeverOwned() {
|
||||
long owned = count("""
|
||||
MATCH (n:AstNode {project: $p})
|
||||
WHERE n.ownerModule <> '' AND n.type IN ['MODULE', 'DB_TABLE']
|
||||
RETURN count(n) AS c
|
||||
""");
|
||||
assertEquals(0, owned, "MODULE/DB_TABLE nodes must remain shared");
|
||||
}
|
||||
|
||||
/**
|
||||
* The symptom item 75 is named after. Not vacuous any more: HOSTB includes the copycode at two
|
||||
* differently-nested sites, which is exactly what makes the shared node contain the block that
|
||||
* contains it.
|
||||
*/
|
||||
@Test
|
||||
void containsIsAcyclic() {
|
||||
assertEquals(0, count("""
|
||||
MATCH (n:AstNode {project: $p})-[:CONTAINS]->(n)
|
||||
RETURN count(n) AS c
|
||||
"""), "no node may contain itself");
|
||||
assertEquals(0, count("""
|
||||
MATCH (a:AstNode {project: $p})-[:CONTAINS]->(b:AstNode {project: $p})-[:CONTAINS]->(a)
|
||||
RETURN count(a) AS c
|
||||
"""), "no two nodes may contain each other");
|
||||
}
|
||||
|
||||
/**
|
||||
* The reap hazard: {@code DELETE_STALE_FILE_NODES} and the item-86/106/124 edge reaps key on the
|
||||
* source file, and the copycode file is in every includer's fresh-file set. Keyed on the file
|
||||
* alone, refreshing HOSTA would delete HOSTB's copies (their {@code ingestGen} is a transaction
|
||||
* old) together with their edges, and nothing would rebuild them.
|
||||
*/
|
||||
@Test
|
||||
void refreshingOneHostLeavesTheOtherHostsCopiesIntact() {
|
||||
String cypher = """
|
||||
MATCH (n:AstNode {project: $p})
|
||||
WHERE n.sourceFile ENDS WITH '.cpy' AND n.ownerModule STARTS WITH 'HOSTB.nat#'
|
||||
OPTIONAL MATCH (n)-[r]-()
|
||||
RETURN count(DISTINCT n) + count(r) AS c
|
||||
""";
|
||||
long before = count(cypher);
|
||||
assertTrue(before > 0, "HOSTB must own copycode nodes before the refresh");
|
||||
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh?paths=HOSTA.nat")
|
||||
.then().statusCode(200);
|
||||
|
||||
assertEquals(before, count(cypher), "refreshing HOSTA must not touch HOSTB's copycode nodes or edges");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.RestAssured;
|
||||
import io.restassured.path.json.JsonPath;
|
||||
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 java.util.List;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* The dispatch-<em>table</em> idiom — an array filled with literal program names, called through an
|
||||
* indexed read of it — resolves to candidate call edges. 65 modules in {@code upms} dispatch this way.
|
||||
*
|
||||
* <p>{@code RESOLVE_DYNAMIC_CALLNAT_INTRA_INDIRECT} handles it: for the assignment writing the
|
||||
* dispatch variable it follows the {@code READS} on that same line to the array, then takes every
|
||||
* string literal written to the array as a target. Item 83's scalar fold does not reach these — the
|
||||
* literals live one hop away, on the array node.
|
||||
*
|
||||
* <p><b>Written while investigating item 108, which claims this idiom is unsupported.</b> It is
|
||||
* supported; what is missing there is only the {@code dispatch-table} <em>endpoint</em> reporting the
|
||||
* rows. The reason `upms` shows nothing is item 107: those target modules are absent from the
|
||||
* checkout entirely, and a literal naming no ingested module correctly yields no edge. This fixture
|
||||
* exists because the behaviour had no test of its own, so nothing would have caught its loss.
|
||||
*
|
||||
* <p>Which index is live at runtime is not statically known, so resolution over-approximates to the
|
||||
* set of literals ever assigned to the array — the same multi-target model item 82 allows a manual
|
||||
* override. `TBRANCH` pins that this is not merely a simplification but the only correct answer: it
|
||||
* builds the table in an `IF`/`ELSE`, so index 1 carries a different program per branch and any
|
||||
* index-keyed pairing would be wrong.
|
||||
*/
|
||||
@QuarkusTest
|
||||
class DispatchTableFoldIT {
|
||||
|
||||
private static final String PROJECT = "nat-dispatch-table-fold";
|
||||
|
||||
/**
|
||||
* Straight table: four literals, called through an indexed read.
|
||||
*/
|
||||
private static final String ROUTER = """
|
||||
* Router dispatching through a literal-filled table.
|
||||
DEFINE DATA LOCAL
|
||||
01 #WT-PROG (A8/1:4)
|
||||
01 #W-ACT (A8)
|
||||
01 #I-OBJ (I2)
|
||||
END-DEFINE
|
||||
*
|
||||
PERFORM INIT-TABLE
|
||||
*
|
||||
#W-ACT := #WT-PROG (#I-OBJ)
|
||||
CALLNAT #W-ACT
|
||||
*
|
||||
DEFINE SUBROUTINE INIT-TABLE
|
||||
ASSIGN #WT-PROG (1) = 'TDISPA'
|
||||
ASSIGN #WT-PROG (2) = 'TDISPB'
|
||||
ASSIGN #WT-PROG (3) = 'TDISPC'
|
||||
ASSIGN #WT-PROG (4) = 'TNOSUCH'
|
||||
END-SUBROUTINE
|
||||
*
|
||||
END
|
||||
""";
|
||||
|
||||
/**
|
||||
* Table built in two branches: index 1 is TDISPA in one and TDISPB in the other. Also proves the
|
||||
* resolver does not depend on the init preceding the call — here the subroutine is defined after
|
||||
* the CALLNAT, as Natural routinely does.
|
||||
*/
|
||||
private static final String BRANCHED = """
|
||||
* Router whose table depends on a runtime condition.
|
||||
DEFINE DATA LOCAL
|
||||
01 #WT-PROG (A8/1:2)
|
||||
01 #W-ACT (A8)
|
||||
01 #I-OBJ (I2)
|
||||
01 #MODE (A4)
|
||||
END-DEFINE
|
||||
*
|
||||
PERFORM INIT-TABLE
|
||||
#W-ACT := #WT-PROG (#I-OBJ)
|
||||
CALLNAT #W-ACT
|
||||
*
|
||||
DEFINE SUBROUTINE INIT-TABLE
|
||||
IF #MODE = 'ADD'
|
||||
ASSIGN #WT-PROG (1) = 'TDISPA'
|
||||
ELSE
|
||||
ASSIGN #WT-PROG (1) = 'TDISPB'
|
||||
END-IF
|
||||
ASSIGN #WT-PROG (2) = 'TDISPC'
|
||||
END-SUBROUTINE
|
||||
*
|
||||
END
|
||||
""";
|
||||
|
||||
/**
|
||||
* A dispatch fed from a plain scalar, not an array — must stay untouched by this resolver.
|
||||
*/
|
||||
private static final String SCALARDSP = """
|
||||
* Dispatch through a scalar the table resolver must not claim.
|
||||
DEFINE DATA LOCAL
|
||||
01 #W-ACT (A8)
|
||||
END-DEFINE
|
||||
*
|
||||
#W-ACT := 'TDISPA'
|
||||
CALLNAT #W-ACT
|
||||
END
|
||||
""";
|
||||
|
||||
private static final String TARGET = "DEFINE DATA LOCAL\nEND-DEFINE\nEND\n";
|
||||
|
||||
@TempDir
|
||||
static Path root;
|
||||
|
||||
@BeforeAll
|
||||
static void ingest() {
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
write("TROUTER.nat", ROUTER);
|
||||
write("TBRANCH.nat", BRANCHED);
|
||||
write("TSCALAR.nat", SCALARDSP);
|
||||
write("TDISPA.nat", TARGET);
|
||||
write("TDISPB.nat", TARGET);
|
||||
write("TDISPC.nat", TARGET);
|
||||
// TNOSUCH.nat deliberately absent: a literal that is not a real module must yield no edge.
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private static void write(String fileName, String content) {
|
||||
try {
|
||||
Files.writeString(root.resolve(fileName), content);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonPath callees(String module) {
|
||||
return given().pathParam("name", module)
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callees?scope=external")
|
||||
.then().statusCode(200).extract().jsonPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every literal in the table becomes a candidate target.
|
||||
*/
|
||||
@Test
|
||||
void everyTableLiteralBecomesACandidateTarget() {
|
||||
List<String> names = callees("TROUTER").getList("items.name");
|
||||
|
||||
assertTrue(names.containsAll(List.of("TDISPA", "TDISPB", "TDISPC")),
|
||||
"all three real table targets must be resolved: " + names);
|
||||
}
|
||||
|
||||
/**
|
||||
* A literal that names no ingested module produces no edge — the resolver does not invent nodes.
|
||||
*/
|
||||
@Test
|
||||
void aLiteralThatIsNotARealModuleYieldsNoEdge() {
|
||||
assertFalse(callees("TROUTER").getList("items.name").contains("TNOSUCH"),
|
||||
"'TNOSUCH' is a literal in the table but no module exists — it must not become an edge");
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolved edges are marked inferred, and distinguishable from item 83's string fold.
|
||||
*/
|
||||
@Test
|
||||
void resolvedEdgesAreMarkedInferredNotStatic() {
|
||||
JsonPath body = callees("TROUTER");
|
||||
assertEquals("CALLNAT_DYNAMIC", body.getString("items.find { it.name == 'TDISPA' }.edgeKind"),
|
||||
"a table dispatch is inferred, so it must not masquerade as a static CALLNAT");
|
||||
}
|
||||
|
||||
/**
|
||||
* The branched table: both branch values are candidates. This is the case that makes the
|
||||
* candidate-set model necessary rather than merely convenient — index 1 has two different targets,
|
||||
* so no index-keyed answer could be right.
|
||||
*/
|
||||
@Test
|
||||
void aTableBuiltInTwoBranchesContributesBothValues() {
|
||||
List<String> names = callees("TBRANCH").getList("items.name");
|
||||
|
||||
assertTrue(names.contains("TDISPA") && names.contains("TDISPB"),
|
||||
"index 1 is TDISPA in one branch and TDISPB in the other; both are possible: " + names);
|
||||
assertTrue(names.contains("TDISPC"), "the unbranched index must resolve too: " + names);
|
||||
}
|
||||
|
||||
/**
|
||||
* The scalar case is item 83's fold, and it must keep resolving on its own — the two paths are
|
||||
* separate, so a change to the indirect resolver must not silently take the scalar one with it.
|
||||
*/
|
||||
@Test
|
||||
void aScalarFedDispatchStillResolvesOnItsOwnPath() {
|
||||
assertTrue(callees("TSCALAR").getList("items.name").contains("TDISPA"),
|
||||
"a directly-assigned literal target must resolve without the array path");
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,33 @@ class DuplicateIdentityIT {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 136: the duplicate marker is a node like any other, and every node must carry
|
||||
* {@code startLine}/{@code endLine}. {@code MARK_DUPLICATE_IDENTITIES} was the one node-creating
|
||||
* query that set neither, and the identifier search's row mapper coerced them unconditionally — so
|
||||
* a page deep enough to reach a marker faulted with an unstructured 500 instead of returning rows.
|
||||
*/
|
||||
@Test
|
||||
void aDuplicateMarkerCarriesLinesAndDoesNotFaultTheIdentifierSearch() {
|
||||
given().queryParam("name", "DUPE").queryParam("limit", 500)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/identifier")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("findAll { it.name == 'DUPE' }.startLine", everyItem(notNullValue()))
|
||||
.body("findAll { it.name == 'DUPE' }.endLine", everyItem(notNullValue()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The same page, fetched whole. Pins the actual reported symptom — a 500 several hundred rows in —
|
||||
* rather than only the property that caused it.
|
||||
*/
|
||||
@Test
|
||||
void aFullIdentifierPageOverTheWholeProjectDoesNotFault() {
|
||||
given().queryParam("name", "E").queryParam("contains", true).queryParam("limit", 500)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/identifier")
|
||||
.then().statusCode(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* The unreferenced duplicate: used to answer 404, i.e. "no such module".
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
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 125: a Java type declaration must be findable through {@code search/identifier} by its
|
||||
* <b>short</b> name, not only by the fully-qualified identity the graph stores (item 117), and
|
||||
* {@code ?contains=} must do what it does on {@code /search/value} instead of being dropped.
|
||||
*
|
||||
* <p>The bug this pins down was not "types are not indexed" — they were, under their FQN — but that
|
||||
* the short form an agent actually types answered {@code []}, which is indistinguishable from "no
|
||||
* such name". The match now also carries {@code simpleName} and {@code moduleKind}, so "is this a
|
||||
* type or a method?" is answered by the search itself.
|
||||
*/
|
||||
@QuarkusTest
|
||||
class IdentifierTypeDeclarationIT {
|
||||
|
||||
private static final String PROJECT = "identifier-type-decl-project";
|
||||
private static final String FQN = "com.example.idt.PartnerUpdateLogic";
|
||||
|
||||
@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/idt");
|
||||
writeSource(pkg, "PartnerUpdateLogic.java", """
|
||||
package com.example.idt;
|
||||
|
||||
public class PartnerUpdateLogic {
|
||||
private String partnerName;
|
||||
|
||||
public void updatePartner() {
|
||||
this.partnerName = "x";
|
||||
}
|
||||
}
|
||||
""");
|
||||
writeSource(pkg, "PartnerReadPort.java", """
|
||||
package com.example.idt;
|
||||
|
||||
public interface PartnerReadPort {
|
||||
String read();
|
||||
}
|
||||
""");
|
||||
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 writeSource(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.specification.RequestSpecification search() {
|
||||
return given().queryParam("type", "MODULE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void theShortNameFindsTheTypeDeclaration() {
|
||||
search()
|
||||
.queryParam("name", "PartnerUpdateLogic")
|
||||
.when().get("/api/projects/" + PROJECT + "/search/identifier")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("name", hasItem(FQN))
|
||||
.body("find { it.name == '" + FQN + "' }.simpleName", equalTo("PartnerUpdateLogic"))
|
||||
.body("find { it.name == '" + FQN + "' }.moduleKind", equalTo("CLASS"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theQualifiedNameStillFindsIt() {
|
||||
search()
|
||||
.queryParam("name", FQN)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/identifier")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("name", hasItem(FQN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void moduleKindDistinguishesAnInterfaceFromAClass() {
|
||||
search()
|
||||
.queryParam("name", "PartnerReadPort")
|
||||
.when().get("/api/projects/" + PROJECT + "/search/identifier")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("moduleKind", everyItem(equalTo("INTERFACE")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void containsMatchesASubstringOfTheShortNameCaseInsensitively() {
|
||||
search()
|
||||
.queryParam("name", "partnerupd").queryParam("contains", true)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/identifier")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("name", hasItem(FQN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void containsAlsoMatchesThePackageFragmentOfTheQualifiedName() {
|
||||
search()
|
||||
.queryParam("name", "com.example.idt").queryParam("contains", true)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/identifier")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("name", hasItems(FQN, "com.example.idt.PartnerReadPort"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutContainsASubstringIsNotAMatch() {
|
||||
search()
|
||||
.queryParam("name", "PartnerUpd")
|
||||
.when().get("/api/projects/" + PROJECT + "/search/identifier")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("$", hasSize(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMethodMatchCarriesNoSimpleNameOrModuleKind() {
|
||||
given()
|
||||
.queryParam("name", "updatePartner")
|
||||
.when().get("/api/projects/" + PROJECT + "/search/identifier")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("find { it.type == 'FUNCTION' }.simpleName", nullValue())
|
||||
.body("find { it.type == 'FUNCTION' }.moduleKind", nullValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void containsWithoutANameIsRejectedRatherThanDumpingEveryNode() {
|
||||
given()
|
||||
.queryParam("contains", true)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/identifier")
|
||||
.then()
|
||||
.statusCode(400)
|
||||
.body("code", equalTo("MISSING_NAME"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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.InputStream;
|
||||
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 140: a Java project with no JPA entity has no {@code DB_TABLE}, so every {@code DB_ACCESS}
|
||||
* candidate the parser's over-approximating heuristic emitted is a false positive by construction
|
||||
* and is reaped at the end of enrichment.
|
||||
*
|
||||
* <p>Both projects ingest the <em>same</em> two source files; the second adds one entity. That is
|
||||
* the whole difference, and it is what makes this a test of the gate rather than of the parser: the
|
||||
* reaper is project-level, so the identical false positives survive in the project that happens to
|
||||
* own a table. {@code sql-statements} is the endpoint under test because it joins the table with
|
||||
* {@code OPTIONAL MATCH} and is therefore the one that actually leaked them.
|
||||
*/
|
||||
@QuarkusTest
|
||||
class JavaDbAccessNoEntityIT {
|
||||
|
||||
private static final String WITHOUT_ENTITY = "item140-no-entity";
|
||||
private static final String WITH_ENTITY = "item140-with-entity";
|
||||
|
||||
@TempDir
|
||||
static Path withoutEntityRoot;
|
||||
|
||||
@TempDir
|
||||
static Path withEntityRoot;
|
||||
|
||||
@BeforeAll
|
||||
static void ingest() {
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
copyFixture(withoutEntityRoot, "fixtures/java/nodb/TextUtils.java");
|
||||
copyFixture(withoutEntityRoot, "fixtures/java/nodb/ReportRenderer.java");
|
||||
copyFixture(withEntityRoot, "fixtures/java/nodb/TextUtils.java");
|
||||
copyFixture(withEntityRoot, "fixtures/java/nodb/ReportRenderer.java");
|
||||
copyFixture(withEntityRoot, "fixtures/java/nodb/Ledger.java");
|
||||
ingestProject(WITHOUT_ENTITY, withoutEntityRoot);
|
||||
ingestProject(WITH_ENTITY, withEntityRoot);
|
||||
}
|
||||
|
||||
private static void ingestProject(String project, Path root) {
|
||||
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 copyFixture(Path root, String classpathResource) {
|
||||
String fileName = classpathResource.substring(classpathResource.lastIndexOf('/') + 1);
|
||||
try (InputStream in = JavaDbAccessNoEntityIT.class.getClassLoader().getResourceAsStream(classpathResource)) {
|
||||
if (in == null) {
|
||||
throw new IllegalStateException("Resource not found: " + classpathResource);
|
||||
}
|
||||
Files.write(root.resolve(fileName), in.readAllBytes());
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Without an entity, {@code TextUtils.getColumn(...)} must not be reported as a database read.
|
||||
*/
|
||||
@Test
|
||||
void noEntityMeansNoDbAccessAtAll() {
|
||||
given().pathParam("name", "ReportRenderer")
|
||||
.when().get("/api/projects/" + WITHOUT_ENTITY + "/modules/{name}/sql-statements")
|
||||
.then().statusCode(200)
|
||||
.body("$", empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* db-accesses was already empty here (it joins the table with a plain MATCH), and stays empty —
|
||||
* the reaper must not have made it worse by leaving a dangling row behind.
|
||||
*/
|
||||
@Test
|
||||
void noEntityMeansNoDbAccessesEither() {
|
||||
given().pathParam("name", "ReportRenderer")
|
||||
.when().get("/api/projects/" + WITHOUT_ENTITY + "/modules/{name}/db-accesses")
|
||||
.then().statusCode(200)
|
||||
.body("$", empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* The gate is project-level, so one entity anywhere in the project keeps every candidate alive —
|
||||
* including the same static-getter false positives, which still arrive with a null table. This
|
||||
* pins the deliberate limit of item 140's chosen rule rather than glossing over it.
|
||||
*/
|
||||
@Test
|
||||
void oneEntityInTheProjectKeepsTheSameFalsePositives() {
|
||||
given().pathParam("name", "ReportRenderer")
|
||||
.when().get("/api/projects/" + WITH_ENTITY + "/modules/{name}/sql-statements")
|
||||
.then().statusCode(200)
|
||||
.body("$", not(empty()))
|
||||
.body("statement", hasItem("TextUtils.getColumn(line, 0, 8)"))
|
||||
.body("table", hasItem(nullValue()));
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.empty;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
/**
|
||||
* Item 72: a dispatch row must report its <em>whole</em> guard chain, not just the innermost one.
|
||||
@@ -45,6 +46,7 @@ class NestedDispatchGuardIT {
|
||||
static void ingest() {
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
copyFixture("fixtures/natural/dispatch/NESTDISP.nat");
|
||||
copyFixture("fixtures/natural/dispatch/DISPCPY.cpy");
|
||||
|
||||
given().contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
|
||||
@@ -118,6 +120,56 @@ class NestedDispatchGuardIT {
|
||||
assertEquals(List.of(List.of("TABL")), body.getList(row + ".guards.values"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 122: a row spliced in from a copycode must name the copycode's file, not the host's.
|
||||
*
|
||||
* <p>Before this, {@code dispatch-table} was the only site-bearing endpoint carrying a bare
|
||||
* {@code lineNo} — {@code callees}, {@code db-accesses}, {@code workfile-accesses} and
|
||||
* {@code functions} all already carried the provenance quartet. So an agent resolved the number
|
||||
* against the host file and landed somewhere arbitrary: here, line 8 of {@code NESTDISP.nat} is a
|
||||
* comment in the module header. In {@code upms} this hit 26 of {@code VCOMIN50}'s 44 rows.
|
||||
*/
|
||||
@Test
|
||||
void aCopycodeDerivedRowNamesTheCopycodeFile() {
|
||||
JsonPath body = dispatchTable();
|
||||
String row = "find { it.assignedValue == 'DESC-FROM-CPY' }";
|
||||
|
||||
assertEquals("DISPCPY.cpy", body.getString(row + ".sourceFile"),
|
||||
"the MOVE is written in the copycode. Full response was: " + body.getList("$"));
|
||||
assertEquals("DISPCPY", body.getString(row + ".viaCopycode"));
|
||||
assertEquals(8, body.getInt(row + ".lineNo"), "the MOVE is on line 8 of DISPCPY.cpy");
|
||||
assertEquals(35, body.getInt(row + ".includedAt"),
|
||||
"and points back at the INCLUDE on line 35 of NESTDISP.nat");
|
||||
}
|
||||
|
||||
/**
|
||||
* The guard chain spans the file boundary: the outer {@code DECIDE} is in the host, the inner one
|
||||
* in the copycode. Expansion happens before parsing, so this should hold — but it is the property
|
||||
* that makes the row usable, and it is worth pinning rather than assuming.
|
||||
*/
|
||||
@Test
|
||||
void theGuardChainSpansTheIncludeBoundary() {
|
||||
JsonPath body = dispatchTable();
|
||||
String row = "find { it.assignedValue == 'DESC-FROM-CPY' }";
|
||||
|
||||
assertEquals(List.of("#SHORT-VIEW", "#FIELD-NAME"), body.getList(row + ".guards.field"),
|
||||
"the host's DECIDE guards the copycode's DECIDE");
|
||||
assertEquals(List.of(List.of("CPYV"), List.of("TX-FROM-CPY")), body.getList(row + ".guards.values"));
|
||||
}
|
||||
|
||||
/**
|
||||
* A host-local row carries no copycode marker and reports the host file.
|
||||
*/
|
||||
@Test
|
||||
void aHostLocalRowReportsTheHostFileWithNoCopycodeMarker() {
|
||||
JsonPath body = dispatchTable();
|
||||
String row = "find { it.assignedValue == 'CODE-TABL' }";
|
||||
|
||||
assertEquals("NESTDISP.nat", body.getString(row + ".sourceFile"));
|
||||
assertNull(body.getString(row + ".viaCopycode"), "nothing included it, so there is no copycode to name");
|
||||
assertEquals(23, body.getInt(row + ".lineNo"), "the MOVE is on line 23 of NESTDISP.nat");
|
||||
}
|
||||
|
||||
/**
|
||||
* The legacy fields keep their exact meaning (the innermost guard), so item 72 is additive: an
|
||||
* existing consumer reading guardField/guardValues sees what it always saw.
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.RestAssured;
|
||||
import org.junit.jupiter.api.*;
|
||||
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 126: {@code /projects} must carry what the last <b>whole-root</b> ingest did, so a negative
|
||||
* answer is evidence rather than a guess. Before this, nothing in the API said whether a project had
|
||||
* ever been fully ingested, so every "not found" had to be cross-checked against the file system.
|
||||
*
|
||||
* <p>The sharpest assertion here is {@link #aByNameRefreshDoesNotMoveIngestedAt()}: a partial ingest
|
||||
* that moved the timestamp would report the project as freshly walked when one module was deepened —
|
||||
* which is the very "looks complete but isn't" answer the item exists to remove.
|
||||
*/
|
||||
@QuarkusTest
|
||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||
class ProjectIngestMetadataIT {
|
||||
|
||||
private static final String PROJECT = "ingest-metadata-project";
|
||||
|
||||
@TempDir
|
||||
static Path root;
|
||||
|
||||
@BeforeAll
|
||||
static void createProject() {
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
writeSource("GOOD.nat", """
|
||||
DEFINE DATA
|
||||
LOCAL
|
||||
1 #A (A8)
|
||||
END-DEFINE
|
||||
*
|
||||
CALLNAT 'OTHER'
|
||||
END
|
||||
""");
|
||||
writeSource("OTHER.nat", """
|
||||
DEFINE DATA
|
||||
LOCAL
|
||||
1 #B (A8)
|
||||
END-DEFINE
|
||||
*
|
||||
END
|
||||
""");
|
||||
given()
|
||||
.contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
|
||||
.when().post("/api/projects/" + PROJECT)
|
||||
.then()
|
||||
.statusCode(201);
|
||||
}
|
||||
|
||||
private static void writeSource(String fileName, String content) {
|
||||
try {
|
||||
Files.writeString(root.resolve(fileName), content);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static io.restassured.response.Response project() {
|
||||
return given().when().get("/api/projects/" + PROJECT);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
void theCreateTimeScanIsRecordedAsTier1() {
|
||||
project().then()
|
||||
.statusCode(200)
|
||||
.body("ingest.mode", equalTo("tier1"))
|
||||
.body("ingest.ingestedAt", notNullValue())
|
||||
.body("ingest.filesExamined", equalTo(2))
|
||||
.body("ingest.filesPersisted", equalTo(2))
|
||||
.body("ingest.filesFailed", equalTo(0))
|
||||
.body("ingest.failuresTruncated", equalTo(false))
|
||||
.body("ingest.serverVersion", not(emptyString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(2)
|
||||
void aDeepRefreshRecordsTheFullModeAndTheNewFileCount() {
|
||||
writeSource("THIRD.nat", """
|
||||
DEFINE DATA
|
||||
LOCAL
|
||||
1 #C (A8)
|
||||
END-DEFINE
|
||||
*
|
||||
END
|
||||
""");
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true").then().statusCode(200);
|
||||
project().then()
|
||||
.statusCode(200)
|
||||
.body("ingest.mode", equalTo("full"))
|
||||
.body("ingest.filesExamined", equalTo(3))
|
||||
.body("ingest.filesPersisted", equalTo(3));
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure <em>list</em> and the failure <em>count</em> must agree whenever the list was not
|
||||
* truncated — that invariant is what stops a short list from being read as "these were all of
|
||||
* them". Deliberately not asserting that the malformed file below fails to parse: the Natural
|
||||
* parser is tolerant by design, so a fixture that "looks broken" is not a reliable way to produce
|
||||
* a failure, and a test that pretends otherwise would be testing the parser's mood.
|
||||
*/
|
||||
@Test
|
||||
@Order(3)
|
||||
void theFailureListAgreesWithTheFailureCount() {
|
||||
writeSource("BROKEN.nat", "DEFINE DATA LOCAL\n1 #X (A8\n");
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh").then().statusCode(200);
|
||||
io.restassured.response.Response response = project();
|
||||
response.then()
|
||||
.statusCode(200)
|
||||
.body("ingest.mode", equalTo("call_graph"))
|
||||
.body("ingest.filesExamined", equalTo(4));
|
||||
int failed = response.jsonPath().getInt("ingest.filesFailed");
|
||||
int listed = response.jsonPath().getList("ingest.failures").size();
|
||||
boolean truncated = response.jsonPath().getBoolean("ingest.failuresTruncated");
|
||||
org.junit.jupiter.api.Assertions.assertEquals(truncated, listed < failed,
|
||||
"failuresTruncated must say exactly whether the list is shorter than the count");
|
||||
org.junit.jupiter.api.Assertions.assertTrue(listed <= failed,
|
||||
"the failure list can never be longer than the failure count");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(4)
|
||||
void aConfigUpdateDoesNotClobberTheIngestMetadata() {
|
||||
String before = project().jsonPath().getString("ingest.ingestedAt");
|
||||
given()
|
||||
.contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest("now described", null, null, null, null, null))
|
||||
.when().put("/api/projects/" + PROJECT)
|
||||
.then().statusCode(200);
|
||||
project().then()
|
||||
.statusCode(200)
|
||||
.body("description", equalTo("now described"))
|
||||
.body("ingest.ingestedAt", equalTo(before));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(5)
|
||||
void aByNameRefreshDoesNotMoveIngestedAt() {
|
||||
String before = project().jsonPath().getString("ingest.ingestedAt");
|
||||
int examinedBefore = project().jsonPath().getInt("ingest.filesExamined");
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh/GOOD").then().statusCode(200);
|
||||
project().then()
|
||||
.statusCode(200)
|
||||
.body("ingest.ingestedAt", equalTo(before))
|
||||
.body("ingest.filesExamined", equalTo(examinedBefore));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(6)
|
||||
void theProjectListingCarriesTheSameMetadata() {
|
||||
given().when().get("/api/projects")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("find { it.name == '" + PROJECT + "' }.ingest.mode", notNullValue())
|
||||
.body("find { it.name == '" + PROJECT + "' }.ingest.filesExamined", greaterThan(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
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 130: the REST surface, composed from the class-level and method-level {@code @Path} that the
|
||||
* parser now persists — the daily "which code runs for this URL" question, previously answerable only
|
||||
* by joining two {@code /search/annotation} calls by hand.
|
||||
*
|
||||
* <p>Also covers the scope/staleness response headers, which exist so an <em>empty</em> answer can be
|
||||
* read correctly: "no callers" means something different in a project that excludes {@code test}.
|
||||
*/
|
||||
@QuarkusTest
|
||||
class RestEndpointsIT {
|
||||
|
||||
private static final String PROJECT = "rest-endpoints-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/rest");
|
||||
write(pkg, "PartnerResource.java", """
|
||||
package com.example.rest;
|
||||
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
|
||||
@Path("/partners")
|
||||
public class PartnerResource {
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
public String byId(String id) {
|
||||
return id;
|
||||
}
|
||||
|
||||
@POST
|
||||
public String create(String body) {
|
||||
return body;
|
||||
}
|
||||
|
||||
public String notAnEndpoint() {
|
||||
return "helper";
|
||||
}
|
||||
}
|
||||
""");
|
||||
write(pkg, "PathConstants.java", """
|
||||
package com.example.rest;
|
||||
|
||||
public final class PathConstants {
|
||||
public static final String ADMIN = "/admin";
|
||||
|
||||
private PathConstants() {
|
||||
}
|
||||
}
|
||||
""");
|
||||
write(pkg, "AdminResource.java", """
|
||||
package com.example.rest;
|
||||
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.Path;
|
||||
|
||||
@Path(AdminResource.BASE)
|
||||
public class AdminResource {
|
||||
static final String BASE = "/admin";
|
||||
|
||||
@DELETE
|
||||
public String wipe() {
|
||||
return "gone";
|
||||
}
|
||||
}
|
||||
""");
|
||||
write(pkg, "AbstractFileSvc.java", """
|
||||
package com.example.rest;
|
||||
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
|
||||
@Path("/files/")
|
||||
public abstract class AbstractFileSvc {
|
||||
|
||||
@POST
|
||||
@Path("/upload")
|
||||
public String upload(String body) {
|
||||
return body;
|
||||
}
|
||||
}
|
||||
""");
|
||||
write(pkg, "ReportFileSvc.java", """
|
||||
package com.example.rest;
|
||||
|
||||
public class ReportFileSvc extends AbstractFileSvc {
|
||||
}
|
||||
""");
|
||||
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 endpoints() {
|
||||
return given().when().get("/api/projects/" + PROJECT + "/rest-endpoints");
|
||||
}
|
||||
|
||||
@Test
|
||||
void theClassAndMethodPathsAreComposedIntoOnePath() {
|
||||
endpoints().then()
|
||||
.statusCode(200)
|
||||
.body("find { it.handler == 'byId' }.path", equalTo("/partners/{id}"))
|
||||
.body("find { it.handler == 'byId' }.httpMethod", equalTo("GET"))
|
||||
.body("find { it.handler == 'byId' }.module", equalTo("com.example.rest.PartnerResource"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMethodWithoutItsOwnPathInheritsTheClassPath() {
|
||||
endpoints().then()
|
||||
.statusCode(200)
|
||||
.body("find { it.handler == 'create' }.path", equalTo("/partners"))
|
||||
.body("find { it.handler == 'create' }.httpMethod", equalTo("POST"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aPathWrittenAsAConstantIsResolved() {
|
||||
endpoints().then()
|
||||
.statusCode(200)
|
||||
.body("find { it.handler == 'wipe' }.path", equalTo("/admin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMethodWithNoHttpVerbIsNotAnEndpoint() {
|
||||
endpoints().then()
|
||||
.statusCode(200)
|
||||
.body("handler", not(hasItem("notAnEndpoint")));
|
||||
}
|
||||
|
||||
/**
|
||||
* Both halves of the path carry their own slashes ({@code "/files/"} + {@code "/upload"}). The
|
||||
* first implementation joined them with a single non-overlapping {@code replace("//", "/")},
|
||||
* which turned {@code "///upload"} into {@code "//upload"} — visible only on real data, where
|
||||
* {@code pur} produced paths like {@code //file}.
|
||||
*/
|
||||
@Test
|
||||
void pathHalvesWithTheirOwnSlashesJoinWithExactlyOne() {
|
||||
endpoints().then()
|
||||
.statusCode(200)
|
||||
.body("path", everyItem(not(containsString("//"))))
|
||||
.body("find { it.handler == 'upload' }.path", equalTo("/files/upload"));
|
||||
}
|
||||
|
||||
/**
|
||||
* JAX-RS inherits {@code @Path} from a base class, and reporting the bare {@code /} for those is a
|
||||
* wrong answer rather than a missing one.
|
||||
*/
|
||||
@Test
|
||||
void aSubclassInheritsItsBaseClassPath() {
|
||||
endpoints().then()
|
||||
.statusCode(200)
|
||||
.body("findAll { it.module.endsWith('ReportFileSvc') }.path", everyItem(equalTo("/files/upload")));
|
||||
}
|
||||
|
||||
/**
|
||||
* The graph can hold more than one {@code CONTAINS} edge between the same module and function
|
||||
* (roadmap item 75), which multiplied endpoints into identical rows — 183 of 436 on {@code pur}.
|
||||
*/
|
||||
@Test
|
||||
void everyRowIsUnique() {
|
||||
java.util.List<java.util.Map<String, Object>> rows = endpoints().jsonPath().getList("$");
|
||||
org.junit.jupiter.api.Assertions.assertEquals(rows.size(), new java.util.HashSet<>(rows).size(),
|
||||
"rest-endpoints must not repeat a row: " + rows);
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyRowPointsAtItsSource() {
|
||||
endpoints().then()
|
||||
.statusCode(200)
|
||||
.body("sourceFile", everyItem(endsWith(".java")))
|
||||
.body("startLine", everyItem(greaterThan(0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theModuleFilterNarrowsToOneClass() {
|
||||
given().queryParam("module", "AdminResource")
|
||||
.when().get("/api/projects/" + PROJECT + "/rest-endpoints")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("module", everyItem(equalTo("com.example.rest.AdminResource")));
|
||||
}
|
||||
|
||||
/**
|
||||
* An outbound {@code @RegisterRestClient} interface declares a call the application *makes*. It is
|
||||
* flagged rather than presented as a served endpoint — `pur` had 3 of them reading as endpoints.
|
||||
*/
|
||||
@Test
|
||||
void everyEndpointHereIsInboundNotAnOutboundRestClient() {
|
||||
endpoints().then()
|
||||
.statusCode(200)
|
||||
.body("outbound", everyItem(equalTo(false)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 129's copycode stand-down must not fire for a Java project. `ac` carries `.cpy` files as
|
||||
* Natural *test fixtures* that its Java walk never ingests; they had no stored hash, counted as
|
||||
* changed, and disabled skipping entirely — 487 of 509 unchanged files were re-parsed.
|
||||
*/
|
||||
@Test
|
||||
void changedOnlyOnAJavaProjectIsNotDisabledByAStrayCopycodeFile() {
|
||||
try {
|
||||
Files.writeString(root.resolve("stray.cpy"), "* a Natural fixture in a Java project\n");
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh").then().statusCode(200);
|
||||
given()
|
||||
.when().post("/api/projects/" + PROJECT + "/refresh?changedOnly=true")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("examinedFiles", empty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyProjectScopedResponseCarriesTheScopeAndFreshnessHeaders() {
|
||||
given().when().get("/api/projects/" + PROJECT + "/modules")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.header(ProjectScopeHeaderFilter.EXCLUDE_DIRS, notNullValue())
|
||||
.header(ProjectScopeHeaderFilter.INGESTED_AT, notNullValue())
|
||||
.header(ProjectScopeHeaderFilter.INCOMPLETE, equalTo("false"));
|
||||
}
|
||||
|
||||
/**
|
||||
* The headers must ride on a <em>bare array</em> response too — that is the whole reason they are
|
||||
* headers and not body fields.
|
||||
*/
|
||||
@Test
|
||||
void theHeadersAlsoRideOnBareArrayResponses() {
|
||||
given().queryParam("name", "PartnerResource")
|
||||
.when().get("/api/projects/" + PROJECT + "/search/identifier")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.header(ProjectScopeHeaderFilter.EXCLUDE_DIRS, notNullValue())
|
||||
.header(ProjectScopeHeaderFilter.INCOMPLETE, equalTo("false"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
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 java.util.Map;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
|
||||
/**
|
||||
* Item 131: a capped search answer must say that it was capped.
|
||||
*
|
||||
* <p>The fixture declares 60 annotated classes against a default {@code limit} of 50, so the default
|
||||
* page is short of the truth by construction — the shape of the real failure, where
|
||||
* {@code search/annotation?name=Immutable} returned 50 of 95 rows and a real audit read the page as
|
||||
* the whole set, reporting 17 entities as having lost the annotation when none had.
|
||||
*/
|
||||
@QuarkusTest
|
||||
class SearchTruncationIT {
|
||||
|
||||
private static final String PROJECT = "search-truncation-project";
|
||||
private static final int CLASSES = 60;
|
||||
/**
|
||||
* Item 135: more endpoints than the default page of 50, so paging them is a real question.
|
||||
*/
|
||||
private static final int ENDPOINTS = 60;
|
||||
|
||||
@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/many");
|
||||
write(pkg, "Marked.java", """
|
||||
package com.example.many;
|
||||
|
||||
public @interface Marked {
|
||||
}
|
||||
""");
|
||||
// Item 135: one project type every Thing imports and declares a field of, so the reference
|
||||
// search has a set larger than the default page to page over. It lives in its own package on
|
||||
// purpose: a same-package type needs no import, and TypeResolver cannot turn the bare simple
|
||||
// name into an identity, so no reference edge is emitted for it at all (documented on
|
||||
// JavaParser#addReferenceEdges).
|
||||
write(root.resolve("src/main/java/com/example/support"), "Support.java", """
|
||||
package com.example.support;
|
||||
|
||||
public class Support {
|
||||
}
|
||||
""");
|
||||
for (int i = 0; i < CLASSES; i++) {
|
||||
write(pkg, "Thing" + i + ".java", """
|
||||
package com.example.many;
|
||||
|
||||
import com.example.support.Support;
|
||||
|
||||
@Marked
|
||||
public class Thing%d {
|
||||
private String shared = "repeated-literal";
|
||||
private Support support;
|
||||
|
||||
public String shared() {
|
||||
return shared;
|
||||
}
|
||||
}
|
||||
""".formatted(i));
|
||||
}
|
||||
// Item 135: a REST surface to page over. Separate classes from the Thing fixture above so the
|
||||
// annotation and identifier counts it pins stay untouched.
|
||||
for (int i = 0; i < ENDPOINTS; i++) {
|
||||
write(pkg, "Endpoint" + i + "Resource.java", """
|
||||
package com.example.many;
|
||||
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
|
||||
@Path("/endpoint%d")
|
||||
public class Endpoint%dResource {
|
||||
|
||||
@GET
|
||||
@Path("/list")
|
||||
public String list() {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
""".formatted(i, i));
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aCappedAnnotationSearchReportsTheTotalAndSaysItWasCut() {
|
||||
given().queryParam("name", "Marked")
|
||||
.when().get("/api/projects/" + PROJECT + "/search/annotation")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("size()", equalTo(50))
|
||||
.header(AnalysisResource.TRUNCATED, equalTo("true"))
|
||||
.header(AnalysisResource.TOTAL_COUNT, equalTo(String.valueOf(CLASSES)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The count must not inherit the page's cap. Capping it would make the total equal the row count
|
||||
* every time, which reads as "complete" and silently defeats the entire item.
|
||||
*/
|
||||
@Test
|
||||
void theTotalExceedsTheRowsItDescribes() {
|
||||
int rows = given().queryParam("name", "Marked")
|
||||
.when().get("/api/projects/" + PROJECT + "/search/annotation")
|
||||
.jsonPath().getList("$").size();
|
||||
long total = Long.parseLong(given().queryParam("name", "Marked")
|
||||
.when().get("/api/projects/" + PROJECT + "/search/annotation")
|
||||
.header(AnalysisResource.TOTAL_COUNT));
|
||||
org.junit.jupiter.api.Assertions.assertTrue(total > rows,
|
||||
"total (" + total + ") must exceed the returned rows (" + rows + ")");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUncappedRequestIsNotReportedAsTruncated() {
|
||||
given().queryParam("name", "Marked").queryParam("limit", 500)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/annotation")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("size()", equalTo(CLASSES))
|
||||
.header(AnalysisResource.TRUNCATED, equalTo("false"))
|
||||
.header(AnalysisResource.TOTAL_COUNT, equalTo(String.valueOf(CLASSES)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void countOnlyAnswersTheCountingQuestionWithoutTheRows() {
|
||||
given().queryParam("name", "Marked").queryParam("countOnly", true)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/annotation")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("count", equalTo(CLASSES))
|
||||
.body("$", not(hasKey("rows")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theIdentifierSearchCarriesTheSameHeaders() {
|
||||
given().queryParam("name", "shared").queryParam("type", "FUNCTION")
|
||||
.when().get("/api/projects/" + PROJECT + "/search/identifier")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.header(AnalysisResource.TOTAL_COUNT, equalTo(String.valueOf(CLASSES)))
|
||||
.header(AnalysisResource.TRUNCATED, equalTo("true"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theIdentifierSearchCountOnlyWorks() {
|
||||
given().queryParam("name", "shared").queryParam("type", "FUNCTION").queryParam("countOnly", true)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/identifier")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("count", equalTo(CLASSES));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theValueSearchCarriesTheSameHeaders() {
|
||||
given().queryParam("value", "repeated-literal")
|
||||
.when().get("/api/projects/" + PROJECT + "/search/value")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.header(AnalysisResource.TOTAL_COUNT, notNullValue())
|
||||
.header(AnalysisResource.TRUNCATED, notNullValue());
|
||||
}
|
||||
|
||||
// --- item 135: the two endpoints item 131 forgot -------------------------------------------
|
||||
|
||||
/**
|
||||
* The failure this item is about: {@code search/references} capped at the default 50 and said
|
||||
* nothing at all — no total, no truncation flag. A rename scoped from that page would have missed
|
||||
* every site past the fiftieth and looked complete doing it.
|
||||
*/
|
||||
@Test
|
||||
void theReferenceSearchReportsItsTotalAndTruncation() {
|
||||
int all = given().queryParam("name", "Support").queryParam("limit", 500)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/references")
|
||||
.jsonPath().getList("$").size();
|
||||
org.junit.jupiter.api.Assertions.assertTrue(all > 50,
|
||||
"the fixture must produce more references than one default page, got " + all);
|
||||
given().queryParam("name", "Support")
|
||||
.when().get("/api/projects/" + PROJECT + "/search/references")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("size()", equalTo(50))
|
||||
.header(AnalysisResource.TRUNCATED, equalTo("true"))
|
||||
.header(AnalysisResource.TOTAL_COUNT, equalTo(String.valueOf(all)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theReferenceSearchCountOnlyAgreesWithTheFullFetch() {
|
||||
int rows = given().queryParam("name", "Support").queryParam("limit", 500)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/references")
|
||||
.jsonPath().getList("$").size();
|
||||
given().queryParam("name", "Support").queryParam("countOnly", true)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/references")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("count", equalTo(rows));
|
||||
}
|
||||
|
||||
/**
|
||||
* The count must not inherit {@code $scanCap} — the same trap {@link #theTotalExceedsTheRowsItDescribes}
|
||||
* pins for the annotation search.
|
||||
*/
|
||||
@Test
|
||||
void theReferenceTotalExceedsTheRowsItDescribes() {
|
||||
int rows = given().queryParam("name", "Support").queryParam("limit", 5)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/references")
|
||||
.jsonPath().getList("$").size();
|
||||
long total = Long.parseLong(given().queryParam("name", "Support").queryParam("limit", 5)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/references")
|
||||
.header(AnalysisResource.TOTAL_COUNT));
|
||||
org.junit.jupiter.api.Assertions.assertEquals(5, rows);
|
||||
org.junit.jupiter.api.Assertions.assertTrue(total > rows,
|
||||
"total (" + total + ") must exceed the returned rows (" + rows + ")");
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code rest-endpoints} defaults to an uncapped limit, so it never lost rows — but it was equally
|
||||
* silent about how many there are. The header has to be there either way.
|
||||
*/
|
||||
@Test
|
||||
void theRestEndpointListReportsItsTotalWhenComplete() {
|
||||
given().when().get("/api/projects/" + PROJECT + "/rest-endpoints")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("size()", equalTo(ENDPOINTS))
|
||||
.header(AnalysisResource.TRUNCATED, equalTo("false"))
|
||||
.header(AnalysisResource.TOTAL_COUNT, equalTo(String.valueOf(ENDPOINTS)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aCappedRestEndpointPageSaysItWasCut() {
|
||||
given().queryParam("limit", 10)
|
||||
.when().get("/api/projects/" + PROJECT + "/rest-endpoints")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("size()", equalTo(10))
|
||||
.header(AnalysisResource.TRUNCATED, equalTo("true"))
|
||||
.header(AnalysisResource.TOTAL_COUNT, equalTo(String.valueOf(ENDPOINTS)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theRestEndpointCountOnlyWorks() {
|
||||
given().queryParam("countOnly", true)
|
||||
.when().get("/api/projects/" + PROJECT + "/rest-endpoints")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("count", equalTo(ENDPOINTS))
|
||||
.body("$", not(hasKey("rows")));
|
||||
}
|
||||
|
||||
/**
|
||||
* Paging has to actually move: two consecutive pages of one must not be the same row. A count
|
||||
* header on a page that never advances would be worse than no header, because it would look right.
|
||||
*/
|
||||
@Test
|
||||
void consecutiveReferencePagesAreDisjoint() {
|
||||
// Compared as whole rows, not by file: one class contributes both an IMPORT and a TYPE
|
||||
// reference, so two adjacent rows legitimately share a sourceFile.
|
||||
Map<String, ?> first = given().queryParam("name", "Support").queryParam("limit", 1).queryParam("offset", 0)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/references")
|
||||
.jsonPath().getMap("[0]");
|
||||
Map<String, ?> second = given().queryParam("name", "Support").queryParam("limit", 1).queryParam("offset", 1)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/references")
|
||||
.jsonPath().getMap("[0]");
|
||||
org.junit.jupiter.api.Assertions.assertNotEquals(first, second);
|
||||
}
|
||||
|
||||
/**
|
||||
* A page shorter than the limit is provably the end of the set, so no count query runs and the
|
||||
* total is arithmetic — this pins that the cheap path reports the same numbers as the counted one.
|
||||
*/
|
||||
@Test
|
||||
void aShortPageIsCompleteAndItsTotalIsExact() {
|
||||
given().queryParam("name", "Marked").queryParam("limit", 50).queryParam("offset", 40)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/annotation")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("size()", equalTo(20))
|
||||
.header(AnalysisResource.TRUNCATED, equalTo("false"))
|
||||
.header(AnalysisResource.TOTAL_COUNT, equalTo(String.valueOf(CLASSES)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
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 java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
|
||||
/**
|
||||
* Item 124: a call edge must not outlive the call it was parsed from.
|
||||
*
|
||||
* <p>Item 58's sweep deletes stale <b>nodes</b>; an edge is only reaped when one of its endpoints goes
|
||||
* with it. So an edge survives whenever both endpoints legitimately survive — which is exactly what a
|
||||
* <b>parser fix</b> produces: the calling subroutine is untouched, and the old target is a placeholder
|
||||
* ({@code sourceFile=""}) that is never file-swept. The corrected call is then merely <em>added</em>
|
||||
* beside the wrong one, and both are served.
|
||||
*
|
||||
* <p>Found for real: after items 120/121/123 shipped, {@code DAGCHEN0/callees} in {@code upms} listed
|
||||
* the correct {@code YAGCHBN0} <em>and</em> the pre-fix phantom {@code AGNT-CHG-CMP-SP} from the same
|
||||
* call site, with 497 such edges surviving a full deep refresh.
|
||||
*
|
||||
* <p>The fixture edits only the CALLNAT target and keeps the enclosing subroutine, because that is the
|
||||
* distinguishing case. {@code DerivedCallsModuleRefreshIT} removes the whole subroutine, so there the
|
||||
* {@code FUNCTION} node disappears and {@code DETACH DELETE} takes the edge along — which is why the
|
||||
* bug hid behind a green suite.
|
||||
*/
|
||||
@QuarkusTest
|
||||
class StaleCallEdgeReapIT {
|
||||
|
||||
private static final String PROJECT = "nat-stale-call-reap";
|
||||
|
||||
private static final String TARGET_A = """
|
||||
* First call target.
|
||||
DEFINE DATA LOCAL
|
||||
END-DEFINE
|
||||
END
|
||||
""";
|
||||
private static final String TARGET_B = """
|
||||
* Second call target.
|
||||
DEFINE DATA LOCAL
|
||||
END-DEFINE
|
||||
END
|
||||
""";
|
||||
|
||||
/** Calls RTARGETA from a subroutine that must survive the edit unchanged. */
|
||||
private static final String CALLER_A = """
|
||||
* Caller in its first state.
|
||||
DEFINE DATA LOCAL
|
||||
01 #TGT (A8)
|
||||
END-DEFINE
|
||||
*
|
||||
PERFORM CALL-STEP
|
||||
*
|
||||
DEFINE SUBROUTINE CALL-STEP
|
||||
CALLNAT 'RTARGETA'
|
||||
END-SUBROUTINE
|
||||
*
|
||||
END
|
||||
""";
|
||||
/** Only the target changed — same subroutine, same line, same everything else. */
|
||||
private static final String CALLER_B = """
|
||||
* Caller in its first state.
|
||||
DEFINE DATA LOCAL
|
||||
01 #TGT (A8)
|
||||
END-DEFINE
|
||||
*
|
||||
PERFORM CALL-STEP
|
||||
*
|
||||
DEFINE SUBROUTINE CALL-STEP
|
||||
CALLNAT 'RTARGETB'
|
||||
END-SUBROUTINE
|
||||
*
|
||||
END
|
||||
""";
|
||||
/** Calls a module that does not exist, so the target is an unresolved placeholder node. */
|
||||
private static final String PHANTOM_CALLER = """
|
||||
* Caller whose target does not exist — a placeholder, as a pre-fix parser artefact is.
|
||||
DEFINE DATA LOCAL
|
||||
END-DEFINE
|
||||
*
|
||||
PERFORM CALL-STEP
|
||||
*
|
||||
DEFINE SUBROUTINE CALL-STEP
|
||||
CALLNAT 'RPHANTOM'
|
||||
END-SUBROUTINE
|
||||
*
|
||||
END
|
||||
""";
|
||||
/** Same subroutine, now calling a real module: the phantom must be reaped, node and all. */
|
||||
private static final String PHANTOM_CALLER_FIXED = """
|
||||
* Caller whose target does not exist — a placeholder, as a pre-fix parser artefact is.
|
||||
DEFINE DATA LOCAL
|
||||
END-DEFINE
|
||||
*
|
||||
PERFORM CALL-STEP
|
||||
*
|
||||
DEFINE SUBROUTINE CALL-STEP
|
||||
CALLNAT 'RTARGETA'
|
||||
END-SUBROUTINE
|
||||
*
|
||||
END
|
||||
""";
|
||||
|
||||
@TempDir
|
||||
static Path root;
|
||||
|
||||
@BeforeAll
|
||||
static void createProject() {
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
write("RTARGETA.nat", TARGET_A);
|
||||
write("RTARGETB.nat", TARGET_B);
|
||||
write("RCALLER.nat", CALLER_A);
|
||||
write("RPHANT.nat", PHANTOM_CALLER);
|
||||
|
||||
given().contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
|
||||
.when().post("/api/projects/" + PROJECT)
|
||||
.then().statusCode(201);
|
||||
}
|
||||
|
||||
private static void write(String fileName, String content) {
|
||||
try {
|
||||
Files.writeString(root.resolve(fileName), content);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void refresh() {
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true").then().statusCode(200);
|
||||
}
|
||||
|
||||
private static io.restassured.path.json.JsonPath callees(String module) {
|
||||
return given().pathParam("name", module)
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callees")
|
||||
.then().statusCode(200).extract().jsonPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* One test per transition rather than per assertion: each asserts a before/after pair on shared
|
||||
* mutable server state, so splitting the halves would make the outcome depend on JUnit method order.
|
||||
*/
|
||||
@Test
|
||||
void aRetargetedCallDoesNotKeepItsOldTarget() {
|
||||
write("RCALLER.nat", CALLER_A);
|
||||
refresh();
|
||||
// Guard: prove the first edge exists, so the "gone" assertion cannot pass vacuously.
|
||||
org.junit.jupiter.api.Assertions.assertTrue(
|
||||
callees("RCALLER").getList("items.name").contains("RTARGETA"),
|
||||
"the first target must be there before we can prove it is removed");
|
||||
|
||||
write("RCALLER.nat", CALLER_B);
|
||||
refresh();
|
||||
|
||||
var names = callees("RCALLER").getList("items.name");
|
||||
org.junit.jupiter.api.Assertions.assertTrue(names.contains("RTARGETB"),
|
||||
"the new target must be present: " + names);
|
||||
org.junit.jupiter.api.Assertions.assertFalse(names.contains("RTARGETA"),
|
||||
"the old target outlived the call it was parsed from: " + names);
|
||||
}
|
||||
|
||||
/**
|
||||
* The placeholder case — the shape a fixed parser bug actually leaves behind. Both the edge and the
|
||||
* now-edgeless placeholder node must go; the node is never file-swept, so without the item-124 node
|
||||
* sweep it would keep surfacing in identifier search as an unresolved call target nothing calls.
|
||||
*/
|
||||
@Test
|
||||
void aReapedPlaceholderTargetIsAlsoRemovedAsANode() {
|
||||
write("RPHANT.nat", PHANTOM_CALLER);
|
||||
refresh();
|
||||
org.junit.jupiter.api.Assertions.assertTrue(
|
||||
callees("RPHANT").getList("items.name").contains("RPHANTOM"),
|
||||
"the phantom target must exist before we can prove it is swept");
|
||||
|
||||
write("RPHANT.nat", PHANTOM_CALLER_FIXED);
|
||||
refresh();
|
||||
|
||||
var names = callees("RPHANT").getList("items.name");
|
||||
org.junit.jupiter.api.Assertions.assertFalse(names.contains("RPHANTOM"),
|
||||
"the stale placeholder edge survived: " + names);
|
||||
given().queryParam("name", "RPHANTOM")
|
||||
.when().get("/api/projects/" + PROJECT + "/search/identifier")
|
||||
.then().statusCode(200)
|
||||
.body("findAll { it.name == 'RPHANTOM' }", is(empty()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The reap deletes <em>all</em> of a re-parsed file's call edges, including the two kinds enrichment
|
||||
* builds rather than the parser: {@code folded} and {@code resolvedBy: 'manual'}. Both are rebuilt by
|
||||
* finalize steps that run at every enrichment level — this pins that, because if it were false the
|
||||
* reap would silently discard a user's manual override on every refresh.
|
||||
*/
|
||||
@Test
|
||||
void aManualOverrideSurvivesTheReap() {
|
||||
write("RCALLER.nat", CALLER_B);
|
||||
refresh();
|
||||
given().contentType("application/json")
|
||||
.body(Map.of("originFile", "RPHANT.nat", "lineNo", 8,
|
||||
"targets", List.of("RTARGETB"), "variable", "MANUAL-PIN"))
|
||||
.when().post("/api/projects/" + PROJECT + "/dynamic-calls/overrides")
|
||||
.then().statusCode(200);
|
||||
|
||||
refresh();
|
||||
|
||||
given().when().get("/api/projects/" + PROJECT + "/dynamic-calls/overrides")
|
||||
.then().statusCode(200)
|
||||
.body("findAll { it.originFile == 'RPHANT.nat' }", not(empty()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.RestAssured;
|
||||
import org.junit.jupiter.api.*;
|
||||
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 129: a refresh must be able to touch only what changed, and an interrupted one must not look
|
||||
* like a clean graph.
|
||||
*
|
||||
* <p>Covers the three pieces separately because they are independent: {@code ?paths=} (targeted
|
||||
* re-ingest), the {@code incomplete} marker (in-flight / aborted), and opt-in {@code ?changedOnly=}
|
||||
* skipping. The assertions about what {@code changedOnly} deliberately does <em>not</em> do matter as
|
||||
* much as the skipping itself — an incremental refresh that quietly kept stale copycode expansions
|
||||
* would be worse than no incremental refresh at all.
|
||||
*/
|
||||
@QuarkusTest
|
||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||
class TargetedRefreshIT {
|
||||
|
||||
private static final String PROJECT = "targeted-refresh-project";
|
||||
|
||||
@TempDir
|
||||
static Path root;
|
||||
|
||||
@BeforeAll
|
||||
static void createProject() {
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
writeSource("MAIN.nat", """
|
||||
DEFINE DATA
|
||||
LOCAL
|
||||
1 #A (A8)
|
||||
END-DEFINE
|
||||
*
|
||||
CALLNAT 'HELPER'
|
||||
END
|
||||
""");
|
||||
writeSource("HELPER.nat", """
|
||||
DEFINE DATA
|
||||
LOCAL
|
||||
1 #B (A8)
|
||||
END-DEFINE
|
||||
*
|
||||
END
|
||||
""");
|
||||
given()
|
||||
.contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
|
||||
.when().post("/api/projects/" + PROJECT)
|
||||
.then()
|
||||
.statusCode(201);
|
||||
}
|
||||
|
||||
private static void writeSource(String fileName, String content) {
|
||||
try {
|
||||
Files.writeString(root.resolve(fileName), content);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
void pathsReIngestsOnlyTheNamedFile() {
|
||||
given()
|
||||
.when().post("/api/projects/" + PROJECT + "/refresh?paths=MAIN.nat")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("examinedFiles", contains("MAIN.nat"))
|
||||
.body("unresolved", empty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(2)
|
||||
void aPathThatMatchesNothingIsReportedRatherThanDropped() {
|
||||
given()
|
||||
.when().post("/api/projects/" + PROJECT + "/refresh?paths=MAIN.nat,NOPE.nat")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("examinedFiles", contains("MAIN.nat"))
|
||||
.body("unresolved", contains("NOPE.nat"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(3)
|
||||
void aPathEscapingTheRootIsRefusedAsUnresolved() {
|
||||
given()
|
||||
.when().post("/api/projects/" + PROJECT + "/refresh?paths=../outside.nat")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("ingested", equalTo(0))
|
||||
.body("unresolved", contains("../outside.nat"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(4)
|
||||
void aTargetedRefreshDoesNotMoveTheProjectsIngestedAt() {
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh").then().statusCode(200);
|
||||
String before = given().when().get("/api/projects/" + PROJECT).jsonPath().getString("ingest.ingestedAt");
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh?paths=MAIN.nat").then().statusCode(200);
|
||||
given().when().get("/api/projects/" + PROJECT)
|
||||
.then().body("ingest.ingestedAt", equalTo(before));
|
||||
}
|
||||
|
||||
/**
|
||||
* A path that exists but is not an ingestible source file must be reported, not accepted. It was
|
||||
* silently listed as examined while nothing about it could be ingested — found by running
|
||||
* {@code ?paths=pom.xml,...} against the real server, where it came back with an empty
|
||||
* {@code unresolved}.
|
||||
*/
|
||||
@Test
|
||||
@Order(4)
|
||||
void aPathThatIsNotAnIngestibleSourceFileIsUnresolved() {
|
||||
writeSource("notes.txt", "not source\n");
|
||||
given()
|
||||
.when().post("/api/projects/" + PROJECT + "/refresh?paths=MAIN.nat,notes.txt")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("examinedFiles", contains("MAIN.nat"))
|
||||
.body("unresolved", contains("notes.txt"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(5)
|
||||
void aCompletedRefreshLeavesTheProjectNotIncomplete() {
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh").then().statusCode(200);
|
||||
given().when().get("/api/projects/" + PROJECT)
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("ingest.incomplete", equalTo(false))
|
||||
.body("ingest.ingestedAt", notNullValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(6)
|
||||
void changedOnlyExaminesJustTheChangedFile() {
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh").then().statusCode(200);
|
||||
writeSource("HELPER.nat", """
|
||||
DEFINE DATA
|
||||
LOCAL
|
||||
1 #B (A8)
|
||||
1 #C (N4)
|
||||
END-DEFINE
|
||||
*
|
||||
END
|
||||
""");
|
||||
given()
|
||||
.when().post("/api/projects/" + PROJECT + "/refresh?changedOnly=true")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("examinedFiles", contains("HELPER.nat"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(7)
|
||||
void changedOnlyWithNoChangesExaminesNothing() {
|
||||
given()
|
||||
.when().post("/api/projects/" + PROJECT + "/refresh?changedOnly=true")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("examinedFiles", empty())
|
||||
.body("ingested", equalTo(0));
|
||||
}
|
||||
|
||||
/**
|
||||
* The correctness case that keeps {@code changedOnly} opt-in: copycode text is inlined into the
|
||||
* including module at parse time, so a module whose {@code .cpy} changed parses differently while
|
||||
* its own hash is unchanged. Skipping it would leave a stale expansion in the graph with nothing
|
||||
* to indicate it — so a changed copycode must re-parse everything, not just itself.
|
||||
*/
|
||||
@Test
|
||||
@Order(8)
|
||||
void aChangedCopycodeDisablesSkippingForTheWholeRun() {
|
||||
writeSource("SHARED.cpy", "* shared\n");
|
||||
writeSource("USER.nat", """
|
||||
DEFINE DATA
|
||||
LOCAL
|
||||
1 #D (A8)
|
||||
END-DEFINE
|
||||
*
|
||||
INCLUDE SHARED
|
||||
END
|
||||
""");
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh").then().statusCode(200);
|
||||
writeSource("SHARED.cpy", "* shared, now different\n");
|
||||
given()
|
||||
.when().post("/api/projects/" + PROJECT + "/refresh?changedOnly=true")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("examinedFiles", hasItems("MAIN.nat", "HELPER.nat", "USER.nat"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.example.nodb;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
/**
|
||||
* The only difference between the two item-140 fixture projects: this entity gives one of them a
|
||||
* {@code DB_TABLE}, which switches the reaper off.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "LEDGER")
|
||||
public class Ledger {
|
||||
|
||||
@Id
|
||||
public Long id;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.example.nodb;
|
||||
|
||||
/**
|
||||
* Touches no database at all — every call below is a static utility getter. Item 140 fixture.
|
||||
*/
|
||||
public class ReportRenderer {
|
||||
|
||||
public String render(String line) {
|
||||
String key = TextUtils.getColumn(line, 0, 8);
|
||||
String text = TextUtils.getTrimmed(TextUtils.getColumn(line, 8, 40));
|
||||
return key + ": " + text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.example.nodb;
|
||||
|
||||
/**
|
||||
* A plain static string utility. Item 140 fixture: its {@code get}-prefixed methods are exactly the
|
||||
* shape that {@code JavaParser#addDbAccessCandidate} mistakes for a persistence read, because the
|
||||
* read gate lets through any static receiver.
|
||||
*/
|
||||
public final class TextUtils {
|
||||
|
||||
private TextUtils() {
|
||||
}
|
||||
|
||||
public static String getColumn(String line, int from, int to) {
|
||||
return line.substring(from, to);
|
||||
}
|
||||
|
||||
public static String getTrimmed(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
* ----------------------------------------------------------------------
|
||||
* Copycode member of the browse family, shaped after the upms idiom: the
|
||||
* caller passes a sort key as &1&, the browse module's own name as &2& and
|
||||
* its key field as &3&. The CALLNAT target is therefore never literal here —
|
||||
* it exists only after substitution, which is what items 120/121/123 broke.
|
||||
* ----------------------------------------------------------------------
|
||||
ASSIGN #SORT-KEY = &1&
|
||||
CALLNAT &2& &3&
|
||||
@@ -0,0 +1,14 @@
|
||||
* Title : Host module exercising the three argument-parsing defects that
|
||||
* kept 263 module-to-module calls out of the upms graph. Its INCLUDE spreads
|
||||
* the arguments over two lines (item 120), writes one of them with Natural's
|
||||
* doubled-quote escape '''X''' (item 121), and one with the double-quote
|
||||
* delimiter '"X"' (item 123). Only when all three hold does YAGCHBN0 appear.
|
||||
DEFINE DATA
|
||||
LOCAL
|
||||
01 #SORT-KEY (A20)
|
||||
END-DEFINE
|
||||
*
|
||||
INCLUDE BROWSECPY '''AGNT-CHG-CMP-SP'''
|
||||
'"YAGCHBN0"' '''YAGCHKEY'''
|
||||
*
|
||||
END
|
||||
@@ -0,0 +1,11 @@
|
||||
* ----------------------------------------------------------------------
|
||||
* Item 122: the guarded assignment lives HERE, spliced into the host's
|
||||
* DECIDE branch by an INCLUDE. Its dispatch row's lineNo is a line of this
|
||||
* file — line 8 of NESTDISP.nat is a comment in the module header.
|
||||
* ----------------------------------------------------------------------
|
||||
DECIDE ON FIRST VALUE OF #FIELD-NAME
|
||||
VALUE 'TX-FROM-CPY'
|
||||
MOVE 'DESC-FROM-CPY' TO #OUT-DESC
|
||||
NONE
|
||||
IGNORE
|
||||
END-DECIDE
|
||||
@@ -31,6 +31,8 @@ DECIDE ON FIRST VALUE OF #SHORT-VIEW
|
||||
END-DECIDE
|
||||
VALUE 'APRF'
|
||||
MOVE 'CODE-APRF' TO #OUT-CODE
|
||||
VALUE 'CPYV'
|
||||
INCLUDE DISPCPY
|
||||
NONE
|
||||
IGNORE
|
||||
END-DECIDE
|
||||
|
||||
@@ -67,10 +67,17 @@ public final class CypherQueries {
|
||||
* fresh id (kept) while a renamed/removed field keeps its old id (deleted). Only files with a real
|
||||
* {@code sourceFile} are reconciled; {@code sourceFile = ""} placeholders are shared across files
|
||||
* and never swept here. Fixes stale identifier-index nodes that outlived a {@code refresh}.
|
||||
*
|
||||
* <p>Items 75-B/75-C: keyed on the {@code (sourceFile, ownerModule)} <em>pair</em>, not the file
|
||||
* alone. A copycode-resident node carries its expansion site ({@code <hostFile>#<includePath>}) as
|
||||
* {@code ownerModule}, so many nodes share one {@code sourceFile}; sweeping by file alone would
|
||||
* delete every other includer's nodes (their {@code ingestGen} is one transaction old) together
|
||||
* with their edges. A module's own nodes carry {@code ownerModule = ""}, so their sweep is
|
||||
* unchanged.
|
||||
*/
|
||||
public static final String DELETE_STALE_FILE_NODES = """
|
||||
UNWIND $sourceFiles AS sf
|
||||
MATCH (n:AstNode {project: $project, sourceFile: sf})
|
||||
UNWIND $files AS p
|
||||
MATCH (n:AstNode {project: $project, sourceFile: p.f, ownerModule: p.o})
|
||||
WHERE n.ingestGen IS NULL OR n.ingestGen <> $ingestGen
|
||||
DETACH DELETE n
|
||||
""";
|
||||
@@ -103,10 +110,10 @@ public final class CypherQueries {
|
||||
* re-ingest, so a coarse Tier-1 (no-finalize) scan never strips resolved edges it cannot rebuild.
|
||||
*/
|
||||
public static final String DELETE_STALE_RESOLVED_FIELD_EDGES = """
|
||||
UNWIND $sourceFiles AS f
|
||||
MATCH (src:AstNode {project: $project, sourceFile: f})-[r:READS|WRITES]->(fld:AstNode)
|
||||
UNWIND $files AS p
|
||||
MATCH (src:AstNode {project: $project, sourceFile: p.f, ownerModule: p.o})-[r:READS|WRITES]->(fld:AstNode)
|
||||
WHERE (fld.type = 'VARIABLE' OR fld.type = 'CONSTANT')
|
||||
AND fld.sourceFile <> "" AND fld.sourceFile <> f
|
||||
AND fld.sourceFile <> "" AND fld.sourceFile <> p.f
|
||||
DELETE r
|
||||
""";
|
||||
|
||||
@@ -124,8 +131,8 @@ public final class CypherQueries {
|
||||
* coarse re-ingest both re-emit them, so this is not gated on {@code reconcile}.
|
||||
*/
|
||||
public static final String DELETE_STALE_NATURAL_TABLE_ACCESS_EDGES = """
|
||||
UNWIND $sourceFiles AS f
|
||||
MATCH (src:AstNode {project: $project, sourceFile: f, language: 'natural'})-[r:READS|WRITES]->(t:AstNode {project: $project, sourceFile: ""})
|
||||
UNWIND $files AS p
|
||||
MATCH (src:AstNode {project: $project, sourceFile: p.f, ownerModule: p.o, language: 'natural'})-[r:READS|WRITES]->(t:AstNode {project: $project, sourceFile: ""})
|
||||
WHERE t.type IN ['DB_TABLE', 'WORKFILE']
|
||||
DELETE r
|
||||
""";
|
||||
@@ -147,8 +154,67 @@ public final class CypherQueries {
|
||||
* identically.
|
||||
*/
|
||||
public static final String DELETE_STALE_NATURAL_USING_EDGES = """
|
||||
UNWIND $sourceFiles AS f
|
||||
MATCH (src:AstNode {project: $project, sourceFile: f, language: 'natural'})-[r:INCLUDES]->(d:AstNode {project: $project, type: 'DATA_STRUCTURE'})
|
||||
UNWIND $files AS p
|
||||
MATCH (src:AstNode {project: $project, sourceFile: p.f, ownerModule: p.o, language: 'natural'})-[r:INCLUDES]->(d:AstNode {project: $project, type: 'DATA_STRUCTURE'})
|
||||
DELETE r
|
||||
""";
|
||||
|
||||
/**
|
||||
* Item 124: the {@code CALLS} counterpart of items 86 and 106 — reaps a re-parsed Natural file's
|
||||
* call edges before the fresh ones are merged.
|
||||
*
|
||||
* <p>Item 58's node sweep only removes nodes the fresh parse no longer produces, so a call edge
|
||||
* survives whenever <em>both</em> its endpoints legitimately survive. That is the normal case when a
|
||||
* <b>parser fix</b> changes a call's target: the calling subroutine is unchanged (its {@code FUNCTION}
|
||||
* node is re-merged and kept) and the old target is a placeholder ({@code sourceFile=""}, never
|
||||
* file-swept), so nothing reaps the edge between them — the corrected call is merely <em>added</em>
|
||||
* beside the wrong one. After items 120/121/123 shipped, {@code DAGCHEN0/callees} listed both the
|
||||
* real {@code YAGCHBN0} and the pre-fix phantom {@code AGNT-CHG-CMP-SP} from the same call site;
|
||||
* 497 such edges survived a full deep refresh of {@code upms}.
|
||||
*
|
||||
* <p>Reaches beyond item 86's placeholder-only scope on purpose: bug #63's `CALLNAT`-in-a-string
|
||||
* matches resolved onto <b>real</b> modules, so a placeholder-only reap would leave that whole
|
||||
* class of artefact behind. The one thing it must <em>not</em> touch is the dynamic-call
|
||||
* resolvers' own output, hence the {@code WHERE}:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>Reaped</b> — every edge to a placeholder target ({@code sourceFile=""}), plus every
|
||||
* {@code PERFORM}/{@code CALLNAT}/{@code INCLUDE_MACRO} edge. All of these are emitted by the
|
||||
* parser on every parse, at <em>both</em> tiers (the coarse scanner expands copycode exactly
|
||||
* as the deep parser does), so the merge that follows re-creates the current ones and
|
||||
* unchanged edges round-trip identically.</li>
|
||||
* <li><b>Kept</b> — {@code CALLNAT_DYNAMIC} edges to a <em>real</em> module. The parser cannot
|
||||
* know a dynamic target and always emits a placeholder, so such an edge is by construction
|
||||
* enrichment-built: a fold, a manual override, or an intra/cross resolution. In {@code upms}
|
||||
* 486 edges are of this kind and <b>392 of them carry no marker at all</b> — no
|
||||
* {@code folded}, no {@code resolvedBy} — so the target's file is the only thing that
|
||||
* distinguishes them from parser output.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>That exception is not theoretical: reaping them broke
|
||||
* {@code AnalysisResourceIT#flowForwardPathWarmCrossesIntoDynamicallyDispatchedCallee}. The item-37a
|
||||
* path-warm resolves a cross-module dispatch in one round and re-ingests in the next, and a
|
||||
* <em>scoped</em> finalize does not reliably re-resolve an edge whose far side is outside its scope —
|
||||
* so the reap deleted a resolution nothing rebuilt, and the dataflow trace stopped at the dispatch
|
||||
* boundary.
|
||||
*
|
||||
* <p>Gated on {@code reconcile} (deep re-ingest), because {@code ..._INTRA_INDIRECT} and
|
||||
* {@code ..._CROSS} are gated on {@code resolveFields}/{@code dataflow} and so do <em>not</em> run
|
||||
* in a coarse finalize. Same rationale as item 74.
|
||||
*
|
||||
* <p><b>Item 75-B — the copycode gap is closed.</b> This used to key on the source node's file
|
||||
* alone and therefore had to miss the 605 call edges whose source subroutine is defined
|
||||
* <em>inside a copycode</em> (14 of them stale): those nodes were MERGEd per
|
||||
* {@code (type, name, sourceFile)} and thus shared by every includer, so reaping during one
|
||||
* module's refresh would have deleted edges the other includers contributed. Copycode-resident
|
||||
* nodes now carry the includer's file as {@code ownerModule}, so the key is the
|
||||
* {@code (sourceFile, ownerModule)} pair and the reap touches exactly the re-parsed module's own
|
||||
* copies — which its own parse re-emits. Same for items 86 and 106 above.
|
||||
*/
|
||||
public static final String DELETE_STALE_NATURAL_CALL_EDGES = """
|
||||
UNWIND $files AS p
|
||||
MATCH (src:AstNode {project: $project, sourceFile: p.f, ownerModule: p.o, language: 'natural'})-[r:CALLS]->(t)
|
||||
WHERE t.sourceFile = "" OR r.callKind <> 'CALLNAT_DYNAMIC'
|
||||
DELETE r
|
||||
""";
|
||||
|
||||
@@ -231,6 +297,33 @@ public final class CypherQueries {
|
||||
DELETE t
|
||||
""";
|
||||
|
||||
/**
|
||||
* Item 124, node half: the {@code MODULE} counterpart of item 88's table sweep. Once
|
||||
* {@link #DELETE_STALE_NATURAL_CALL_EDGES} reaps the last call to a call-target placeholder, the
|
||||
* placeholder <em>node</em> is left edgeless — it is never file-swept ({@code sourceFile=""}) — and
|
||||
* still surfaces in {@code search/identifier} and the module inventory as an unresolved call target
|
||||
* that nothing actually calls.
|
||||
*
|
||||
* <p>Guarded on {@code sourceFile = ""} so it can only ever remove a placeholder: a <b>real</b>
|
||||
* parsed module that happens to call nothing and be called by nothing is a legitimate standalone
|
||||
* program and must survive. Degree-0 only, so a placeholder still referenced by any call is kept.
|
||||
* Runs beside item 88's table sweep, after all edge resolution.
|
||||
*
|
||||
* <p>{@code duplicatePaths IS NULL} excludes item 114's duplicate markers, which are a
|
||||
* <em>second</em> reason for an edgeless placeholder to exist and are deliberately kept: an
|
||||
* unreferenced duplicate identity is a degree-0 placeholder whose whole purpose is to record "this
|
||||
* name exists in two files", so the module endpoints can answer {@code 409 DUPLICATE_IDENTITY}
|
||||
* rather than {@code 404}. {@link #MARK_DUPLICATE_IDENTITIES} runs <em>before</em> finalize, so
|
||||
* without this guard the sweep deleted the marker it had just written and the endpoints fell back
|
||||
* to {@code 404} — caught by {@code DuplicateIdentityIT}. Markers are reaped on their own terms by
|
||||
* {@link #CLEAR_DUPLICATE_MARKERS} when the source conflict goes away.
|
||||
*/
|
||||
public static final String DELETE_ORPHANED_PLACEHOLDER_MODULES = """
|
||||
MATCH (t:AstNode {project: $project, type: 'MODULE', sourceFile: ""})
|
||||
WHERE t.duplicatePaths IS NULL AND NOT (t)--()
|
||||
DELETE t
|
||||
""";
|
||||
|
||||
public static final String MODULE_SOURCE_FILE = """
|
||||
MATCH (m:MODULE {project: $project})
|
||||
// Item 117: this endpoint is not behind the resolving guard, so it accepts the identity or
|
||||
@@ -248,6 +341,18 @@ public final class CypherQueries {
|
||||
* slice. Returns {@code null} when no hash was stored (e.g. a legacy project), so the read falls
|
||||
* back to serving the snippet unchecked.
|
||||
*/
|
||||
/**
|
||||
* Item 129: every ingested file's stored content hash in one round trip, so a {@code changedOnly}
|
||||
* refresh can decide what to re-parse without a query per file. One row per {@code sourceFile};
|
||||
* files whose shell carries no hash (never coarse-scanned) are absent, and therefore treated as
|
||||
* changed — the safe direction.
|
||||
*/
|
||||
public static final String SOURCE_HASHES = """
|
||||
MATCH (n:AstNode {project: $project})
|
||||
WHERE n.sourceHash IS NOT NULL AND n.sourceFile <> ""
|
||||
RETURN DISTINCT n.sourceFile AS sourceFile, n.sourceHash AS hash
|
||||
""";
|
||||
|
||||
public static final String SOURCE_HASH = """
|
||||
MATCH (n:AstNode {project: $project, sourceFile: $sourceFile})
|
||||
WHERE n.sourceHash IS NOT NULL
|
||||
@@ -366,7 +471,16 @@ public final class CypherQueries {
|
||||
public static final String MARK_DUPLICATE_IDENTITIES = """
|
||||
UNWIND $duplicates AS d
|
||||
MERGE (n:AstNode {type: d.type, name: d.name, sourceFile: '', project: $project, ownerModule: ''})
|
||||
SET n:$(d.type), n.duplicatePaths = d.paths
|
||||
SET n:$(d.type), n.duplicatePaths = d.paths,
|
||||
// Item 136: every AstNode must carry startLine/endLine (CLAUDE.md), and this MERGE was the
|
||||
// one node-creating query that did not. A marker has no line — 0 is a convention, not a
|
||||
// truth — but the alternative, letting these two properties be null on a handful of nodes,
|
||||
// forces null handling into every row mapper in the project; one of them missed it and
|
||||
// faulted `search/identifier` with an unstructured 500 once a page reached row 487.
|
||||
// coalesce, because the MERGE key is deliberately the same as MERGE_NODES': when something
|
||||
// already references the skipped identity, marker and placeholder are one node and the
|
||||
// placeholder's real lines must survive.
|
||||
n.startLine = coalesce(n.startLine, 0), n.endLine = coalesce(n.endLine, 0)
|
||||
""";
|
||||
|
||||
/**
|
||||
@@ -1070,6 +1184,34 @@ public final class CypherQueries {
|
||||
MERGE (fn)-[:WRITES {lineNo: a.startLine}]->(t))
|
||||
""";
|
||||
|
||||
/**
|
||||
* Item 140: in a project that has <b>no</b> {@code DB_TABLE} at all, every Java
|
||||
* {@code DB_ACCESS} is a false positive by construction — a table node only ever comes from a
|
||||
* JPA/Panache entity, so with none in the graph not a single candidate can resolve, and the
|
||||
* over-approximating parse-time heuristic ({@code JavaParser#addDbAccessCandidate}, whose read
|
||||
* gate lets through <em>any</em> static receiver) is left standing alone. Reaps them, so
|
||||
* {@code sql-statements} — which joins the table with {@code OPTIONAL MATCH} and therefore does
|
||||
* emit unresolved candidates as {@code table: null} rows — stays silent instead of reporting
|
||||
* {@code UserContext.getCurrent()} as a database read.
|
||||
*
|
||||
* <p>Deliberately <b>Java only</b>: a Natural {@code DB_ACCESS} comes from a literal
|
||||
* {@code READ}/{@code FIND}/{@code STORE} statement and is a database access whether or not its
|
||||
* view resolved, so the same reaping would destroy real information there.
|
||||
*
|
||||
* <p>Must run after all three resolvers. Idempotent. Two known limits, both accepted: a project
|
||||
* whose DB access is exclusively native SQL has no entity and thus no table, so its (already
|
||||
* unresolvable) accesses are reaped too; and once such a project gains its first entity, the
|
||||
* reaped nodes only return for files that are actually re-parsed — a {@code changedOnly}
|
||||
* refresh will not bring them back, a full one will.
|
||||
*/
|
||||
public static final String REAP_JAVA_DB_ACCESS_WITHOUT_TABLES = """
|
||||
OPTIONAL MATCH (t:DB_TABLE {type: 'DB_TABLE', project: $project})
|
||||
WITH count(t) AS tables
|
||||
WHERE tables = 0
|
||||
MATCH (a:DB_ACCESS {type: 'DB_ACCESS', project: $project, language: 'java'})
|
||||
DETACH DELETE a
|
||||
""";
|
||||
|
||||
/**
|
||||
* Dataflow enrichment: for each {@code CALLS} edge carrying an {@code args} list (the
|
||||
* positional {@code CALLNAT} arguments), links each caller argument variable to the callee
|
||||
@@ -1348,7 +1490,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.
|
||||
@@ -1586,6 +1730,7 @@ public final class CypherQueries {
|
||||
ON CREATE SET r2.folded = true
|
||||
SET r2.dynamicVar = dyn.dynamicVar, r2.args = dyn.args
|
||||
""";
|
||||
|
||||
/**
|
||||
* Deletes the dynamic-call marker edges (the {@code CALLNAT_DYNAMIC} {@code CALLS} edges that
|
||||
* point at a variable-named placeholder, {@code sourceFile = ""}) <em>only for call sites that
|
||||
@@ -1906,7 +2051,10 @@ public final class CypherQueries {
|
||||
CASE WHEN w.whenValues IS NULL THEN [w.whenValue] ELSE split(w.whenValues, '\\u001F') END
|
||||
AS guardValues,
|
||||
w.whenChainFields AS chainFields, w.whenChainValues AS chainValues,
|
||||
t.name AS assignedField, w.value AS assignedValue, w.lineNo AS lineNo
|
||||
t.name AS assignedField, w.value AS assignedValue, w.lineNo AS lineNo,
|
||||
coalesce(w.originFile, m.sourceFile) AS sourceFile,
|
||||
w.viaCopycode AS viaCopycode, w.includedAt AS includedAt,
|
||||
w.includePath AS includePath
|
||||
ORDER BY guardValue, assignedValue, lineNo
|
||||
""";
|
||||
|
||||
@@ -2082,7 +2230,12 @@ public final class CypherQueries {
|
||||
public static final String SEARCH_BY_VALUE = """
|
||||
MATCH (n:AstNode {project: $project})
|
||||
WHERE n.value IS NOT NULL AND trim(replace(n.value, "'", "")) = $value
|
||||
RETURN 'NODE' AS kind, n.name AS name, n.value AS value, null AS module,
|
||||
// Item 141: a comment block's text lives in `value`, so it would otherwise join this
|
||||
// result set by default and move every existing completeness count (item 131's lesson).
|
||||
// Opt-in only; the rows are marked 'COMMENT' so a comment hit is never read as code.
|
||||
AND ($includeComments OR n.type <> 'COMMENT')
|
||||
RETURN CASE WHEN n.type = 'COMMENT' THEN 'COMMENT' ELSE 'NODE' END AS kind,
|
||||
n.name AS name, n.value AS value, null AS module,
|
||||
n.sourceFile AS sourceFile, n.startLine AS startLine, n.endLine AS endLine
|
||||
UNION
|
||||
MATCH (m:AstNode {type: 'MODULE', project: $project})-[:CONTAINS*0..1]->(src:AstNode)-[w:WRITES]->(v:AstNode)
|
||||
@@ -2091,6 +2244,79 @@ public final class CypherQueries {
|
||||
m.sourceFile AS sourceFile, w.lineNo AS startLine, w.lineNo AS endLine
|
||||
""";
|
||||
|
||||
/**
|
||||
* Item 130: the project's REST surface — one row per handler method, with the endpoint path
|
||||
* composed from the class-level and method-level {@code @Path} (item 130 persists both as
|
||||
* {@code restPath}).
|
||||
*
|
||||
* <p>"Endpoint path → controller method → logic" is the daily question in a JAX-RS codebase and is
|
||||
* derivable from the annotations, but it took two {@code /search/annotation} calls and a manual
|
||||
* join to answer, because annotations are stored by name without their arguments.
|
||||
*
|
||||
* <p>A method with no {@code httpMethod} is not an endpoint (a sub-resource locator, a helper) and
|
||||
* is excluded. Path segments are joined with exactly one {@code /}, and a class with no
|
||||
* {@code @Path} contributes nothing to the prefix rather than a literal "null".
|
||||
*/
|
||||
private static final String REST_ENDPOINTS_CORE = """
|
||||
MATCH (m:AstNode {project: $project, type: 'MODULE'})-[:CONTAINS]->(f:AstNode {type: 'FUNCTION'})
|
||||
WHERE f.httpMethod IS NOT NULL
|
||||
AND ($module IS NULL OR m.name = $module OR m.simpleName = $module)
|
||||
// JAX-RS inherits @Path from a base class or interface, and this codebase uses that
|
||||
// heavily (AbstractFileTransferUiSvc and friends). Without the ancestor lookup those
|
||||
// endpoints all reported the bare path '/', which is a wrong answer, not a missing one.
|
||||
// Nearest ancestor wins; the depth bound keeps this from walking deep hierarchies.
|
||||
OPTIONAL MATCH ancestry = (m)-[:EXTENDS|IMPLEMENTS*1..4]->(base:AstNode {type: 'MODULE'})
|
||||
WHERE base.restPath IS NOT NULL
|
||||
WITH m, f, base, length(ancestry) AS depth
|
||||
ORDER BY depth
|
||||
WITH m, f, head(collect(base.restPath)) AS inheritedPath
|
||||
WITH m, f, coalesce(m.restPath, inheritedPath, '') AS classPath,
|
||||
coalesce(f.restPath, '') AS methodPath
|
||||
// Normalise each half by stripping its own leading and trailing '/', then join the
|
||||
// non-empty ones with exactly one '/'. The previous single replace('//', '/') was wrong:
|
||||
// replacement is non-overlapping, so '///file' collapsed to '//file' rather than '/file'.
|
||||
WITH m, f, [p IN [classPath, methodPath] WHERE p <> '' AND p <> '/' |
|
||||
CASE WHEN left(p, 1) = '/' THEN substring(p, 1) ELSE p END] AS lead
|
||||
WITH m, f, [p IN lead |
|
||||
CASE WHEN size(p) > 0 AND right(p, 1) = '/'
|
||||
THEN left(p, size(p) - 1) ELSE p END] AS parts
|
||||
WITH m, f, '/' + reduce(acc = '', p IN parts |
|
||||
CASE WHEN acc = '' THEN p ELSE acc + '/' + p END) AS path
|
||||
// DISTINCT is load-bearing: the graph can hold more than one CONTAINS edge between the
|
||||
// same module and function (see item 75), which multiplied every such endpoint into
|
||||
// identical rows — 183 of 436 on `pur`.
|
||||
""";
|
||||
|
||||
/**
|
||||
* The row projection; {@link #REST_ENDPOINTS_COUNT} must stay distinct over the same columns.
|
||||
*/
|
||||
private static final String REST_ENDPOINTS_ROW = """
|
||||
DISTINCT f.httpMethod AS httpMethod, path AS path,
|
||||
m.name AS module, m.simpleName AS moduleSimpleName, f.name AS handler,
|
||||
f.sourceFile AS sourceFile, f.startLine AS startLine,
|
||||
// A @RegisterRestClient interface declares a call this application *makes*, not one
|
||||
// it serves. Listing those as endpoints (3 in `pur`) states the traffic's direction
|
||||
// backwards; they are flagged rather than dropped, because "who calls out to what"
|
||||
// is a real question too.
|
||||
coalesce(m.annotations, '') CONTAINS 'RegisterRestClient' AS outbound
|
||||
""";
|
||||
|
||||
public static final String REST_ENDPOINTS = REST_ENDPOINTS_CORE + "RETURN " + REST_ENDPOINTS_ROW + """
|
||||
ORDER BY path, httpMethod
|
||||
LIMIT $scanCap
|
||||
""";
|
||||
|
||||
/**
|
||||
* Item 135: the endpoint count. Distinct over the full projected column set, not just
|
||||
* {@code (module, handler)}: the narrower key would be equal here only by accident of the data
|
||||
* model, and a count that disagrees with the page it describes is worse than none. Being distinct
|
||||
* also makes it immune to item 75's duplicate {@code CONTAINS} edges — which it masks exactly as
|
||||
* the row query does; item 75 is still open.
|
||||
*/
|
||||
public static final String REST_ENDPOINTS_COUNT = REST_ENDPOINTS_CORE + "WITH " + REST_ENDPOINTS_ROW + """
|
||||
RETURN count(*) AS total
|
||||
""";
|
||||
|
||||
private static String flowQuery(boolean forward, int maxDepth) {
|
||||
String path = forward
|
||||
? "(v)-[:ARG_TO_PARAM*1..%d]->(t:AstNode)"
|
||||
@@ -2138,7 +2364,9 @@ public final class CypherQueries {
|
||||
public static final String SEARCH_BY_VALUE_CONTAINS = """
|
||||
MATCH (n:AstNode {project: $project})
|
||||
WHERE n.value IS NOT NULL AND toLower(replace(n.value, "'", "")) CONTAINS toLower($value)
|
||||
RETURN 'NODE' AS kind, n.name AS name, n.value AS value, null AS module,
|
||||
AND ($includeComments OR n.type <> 'COMMENT')
|
||||
RETURN CASE WHEN n.type = 'COMMENT' THEN 'COMMENT' ELSE 'NODE' END AS kind,
|
||||
n.name AS name, n.value AS value, null AS module,
|
||||
n.sourceFile AS sourceFile, n.startLine AS startLine, n.endLine AS endLine
|
||||
UNION
|
||||
MATCH (m:AstNode {type: 'MODULE', project: $project})-[:CONTAINS*0..1]->(src:AstNode)-[w:WRITES]->(v:AstNode)
|
||||
@@ -2206,6 +2434,35 @@ public final class CypherQueries {
|
||||
m.sourceFile AS sourceFile
|
||||
ORDER BY lineNo, tag
|
||||
""";
|
||||
/**
|
||||
* Item 141: the comment blocks of a module, each with the declaration it documents.
|
||||
*
|
||||
* <p>Matched by the module's own {@code sourceFile} rather than through the {@code DOCUMENTS}
|
||||
* edge, because a comment documents a <em>declaration</em> (a field, a subroutine), and walking
|
||||
* back from there to "which module" would have to re-derive containment for every row. A comment
|
||||
* node only ever comes from the module's own file — copycode comments belong to the copycode,
|
||||
* which is its own module (see {@code NaturalParser.withComments}) — so the file <em>is</em> the
|
||||
* module scope.
|
||||
*
|
||||
* <p>{@code $kind} filters to one comment kind. With no filter, {@code **SAG} generator
|
||||
* directives are left out: they are machine-written metadata rather than a human note, and they
|
||||
* would otherwise be the bulk of the answer for every generated Natural module. Ask for
|
||||
* {@code kind=SAG} to see them.
|
||||
*/
|
||||
public static final String MODULE_COMMENTS = """
|
||||
MATCH (m:MODULE {name: $name, project: $project})
|
||||
WHERE ($sourceFile = '' OR m.sourceFile = $sourceFile)
|
||||
MATCH (c:AstNode {type: 'COMMENT', project: $project, sourceFile: m.sourceFile})
|
||||
WHERE ($kind IS NULL AND c.commentKind <> 'SAG') OR c.commentKind = $kind
|
||||
OPTIONAL MATCH (c)-[:DOCUMENTS]->(t:AstNode)
|
||||
RETURN c.value AS text, c.commentKind AS kind, c.sourceFile AS sourceFile,
|
||||
c.startLine AS startLine, c.endLine AS endLine,
|
||||
t.name AS target, t.type AS targetType,
|
||||
coalesce(c.truncated, 'false') AS truncated
|
||||
ORDER BY startLine
|
||||
SKIP $offset LIMIT $limit
|
||||
""";
|
||||
|
||||
/**
|
||||
* Finds nodes carrying a given annotation (item 29, Java only): matches case-insensitively as
|
||||
* a substring against each name in the node's comma-joined {@code annotations} property (set at
|
||||
@@ -2213,15 +2470,30 @@ public final class CypherQueries {
|
||||
* annotation's own specific interpretation elsewhere, e.g. {@code @Entity}/{@code @Query}).
|
||||
* Optional {@code $type} restricts to one {@code NodeType}.
|
||||
*/
|
||||
public static final String SEARCH_ANNOTATION = """
|
||||
/**
|
||||
* Item 131: shared predicate behind the annotation search's row and count projections.
|
||||
*/
|
||||
private static final String SEARCH_ANNOTATION_CORE = """
|
||||
MATCH (n:AstNode {project: $project})
|
||||
WHERE n.annotations IS NOT NULL
|
||||
AND ($type IS NULL OR n.type = $type)
|
||||
AND ANY(a IN split(n.annotations, ',') WHERE toLower(a) CONTAINS toLower($name))
|
||||
""";
|
||||
|
||||
public static final String SEARCH_ANNOTATION = SEARCH_ANNOTATION_CORE + """
|
||||
RETURN elementId(n) AS id, n.type AS type, n.name AS name, n.sourceFile AS sourceFile,
|
||||
n.startLine AS startLine, n.endLine AS endLine, n.annotations AS annotations
|
||||
ORDER BY n.name
|
||||
""";
|
||||
|
||||
/**
|
||||
* Item 131: the annotation search's total. This is the endpoint the item was written about —
|
||||
* {@code @Immutable} returned 50 of 95 rows with nothing to say so, and a real audit concluded
|
||||
* that 17 entities had lost the annotation when in fact zero had.
|
||||
*/
|
||||
public static final String SEARCH_ANNOTATION_COUNT = SEARCH_ANNOTATION_CORE + """
|
||||
RETURN count(n) AS total
|
||||
""";
|
||||
/**
|
||||
* Item 44: per-language LoC/SLoC rollup for a project. Aggregates the file-level shell nodes
|
||||
* ({@code MODULE}/{@code DATA_STRUCTURE} with a non-empty {@code sourceFile}). Because a single
|
||||
@@ -2296,17 +2568,104 @@ 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).
|
||||
public static final String SEARCH_IDENTIFIER = """
|
||||
/**
|
||||
* Item 131: the shared predicate of the identifier search. The row projection and the
|
||||
* {@code count(*)} projection are composed from this one constant on purpose — two hand-maintained
|
||||
* copies would drift, and a total that disagrees with the rows it describes is worse than no total
|
||||
* at all: it turns a visible truncation into a confident wrong number.
|
||||
*/
|
||||
private static final String SEARCH_IDENTIFIER_CORE = """
|
||||
MATCH (n:AstNode {project: $project})
|
||||
// Item 125: a Java type declaration's identity is its FQN (item 117), so matching n.name
|
||||
// alone made a class findable only by its fully-qualified name — the short form an agent
|
||||
// actually types answered [], which reads as "no such name" rather than "wrong form of the
|
||||
// name". n.simpleName is matched alongside it. It is null for Natural, where the
|
||||
// sigil-stripped n.name remains the only identity, so nothing changes there.
|
||||
WITH n, (CASE WHEN left(n.name, 1) IN ['#', '&', '+'] THEN substring(n.name, 1) ELSE n.name END) AS bare
|
||||
// $contains (item 125) switches the name predicate to a case-insensitive substring match,
|
||||
// as /search/value already does — "every identifier containing 'upd'" was unanswerable.
|
||||
// It matches the FQN too, so a package fragment also hits: over-matching is visible and
|
||||
// filterable (?type=MODULE, moduleKind), silence is not.
|
||||
WHERE ($name IS NULL OR
|
||||
(CASE WHEN left(n.name, 1) IN ['#', '&', '+'] THEN substring(n.name, 1) ELSE n.name END) = $name)
|
||||
CASE WHEN $contains
|
||||
THEN toLower(bare) CONTAINS toLower($name)
|
||||
OR (n.simpleName IS NOT NULL AND toLower(n.simpleName) CONTAINS toLower($name))
|
||||
ELSE bare = $name OR n.simpleName = $name
|
||||
END)
|
||||
AND ($type IS NULL OR n.type = $type)
|
||||
// Item 141: a COMMENT node's name is the synthetic `comment@<line>`, never prose - but it
|
||||
// is still a name, and this query matches every node's name with no type filter. Excluded
|
||||
// explicitly so `contains=true` can never drift into returning comment rows.
|
||||
AND n.type <> 'COMMENT'
|
||||
AND ($sourceFile IS NULL OR n.sourceFile = $sourceFile)
|
||||
AND ($module IS NULL OR EXISTS {
|
||||
MATCH (mod:MODULE {project: $project})
|
||||
WHERE (mod.name = $module OR mod.simpleName = $module)
|
||||
AND mod.sourceFile = n.sourceFile
|
||||
})
|
||||
""";
|
||||
|
||||
/**
|
||||
* 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}.
|
||||
*/
|
||||
private static final String SEARCH_REFERENCES_CORE = """
|
||||
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
|
||||
""";
|
||||
|
||||
/**
|
||||
* The row projection: {@link #SEARCH_REFERENCES_COUNT} must stay distinct over the same columns.
|
||||
*/
|
||||
private static final String SEARCH_REFERENCES_ROW = """
|
||||
DISTINCT s.sourceFile AS sourceFile, coalesce(r.lineNo, s.startLine) AS lineNo,
|
||||
kind AS kind, inModule AS inModule, t.name AS target
|
||||
""";
|
||||
|
||||
public static final String SEARCH_REFERENCES = SEARCH_REFERENCES_CORE + "RETURN " + SEARCH_REFERENCES_ROW + """
|
||||
ORDER BY sourceFile ASC, lineNo ASC, kind ASC
|
||||
LIMIT $scanCap
|
||||
""";
|
||||
|
||||
/**
|
||||
* Item 135: how many reference sites the search <em>would</em> return. Distinct over exactly the
|
||||
* columns {@link #SEARCH_REFERENCES_ROW} projects — a narrower key would count fewer rows than the
|
||||
* page delivers. Deliberately carries no {@code $scanCap}, for the reason spelled out on
|
||||
* {@link #SEARCH_IDENTIFIER_COUNT}: a capped count equals the page size and reports nothing.
|
||||
*/
|
||||
public static final String SEARCH_REFERENCES_COUNT = SEARCH_REFERENCES_CORE + "WITH " + SEARCH_REFERENCES_ROW + """
|
||||
RETURN count(*) AS total
|
||||
""";
|
||||
public static final String SEARCH_IDENTIFIER = SEARCH_IDENTIFIER_CORE + """
|
||||
WITH n, CASE WHEN $priorityModule IS NOT NULL AND EXISTS {
|
||||
MATCH (pm:MODULE {project: $project})
|
||||
WHERE (pm.name = $priorityModule OR pm.simpleName = $priorityModule)
|
||||
@@ -2314,9 +2673,43 @@ public final class CypherQueries {
|
||||
} THEN 0 ELSE 1 END AS pinRank
|
||||
RETURN elementId(n) AS id, n.type AS type, n.name AS name, n.sourceFile AS sourceFile,
|
||||
n.startLine AS startLine, n.endLine AS endLine,
|
||||
n.dataType AS dataType, n.value AS value, n.scope AS scope, n.unresolved AS unresolved
|
||||
n.dataType AS dataType, n.value AS value, n.scope AS scope, n.unresolved AS unresolved,
|
||||
// Item 125: the short form and the declaration kind (CLASS/INTERFACE/ENUM/RECORD,
|
||||
// PROGRAM/SUBPROGRAM for Natural), so a caller can tell a type declaration from a
|
||||
// method or a variable without a second round trip. Null for non-MODULE nodes.
|
||||
n.simpleName AS simpleName, n.moduleKind AS moduleKind
|
||||
ORDER BY pinRank ASC, n.sourceFile ASC, n.startLine ASC
|
||||
// $scanCap bounds an unindexed CONTAINS over a large project: it is offset+limit, i.e.
|
||||
// exactly the rows the caller's page can need, and Integer.MAX_VALUE when the caller asked
|
||||
// for everything (limit <= 0). Slicing [offset, offset+limit) out of the first offset+limit
|
||||
// ordered rows is identical to slicing it out of the full ordered list, so paginate()'s
|
||||
// semantics — including "limit <= 0 means unlimited" — are untouched.
|
||||
LIMIT $scanCap
|
||||
""";
|
||||
/**
|
||||
* Item 131: how many rows the identifier search <em>would</em> return. Deliberately carries no
|
||||
* {@code $scanCap}: capping the count to the page size would make it equal the row count every
|
||||
* time and silently defeat the whole point of reporting a total.
|
||||
*/
|
||||
public static final String SEARCH_IDENTIFIER_COUNT = SEARCH_IDENTIFIER_CORE + """
|
||||
RETURN count(n) AS total
|
||||
""";
|
||||
|
||||
/**
|
||||
* Item 131: the total for a value search, exact or substring. The union has to be wrapped in a
|
||||
* {@code CALL} subquery to be counted, and it is a {@code UNION} (not {@code UNION ALL}) in both
|
||||
* projections — so the count counts the same deduplicated rows the caller can actually page
|
||||
* through, rather than a larger number no page will ever reach.
|
||||
*/
|
||||
public static String searchByValueCount(boolean substring) {
|
||||
return """
|
||||
CALL {
|
||||
""" + (substring ? SEARCH_BY_VALUE_CONTAINS : SEARCH_BY_VALUE) + """
|
||||
}
|
||||
RETURN count(*) AS total
|
||||
""";
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 78: deletes a project's {@code AstNode}s <b>in batches</b>, leaving its {@code (:Project)}
|
||||
* shell â the config â untouched. This is what {@code recreate} runs: the shell never leaves the
|
||||
@@ -2503,7 +2896,17 @@ public final class CypherQueries {
|
||||
public static final String GET_PROJECT = """
|
||||
MATCH (p:Project {name: $name})
|
||||
RETURN p.name AS name, p.description AS description, p.root AS root, p.excludeDirs AS excludeDirs,
|
||||
p.language AS language, p.generatedDir AS generatedDir, p.userExitDir AS userExitDir
|
||||
p.language AS language, p.generatedDir AS generatedDir, p.userExitDir AS userExitDir,
|
||||
// Item 126: what the last whole-root ingest did. Null on a project last ingested
|
||||
// before this was recorded — "never measured", which is not the same as zero.
|
||||
p.ingestedAt AS ingestedAt, p.ingestMode AS ingestMode,
|
||||
p.ingestFilesExamined AS ingestFilesExamined, p.ingestFilesPersisted AS ingestFilesPersisted,
|
||||
p.ingestFilesFailed AS ingestFilesFailed, p.ingestFailures AS ingestFailures,
|
||||
p.ingestFailuresTruncated AS ingestFailuresTruncated,
|
||||
p.ingestDurationSeconds AS ingestDurationSeconds, p.ingestServerVersion AS ingestServerVersion,
|
||||
// Item 129: true while a whole-root pass is running, and left true by one that never
|
||||
// finished — the graph is then half-updated and every answer is drawn from that state.
|
||||
p.ingestIncomplete AS ingestIncomplete, p.ingestStartedAt AS ingestStartedAt
|
||||
""";
|
||||
|
||||
/**
|
||||
@@ -2528,10 +2931,55 @@ public final class CypherQueries {
|
||||
public static final String LIST_PROJECTS = """
|
||||
MATCH (p:Project)
|
||||
RETURN p.name AS name, p.description AS description, p.root AS root, p.excludeDirs AS excludeDirs,
|
||||
p.language AS language, p.generatedDir AS generatedDir, p.userExitDir AS userExitDir
|
||||
p.language AS language, p.generatedDir AS generatedDir, p.userExitDir AS userExitDir,
|
||||
// Item 126: what the last whole-root ingest did. Null on a project last ingested
|
||||
// before this was recorded — "never measured", which is not the same as zero.
|
||||
p.ingestedAt AS ingestedAt, p.ingestMode AS ingestMode,
|
||||
p.ingestFilesExamined AS ingestFilesExamined, p.ingestFilesPersisted AS ingestFilesPersisted,
|
||||
p.ingestFilesFailed AS ingestFilesFailed, p.ingestFailures AS ingestFailures,
|
||||
p.ingestFailuresTruncated AS ingestFailuresTruncated,
|
||||
p.ingestDurationSeconds AS ingestDurationSeconds, p.ingestServerVersion AS ingestServerVersion,
|
||||
// Item 129: true while a whole-root pass is running, and left true by one that never
|
||||
// finished — the graph is then half-updated and every answer is drawn from that state.
|
||||
p.ingestIncomplete AS ingestIncomplete, p.ingestStartedAt AS ingestStartedAt
|
||||
ORDER BY p.name
|
||||
""";
|
||||
|
||||
/**
|
||||
* Item 126: stamps the {@code (:Project)} shell with what a <b>whole-root</b> ingest just did, so
|
||||
* every later answer can be dated and its completeness judged from the API alone. Written only by
|
||||
* the whole-root passes — a by-name or fan-out ingest walks a fraction of the tree, and letting it
|
||||
* move {@code ingestedAt} would report the project as freshly walked when one module was deepened.
|
||||
*
|
||||
* <p>Separate from {@link #UPDATE_PROJECT} on purpose: that one {@code COALESCE}s user config, and
|
||||
* these are server observations. Neither can clobber the other.
|
||||
*/
|
||||
/**
|
||||
* Item 129: marks a whole-root ingest as <b>in flight</b> before it starts. {@link #RECORD_PROJECT_INGEST}
|
||||
* clears it on success, so a pass that never finished — a crash, a container stop, an aborted deep
|
||||
* refresh — leaves {@code ingestIncomplete = true} behind and every later answer can say so.
|
||||
*
|
||||
* <p>Before this, an interrupted deep refresh was indistinguishable from a clean graph: the earlier
|
||||
* enrichment steps are already committed, so queries keep answering, just from a half-updated graph.
|
||||
* The marker cannot self-heal (a killed process clears nothing), and that is the correct direction to
|
||||
* fail — a false "incomplete" costs one refresh, a false "clean" costs trust in every answer.
|
||||
*/
|
||||
public static final String MARK_PROJECT_INGEST_STARTED = """
|
||||
MATCH (p:Project {name: $name})
|
||||
SET p.ingestIncomplete = true, p.ingestStartedAt = $startedAt, p.ingestMode = $mode
|
||||
""";
|
||||
|
||||
public static final String RECORD_PROJECT_INGEST = """
|
||||
MATCH (p:Project {name: $name})
|
||||
SET p.ingestedAt = $ingestedAt, p.ingestMode = $mode,
|
||||
p.ingestFilesExamined = $filesExamined, p.ingestFilesPersisted = $filesPersisted,
|
||||
p.ingestFilesFailed = $filesFailed, p.ingestFailures = $failures,
|
||||
p.ingestFailuresTruncated = $failuresTruncated,
|
||||
p.ingestDurationSeconds = $durationSeconds, p.ingestServerVersion = $serverVersion,
|
||||
// Item 129: reaching here means the pass completed, so the in-flight marker is cleared.
|
||||
p.ingestIncomplete = false
|
||||
""";
|
||||
|
||||
/**
|
||||
* @return the callers query, grouping all call sites to the same caller into a single row
|
||||
* with {@code lineNos}. Includes incoming {@code EXTENDS}/{@code IMPLEMENTS} edges (subtypes of
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.agenticcode.neo4jstore.graph;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -28,9 +30,22 @@ import java.util.List;
|
||||
* a blank {@code guardField} also routes here (item 64)
|
||||
* @param assignedField the field assigned in the branch (e.g. {@code #P-CALLED-PROG})
|
||||
* @param assignedValue the assigned literal, quotes stripped (e.g. {@code WGEAGB0S})
|
||||
* @param lineNo source line of the assignment
|
||||
* @param lineNo source line of the assignment, <em>within {@link #sourceFile}</em>
|
||||
* @param sourceFile item 122 — the file {@code lineNo} is in. Usually the module's own file, but for a
|
||||
* row spliced in from a copycode it is that {@code .cpy}. Until this was carried
|
||||
* through, every endpoint but this one reported provenance, so a copycode row's
|
||||
* {@code lineNo} read as a line of the host module: 26 of {@code VCOMIN50}'s 44 rows
|
||||
* pointed at lines 18/20, which there are change-history comments, while the real
|
||||
* sites were {@code ISICINDE.cpy:18} and {@code ISICINDI.cpy:20}.
|
||||
* @param viaCopycode the {@code .cpy} member this row was spliced from, or {@code null} when it is
|
||||
* written directly in the module's own file
|
||||
* @param includedAt 1-based line of the {@code INCLUDE} in the host module — where to look in the
|
||||
* module itself. {@code null} unless {@code viaCopycode} is set.
|
||||
* @param includePath the whole {@code INCLUDE} chain, host first; empty for a row written in the module.
|
||||
* A nested include is otherwise invisible (item 104).
|
||||
*/
|
||||
public record DispatchEntry(List<DispatchGuard> guards, String guardField, String guardValue,
|
||||
List<String> guardValues, String assignedField, String assignedValue,
|
||||
int lineNo) {
|
||||
int lineNo, String sourceFile, @Nullable String viaCopycode,
|
||||
@Nullable Integer includedAt, List<IncludeStep> includePath) {
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package com.agenticcode.neo4jstore.graph;
|
||||
|
||||
import com.agenticcode.parsercore.ast.model.AstEdge;
|
||||
import com.agenticcode.parsercore.ast.model.AstNode;
|
||||
import com.agenticcode.parsercore.ast.model.EdgeType;
|
||||
import com.agenticcode.parsercore.ast.model.NodeType;
|
||||
import com.agenticcode.parsercore.ast.model.*;
|
||||
import com.agenticcode.parsercore.ast.spi.LanguageParser.ParseResult;
|
||||
import io.smallrye.mutiny.Uni;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
@@ -15,6 +12,7 @@ import org.neo4j.driver.Record;
|
||||
import org.neo4j.driver.summary.SummaryCounters;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -146,6 +144,12 @@ public class GraphRepository {
|
||||
// (entity -> table); native SQL matches a literal DB_TABLE name directly, no dependency.
|
||||
statements.add(new EnrichmentStep("resolve-java-query-jpql", CypherQueries.RESOLVE_JAVA_QUERY_JPQL));
|
||||
statements.add(new EnrichmentStep("resolve-java-query-native-sql", CypherQueries.RESOLVE_JAVA_QUERY_NATIVE_SQL));
|
||||
// Item 140: a project with no DB_TABLE cannot resolve a single Java DB_ACCESS candidate, so
|
||||
// everything the over-approximating parse-time heuristic emitted there is a false positive.
|
||||
// Reap it (Java only — a Natural READ/FIND is a real access even with an unresolved view).
|
||||
// Must follow all three resolvers above.
|
||||
statements.add(new EnrichmentStep("reap-java-db-access-without-tables",
|
||||
CypherQueries.REAP_JAVA_DB_ACCESS_WITHOUT_TABLES));
|
||||
// Reap self-EXTENDS/IMPLEMENTS edges (a class cannot extend/implement itself) before any step
|
||||
// traverses the inheritance graph — clears stale name-collision edges a non-wiping refresh leaves.
|
||||
statements.add(new EnrichmentStep("delete-self-inheritance-edges", CypherQueries.DELETE_SELF_INHERITANCE_EDGES));
|
||||
@@ -263,6 +267,9 @@ public class GraphRepository {
|
||||
statements.add(new EnrichmentStep("resolve-view-alias-access-nodes",
|
||||
CypherQueries.RESOLVE_VIEW_ALIAS_ACCESS_NODES));
|
||||
statements.add(new EnrichmentStep("delete-orphaned-placeholder-tables", CypherQueries.DELETE_ORPHANED_PLACEHOLDER_TABLES));
|
||||
// Item 124: the same for call-target placeholders left edgeless by the CALLS reap — otherwise a
|
||||
// phantom target of a since-fixed parser bug keeps showing up as an unresolved module.
|
||||
statements.add(new EnrichmentStep("delete-orphaned-placeholder-modules", CypherQueries.DELETE_ORPHANED_PLACEHOLDER_MODULES));
|
||||
// Item 40: flag surviving placeholders as (un)resolved by whether a real definition now exists.
|
||||
// Runs last so it sees the fully redirected/reaped graph. Project-wide, cheap, idempotent.
|
||||
statements.add(new EnrichmentStep("stamp-unresolved-placeholders", CypherQueries.STAMP_UNRESOLVED_PLACEHOLDERS));
|
||||
@@ -932,6 +939,32 @@ public class GraphRepository {
|
||||
GraphRepository::toPayloadField));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 141: the comment blocks of a module, ordered by line. {@code kind} filters to one comment
|
||||
* kind; with none, {@code **SAG} generator directives are excluded (see
|
||||
* {@link CypherQueries#MODULE_COMMENTS}).
|
||||
*/
|
||||
public Uni<List<CommentBlock>> moduleComments(String project, String moduleName, String sourceFile,
|
||||
@Nullable String kind, int limit, int offset) {
|
||||
Map<String, @Nullable Object> params = new HashMap<>(moduleParams(project, moduleName, sourceFile));
|
||||
params.put("kind", kind);
|
||||
params.put("limit", limit);
|
||||
params.put("offset", offset);
|
||||
return read(CypherQueries.MODULE_COMMENTS, params, GraphRepository::toCommentBlock);
|
||||
}
|
||||
|
||||
private static CommentBlock toCommentBlock(Record record) {
|
||||
return new CommentBlock(
|
||||
record.get("text").asString(""),
|
||||
record.get("kind").asString(""),
|
||||
record.get("sourceFile").asString(""),
|
||||
record.get("startLine").asInt(),
|
||||
record.get("endLine").asInt(),
|
||||
record.get("target").isNull() ? null : record.get("target").asString(),
|
||||
record.get("targetType").isNull() ? null : record.get("targetType").asString(),
|
||||
Boolean.parseBoolean(record.get("truncated").asString("false")));
|
||||
}
|
||||
|
||||
private static PayloadField toPayloadField(Record record) {
|
||||
return new PayloadField(
|
||||
record.get("tag").asString(),
|
||||
@@ -1078,19 +1111,74 @@ public class GraphRepository {
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated variant of {@link #searchIdentifier(String, String, String, String, String, String)} for the endpoint.
|
||||
* Item 126: the last whole-root ingest's facts, or {@code null} when the project shell carries no
|
||||
* {@code ingestedAt} — i.e. it was last ingested before this was recorded. Null rather than a
|
||||
* zero-filled record: "never measured" and "measured as none" are different answers, and
|
||||
* conflating them is the failure mode the item is about.
|
||||
*/
|
||||
private static @Nullable ProjectIngestInfo toProjectIngestInfo(Record record) {
|
||||
@Nullable String ingestedAt = nullableString(record, "ingestedAt");
|
||||
boolean incomplete = record.containsKey("ingestIncomplete") && !record.get("ingestIncomplete").isNull()
|
||||
&& record.get("ingestIncomplete").asBoolean();
|
||||
// Item 129: a project whose very first whole-root pass is still running (or died) has no
|
||||
// ingestedAt yet, but the in-flight marker is the most important thing to report about it —
|
||||
// so it is not folded into the "never recorded" null case below.
|
||||
if (ingestedAt == null && !incomplete) {
|
||||
return null;
|
||||
}
|
||||
if (ingestedAt == null) {
|
||||
return new ProjectIngestInfo("", nullableString(record, "ingestMode") != null
|
||||
? record.get("ingestMode").asString() : "", 0, 0, 0, List.of(), false, 0L, "",
|
||||
true, nullableString(record, "ingestStartedAt"));
|
||||
}
|
||||
List<String> failures = record.containsKey("ingestFailures") && !record.get("ingestFailures").isNull()
|
||||
? record.get("ingestFailures").asList(value -> value.asString())
|
||||
: List.of();
|
||||
return new ProjectIngestInfo(ingestedAt,
|
||||
nullableString(record, "ingestMode") != null ? record.get("ingestMode").asString() : "",
|
||||
intOrZero(record, "ingestFilesExamined"), intOrZero(record, "ingestFilesPersisted"),
|
||||
intOrZero(record, "ingestFilesFailed"), failures,
|
||||
record.containsKey("ingestFailuresTruncated") && !record.get("ingestFailuresTruncated").isNull()
|
||||
&& record.get("ingestFailuresTruncated").asBoolean(),
|
||||
record.containsKey("ingestDurationSeconds") && !record.get("ingestDurationSeconds").isNull()
|
||||
? record.get("ingestDurationSeconds").asLong() : 0L,
|
||||
nullableString(record, "ingestServerVersion") != null
|
||||
? record.get("ingestServerVersion").asString() : "",
|
||||
incomplete, nullableString(record, "ingestStartedAt"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated variant of {@link #searchIdentifier(String, String, String, String, String, String, boolean, int)}
|
||||
* for the endpoint.
|
||||
*
|
||||
* @param contains item 125: match names that <em>contain</em> {@code identifierName}
|
||||
* (case-insensitive) instead of equalling it
|
||||
*/
|
||||
public Uni<List<IdentifierMatch>> searchIdentifier(String project, @Nullable String identifierName,
|
||||
@Nullable String type, @Nullable String sourceFile,
|
||||
@Nullable String module, @Nullable String priorityModule,
|
||||
int limit, int offset) {
|
||||
return searchIdentifier(project, identifierName, type, sourceFile, module, priorityModule)
|
||||
boolean contains, int limit, int offset) {
|
||||
// Item 125: fetch at most the rows this page can consume, so an unindexed CONTAINS over a
|
||||
// large project cannot materialise its whole match set here. paginate() then slices exactly
|
||||
// as before — including its "limit <= 0 means unlimited" rule, which is why the cap is
|
||||
// Integer.MAX_VALUE in that case rather than 0.
|
||||
int scanCap = limit > 0 ? (int) Math.min((long) Math.max(offset, 0) + limit, Integer.MAX_VALUE)
|
||||
: Integer.MAX_VALUE;
|
||||
return searchIdentifier(project, identifierName, type, sourceFile, module, priorityModule, contains, scanCap)
|
||||
.map(list -> paginate(list, limit, offset));
|
||||
}
|
||||
|
||||
public Uni<List<IdentifierMatch>> searchIdentifier(String project, @Nullable String identifierName,
|
||||
@Nullable String type, @Nullable String sourceFile,
|
||||
@Nullable String module, @Nullable String priorityModule) {
|
||||
return searchIdentifier(project, identifierName, type, sourceFile, module, priorityModule,
|
||||
false, Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
public Uni<List<IdentifierMatch>> searchIdentifier(String project, @Nullable String identifierName,
|
||||
@Nullable String type, @Nullable String sourceFile,
|
||||
@Nullable String module, @Nullable String priorityModule,
|
||||
boolean contains, int scanCap) {
|
||||
Map<String, @Nullable Object> params = new java.util.HashMap<>();
|
||||
params.put("project", project);
|
||||
// Sigil-insensitive: strip a leading Natural sigil (# user, & AIV, + GDA) so a search for
|
||||
@@ -1103,26 +1191,40 @@ public class GraphRepository {
|
||||
// Pin this module's matches to the front so they survive limit/paginate truncation when a
|
||||
// name recurs across many modules (the caller's local declaration would otherwise be lost).
|
||||
params.put("priorityModule", priorityModule);
|
||||
// Item 125: substring vs exact name match, and the row cap the query's LIMIT reads.
|
||||
params.put("contains", contains);
|
||||
params.put("scanCap", scanCap);
|
||||
return read(CypherQueries.SEARCH_IDENTIFIER, params, record -> new IdentifierMatch(
|
||||
record.get("id").asString(),
|
||||
NodeType.valueOf(record.get("type").asString()),
|
||||
record.get("name").asString(),
|
||||
record.get("sourceFile").asString(),
|
||||
record.get("startLine").asInt(),
|
||||
record.get("endLine").asInt(),
|
||||
// Item 136: asInt() on a NULL throws Uncoercible and escaped as an unstructured 500.
|
||||
// Item 114's duplicate markers were created without lines; that is fixed at the write
|
||||
// side too, but a row mapper must never be the thing that faults an endpoint.
|
||||
intOrZero(record, "startLine"),
|
||||
intOrZero(record, "endLine"),
|
||||
record.get("dataType").isNull() ? null : record.get("dataType").asString(),
|
||||
record.get("value").isNull() ? null : record.get("value").asString(),
|
||||
record.get("scope").isNull() ? null : record.get("scope").asString(),
|
||||
record.get("unresolved").isNull() ? null : record.get("unresolved").asBoolean()));
|
||||
record.get("unresolved").isNull() ? null : record.get("unresolved").asBoolean(),
|
||||
record.get("simpleName").isNull() ? null : record.get("simpleName").asString(),
|
||||
record.get("moduleKind").isNull() ? null : record.get("moduleKind").asString()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param substring when {@code true}, uses {@link CypherQueries#SEARCH_BY_VALUE_CONTAINS}
|
||||
* (case-insensitive substring, item 29) instead of the default exact match.
|
||||
* @param substring when {@code true}, uses {@link CypherQueries#SEARCH_BY_VALUE_CONTAINS}
|
||||
* (case-insensitive substring, item 29) instead of the default exact match.
|
||||
* @param includeComments item 141: when {@code true}, comment blocks are searched too and their
|
||||
* hits come back as {@code kind = "COMMENT"}. Default {@code false} — the
|
||||
* text of a comment is not the same evidence as a literal in code, and
|
||||
* folding it in silently would move every existing completeness count.
|
||||
*/
|
||||
public Uni<List<ValueMatch>> searchByValue(String project, String value, boolean substring) {
|
||||
public Uni<List<ValueMatch>> searchByValue(String project, String value, boolean substring,
|
||||
boolean includeComments) {
|
||||
String query = substring ? CypherQueries.SEARCH_BY_VALUE_CONTAINS : CypherQueries.SEARCH_BY_VALUE;
|
||||
return read(query, Map.of("project", project, "value", value), GraphRepository::toValueMatch);
|
||||
return read(query, Map.of("project", project, "value", value, "includeComments", includeComments),
|
||||
GraphRepository::toValueMatch);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1313,7 +1415,229 @@ public class GraphRepository {
|
||||
: record.get("excludeDirs").asList(value -> value.asString());
|
||||
return new ProjectInfo(record.get("name").asString(), description, root, excludeDirs,
|
||||
nullableString(record, "language"), nullableString(record, "generatedDir"),
|
||||
nullableString(record, "userExitDir"));
|
||||
nullableString(record, "userExitDir"), toProjectIngestInfo(record));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 131: the identifier search as a {@link Page} — the rows plus how many there are in total.
|
||||
*
|
||||
* <p>The total costs a second query only when it can matter. A page that came back <b>shorter</b>
|
||||
* than {@code limit} is provably the end of the result set, so the total is arithmetic
|
||||
* ({@code offset + rows}); only a <b>full</b> page — the one case where rows may have been cut —
|
||||
* pays for a {@code count(*)}. For {@code contains=true}, which is an unindexed label scan
|
||||
* (1.2-2.3s on {@code upms}), that difference is the whole cost of the feature.
|
||||
*
|
||||
* <p>A total that divides evenly by {@code limit} makes the last full page report
|
||||
* {@code truncated} and the next page come back empty: one wasted call, never a wrong answer.
|
||||
*/
|
||||
public Uni<Page<IdentifierMatch>> searchIdentifierPage(String project, @Nullable String identifierName,
|
||||
@Nullable String type, @Nullable String sourceFile,
|
||||
@Nullable String module, @Nullable String priorityModule,
|
||||
boolean contains, int limit, int offset) {
|
||||
return searchIdentifier(project, identifierName, type, sourceFile, module, priorityModule,
|
||||
contains, limit, offset)
|
||||
.flatMap(rows -> withTotal(rows, limit, offset,
|
||||
() -> countIdentifier(project, identifierName, type, sourceFile, module, contains)));
|
||||
}
|
||||
|
||||
private Uni<Long> countIdentifier(String project, @Nullable String identifierName, @Nullable String type,
|
||||
@Nullable String sourceFile, @Nullable String module, boolean contains) {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("project", project);
|
||||
params.put("name", stripLeadingSigil(identifierName));
|
||||
params.put("type", type);
|
||||
params.put("sourceFile", sourceFile);
|
||||
params.put("module", module);
|
||||
params.put("contains", contains);
|
||||
return count(CypherQueries.SEARCH_IDENTIFIER_COUNT, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 131: the value search as a {@link Page}. See {@link #searchIdentifierPage} for why the
|
||||
* count is conditional.
|
||||
*/
|
||||
public Uni<Page<ValueMatch>> searchByValuePage(String project, String value, boolean substring,
|
||||
boolean includeComments, int limit, int offset) {
|
||||
return searchByValue(project, value, substring, includeComments, limit, offset)
|
||||
.flatMap(rows -> withTotal(rows, limit, offset,
|
||||
() -> count(CypherQueries.searchByValueCount(substring),
|
||||
Map.of("project", project, "value", value, "includeComments", includeComments))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 131: the annotation search as a {@link Page} — the endpoint this item was written about.
|
||||
*/
|
||||
public Uni<Page<AnnotationMatch>> searchAnnotationPage(String project, String name, @Nullable String type,
|
||||
int limit, int offset) {
|
||||
return searchAnnotation(project, name, type, limit, offset)
|
||||
.flatMap(rows -> withTotal(rows, limit, offset, () -> {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("project", project);
|
||||
params.put("name", name);
|
||||
params.put("type", type);
|
||||
return count(CypherQueries.SEARCH_ANNOTATION_COUNT, params);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps {@code rows} in a {@link Page}, running {@code counter} only when the page is full (and a
|
||||
* limit was given at all — {@code limit <= 0} means the caller asked for everything, so what came
|
||||
* back is by definition the whole set).
|
||||
*/
|
||||
private <T> Uni<Page<T>> withTotal(List<T> rows, int limit, int offset, Supplier<Uni<Long>> counter) {
|
||||
if (limit <= 0 || rows.size() < limit) {
|
||||
long total = (long) Math.max(offset, 0) + rows.size();
|
||||
return Uni.createFrom().item(new Page<>(rows, total, false));
|
||||
}
|
||||
return counter.get().map(total -> new Page<>(rows, total,
|
||||
total > (long) Math.max(offset, 0) + rows.size()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a query whose single row is a {@code total} column.
|
||||
*/
|
||||
private Uni<Long> count(String query, Map<String, @Nullable Object> params) {
|
||||
return read(query, params, record -> record.get("total").asLong())
|
||||
.map(totals -> totals.isEmpty() ? 0L : totals.get(0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 130: the project's REST endpoints (path + verb + handler), optionally narrowed to one
|
||||
* declaring class.
|
||||
*/
|
||||
public Uni<List<RestEndpoint>> restEndpoints(String project, @Nullable String module, 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("module", module);
|
||||
params.put("scanCap", scanCap);
|
||||
return read(CypherQueries.REST_ENDPOINTS, params, record -> new RestEndpoint(
|
||||
record.get("httpMethod").asString(),
|
||||
record.get("path").asString(),
|
||||
record.get("module").asString(),
|
||||
record.get("moduleSimpleName").isNull() ? null : record.get("moduleSimpleName").asString(),
|
||||
record.get("handler").asString(),
|
||||
record.get("sourceFile").asString(),
|
||||
intOrZero(record, "startLine"),
|
||||
!record.get("outbound").isNull() && record.get("outbound").asBoolean()))
|
||||
.map(list -> paginate(list, limit, offset));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 135: the REST surface as a {@link Page}. Item 131 shipped without this endpoint, so the
|
||||
* answer carried no total at all. With the default (uncapped) limit no count query runs — see
|
||||
* {@link #withTotal}.
|
||||
*/
|
||||
public Uni<Page<RestEndpoint>> restEndpointsPage(String project, @Nullable String module,
|
||||
int limit, int offset) {
|
||||
return restEndpoints(project, module, limit, offset)
|
||||
.flatMap(rows -> withTotal(rows, limit, offset, () -> {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("project", project);
|
||||
params.put("module", module);
|
||||
return count(CypherQueries.REST_ENDPOINTS_COUNT, params);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 135: the reference search as a {@link Page}. This is the one that actually lost rows — it
|
||||
* capped at the default 50 with no signal whatsoever, which is precisely the failure item 131 was
|
||||
* written to remove.
|
||||
*/
|
||||
public Uni<Page<ReferenceSite>> searchReferencesPage(String project, String name, @Nullable String kind,
|
||||
int limit, int offset) {
|
||||
return searchReferences(project, name, kind, limit, offset)
|
||||
.flatMap(rows -> withTotal(rows, limit, offset, () -> {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("project", project);
|
||||
params.put("name", name);
|
||||
params.put("kind", kind);
|
||||
return count(CypherQueries.SEARCH_REFERENCES_COUNT, params);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* counts as changed.
|
||||
*/
|
||||
public Uni<Map<String, String>> sourceHashes(String project) {
|
||||
return read(CypherQueries.SOURCE_HASHES, Map.of("project", project),
|
||||
record -> Map.entry(record.get("sourceFile").asString(), record.get("hash").asString()))
|
||||
.map(entries -> entries.stream().collect(java.util.stream.Collectors.toMap(
|
||||
Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a)));
|
||||
}
|
||||
|
||||
private static int intOrZero(Record record, String key) {
|
||||
return record.containsKey(key) && !record.get(key).isNull() ? record.get(key).asInt() : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 126: records what a whole-root ingest just did on the {@code (:Project)} shell. The failure
|
||||
* list is capped at {@link ProjectIngestInfo#MAX_FAILURES} with an explicit truncation flag — the
|
||||
* count stays exact, so a short list never reads as the whole story.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Item 129: marks a whole-root pass as in flight. Cleared by {@link #recordProjectIngest}, so an
|
||||
* interrupted run leaves the marker set and stops looking like a clean graph.
|
||||
*/
|
||||
public Uni<Void> markProjectIngestStarted(String project, String mode, String startedAt) {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("name", project);
|
||||
params.put("mode", mode);
|
||||
params.put("startedAt", startedAt);
|
||||
return Uni.createFrom().item(() -> {
|
||||
try (Session session = driver.session()) {
|
||||
session.executeWriteWithoutResult(tx -> tx.run(CypherQueries.MARK_PROJECT_INGEST_STARTED, params));
|
||||
}
|
||||
return project;
|
||||
}).replaceWithVoid();
|
||||
}
|
||||
|
||||
public Uni<Void> recordProjectIngest(String project, ProjectIngestInfo ingest) {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("name", project);
|
||||
params.put("ingestedAt", ingest.ingestedAt());
|
||||
params.put("mode", ingest.mode());
|
||||
params.put("filesExamined", ingest.filesExamined());
|
||||
params.put("filesPersisted", ingest.filesPersisted());
|
||||
params.put("filesFailed", ingest.filesFailed());
|
||||
params.put("failures", ingest.failures());
|
||||
params.put("failuresTruncated", ingest.failuresTruncated());
|
||||
params.put("durationSeconds", ingest.durationSeconds());
|
||||
params.put("serverVersion", ingest.serverVersion());
|
||||
return Uni.createFrom().item(() -> {
|
||||
try (Session session = driver.session()) {
|
||||
session.executeWriteWithoutResult(tx -> tx.run(CypherQueries.RECORD_PROJECT_INGEST, params));
|
||||
}
|
||||
return project;
|
||||
}).replaceWithVoid();
|
||||
}
|
||||
|
||||
private static @Nullable String nullableString(Record record, String key) {
|
||||
@@ -1352,21 +1676,56 @@ public class GraphRepository {
|
||||
*/
|
||||
private static String mergeKey(AstNode node, boolean positional, String project, String ownerFile) {
|
||||
String base = node.type() + " | ||||