Java improvements
This commit is contained in:
@@ -30,7 +30,7 @@ abstract class AbstractApiCommand implements Callable<Integer> {
|
||||
return new ApiClient(baseUrl);
|
||||
}
|
||||
|
||||
protected static String encode(String value) {
|
||||
static String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Mixin;
|
||||
import picocli.CommandLine.Option;
|
||||
import picocli.CommandLine.Parameters;
|
||||
|
||||
@@ -14,6 +15,9 @@ final class CallTreeCommand extends AbstractProjectCommand {
|
||||
@Parameters(index = "0", description = "Module name")
|
||||
String moduleName;
|
||||
|
||||
@Mixin
|
||||
ModuleSelector selector = new ModuleSelector();
|
||||
|
||||
@Option(names = "--depth", description = "Maximum number of module hops to traverse (a call made from "
|
||||
+ "inside a subroutine is still one hop)")
|
||||
int depth = -1;
|
||||
@@ -25,7 +29,7 @@ final class CallTreeCommand extends AbstractProjectCommand {
|
||||
if (depth >= 0) {
|
||||
path += "?depth=" + depth;
|
||||
}
|
||||
return printResponse(apiClient().get(path));
|
||||
return printResponse(apiClient().get(selector.append(path)));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Mixin;
|
||||
import picocli.CommandLine.Option;
|
||||
import picocli.CommandLine.Parameters;
|
||||
|
||||
@@ -14,6 +15,9 @@ final class CalleesCommand extends AbstractProjectCommand {
|
||||
@Parameters(index = "0", description = "Module name")
|
||||
String moduleName;
|
||||
|
||||
@Mixin
|
||||
ModuleSelector selector = new ModuleSelector();
|
||||
|
||||
@Option(names = "--scope", description = "Filter by call kind: 'external' (CALLNAT only) or 'internal' (PERFORM only)")
|
||||
String scope = "";
|
||||
|
||||
@@ -31,7 +35,7 @@ final class CalleesCommand extends AbstractProjectCommand {
|
||||
path += "?scope=" + encode(scope);
|
||||
}
|
||||
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
|
||||
return printResponse(apiClient().get(path));
|
||||
return printResponse(apiClient().get(selector.append(path)));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Mixin;
|
||||
import picocli.CommandLine.Option;
|
||||
import picocli.CommandLine.Parameters;
|
||||
|
||||
@@ -14,6 +15,9 @@ final class CallersCommand extends AbstractProjectCommand {
|
||||
@Parameters(index = "0", description = "Module name")
|
||||
String moduleName;
|
||||
|
||||
@Mixin
|
||||
ModuleSelector selector = new ModuleSelector();
|
||||
|
||||
@Option(names = "--scope", description = "'external' (default) = module callers (CALLNAT/inheritance); 'internal' = own-subroutine PERFORM wiring")
|
||||
String scope = "";
|
||||
|
||||
@@ -31,7 +35,7 @@ final class CallersCommand extends AbstractProjectCommand {
|
||||
path += "?scope=" + encode(scope);
|
||||
}
|
||||
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
|
||||
return printResponse(apiClient().get(path));
|
||||
return printResponse(apiClient().get(selector.append(path)));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Mixin;
|
||||
import picocli.CommandLine.Option;
|
||||
import picocli.CommandLine.Parameters;
|
||||
|
||||
@@ -15,6 +16,9 @@ final class ContextCommand extends AbstractProjectCommand {
|
||||
@Parameters(index = "0", description = "Module name")
|
||||
String moduleName;
|
||||
|
||||
@Mixin
|
||||
ModuleSelector selector = new ModuleSelector();
|
||||
|
||||
@Option(names = "--include", description = "Comma-separated sections to include (functions,callers,callees,dbAccesses,sqlStatements,variableAccesses); default: all")
|
||||
String include = "";
|
||||
|
||||
@@ -43,7 +47,7 @@ final class ContextCommand extends AbstractProjectCommand {
|
||||
if (!query.isEmpty()) {
|
||||
path += "?" + query;
|
||||
}
|
||||
return printResponse(apiClient().get(path));
|
||||
return printResponse(apiClient().get(selector.append(path)));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Mixin;
|
||||
import picocli.CommandLine.Option;
|
||||
import picocli.CommandLine.Parameters;
|
||||
|
||||
@@ -14,6 +15,9 @@ final class DbAccessesCommand extends AbstractProjectCommand {
|
||||
@Parameters(index = "0", description = "Module name")
|
||||
String moduleName;
|
||||
|
||||
@Mixin
|
||||
ModuleSelector selector = new ModuleSelector();
|
||||
|
||||
@Option(names = "--depth", description = "Maximum CALLS depth for transitive access resolution (default: direct only)")
|
||||
int depth = -1;
|
||||
|
||||
@@ -31,7 +35,7 @@ final class DbAccessesCommand extends AbstractProjectCommand {
|
||||
path += "?depth=" + depth;
|
||||
}
|
||||
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
|
||||
return printResponse(apiClient().get(path));
|
||||
return printResponse(apiClient().get(selector.append(path)));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Mixin;
|
||||
import picocli.CommandLine.Parameters;
|
||||
|
||||
/**
|
||||
@@ -14,10 +15,13 @@ final class DigestCommand extends AbstractProjectCommand {
|
||||
@Parameters(index = "0", description = "Module name")
|
||||
String moduleName;
|
||||
|
||||
@Mixin
|
||||
ModuleSelector selector = new ModuleSelector();
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
try {
|
||||
return printResponse(apiClient().get(projectPath() + "/modules/" + encode(moduleName) + "/digest"));
|
||||
return printResponse(apiClient().get(selector.append(projectPath() + "/modules/" + encode(moduleName) + "/digest")));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Mixin;
|
||||
import picocli.CommandLine.Parameters;
|
||||
|
||||
/**
|
||||
@@ -14,10 +15,13 @@ final class DispatchTableCommand extends AbstractProjectCommand {
|
||||
@Parameters(index = "0", description = "Module name")
|
||||
String moduleName;
|
||||
|
||||
@Mixin
|
||||
ModuleSelector selector = new ModuleSelector();
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
try {
|
||||
return printResponse(apiClient().get(projectPath() + "/modules/" + encode(moduleName) + "/dispatch-table"));
|
||||
return printResponse(apiClient().get(selector.append(projectPath() + "/modules/" + encode(moduleName) + "/dispatch-table")));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Mixin;
|
||||
import picocli.CommandLine.Option;
|
||||
import picocli.CommandLine.Parameters;
|
||||
|
||||
@@ -15,6 +16,9 @@ final class EgoGraphCommand extends AbstractProjectCommand {
|
||||
@Parameters(index = "0", description = "Module name")
|
||||
String moduleName;
|
||||
|
||||
@Mixin
|
||||
ModuleSelector selector = new ModuleSelector();
|
||||
|
||||
@Option(names = "--depth", description = "Max call hops from the module (clamped to the configured maximum)")
|
||||
int depth = -1;
|
||||
|
||||
@@ -33,7 +37,7 @@ final class EgoGraphCommand extends AbstractProjectCommand {
|
||||
}
|
||||
path = appendQuery(path, "depth", depth);
|
||||
path = appendQuery(path, "limit", limit);
|
||||
return printResponse(apiClient().get(path));
|
||||
return printResponse(apiClient().get(selector.append(path)));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Mixin;
|
||||
import picocli.CommandLine.Parameters;
|
||||
|
||||
/**
|
||||
@@ -14,10 +15,13 @@ final class EntityColumnsCommand extends AbstractProjectCommand {
|
||||
@Parameters(index = "0", description = "Entity module (class) name")
|
||||
String moduleName;
|
||||
|
||||
@Mixin
|
||||
ModuleSelector selector = new ModuleSelector();
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
try {
|
||||
return printResponse(apiClient().get(projectPath() + "/modules/" + encode(moduleName) + "/columns"));
|
||||
return printResponse(apiClient().get(selector.append(projectPath() + "/modules/" + encode(moduleName) + "/columns")));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Mixin;
|
||||
import picocli.CommandLine.Parameters;
|
||||
|
||||
/**
|
||||
@@ -16,6 +17,9 @@ final class FunctionCallersCommand extends AbstractProjectCommand {
|
||||
@Parameters(index = "0", description = "Module name that defines the subroutine/method")
|
||||
String moduleName;
|
||||
|
||||
@Mixin
|
||||
ModuleSelector selector = new ModuleSelector();
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
@Parameters(index = "1", description = "Subroutine/method name")
|
||||
String functionName;
|
||||
@@ -25,7 +29,7 @@ final class FunctionCallersCommand extends AbstractProjectCommand {
|
||||
try {
|
||||
String path = projectPath() + "/modules/" + encode(moduleName)
|
||||
+ "/functions/" + encode(functionName) + "/callers";
|
||||
return printResponse(apiClient().get(path));
|
||||
return printResponse(apiClient().get(selector.append(path)));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.agenticcode.cli;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Mixin;
|
||||
import picocli.CommandLine.Parameters;
|
||||
|
||||
/**
|
||||
@@ -17,6 +18,9 @@ final class FunctionOverridesCommand extends AbstractProjectCommand {
|
||||
@Parameters(index = "0", description = "Module (base class) name")
|
||||
String moduleName;
|
||||
|
||||
@Mixin
|
||||
ModuleSelector selector = new ModuleSelector();
|
||||
|
||||
@Parameters(index = "1", arity = "0..1", description = "Function name (omit for all abstract methods)")
|
||||
@Nullable String functionName;
|
||||
|
||||
@@ -25,9 +29,10 @@ final class FunctionOverridesCommand extends AbstractProjectCommand {
|
||||
try {
|
||||
String path = projectPath() + "/modules/" + encode(moduleName) + "/functions/overrides";
|
||||
if (functionName != null && !functionName.isBlank()) {
|
||||
path = projectPath() + "/modules/" + encode(moduleName) + "/functions/" + encode(functionName) + "/overrides";
|
||||
path = projectPath() + "/modules/" + encode(moduleName) + "/functions/"
|
||||
+ encode(functionName) + "/overrides";
|
||||
}
|
||||
return printResponse(apiClient().get(path));
|
||||
return printResponse(apiClient().get(selector.append(path)));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Mixin;
|
||||
import picocli.CommandLine.Option;
|
||||
import picocli.CommandLine.Parameters;
|
||||
|
||||
@@ -15,6 +16,9 @@ final class FunctionsCommand extends AbstractProjectCommand {
|
||||
@Parameters(index = "0", description = "Module (class) name")
|
||||
String moduleName;
|
||||
|
||||
@Mixin
|
||||
ModuleSelector selector = new ModuleSelector();
|
||||
|
||||
@Option(names = {"--include-inherited"}, description = "Include functions inherited from ancestors")
|
||||
boolean includeInherited;
|
||||
|
||||
@@ -25,7 +29,7 @@ final class FunctionsCommand extends AbstractProjectCommand {
|
||||
if (includeInherited) {
|
||||
path += "?includeInherited=true";
|
||||
}
|
||||
return printResponse(apiClient().get(path));
|
||||
return printResponse(apiClient().get(selector.append(path)));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Mixin;
|
||||
import picocli.CommandLine.Parameters;
|
||||
|
||||
/**
|
||||
@@ -14,10 +15,13 @@ final class ModuleDataStructuresCommand extends AbstractProjectCommand {
|
||||
@Parameters(index = "0", description = "Module name")
|
||||
String moduleName;
|
||||
|
||||
@Mixin
|
||||
ModuleSelector selector = new ModuleSelector();
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
try {
|
||||
return printResponse(apiClient().get(projectPath() + "/modules/" + encode(moduleName) + "/data-structures"));
|
||||
return printResponse(apiClient().get(selector.append(projectPath() + "/modules/" + encode(moduleName) + "/data-structures")));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
|
||||
36
ac-cli/src/main/java/com/agenticcode/cli/ModuleSelector.java
Normal file
36
ac-cli/src/main/java/com/agenticcode/cli/ModuleSelector.java
Normal file
@@ -0,0 +1,36 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Option;
|
||||
|
||||
/**
|
||||
* Item 115: the {@code --source-file} selector shared by every command that addresses a module by
|
||||
* name.
|
||||
*
|
||||
* <p>A Java simple name can identify several modules — nested {@code @Nested} test classes,
|
||||
* {@code Builder}, {@code WorkingStorage}. Those endpoints answer {@code 409 AMBIGUOUS_NAME} and list
|
||||
* the candidates; this option repeats the request against one of them. Natural module names are unique
|
||||
* by construction, so the option never has to be given there.
|
||||
*
|
||||
* <p>A mixin rather than a base-class field so it does not appear on the project-level commands, where
|
||||
* it would be silently ignored.
|
||||
*/
|
||||
final class ModuleSelector {
|
||||
|
||||
@Option(names = {"--source-file"},
|
||||
description = "Pick one module when the name is ambiguous (as listed in a 409 AMBIGUOUS_NAME "
|
||||
+ "response); a source path relative to the project root")
|
||||
String sourceFile = "";
|
||||
|
||||
/**
|
||||
* @return {@code path} with the selector appended, using {@code &} when the path already carries a
|
||||
* query string and {@code ?} otherwise. Choosing the separator here is the point: several commands
|
||||
* append their own parameters after the base path, and a fixed {@code "?"} would emit two of them.
|
||||
*/
|
||||
String append(String path) {
|
||||
if (sourceFile == null || sourceFile.isBlank()) {
|
||||
return path;
|
||||
}
|
||||
return path + (path.indexOf('?') >= 0 ? '&' : '?')
|
||||
+ "sourceFile=" + AbstractApiCommand.encode(sourceFile.strip());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Mixin;
|
||||
import picocli.CommandLine.Option;
|
||||
import picocli.CommandLine.Parameters;
|
||||
|
||||
@@ -15,6 +16,9 @@ final class ModuleSourceCommand extends AbstractProjectCommand {
|
||||
@Parameters(index = "0", description = "Module name")
|
||||
String moduleName;
|
||||
|
||||
@Mixin
|
||||
ModuleSelector selector = new ModuleSelector();
|
||||
|
||||
@Option(names = "--start-line", description = "First line to include (1-based); omit with --end-line for the whole file")
|
||||
int startLine = -1;
|
||||
|
||||
@@ -29,7 +33,7 @@ final class ModuleSourceCommand extends AbstractProjectCommand {
|
||||
// half-open range).
|
||||
path = appendQuery(path, "startLine", startLine);
|
||||
path = appendQuery(path, "endLine", endLine);
|
||||
return printResponse(apiClient().get(path));
|
||||
return printResponse(apiClient().get(selector.append(path)));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Mixin;
|
||||
import picocli.CommandLine.Parameters;
|
||||
|
||||
/**
|
||||
@@ -14,10 +15,13 @@ final class PayloadCommand extends AbstractProjectCommand {
|
||||
@Parameters(index = "0", description = "Module name")
|
||||
String moduleName;
|
||||
|
||||
@Mixin
|
||||
ModuleSelector selector = new ModuleSelector();
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
try {
|
||||
return printResponse(apiClient().get(projectPath() + "/modules/" + encode(moduleName) + "/payload"));
|
||||
return printResponse(apiClient().get(selector.append(projectPath() + "/modules/" + encode(moduleName) + "/payload")));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Mixin;
|
||||
import picocli.CommandLine.Option;
|
||||
import picocli.CommandLine.Parameters;
|
||||
|
||||
@@ -14,6 +15,9 @@ final class SqlStatementsCommand extends AbstractProjectCommand {
|
||||
@Parameters(index = "0", description = "Module name")
|
||||
String moduleName;
|
||||
|
||||
@Mixin
|
||||
ModuleSelector selector = new ModuleSelector();
|
||||
|
||||
@Option(names = "--depth", description = "Maximum CALLS depth for transitive statement resolution (default: direct only)")
|
||||
int depth = -1;
|
||||
|
||||
@@ -24,7 +28,7 @@ final class SqlStatementsCommand extends AbstractProjectCommand {
|
||||
if (depth >= 0) {
|
||||
path += "?depth=" + depth;
|
||||
}
|
||||
return printResponse(apiClient().get(path));
|
||||
return printResponse(apiClient().get(selector.append(path)));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Mixin;
|
||||
import picocli.CommandLine.Option;
|
||||
import picocli.CommandLine.Parameters;
|
||||
|
||||
@@ -16,6 +17,9 @@ final class WorkfileAccessesCommand extends AbstractProjectCommand {
|
||||
@Parameters(index = "0", description = "Module name")
|
||||
String moduleName;
|
||||
|
||||
@Mixin
|
||||
ModuleSelector selector = new ModuleSelector();
|
||||
|
||||
@Option(names = "--limit", description = "Max items to return")
|
||||
int limit = -1;
|
||||
|
||||
@@ -27,7 +31,7 @@ final class WorkfileAccessesCommand extends AbstractProjectCommand {
|
||||
try {
|
||||
String path = projectPath() + "/modules/" + encode(moduleName) + "/workfile-accesses";
|
||||
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
|
||||
return printResponse(apiClient().get(path));
|
||||
return printResponse(apiClient().get(selector.append(path)));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
server.url=http://localhost:8787
|
||||
# Stamped by manage-ac.sh (stamp_cli_version) from ac-code-server's agenticcode.version
|
||||
# at build time. "dev" means this jar wasn't built via manage-ac.sh.
|
||||
version=166
|
||||
version=171
|
||||
|
||||
@@ -200,6 +200,19 @@ public class AnalysisResource {
|
||||
"No module '" + name + "' in project '" + project + "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 115: the name matches several real modules, so any single answer would be a union across
|
||||
* unrelated ones. Java makes this ordinary (nested {@code @Nested} test classes, {@code Builder},
|
||||
* {@code WorkingStorage}); the caller picks one with {@code ?sourceFile=}. Refusing is deliberate:
|
||||
* a merged answer is indistinguishable from a correct one, whereas this is not.
|
||||
*/
|
||||
private static Response ambiguousName(String project, String name, List<String> candidates) {
|
||||
return ProjectResource.error(Response.Status.CONFLICT, "AMBIGUOUS_NAME",
|
||||
"Module name '" + name + "' matches " + candidates.size() + " modules in project '" + project
|
||||
+ "'. Repeat the request with ?sourceFile=<one of the candidates>.",
|
||||
Map.of("candidates", candidates));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 107: the placeholder answer — the module is known to the graph but its source was never
|
||||
* ingested, so any result would be empty for want of data, not for want of matches.
|
||||
@@ -397,18 +410,28 @@ public class AnalysisResource {
|
||||
return withProject(project, () -> graphRepository.dbTableColumns(project, name).map(this::ok));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 115: an absent or blank {@code ?sourceFile=} means "any candidate". Normalising here rather
|
||||
* than at 18 call sites is what keeps the repository parameter non-null and always bound — Cypher
|
||||
* fails at runtime, not compile time, on a parameter that never arrives.
|
||||
*/
|
||||
private static String anySource(@Nullable String sourceFile) {
|
||||
return sourceFile == null || sourceFile.isBlank() ? GraphRepository.ANY_SOURCE_FILE : sourceFile.strip();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/modules/{name}/sql-statements")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = SqlStatement.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project or module not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "409", description = "Module is an unresolved placeholder — its source is not ingested.", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
@APIResponse(responseCode = "409", description = "Ambiguous module name (AMBIGUOUS_NAME, with the candidate sourceFiles in details) or an unresolved placeholder whose source is not ingested (NOT_INGESTED).", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
public Uni<Response> sqlStatements(@PathParam("project") String project, @PathParam("name") String name,
|
||||
@QueryParam("depth") @Nullable Integer depth) {
|
||||
return withIngestedModule(project, name, () -> {
|
||||
@QueryParam("depth") @Nullable Integer depth,
|
||||
@QueryParam("sourceFile") @Nullable String sourceFile) {
|
||||
return withIngestedModule(project, name, sourceFile, () -> {
|
||||
if (depth != null && depth > 0) {
|
||||
return graphRepository.sqlStatementsTransitive(project, name, depth).map(this::ok);
|
||||
}
|
||||
return graphRepository.sqlStatements(project, name).map(this::ok);
|
||||
return graphRepository.sqlStatements(project, name, anySource(sourceFile)).map(this::ok);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -416,14 +439,15 @@ public class AnalysisResource {
|
||||
@Path("/modules/{name}/context")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = ModuleContext.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project or module not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "409", description = "Module is an unresolved placeholder — its source is not ingested.", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
@APIResponse(responseCode = "409", description = "Ambiguous module name (AMBIGUOUS_NAME, with the candidate sourceFiles in details) or an unresolved placeholder whose source is not ingested (NOT_INGESTED).", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
public Uni<Response> moduleContext(@PathParam("project") String project, @PathParam("name") String name,
|
||||
@QueryParam("include") @Nullable String include,
|
||||
@QueryParam("limit") @Nullable Integer limit,
|
||||
@QueryParam("offset") @Nullable Integer offset) {
|
||||
@QueryParam("offset") @Nullable Integer offset,
|
||||
@QueryParam("sourceFile") @Nullable String sourceFile) {
|
||||
int effectiveLimit = limit != null ? Math.max(limit, 0) : 0;
|
||||
int effectiveOffset = offset != null ? Math.max(offset, 0) : 0;
|
||||
return withIngestedModule(project, name, () -> graphRepository.moduleContext(project, name, include, effectiveLimit, effectiveOffset)
|
||||
return withIngestedModule(project, name, sourceFile, () -> graphRepository.moduleContext(project, name, include, effectiveLimit, effectiveOffset, anySource(sourceFile))
|
||||
.map(context -> Response.ok(context).build()));
|
||||
}
|
||||
|
||||
@@ -431,62 +455,53 @@ public class AnalysisResource {
|
||||
@Path("/modules/{name}/columns")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = EntityColumn.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project or module not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "409", description = "Module is an unresolved placeholder — its source is not ingested.", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
public Uni<Response> entityColumns(@PathParam("project") String project, @PathParam("name") String name) {
|
||||
return withIngestedModule(project, name, () -> graphRepository.entityColumns(project, name).map(this::ok));
|
||||
@APIResponse(responseCode = "409", description = "Ambiguous module name (AMBIGUOUS_NAME, with the candidate sourceFiles in details) or an unresolved placeholder whose source is not ingested (NOT_INGESTED).", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
public Uni<Response> entityColumns(@PathParam("project") String project, @PathParam("name") String name,
|
||||
@QueryParam("sourceFile") @Nullable String sourceFile) {
|
||||
return withIngestedModule(project, name, sourceFile, () -> graphRepository.entityColumns(project, name, anySource(sourceFile)).map(this::ok));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/modules/{name}/data-structures")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = ModuleDataStructure.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project or module not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "409", description = "Module is an unresolved placeholder — its source is not ingested.", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
public Uni<Response> moduleDataStructures(@PathParam("project") String project, @PathParam("name") String name) {
|
||||
return withIngestedModule(project, name, () -> graphRepository.moduleDataStructures(project, name).map(this::ok));
|
||||
@APIResponse(responseCode = "409", description = "Ambiguous module name (AMBIGUOUS_NAME, with the candidate sourceFiles in details) or an unresolved placeholder whose source is not ingested (NOT_INGESTED).", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
public Uni<Response> moduleDataStructures(@PathParam("project") String project, @PathParam("name") String name,
|
||||
@QueryParam("sourceFile") @Nullable String sourceFile) {
|
||||
return withIngestedModule(project, name, sourceFile, () -> graphRepository.moduleDataStructures(project, name, anySource(sourceFile)).map(this::ok));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/modules/{name}/payload")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = PayloadField.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project or module not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "409", description = "Module is an unresolved placeholder — its source is not ingested.", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
public Uni<Response> payload(@PathParam("project") String project, @PathParam("name") String name) {
|
||||
return withIngestedModule(project, name, () -> graphRepository.payload(project, name).map(this::ok));
|
||||
@APIResponse(responseCode = "409", description = "Ambiguous module name (AMBIGUOUS_NAME, with the candidate sourceFiles in details) or an unresolved placeholder whose source is not ingested (NOT_INGESTED).", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
public Uni<Response> payload(@PathParam("project") String project, @PathParam("name") String name,
|
||||
@QueryParam("sourceFile") @Nullable String sourceFile) {
|
||||
return withIngestedModule(project, name, sourceFile, () -> graphRepository.payload(project, name, anySource(sourceFile)).map(this::ok));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/modules/{name}/dispatch-table")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = DispatchEntry.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project or module not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "409", description = "Module is an unresolved placeholder — its source is not ingested.", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
public Uni<Response> dispatchTable(@PathParam("project") String project, @PathParam("name") String name) {
|
||||
return withIngestedModule(project, name, () -> graphRepository.dispatchTable(project, name).map(this::ok));
|
||||
@APIResponse(responseCode = "409", description = "Ambiguous module name (AMBIGUOUS_NAME, with the candidate sourceFiles in details) or an unresolved placeholder whose source is not ingested (NOT_INGESTED).", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
public Uni<Response> dispatchTable(@PathParam("project") String project, @PathParam("name") String name,
|
||||
@QueryParam("sourceFile") @Nullable String sourceFile) {
|
||||
return withIngestedModule(project, name, sourceFile, () -> graphRepository.dispatchTable(project, name, anySource(sourceFile)).map(this::ok));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/modules/{name}/functions")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = InheritedFunction.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project or module not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "409", description = "Module is an unresolved placeholder — its source is not ingested.", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
@APIResponse(responseCode = "409", description = "Ambiguous module name (AMBIGUOUS_NAME, with the candidate sourceFiles in details) or an unresolved placeholder whose source is not ingested (NOT_INGESTED).", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
public Uni<Response> moduleFunctions(@PathParam("project") String project, @PathParam("name") String name,
|
||||
@QueryParam("includeInherited") @Nullable Boolean includeInherited,
|
||||
@QueryParam("kind") @Nullable String kind) {
|
||||
@QueryParam("kind") @Nullable String kind,
|
||||
@QueryParam("sourceFile") @Nullable String sourceFile) {
|
||||
boolean inherited = includeInherited != null && includeInherited;
|
||||
return withIngestedModule(project, name, () -> graphRepository.moduleFunctions(project, name, inherited, kind).map(this::ok));
|
||||
}
|
||||
|
||||
/**
|
||||
* Roadmap item 34: bulk overrides for every abstract method of {@code name} at once. Registered
|
||||
* before {@code /functions/{function}/overrides} is irrelevant to JAX-RS routing — the two
|
||||
* templates have a different segment count and never collide.
|
||||
*/
|
||||
@GET
|
||||
@Path("/modules/{name}/functions/overrides")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = BulkFunctionOverride.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project or module not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "409", description = "Module is an unresolved placeholder — its source is not ingested.", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
public Uni<Response> bulkFunctionOverrides(@PathParam("project") String project, @PathParam("name") String name) {
|
||||
return withIngestedModule(project, name, () -> graphRepository.functionOverrides(project, name).map(this::ok));
|
||||
return withIngestedModule(project, name, sourceFile, () -> graphRepository.moduleFunctions(project, name, inherited, kind, anySource(sourceFile)).map(this::ok));
|
||||
}
|
||||
|
||||
private static List<NameRef> identifierNames(List<IdentifierMatch> matches) {
|
||||
@@ -531,6 +546,21 @@ public class AnalysisResource {
|
||||
*/
|
||||
private static final int NEIGHBOURHOOD_NODE_BUDGET = 5000;
|
||||
|
||||
/**
|
||||
* Roadmap item 34: bulk overrides for every abstract method of {@code name} at once. Registered
|
||||
* before {@code /functions/{function}/overrides} is irrelevant to JAX-RS routing — the two
|
||||
* templates have a different segment count and never collide.
|
||||
*/
|
||||
@GET
|
||||
@Path("/modules/{name}/functions/overrides")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = BulkFunctionOverride.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, with the candidate sourceFiles in details) or an unresolved placeholder whose source is not ingested (NOT_INGESTED).", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
public Uni<Response> bulkFunctionOverrides(@PathParam("project") String project, @PathParam("name") String name,
|
||||
@QueryParam("sourceFile") @Nullable String sourceFile) {
|
||||
return withIngestedModule(project, name, sourceFile, () -> graphRepository.functionOverrides(project, name).map(this::ok));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/refresh/{name}")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = IngestSummary.class)))
|
||||
@@ -547,7 +577,8 @@ public class AnalysisResource {
|
||||
// (module-granularity CALLS closure, both directions) so the whole call-graph neighbourhood —
|
||||
// and via each module's own USING/INCLUDE fan-out, its data structures — is deep-ingested.
|
||||
// The class is @Blocking, so awaiting the ego-graph query here runs on a worker thread.
|
||||
EgoGraphResponse ego = graphRepository.egoGraph(project, name, maxCallTreeDepth, "both", NEIGHBOURHOOD_NODE_BUDGET)
|
||||
EgoGraphResponse ego = graphRepository.egoGraph(project, name, maxCallTreeDepth, "both", NEIGHBOURHOOD_NODE_BUDGET,
|
||||
GraphRepository.ANY_SOURCE_FILE)
|
||||
.await().indefinitely();
|
||||
List<String> seeds = new ArrayList<>();
|
||||
seeds.add(name);
|
||||
@@ -560,16 +591,6 @@ public class AnalysisResource {
|
||||
return withResolvedRoot(project, info -> projectIngestService.ingestModules(info, seeds, maxDepth, nodeBudget));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/modules/{name}/functions/{function}/overrides")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = FunctionOverride.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project or module not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "409", description = "Module is an unresolved placeholder — its source is not ingested.", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
public Uni<Response> functionOverrides(@PathParam("project") String project, @PathParam("name") String name,
|
||||
@PathParam("function") String function) {
|
||||
return withIngestedModule(project, name, () -> graphRepository.functionOverrides(project, name, function).map(this::ok));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/variables/{name}/flow-forward")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = DataflowStep.class)))
|
||||
@@ -582,6 +603,17 @@ public class AnalysisResource {
|
||||
AnalysisResource::stepModules);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/modules/{name}/functions/{function}/overrides")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = FunctionOverride.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, with the candidate sourceFiles in details) or an unresolved placeholder whose source is not ingested (NOT_INGESTED).", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
public Uni<Response> functionOverrides(@PathParam("project") String project, @PathParam("name") String name,
|
||||
@PathParam("function") String function,
|
||||
@QueryParam("sourceFile") @Nullable String sourceFile) {
|
||||
return withIngestedModule(project, name, sourceFile, () -> graphRepository.functionOverrides(project, name, function, anySource(sourceFile)).map(this::ok));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 52: the FUNCTION-level callers of a subroutine/method — who {@code PERFORM}s (Natural) or
|
||||
* calls (Java cross-class) {@code function} in module {@code name}. Complements the
|
||||
@@ -593,8 +625,9 @@ public class AnalysisResource {
|
||||
@APIResponse(responseCode = "404", description = "Project or module not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "409", description = "Module is an unresolved placeholder — its source is not ingested, so it has no FUNCTION nodes.", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
public Uni<Response> functionCallers(@PathParam("project") String project, @PathParam("name") String name,
|
||||
@PathParam("function") String function) {
|
||||
return withIngestedModule(project, name, () -> graphRepository.functionCallers(project, name, function)
|
||||
@PathParam("function") String function,
|
||||
@QueryParam("sourceFile") @Nullable String sourceFile) {
|
||||
return withIngestedModule(project, name, sourceFile, () -> graphRepository.functionCallers(project, name, function, anySource(sourceFile))
|
||||
.map(resp -> Response.ok(resp).build()));
|
||||
}
|
||||
|
||||
@@ -603,8 +636,9 @@ public class AnalysisResource {
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = ModuleDigest.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project or module not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "409", description = "Module is an unresolved placeholder — its source is not ingested. Its callers are still knowable via /callers.", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
public Uni<Response> moduleDigest(@PathParam("project") String project, @PathParam("name") String name) {
|
||||
return withIngestedModule(project, name, () -> graphRepository.moduleDigest(project, name)
|
||||
public Uni<Response> moduleDigest(@PathParam("project") String project, @PathParam("name") String name,
|
||||
@QueryParam("sourceFile") @Nullable String sourceFile) {
|
||||
return withIngestedModule(project, name, sourceFile, () -> graphRepository.moduleDigest(project, name, anySource(sourceFile))
|
||||
.map(digest -> Response.ok(digest).build()));
|
||||
}
|
||||
|
||||
@@ -617,8 +651,9 @@ public class AnalysisResource {
|
||||
@QueryParam("scope") @Nullable String scope,
|
||||
@QueryParam("limit") @Nullable Integer limit, @QueryParam("offset") @Nullable Integer offset,
|
||||
@QueryParam("fields") @Nullable String fields,
|
||||
@QueryParam("resolveInterfaces") @Nullable Boolean resolveInterfaces) {
|
||||
return withIngestedModule(project, name, () -> graphRepository.callees(project, name, scope, effectiveLimit(limit), effectiveOffset(offset),
|
||||
@QueryParam("resolveInterfaces") @Nullable Boolean resolveInterfaces,
|
||||
@QueryParam("sourceFile") @Nullable String sourceFile) {
|
||||
return withIngestedModule(project, name, sourceFile, () -> graphRepository.callees(project, name, scope, effectiveLimit(limit), effectiveOffset(offset),
|
||||
resolveInterfaces != null && resolveInterfaces)
|
||||
.map(resp -> namesOnly(fields)
|
||||
? ok(callRefNames(resp))
|
||||
@@ -629,17 +664,18 @@ public class AnalysisResource {
|
||||
@Path("/modules/{name}/db-accesses")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = DbAccess.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project or module not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "409", description = "Module is an unresolved placeholder — its source is not ingested.", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
@APIResponse(responseCode = "409", description = "Ambiguous module name (AMBIGUOUS_NAME, with the candidate sourceFiles in details) or an unresolved placeholder whose source is not ingested (NOT_INGESTED).", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
public Uni<Response> dbAccesses(@PathParam("project") String project, @PathParam("name") String name,
|
||||
@QueryParam("depth") @Nullable Integer depth,
|
||||
@QueryParam("limit") @Nullable Integer limit, @QueryParam("offset") @Nullable Integer offset) {
|
||||
@QueryParam("limit") @Nullable Integer limit, @QueryParam("offset") @Nullable Integer offset,
|
||||
@QueryParam("sourceFile") @Nullable String sourceFile) {
|
||||
int effLimit = uncappedLimit(limit);
|
||||
int effOffset = effectiveOffset(offset);
|
||||
return withIngestedModule(project, name, () -> {
|
||||
return withIngestedModule(project, name, sourceFile, () -> {
|
||||
if (depth != null && depth > 0) {
|
||||
return graphRepository.dbAccessesTransitive(project, name, depth, effLimit, effOffset).map(this::ok);
|
||||
}
|
||||
return graphRepository.dbAccesses(project, name, effLimit, effOffset).map(this::ok);
|
||||
return graphRepository.dbAccesses(project, name, effLimit, effOffset, anySource(sourceFile)).map(this::ok);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -647,31 +683,13 @@ public class AnalysisResource {
|
||||
@Path("/modules/{name}/workfile-accesses")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = WorkfileAccess.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project or module not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "409", description = "Module is an unresolved placeholder — its source is not ingested.", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
@APIResponse(responseCode = "409", description = "Ambiguous module name (AMBIGUOUS_NAME, with the candidate sourceFiles in details) or an unresolved placeholder whose source is not ingested (NOT_INGESTED).", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
public Uni<Response> workfileAccesses(@PathParam("project") String project, @PathParam("name") String name,
|
||||
@QueryParam("limit") @Nullable Integer limit, @QueryParam("offset") @Nullable Integer offset) {
|
||||
@QueryParam("limit") @Nullable Integer limit, @QueryParam("offset") @Nullable Integer offset,
|
||||
@QueryParam("sourceFile") @Nullable String sourceFile) {
|
||||
int effLimit = uncappedLimit(limit);
|
||||
int effOffset = effectiveOffset(offset);
|
||||
return withIngestedModule(project, name, () -> graphRepository.workfileAccesses(project, name, effLimit, effOffset).map(this::ok));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/modules/{name}/call-tree")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = CallTreeResponse.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project or module not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "409", description = "Module is an unresolved placeholder — its source is not ingested, so the tree would be empty for want of data, not for want of matches.", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
public Uni<Response> callTree(@PathParam("project") String project, @PathParam("name") String name,
|
||||
@QueryParam("depth") @Nullable Integer depth,
|
||||
@QueryParam("fields") @Nullable String fields,
|
||||
@QueryParam("resolveInterfaces") @Nullable Boolean resolveInterfaces,
|
||||
@QueryParam("followWiring") @Nullable Boolean followWiring) {
|
||||
int effectiveDepth = Math.clamp(depth != null ? depth : defaultCallTreeDepth, 1, maxCallTreeDepth);
|
||||
return withIngestedModule(project, name, () -> fanoutWarm(project,
|
||||
() -> graphRepository.callTree(project, name, effectiveDepth,
|
||||
resolveInterfaces != null && resolveInterfaces, followWiring != null && followWiring,
|
||||
callTreeInternalBudget),
|
||||
CallTreeResponse::sourceFiles,
|
||||
resp -> namesOnly(fields) ? ok(callTreeNames(resp)) : Response.ok(resp).build()));
|
||||
return withIngestedModule(project, name, sourceFile, () -> graphRepository.workfileAccesses(project, name, effLimit, effOffset, anySource(sourceFile)).map(this::ok));
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -785,34 +803,24 @@ public class AnalysisResource {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Module-granularity callers. {@code scope}: {@code external} (default) = modules that call this
|
||||
* module (incoming CALLNAT / inheritance), each <b>rolled up to the calling module</b> — a call made
|
||||
* from inside a subroutine/method is attributed to its owning module (not the calling {@code FUNCTION}
|
||||
* node), and repeated call sites from one caller collapse to a single row whose {@code sites} list
|
||||
* every line (symmetric with {@code callees}); {@code internal} = the module's own subroutines that
|
||||
* {@code PERFORM} into it. The default deliberately excludes intra-module subroutine wiring and
|
||||
* never reports the module as its own caller; use {@code scope=internal} or the function-level
|
||||
* {@code /functions/{fn}/callers} endpoint for that.
|
||||
*
|
||||
* <p>Item 107: this endpoint is guarded by {@link #withModule}, not {@link #withIngestedModule} —
|
||||
* an unresolved <b>placeholder</b> module still answers {@code 200} here, because its callers are
|
||||
* read from the <em>calling</em> modules' source and are genuine. It is the one honest answer
|
||||
* available for a module whose own source was never ingested, so the {@code 409} on the other
|
||||
* endpoints points here.
|
||||
*/
|
||||
@GET
|
||||
@Path("/modules/{name}/callers")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = CallRefResponse.class)))
|
||||
@Path("/modules/{name}/call-tree")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = CallTreeResponse.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project or module not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> callers(@PathParam("project") String project, @PathParam("name") String name,
|
||||
@QueryParam("scope") @Nullable String scope,
|
||||
@QueryParam("limit") @Nullable Integer limit, @QueryParam("offset") @Nullable Integer offset,
|
||||
@QueryParam("fields") @Nullable String fields) {
|
||||
return withModule(project, name, () -> fanoutWarm(project,
|
||||
() -> graphRepository.callers(project, name, scope, effectiveLimit(limit), effectiveOffset(offset)),
|
||||
CallRefResponse::sourceFiles,
|
||||
resp -> namesOnly(fields) ? ok(callRefNames(resp)) : Response.ok(resp).build()));
|
||||
@APIResponse(responseCode = "409", description = "Module is an unresolved placeholder — its source is not ingested, so the tree would be empty for want of data, not for want of matches.", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
|
||||
public Uni<Response> callTree(@PathParam("project") String project, @PathParam("name") String name,
|
||||
@QueryParam("depth") @Nullable Integer depth,
|
||||
@QueryParam("fields") @Nullable String fields,
|
||||
@QueryParam("resolveInterfaces") @Nullable Boolean resolveInterfaces,
|
||||
@QueryParam("followWiring") @Nullable Boolean followWiring,
|
||||
@QueryParam("sourceFile") @Nullable String sourceFile) {
|
||||
int effectiveDepth = Math.clamp(depth != null ? depth : defaultCallTreeDepth, 1, maxCallTreeDepth);
|
||||
return withIngestedModule(project, name, sourceFile, () -> fanoutWarm(project,
|
||||
() -> graphRepository.callTree(project, name, effectiveDepth,
|
||||
resolveInterfaces != null && resolveInterfaces, followWiring != null && followWiring,
|
||||
callTreeInternalBudget),
|
||||
CallTreeResponse::sourceFiles,
|
||||
resp -> namesOnly(fields) ? ok(callTreeNames(resp)) : Response.ok(resp).build()));
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -853,6 +861,37 @@ public class AnalysisResource {
|
||||
"Project '" + project + "' does not exist")));
|
||||
}
|
||||
|
||||
/**
|
||||
* Module-granularity callers. {@code scope}: {@code external} (default) = modules that call this
|
||||
* module (incoming CALLNAT / inheritance), each <b>rolled up to the calling module</b> — a call made
|
||||
* from inside a subroutine/method is attributed to its owning module (not the calling {@code FUNCTION}
|
||||
* node), and repeated call sites from one caller collapse to a single row whose {@code sites} list
|
||||
* every line (symmetric with {@code callees}); {@code internal} = the module's own subroutines that
|
||||
* {@code PERFORM} into it. The default deliberately excludes intra-module subroutine wiring and
|
||||
* never reports the module as its own caller; use {@code scope=internal} or the function-level
|
||||
* {@code /functions/{fn}/callers} endpoint for that.
|
||||
*
|
||||
* <p>Item 107: this endpoint is guarded by {@link #withModule}, not {@link #withIngestedModule} —
|
||||
* an unresolved <b>placeholder</b> module still answers {@code 200} here, because its callers are
|
||||
* read from the <em>calling</em> modules' source and are genuine. It is the one honest answer
|
||||
* available for a module whose own source was never ingested, so the {@code 409} on the other
|
||||
* endpoints points here.
|
||||
*/
|
||||
@GET
|
||||
@Path("/modules/{name}/callers")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = CallRefResponse.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project or module not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> callers(@PathParam("project") String project, @PathParam("name") String name,
|
||||
@QueryParam("scope") @Nullable String scope,
|
||||
@QueryParam("limit") @Nullable Integer limit, @QueryParam("offset") @Nullable Integer offset,
|
||||
@QueryParam("fields") @Nullable String fields,
|
||||
@QueryParam("sourceFile") @Nullable String sourceFile) {
|
||||
return withModule(project, name, sourceFile, () -> fanoutWarm(project,
|
||||
() -> graphRepository.callers(project, name, scope, effectiveLimit(limit), effectiveOffset(offset)),
|
||||
CallRefResponse::sourceFiles,
|
||||
resp -> namesOnly(fields) ? ok(callRefNames(resp)) : Response.ok(resp).build()));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/modules/{name}/graph")
|
||||
@Operation(summary = "Ego graph around a module",
|
||||
@@ -868,13 +907,14 @@ public class AnalysisResource {
|
||||
@Parameter(description = "Traversal direction: out (callees), in (callers), or both.")
|
||||
@QueryParam("direction") @Nullable String direction,
|
||||
@Parameter(description = "Cap on the number of nodes returned (BFS order).")
|
||||
@QueryParam("limit") @Nullable Integer limit) {
|
||||
@QueryParam("limit") @Nullable Integer limit,
|
||||
@QueryParam("sourceFile") @Nullable String sourceFile) {
|
||||
int effectiveDepth = depth != null ? depth : defaultCallTreeDepth;
|
||||
String dir = direction != null ? direction : "out";
|
||||
int effectiveLimit = limit != null ? limit : DEFAULT_PAGE_LIMIT;
|
||||
// Item 107: withModule, not withIngestedModule — a placeholder legitimately has an incoming
|
||||
// neighbourhood (that is why the node exists at all), and the UI needs to render it.
|
||||
return withModule(project, name, () -> graphRepository.egoGraph(project, name, effectiveDepth, dir, effectiveLimit)
|
||||
return withModule(project, name, sourceFile, () -> graphRepository.egoGraph(project, name, effectiveDepth, dir, effectiveLimit, anySource(sourceFile))
|
||||
.map(resp -> Response.ok(resp).build()));
|
||||
}
|
||||
|
||||
@@ -884,7 +924,8 @@ public class AnalysisResource {
|
||||
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> moduleSource(@PathParam("project") String project, @PathParam("name") String name,
|
||||
@QueryParam("startLine") @Nullable Integer startLine,
|
||||
@QueryParam("endLine") @Nullable Integer endLine) {
|
||||
@QueryParam("endLine") @Nullable Integer endLine,
|
||||
@QueryParam("sourceFile") @Nullable String sourceFile) {
|
||||
// M1: omit BOTH bounds to get the whole file (the source-viewer's default). Supplying exactly
|
||||
// one bound is ambiguous and rejected.
|
||||
boolean wholeFile = startLine == null && endLine == null;
|
||||
@@ -897,11 +938,11 @@ public class AnalysisResource {
|
||||
// Item 107: already module-aware — moduleSourceFile filters `sourceFile <> ""`, so both an
|
||||
// absent module and an unresolved placeholder land on MODULE_NOT_FOUND. Kept as-is (there is
|
||||
// no source to serve either way); only the error construction is shared with the new guards.
|
||||
return withProject(project, () -> graphRepository.moduleSourceFile(project, name).flatMap(sourceFile -> sourceFile == null
|
||||
return withProject(project, () -> graphRepository.moduleSourceFile(project, name, anySource(sourceFile)).flatMap(resolved -> resolved == null
|
||||
? Uni.createFrom().item(moduleNotFound(project, name))
|
||||
: wholeFile
|
||||
? sourceSnippetWhole(project, sourceFile)
|
||||
: sourceSnippet(project, sourceFile, sl, el)));
|
||||
? sourceSnippetWhole(project, resolved)
|
||||
: sourceSnippet(project, resolved, sl, el)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -920,11 +961,18 @@ public class AnalysisResource {
|
||||
* read the module's <b>own</b> source must use {@link #withIngestedModule} instead, so a
|
||||
* placeholder does not silently degrade to an empty-but-successful answer.
|
||||
*/
|
||||
private Uni<Response> withModule(String project, String name, Supplier<Uni<Response>> action) {
|
||||
return withProject(project, () -> graphRepository.moduleIngestState(project, name)
|
||||
.flatMap(state -> state.present()
|
||||
? action.get()
|
||||
: Uni.createFrom().item(moduleNotFound(project, name))));
|
||||
private Uni<Response> withModule(String project, String name, @Nullable String sourceFile,
|
||||
Supplier<Uni<Response>> action) {
|
||||
return withProject(project, () -> graphRepository.moduleIngestState(project, name, anySource(sourceFile))
|
||||
.flatMap(state -> {
|
||||
if (!state.present()) {
|
||||
return Uni.createFrom().item(moduleNotFound(project, name));
|
||||
}
|
||||
if (state.ambiguous()) {
|
||||
return Uni.createFrom().item(ambiguousName(project, name, state.candidates()));
|
||||
}
|
||||
return action.get();
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -939,16 +987,25 @@ public class AnalysisResource {
|
||||
* files a result surfaced, and a placeholder surfaces none. Callers wanting the part that <em>is</em>
|
||||
* knowable should use {@code /callers}, which stays {@code 200}.
|
||||
*/
|
||||
private Uni<Response> withIngestedModule(String project, String name, Supplier<Uni<Response>> action) {
|
||||
return withProject(project, () -> graphRepository.moduleIngestState(project, name).flatMap(state -> {
|
||||
if (!state.present()) {
|
||||
return Uni.createFrom().item(moduleNotFound(project, name));
|
||||
}
|
||||
if (state.placeholder()) {
|
||||
return Uni.createFrom().item(notIngested(project, name));
|
||||
}
|
||||
return action.get();
|
||||
}));
|
||||
private Uni<Response> withIngestedModule(String project, String name, @Nullable String sourceFile,
|
||||
Supplier<Uni<Response>> action) {
|
||||
return withProject(project, () -> graphRepository.moduleIngestState(project, name, anySource(sourceFile))
|
||||
.flatMap(state -> {
|
||||
// Order matters: absent before ambiguous before placeholder. A placeholder never
|
||||
// counts as a candidate, so one real module alongside a placeholder is not
|
||||
// ambiguous — the real one wins, and #SUBPROGRAM-style references keep answering 409
|
||||
// NOT_INGESTED rather than being reclassified.
|
||||
if (!state.present()) {
|
||||
return Uni.createFrom().item(moduleNotFound(project, name));
|
||||
}
|
||||
if (state.ambiguous()) {
|
||||
return Uni.createFrom().item(ambiguousName(project, name, state.candidates()));
|
||||
}
|
||||
if (state.placeholder()) {
|
||||
return Uni.createFrom().item(notIngested(project, name));
|
||||
}
|
||||
return action.get();
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,4 +10,13 @@ public record ErrorResponse(String error, String code, Map<String, Object> detai
|
||||
public static ErrorResponse of(String code, String message) {
|
||||
return new ErrorResponse(message, code, Map.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant carrying machine-readable {@code details} — e.g. item 115's {@code candidates}, the
|
||||
* {@code sourceFile}s a caller can pick from when a module name is ambiguous. Without them the
|
||||
* caller would know the request failed but not how to repeat it successfully.
|
||||
*/
|
||||
public static ErrorResponse of(String code, String message, Map<String, Object> details) {
|
||||
return new ErrorResponse(message, code, details);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* REST API for creating, updating, deleting and listing projects.
|
||||
@@ -54,6 +55,12 @@ public class ProjectResource {
|
||||
.build();
|
||||
}
|
||||
|
||||
static Response error(Response.Status status, String code, String message, Map<String, Object> details) {
|
||||
return Response.status(status)
|
||||
.entity(ErrorResponse.of(code, message, details))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static final List<String> SUPPORTED_LANGUAGES = List.of("natural", "java");
|
||||
|
||||
private static @Nullable String normalize(@Nullable String value) {
|
||||
|
||||
@@ -166,7 +166,7 @@ public class DeepIngestCoordinator {
|
||||
if (!autoInvalidateEnabled) {
|
||||
return false;
|
||||
}
|
||||
String sourceFile = graphRepository.moduleSourceFile(project, module).await().indefinitely();
|
||||
String sourceFile = graphRepository.moduleSourceFile(project, module, GraphRepository.ANY_SOURCE_FILE).await().indefinitely();
|
||||
if (sourceFile == null || sourceFile.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -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=166
|
||||
agenticcode.version=171
|
||||
# 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,143 @@
|
||||
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.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
|
||||
/**
|
||||
* Item 115: a Java simple name can identify several modules, and the module endpoints must say so
|
||||
* instead of unioning them.
|
||||
*
|
||||
* <p>Measured on a real codebase before the fix: {@code /modules/BrokerHistoryTests/functions} returned
|
||||
* 627 functions for a class that has 107 — the sum across five same-named {@code @Nested} classes in
|
||||
* five files. 163 names / 385 modules (~8%) were affected there, production code included.
|
||||
*
|
||||
* <p>Both halves are asserted, because either alone is a regression: refusing an ambiguous name is
|
||||
* useless if {@code ?sourceFile=} cannot then reach the module, and selecting is unsafe if the
|
||||
* unqualified request keeps answering with a merge.
|
||||
*/
|
||||
@QuarkusTest
|
||||
class AmbiguousModuleIT {
|
||||
|
||||
private static final String PROJECT = "item115-ambiguous";
|
||||
|
||||
@TempDir
|
||||
static Path root;
|
||||
|
||||
@BeforeAll
|
||||
static void createProject() {
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
// Same simple name, different packages -> two distinct real modules, both named "Shared".
|
||||
write("a/Shared.java", """
|
||||
package a;
|
||||
public class Shared {
|
||||
public void onlyInA() {
|
||||
}
|
||||
}
|
||||
""");
|
||||
write("b/Shared.java", """
|
||||
package b;
|
||||
public class Shared {
|
||||
public void onlyInB() {
|
||||
}
|
||||
public void alsoOnlyInB() {
|
||||
}
|
||||
}
|
||||
""");
|
||||
write("a/Unique.java", """
|
||||
package a;
|
||||
public class Unique {
|
||||
public void solo() {
|
||||
}
|
||||
}
|
||||
""");
|
||||
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(String relative, String content) {
|
||||
try {
|
||||
Path file = root.resolve(relative);
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.write(file, content.getBytes(StandardCharsets.UTF_8));
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void ambiguousNameIsRefusedWithItsCandidates() {
|
||||
given().when().get("/api/projects/" + PROJECT + "/modules/Shared/digest")
|
||||
.then().statusCode(409)
|
||||
.body("code", equalTo("AMBIGUOUS_NAME"))
|
||||
.body("details.candidates", hasSize(2))
|
||||
.body("details.candidates", hasItem("a/Shared.java"))
|
||||
.body("details.candidates", hasItem("b/Shared.java"));
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusal has to reach the endpoints that actually produced wrong answers, not just the one
|
||||
* that happened to be tested first.
|
||||
*/
|
||||
@Test
|
||||
void everyModuleEndpointRefusesAnAmbiguousName() {
|
||||
for (String path : List.of("digest", "functions", "callees", "db-accesses", "context",
|
||||
"data-structures", "sql-statements", "workfile-accesses", "call-tree")) {
|
||||
given().when().get("/api/projects/" + PROJECT + "/modules/Shared/" + path)
|
||||
.then().statusCode(409)
|
||||
.body("code", equalTo("AMBIGUOUS_NAME"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The selector must actually select — not merely be accepted. Each candidate has a different
|
||||
* method count, so a merged answer is distinguishable from a correct one.
|
||||
*/
|
||||
@Test
|
||||
void sourceFileSelectsOneCandidate() {
|
||||
given().queryParam("sourceFile", "a/Shared.java")
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/Shared/functions")
|
||||
.then().statusCode(200)
|
||||
.body("name", hasItem("onlyInA"))
|
||||
.body("size()", equalTo(1));
|
||||
|
||||
given().queryParam("sourceFile", "b/Shared.java")
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/Shared/functions")
|
||||
.then().statusCode(200)
|
||||
.body("name", hasItem("onlyInB"))
|
||||
.body("size()", equalTo(2));
|
||||
}
|
||||
|
||||
/**
|
||||
* An unambiguous name must not need the selector — otherwise the fix would break every existing
|
||||
* caller, and Natural (whose module names are unique by construction) with it.
|
||||
*/
|
||||
@Test
|
||||
void unambiguousNameStillAnswersWithoutSelector() {
|
||||
given().when().get("/api/projects/" + PROJECT + "/modules/Unique/functions")
|
||||
.then().statusCode(200)
|
||||
.body("name", hasItem("solo"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownNameStillAnswers404() {
|
||||
given().when().get("/api/projects/" + PROJECT + "/modules/NoSuchClass/digest")
|
||||
.then().statusCode(404)
|
||||
.body("code", equalTo("MODULE_NOT_FOUND"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
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.Map;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.hasItem;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* Item 116b: a call on a field <em>inherited</em> from a supertype must produce a call edge.
|
||||
*
|
||||
* <p>The parser sees one file at a time, so a subclass calling {@code repo.find()} — where {@code repo}
|
||||
* is declared in a base class in another file — resolved to nothing and the edge was dropped. Measured
|
||||
* on a real codebase, that made a repository interface invisible to its actual callers, and the API
|
||||
* reported the absence as fact rather than as unknown.
|
||||
*
|
||||
* <p>Also asserts the cleanup: the {@code field:*} placeholders the parser emits to carry the
|
||||
* unresolved receiver must not survive enrichment, or they would show up as modules.
|
||||
*/
|
||||
@QuarkusTest
|
||||
class InheritedFieldCallIT {
|
||||
|
||||
private static final String PROJECT = "item116b-inherited-field";
|
||||
|
||||
@TempDir
|
||||
static Path root;
|
||||
|
||||
@Inject
|
||||
Driver driver;
|
||||
|
||||
@BeforeAll
|
||||
static void createProject() {
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
write("Repo.java", """
|
||||
package p;
|
||||
public class Repo {
|
||||
public void find() {
|
||||
}
|
||||
}
|
||||
""");
|
||||
write("BaseLogic.java", """
|
||||
package p;
|
||||
public class BaseLogic {
|
||||
protected Repo repo;
|
||||
}
|
||||
""");
|
||||
// One hop: declares nothing, calls the field it inherits.
|
||||
write("ChildLogic.java", """
|
||||
package p;
|
||||
public class ChildLogic extends BaseLogic {
|
||||
public void work() {
|
||||
repo.find();
|
||||
}
|
||||
}
|
||||
""");
|
||||
// Two hops: the chain must be walked, not just the direct supertype.
|
||||
write("GrandChildLogic.java", """
|
||||
package p;
|
||||
public class GrandChildLogic extends ChildLogic {
|
||||
public void alsoWork() {
|
||||
repo.find();
|
||||
}
|
||||
}
|
||||
""");
|
||||
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(String fileName, String content) {
|
||||
try {
|
||||
Files.write(root.resolve(fileName), content.getBytes(StandardCharsets.UTF_8));
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void callOnAnInheritedFieldReachesItsType() {
|
||||
given().when().get("/api/projects/" + PROJECT + "/modules/Repo/callers")
|
||||
.then().statusCode(200)
|
||||
.body("items.name", hasItem("ChildLogic"));
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure this guards is the one that made the finding hard to see: only the direct subclass
|
||||
* resolving would look like success while deeper hierarchies stayed silently empty.
|
||||
*/
|
||||
@Test
|
||||
void theExtendsChainIsWalkedNotJustOneHop() {
|
||||
given().when().get("/api/projects/" + PROJECT + "/modules/Repo/callers")
|
||||
.then().statusCode(200)
|
||||
.body("items.name", hasItem("GrandChildLogic"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void placeholderMarkersDoNotSurviveEnrichment() {
|
||||
try (Session session = driver.session()) {
|
||||
long leftovers = session.run(
|
||||
"MATCH (m:MODULE {project: $p}) WHERE m.name STARTS WITH 'field:' RETURN count(m) AS c",
|
||||
Map.of("p", PROJECT)).single().get("c").asLong();
|
||||
assertEquals(0, leftovers, "the field:* receiver markers are scaffolding and must be cleaned up");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void theMarkerIsNotExposedAsAModule() {
|
||||
given().when().get("/api/projects/" + PROJECT + "/modules?limit=100")
|
||||
.then().statusCode(200)
|
||||
.body("name", not(hasItem("field:repo")));
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,7 @@ public final class CypherQueries {
|
||||
*/
|
||||
public static final String DB_ACCESSES = """
|
||||
MATCH (m:AstNode {type: 'MODULE', name: $name, project: $project})
|
||||
WHERE ($sourceFile = '' OR m.sourceFile = $sourceFile)
|
||||
CALL {
|
||||
WITH m
|
||||
MATCH (m)-[:CONTAINS*0..1]->(f:AstNode)-[r:READS|WRITES]->(t:AstNode {type: 'DB_TABLE'})
|
||||
@@ -231,7 +232,7 @@ public final class CypherQueries {
|
||||
|
||||
public static final String MODULE_SOURCE_FILE = """
|
||||
MATCH (m:AstNode {type: 'MODULE', name: $name, project: $project})
|
||||
WHERE m.sourceFile <> ""
|
||||
WHERE ($sourceFile = '' OR m.sourceFile = $sourceFile) AND m.sourceFile <> ""
|
||||
RETURN m.sourceFile AS sourceFile, m.description AS description,
|
||||
toInteger(m.loc) AS loc, toInteger(m.sloc) AS sloc
|
||||
LIMIT 1
|
||||
@@ -300,15 +301,30 @@ public final class CypherQueries {
|
||||
|
||||
/**
|
||||
* Returns a module's {@code ingestDepth}, {@code ingestStatus}/{@code ingestStatusAt} (item 36)
|
||||
* and {@code sourceFile}, preferring the real node over a placeholder ({@code sourceFile} sorts the
|
||||
* non-empty path ahead of {@code ""}).
|
||||
* and {@code sourceFile}, preferring a real node over a placeholder — plus (item 115) the
|
||||
* {@code sourceFile} of <em>every</em> real module sharing the name, which is what lets the
|
||||
* endpoints answer {@code 409 AMBIGUOUS_NAME} instead of unioning unrelated modules.
|
||||
*
|
||||
* <p>The real-over-placeholder preference used to be implicit in {@code ORDER BY m.sourceFile DESC
|
||||
* LIMIT 1} (a non-empty path sorts ahead of {@code ""}). It is now spelled out as
|
||||
* {@code real[0] ELSE head(found)}, because the same query has to report the candidate list and a
|
||||
* sort order that doubles as a filter would be easy to break by accident.
|
||||
*
|
||||
* <p>{@code $sourceFile} narrows to one candidate; the empty string means "any", so the parameter
|
||||
* is always bound and can never be missing at runtime.
|
||||
*/
|
||||
public static final String MODULE_INGEST_STATE = """
|
||||
MATCH (m:AstNode {type: 'MODULE', name: $name, project: $project})
|
||||
RETURN m.ingestDepth AS depth, m.ingestStatus AS status,
|
||||
m.ingestStatusAt AS statusAt, m.sourceFile AS sourceFile
|
||||
ORDER BY m.sourceFile DESC
|
||||
LIMIT 1
|
||||
MATCH (m:MODULE {name: $name, project: $project})
|
||||
WHERE $sourceFile = '' OR m.sourceFile = $sourceFile
|
||||
WITH m ORDER BY m.sourceFile
|
||||
WITH collect(m) AS found
|
||||
WITH found, [x IN found WHERE x.sourceFile <> ''] AS real
|
||||
WITH found, real,
|
||||
CASE WHEN size(real) > 0 THEN real[0] ELSE head(found) END AS pick
|
||||
WHERE pick IS NOT NULL
|
||||
RETURN pick.ingestDepth AS depth, pick.ingestStatus AS status,
|
||||
pick.ingestStatusAt AS statusAt, pick.sourceFile AS sourceFile,
|
||||
[x IN real | x.sourceFile] AS candidates
|
||||
""";
|
||||
|
||||
/**
|
||||
@@ -419,6 +435,7 @@ public final class CypherQueries {
|
||||
*/
|
||||
public static final String WORKFILE_ACCESSES = """
|
||||
MATCH (m:AstNode {type: 'MODULE', name: $name, project: $project})
|
||||
WHERE ($sourceFile = '' OR m.sourceFile = $sourceFile)
|
||||
MATCH (m)-[:CONTAINS*0..1]->(f:AstNode)-[r:READS|WRITES]->(w:AstNode {type: 'WORKFILE'})
|
||||
RETURN w.name AS workFile, w.physicalName AS physicalName, type(r) AS mode,
|
||||
[x IN collect(r.lineNo) WHERE x IS NOT NULL] AS lineNos,
|
||||
@@ -476,6 +493,7 @@ public final class CypherQueries {
|
||||
|
||||
public static final String SQL_STATEMENTS = """
|
||||
MATCH (m:AstNode {type: 'MODULE', name: $name, project: $project})
|
||||
WHERE ($sourceFile = '' OR m.sourceFile = $sourceFile)
|
||||
MATCH (m)-[:CONTAINS*0..1]->(f:AstNode)-[:CONTAINS]->(a:AstNode {type: 'DB_ACCESS'})
|
||||
OPTIONAL MATCH (a)-[:USES_TYPE]->(t:AstNode {type: 'DB_TABLE'})
|
||||
OPTIONAL MATCH (a)-[:USES_TYPE]->(v:AstNode {type: 'DATA_STRUCTURE'})
|
||||
@@ -487,6 +505,7 @@ public final class CypherQueries {
|
||||
|
||||
public static final String MODULE_FUNCTIONS = """
|
||||
MATCH (m:AstNode {type: 'MODULE', name: $name, project: $project})
|
||||
WHERE ($sourceFile = '' OR m.sourceFile = $sourceFile)
|
||||
MATCH (m)-[:CONTAINS]->(f:AstNode {type: 'FUNCTION'})
|
||||
RETURN DISTINCT f.name AS name, f.sourceFile AS sourceFile, f.startLine AS startLine, f.endLine AS endLine
|
||||
""";
|
||||
@@ -501,6 +520,7 @@ public final class CypherQueries {
|
||||
*/
|
||||
public static final String MODULE_FUNCTIONS_OWN = """
|
||||
MATCH (m:AstNode {type: 'MODULE', name: $name, project: $project})
|
||||
WHERE ($sourceFile = '' OR m.sourceFile = $sourceFile)
|
||||
MATCH (m)-[:CONTAINS]->(f:AstNode {type: 'FUNCTION'})
|
||||
WHERE $kind IS NULL OR f.kind = $kind
|
||||
RETURN DISTINCT f.name AS name, m.name AS declaredIn, f.sourceFile AS sourceFile,
|
||||
@@ -516,6 +536,7 @@ public final class CypherQueries {
|
||||
*/
|
||||
public static final String MODULE_FUNCTIONS_INHERITED = """
|
||||
MATCH (m:AstNode {type: 'MODULE', name: $name, project: $project})
|
||||
WHERE ($sourceFile = '' OR m.sourceFile = $sourceFile)
|
||||
MATCH (m)-[:EXTENDS|IMPLEMENTS*0..]->(c:AstNode {type: 'MODULE'})
|
||||
MATCH (c)-[:CONTAINS]->(f:AstNode {type: 'FUNCTION'})
|
||||
WHERE $kind IS NULL OR f.kind = $kind
|
||||
@@ -527,6 +548,7 @@ public final class CypherQueries {
|
||||
|
||||
public static final String VARIABLE_ACCESSES = """
|
||||
MATCH (m:AstNode {type: 'MODULE', name: $name, project: $project})
|
||||
WHERE ($sourceFile = '' OR m.sourceFile = $sourceFile)
|
||||
MATCH (m)-[:CONTAINS*0..1]->(f:AstNode)-[r:READS|WRITES]->(v:AstNode)
|
||||
WHERE v.type IN ['VARIABLE', 'CONSTANT', 'FIELD']
|
||||
RETURN DISTINCT f.name AS function, v.name AS variable, v.type AS variableType, type(r) AS mode,
|
||||
@@ -635,6 +657,56 @@ public final class CypherQueries {
|
||||
* Same as {@link #LINK_REFERENCES_TO_SUBCLASSES} for {@code INJECTS} (CDI injection points
|
||||
* declared on a base class).
|
||||
*/
|
||||
/**
|
||||
* Item 116b: rewires a call whose receiver was an <em>inherited</em> field to the field's type.
|
||||
*
|
||||
* <p>The parser sees one file, so {@code partnerRepository.findFirst(…)} in a subclass that does not
|
||||
* declare {@code partnerRepository} resolves to nothing — and the target class then reports no
|
||||
* callers at all. The parser records the receiver's identifier on a placeholder edge instead of
|
||||
* dropping it; this step walks the {@code EXTENDS} chain the graph does know, finds the declaring
|
||||
* field, and re-points the edge at that field's type.
|
||||
*
|
||||
* <p>The type name is normalised in Cypher rather than at parse time because a field's declared
|
||||
* type may carry generics or a package ({@code List<Foo>}, {@code a.b.Foo}) and modules are keyed on
|
||||
* the simple name.
|
||||
*/
|
||||
public static final String RESOLVE_INHERITED_FIELD_RECEIVERS = """
|
||||
MATCH (c:MODULE {project: $project})-[r:CALLS]->(ph:MODULE {project: $project})
|
||||
WHERE r.unresolvedFieldReceiver IS NOT NULL AND ph.sourceFile = ''
|
||||
MATCH (c)-[:EXTENDS|IMPLEMENTS*1..]->(base:MODULE {project: $project})
|
||||
MATCH (base)-[:CONTAINS]->(f:AstNode {project: $project})
|
||||
WHERE f.type IN ['FIELD', 'VARIABLE'] AND f.name = r.unresolvedFieldReceiver
|
||||
AND f.dataType IS NOT NULL AND f.dataType <> ''
|
||||
WITH DISTINCT c, r, f,
|
||||
CASE WHEN f.dataType CONTAINS '<'
|
||||
THEN split(f.dataType, '<')[0] ELSE f.dataType END AS raw
|
||||
WITH c, r, last(split(raw, '.')) AS typeName
|
||||
MATCH (target:MODULE {name: typeName, project: $project})
|
||||
// Collapse per marker edge, not globally: a plain LIMIT 1 here resolved exactly one edge
|
||||
// in the whole project, which looked like success on a one-level hierarchy and silently
|
||||
// dropped every deeper one.
|
||||
WITH c, r, collect(target) AS targets
|
||||
WITH c, r, targets, [t IN targets WHERE t.sourceFile <> ''] AS real
|
||||
WITH c, r, CASE WHEN size(real) > 0 THEN real[0] ELSE head(targets) END AS target
|
||||
WHERE target IS NOT NULL
|
||||
MERGE (c)-[nr:CALLS {callKind: 'METHOD_CALL', lineNo: r.lineNo,
|
||||
calleeMethod: r.calleeMethod}]->(target)
|
||||
SET nr.callerFn = r.callerFn, nr.viaInheritedField = r.unresolvedFieldReceiver
|
||||
DELETE r
|
||||
""";
|
||||
|
||||
/**
|
||||
* Item 116b cleanup: drops the {@code field:*} placeholders and any marker edge that
|
||||
* {@link #RESOLVE_INHERITED_FIELD_RECEIVERS} could not resolve — the receiver may be a static
|
||||
* import, an outer-class field already handled at parse time (116a), or a type absent from the
|
||||
* project. Leaving them would put {@code field:partnerRepository} into the module namespace.
|
||||
*/
|
||||
public static final String DELETE_UNRESOLVED_FIELD_RECEIVERS = """
|
||||
MATCH (ph:MODULE {project: $project})
|
||||
WHERE ph.sourceFile = '' AND ph.name STARTS WITH 'field:'
|
||||
DETACH DELETE ph
|
||||
""";
|
||||
|
||||
public static final String LINK_INJECTS_TO_SUBCLASSES = """
|
||||
MATCH (sub:AstNode {type: 'MODULE', project: $project})-[:EXTENDS*1..]->(base:AstNode {type: 'MODULE', project: $project})
|
||||
WHERE sub.sourceFile <> "" AND sub <> base
|
||||
@@ -721,7 +793,9 @@ public final class CypherQueries {
|
||||
* returns each overriding method with its declaring subclass.
|
||||
*/
|
||||
public static final String FUNCTION_OVERRIDES = """
|
||||
MATCH (m:AstNode {type: 'MODULE', name: $name, project: $project})-[:CONTAINS]->(bm:AstNode {type: 'FUNCTION', name: $function})
|
||||
MATCH (m:MODULE {name: $name, project: $project})
|
||||
WHERE ($sourceFile = '' OR m.sourceFile = $sourceFile)
|
||||
MATCH (m)-[:CONTAINS]->(bm:AstNode {type: 'FUNCTION', name: $function})
|
||||
MATCH (bm)-[:OVERRIDDEN_BY]->(sm:AstNode {type: 'FUNCTION'})<-[:CONTAINS]-(sub:AstNode {type: 'MODULE'})
|
||||
RETURN DISTINCT sub.name AS module, sm.name AS name, sm.sourceFile AS sourceFile,
|
||||
sm.startLine AS startLine, sm.endLine AS endLine
|
||||
@@ -972,6 +1046,7 @@ public final class CypherQueries {
|
||||
*/
|
||||
public static final String FUNCTION_CALLERS = """
|
||||
MATCH (m:MODULE {name: $name, project: $project})
|
||||
WHERE ($sourceFile = '' OR m.sourceFile = $sourceFile)
|
||||
MATCH (m)-[:CONTAINS*0..1]->(callee:FUNCTION {name: $function})
|
||||
MATCH (caller:FUNCTION)-[r:CALLS]->(callee)
|
||||
RETURN caller.name AS name, caller.type AS type, caller.sourceFile AS sourceFile,
|
||||
@@ -1644,7 +1719,9 @@ public final class CypherQueries {
|
||||
* source. Restricted to assignments that carry a {@code whenValue} guard.
|
||||
*/
|
||||
public static final String DISPATCH_TABLE = """
|
||||
MATCH (m:AstNode {type: 'MODULE', name: $name, project: $project})-[:CONTAINS*0..1]->(src:AstNode)
|
||||
MATCH (m:MODULE {name: $name, project: $project})
|
||||
WHERE ($sourceFile = '' OR m.sourceFile = $sourceFile)
|
||||
MATCH (m)-[:CONTAINS*0..1]->(src:AstNode)
|
||||
WHERE src.type IN ['MODULE', 'FUNCTION']
|
||||
MATCH (src)-[w:WRITES]->(t:AstNode)
|
||||
WHERE w.whenValue IS NOT NULL
|
||||
@@ -1747,7 +1824,9 @@ public final class CypherQueries {
|
||||
* not ingested) reports {@code area = UNKNOWN}, {@code fieldCount = 0}, {@code sourceFile = null}.
|
||||
*/
|
||||
public static final String MODULE_DATA_STRUCTURES = """
|
||||
MATCH (m:AstNode {type: 'MODULE', name: $name, project: $project})-[r:INCLUDES|CONTAINS]->(d:AstNode {type: 'DATA_STRUCTURE'})
|
||||
MATCH (m:MODULE {name: $name, project: $project})
|
||||
WHERE ($sourceFile = '' OR m.sourceFile = $sourceFile)
|
||||
MATCH (m)-[r:INCLUDES|CONTAINS]->(d:AstNode {type: 'DATA_STRUCTURE'})
|
||||
OPTIONAL MATCH (d)-[:CONTAINS*1..]->(f:AstNode)
|
||||
WHERE f.type IN ['VARIABLE', 'CONSTANT', 'DATA_STRUCTURE']
|
||||
WITH d, type(r) AS rel, count(DISTINCT f) AS fc
|
||||
@@ -1783,6 +1862,7 @@ public final class CypherQueries {
|
||||
*/
|
||||
public static final String EGO_ROOT = """
|
||||
MATCH (m:AstNode {type: 'MODULE', name: $name, project: $project})
|
||||
WHERE ($sourceFile = '' OR m.sourceFile = $sourceFile)
|
||||
RETURN m.sourceFile AS sourceFile, coalesce(m.unresolved, false) AS unresolved
|
||||
ORDER BY m.sourceFile DESC
|
||||
LIMIT 1
|
||||
@@ -1806,7 +1886,7 @@ public final class CypherQueries {
|
||||
*/
|
||||
public static final String PAYLOAD_FROM_PDA = """
|
||||
MATCH (m:AstNode {type: 'MODULE', name: $name, project: $project})
|
||||
WHERE m.xmlWrapper = 'true' AND m.interfacePda IS NOT NULL
|
||||
WHERE ($sourceFile = '' OR m.sourceFile = $sourceFile) AND m.xmlWrapper = 'true' AND m.interfacePda IS NOT NULL
|
||||
MATCH (m)-[:INCLUDES]->(pda:AstNode {type: 'DATA_STRUCTURE'})
|
||||
WHERE toUpper(pda.name) = toUpper(m.interfacePda) AND pda.sourceFile <> ''
|
||||
MATCH (pda)-[:CONTAINS*1..]->(f:AstNode)
|
||||
@@ -1861,6 +1941,7 @@ public final class CypherQueries {
|
||||
*/
|
||||
public static final String ENTITY_COLUMNS = """
|
||||
MATCH (m:AstNode {type: 'MODULE', name: $name, project: $project})
|
||||
WHERE ($sourceFile = '' OR m.sourceFile = $sourceFile)
|
||||
MATCH (m)-[:EXTENDS*0..]->(c:AstNode {type: 'MODULE'})
|
||||
MATCH (c)-[:CONTAINS]->(f:AstNode {type: 'FIELD'})
|
||||
WHERE f.columnName IS NOT NULL
|
||||
@@ -1941,7 +2022,9 @@ public final class CypherQueries {
|
||||
* that do not use the idiom (or are not deeply ingested).
|
||||
*/
|
||||
public static final String PAYLOAD = """
|
||||
MATCH (m:AstNode {type: 'MODULE', name: $name, project: $project})-[:CONTAINS]->(p:AstNode {type: 'PAYLOAD_FIELD'})
|
||||
MATCH (m:MODULE {name: $name, project: $project})
|
||||
WHERE ($sourceFile = '' OR m.sourceFile = $sourceFile)
|
||||
MATCH (m)-[:CONTAINS]->(p:AstNode {type: 'PAYLOAD_FIELD'})
|
||||
RETURN p.tag AS tag, p.field AS field, p.direction AS direction, 'IDIOM' AS source, p.startLine AS lineNo,
|
||||
m.sourceFile AS sourceFile
|
||||
ORDER BY lineNo, tag
|
||||
|
||||
@@ -27,6 +27,33 @@ public class GraphRepository {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(GraphRepository.class);
|
||||
|
||||
/**
|
||||
* Item 115: the {@code $sourceFile} value meaning "any candidate". Every module query binds the
|
||||
* parameter unconditionally via {@link #moduleParams}, because Cypher fails at <em>runtime</em> on
|
||||
* an unbound parameter — a forgotten binding compiles cleanly and breaks only in production.
|
||||
*/
|
||||
public static final String ANY_SOURCE_FILE = "";
|
||||
|
||||
/**
|
||||
* The parameter map every {@code MODULE}-by-name query is run with. Exists so {@code sourceFile}
|
||||
* cannot be omitted at a call site: it replaced 22 hand-written
|
||||
* {@code Map.of("project", …, "name", …)} literals, each of which would otherwise have had to
|
||||
* remember the new parameter.
|
||||
*/
|
||||
private static Map<String, @Nullable Object> moduleParams(String project, String name, String sourceFile) {
|
||||
return Map.of("project", project, "name", name, "sourceFile", sourceFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link #moduleParams(String, String, String)} plus one extra key — the queries that also take a
|
||||
* {@code $function}. Kept as an overload rather than a builder so the three mandatory module keys
|
||||
* stay impossible to forget.
|
||||
*/
|
||||
private static Map<String, @Nullable Object> moduleParams(String project, String name, String sourceFile,
|
||||
String extraKey, Object extraValue) {
|
||||
return Map.of("project", project, "name", name, "sourceFile", sourceFile, extraKey, extraValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 72: the guard-chain encoding, mirrored from {@code NaturalParser.GUARD_CHAIN_SEPARATOR} /
|
||||
* {@code GUARD_VALUE_SEPARATOR} — links RS-separated, each link's alternatives US-separated.
|
||||
@@ -77,30 +104,6 @@ public class GraphRepository {
|
||||
// Call graph
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public Uni<CallRefResponse> callers(String project, String moduleName, @Nullable String scope) {
|
||||
return read(CypherQueries.callers(scope), Map.of("project", project, "name", moduleName),
|
||||
GraphRepository::toCallRefRow)
|
||||
.map(GraphRepository::buildCallRefResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips a single leading Natural sigil ({@code #}, {@code &}, {@code +}) so name search is sigil-insensitive.
|
||||
*/
|
||||
private static @Nullable String stripLeadingSigil(@Nullable String name) {
|
||||
if (name == null || name.isEmpty()) return name;
|
||||
char c = name.charAt(0);
|
||||
return (c == '#' || c == '&' || c == '+') ? name.substring(1) : name;
|
||||
}
|
||||
|
||||
private static FieldFlow toFieldFlow(Record record) {
|
||||
return new FieldFlow(
|
||||
record.get("field").asString(),
|
||||
record.get("producer").asString(),
|
||||
record.get("producedAt").asInt(),
|
||||
record.get("consumer").asString(),
|
||||
record.get("consumedAt").asInt());
|
||||
}
|
||||
|
||||
/**
|
||||
* The ordered enrichment statements: placeholder resolution → bare-include redirection →
|
||||
* placeholder cleanup → dataflow → polymorphic fan-out. The order matters (e.g. dataflow and
|
||||
@@ -213,6 +216,13 @@ public class GraphRepository {
|
||||
// Polymorphic call resolution: fan class-level CALLS edges that target an interface/base
|
||||
// type out to its implementations/subclasses (CHA). Runs last so it sees resolved
|
||||
// CALLS + IMPLEMENTS/EXTENDS edges and doesn't perturb dataflow. Project-wide in both modes.
|
||||
// Item 116b: resolve calls on inherited fields before the polymorphic fan-out, so an edge
|
||||
// recovered here is itself eligible for CHA expansion — a repository interface reached through
|
||||
// an inherited field should fan out to its implementations like any other.
|
||||
statements.add(new EnrichmentStep("resolve-inherited-field-receivers",
|
||||
CypherQueries.RESOLVE_INHERITED_FIELD_RECEIVERS));
|
||||
statements.add(new EnrichmentStep("delete-unresolved-field-receivers",
|
||||
CypherQueries.DELETE_UNRESOLVED_FIELD_RECEIVERS));
|
||||
statements.add(new EnrichmentStep("link-calls-to-implementations", CypherQueries.LINK_CALLS_TO_IMPLEMENTATIONS));
|
||||
// Item 82: apply human/agent-set manual overrides for dynamic CALLNAT sites the auto-resolvers
|
||||
// couldn't reach. Runs after the auto dynamic-CALLNAT resolvers and before the placeholder
|
||||
@@ -265,6 +275,62 @@ public class GraphRepository {
|
||||
return statements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips a single leading Natural sigil ({@code #}, {@code &}, {@code +}) so name search is sigil-insensitive.
|
||||
*/
|
||||
private static @Nullable String stripLeadingSigil(@Nullable String name) {
|
||||
if (name == null || name.isEmpty()) return name;
|
||||
char c = name.charAt(0);
|
||||
return (c == '#' || c == '&' || c == '+') ? name.substring(1) : name;
|
||||
}
|
||||
|
||||
private static FieldFlow toFieldFlow(Record record) {
|
||||
return new FieldFlow(
|
||||
record.get("field").asString(),
|
||||
record.get("producer").asString(),
|
||||
record.get("producedAt").asInt(),
|
||||
record.get("consumer").asString(),
|
||||
record.get("consumedAt").asInt());
|
||||
}
|
||||
|
||||
private static EgoGraphResponse expandEgoGraph(TransactionContext tx, String project, String moduleName,
|
||||
int depth, String dir, int limit,
|
||||
boolean traverseOut, boolean traverseIn, String sourceFile) {
|
||||
// Resolve the seed node's stable key (name + sourceFile); prefer a real, non-placeholder node.
|
||||
List<Record> rootRows = tx.run(CypherQueries.EGO_ROOT, moduleParams(project, moduleName, sourceFile)).list();
|
||||
String rootSourceFile = rootRows.isEmpty() ? "" : rootRows.get(0).get("sourceFile").asString("");
|
||||
boolean rootUnresolved = !rootRows.isEmpty() && rootRows.get(0).get("unresolved").asBoolean(false);
|
||||
|
||||
Map<String, EgoGraphResponse.GraphNode> nodes = new LinkedHashMap<>();
|
||||
nodes.put(moduleName, new EgoGraphResponse.GraphNode(moduleName, rootSourceFile, "MODULE", rootUnresolved, 0));
|
||||
// Distinct edges keyed on from|to|edgeKind, preserving discovery order.
|
||||
Map<String, EgoGraphResponse.GraphEdge> edges = new LinkedHashMap<>();
|
||||
boolean truncated = false;
|
||||
|
||||
// BFS frontier of module names at the current hop distance.
|
||||
List<String> frontier = new ArrayList<>(List.of(moduleName));
|
||||
for (int distance = 0; distance < depth && !frontier.isEmpty(); distance++) {
|
||||
List<String> next = new ArrayList<>();
|
||||
for (String current : frontier) {
|
||||
if (traverseOut) {
|
||||
truncated |= collectNeighbors(tx, project, current, CypherQueries.EGO_NEIGHBORS_OUT,
|
||||
true, distance + 1, limit, nodes, edges, next);
|
||||
}
|
||||
if (traverseIn) {
|
||||
truncated |= collectNeighbors(tx, project, current, CypherQueries.EGO_NEIGHBORS_IN,
|
||||
false, distance + 1, limit, nodes, edges, next);
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
|
||||
// Keep only edges whose endpoints both survived the node cap.
|
||||
List<EgoGraphResponse.GraphEdge> keptEdges = edges.values().stream()
|
||||
.filter(e -> nodes.containsKey(e.from()) && nodes.containsKey(e.to()))
|
||||
.toList();
|
||||
return new EgoGraphResponse(moduleName, dir, depth, List.copyOf(nodes.values()), keptEdges, truncated);
|
||||
}
|
||||
|
||||
private static FunctionOverride toFunctionOverride(Record record) {
|
||||
return new FunctionOverride(
|
||||
record.get("module").asString(),
|
||||
@@ -296,22 +362,51 @@ public class GraphRepository {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param resolveInterfaces item J3 — hop interface callees to their {@code IMPLEMENTED_BY}
|
||||
* implementation(s); {@code false} keeps the default behavior.
|
||||
* Runs one direct-neighbour query for {@code current}, recording edges and adding any newly seen
|
||||
* module (BFS order) up to the {@code limit} node cap. Newly added modules are enqueued in
|
||||
* {@code next} for the following hop.
|
||||
*
|
||||
* @param outward whether {@code query} returns callees ({@code current -> neighbour}) or callers
|
||||
* ({@code neighbour -> current})
|
||||
* @return whether the node cap was hit (a neighbour could not be added)
|
||||
*/
|
||||
public Uni<CallRefResponse> callees(String project, String moduleName, @Nullable String scope, boolean resolveInterfaces) {
|
||||
return read(CypherQueries.callees(scope, resolveInterfaces), Map.of("project", project, "name", moduleName),
|
||||
GraphRepository::toCallRefRow)
|
||||
.map(GraphRepository::buildCallRefResponse);
|
||||
private static boolean collectNeighbors(TransactionContext tx, String project, String current, String query,
|
||||
boolean outward, int distance, int limit,
|
||||
Map<String, EgoGraphResponse.GraphNode> nodes,
|
||||
Map<String, EgoGraphResponse.GraphEdge> edges, List<String> next) {
|
||||
boolean truncated = false;
|
||||
for (Record r : tx.run(query, moduleParams(project, current, ANY_SOURCE_FILE)).list()) {
|
||||
String neighbor = r.get("name").asString();
|
||||
if (neighbor.equals(current)) {
|
||||
continue; // skip self-calls
|
||||
}
|
||||
String from = outward ? current : neighbor;
|
||||
String to = outward ? neighbor : current;
|
||||
String edgeKind = r.get("edgeKind").asString("CALLNAT");
|
||||
edges.putIfAbsent(from + " | ||||