Java improvements

This commit is contained in:
Ingo Schnabel
2026-08-06 12:57:11 +02:00
parent 19f59ff04e
commit cac0cba379
38 changed files with 1344 additions and 337 deletions

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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());
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -4,4 +4,4 @@
server.url=http://localhost:8787
# Stamped by manage-ac.sh (stamp_cli_version) from ac-code-server's agenticcode.version
# at build time. "dev" means this jar wasn't built via manage-ac.sh.
version=166
version=171

View File

@@ -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();
}));
}
/**

View File

@@ -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);
}
}

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -3,7 +3,7 @@ quarkus.http.port=8787
# AgenticCode's own release counter (not the Maven project version) — bump this by hand for each
# release. Single source of truth for the startup log line, GET /api/version, and the OpenAPI
# info version (referenced below via property expression, not duplicated).
agenticcode.version=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

View File

@@ -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"));
}
}

View File

@@ -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")));
}
}

View File

@@ -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

View File

@@ -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 + "" + to + "" + edgeKind,
new EgoGraphResponse.GraphEdge(from, to, edgeKind));
if (!nodes.containsKey(neighbor)) {
if (nodes.size() >= limit) {
truncated = true;
continue;
}
nodes.put(neighbor, new EgoGraphResponse.GraphNode(neighbor,
r.get("sourceFile").asString(""), "MODULE",
r.get("unresolved").asBoolean(false), distance));
next.add(neighbor);
}
}
return truncated;
}
// -------------------------------------------------------------------------
// DB accesses and SQL statements
// -------------------------------------------------------------------------
public Uni<List<DbAccess>> dbAccesses(String project, String moduleName) {
return read(CypherQueries.DB_ACCESSES, Map.of("project", project, "name", moduleName),
GraphRepository::toDbAccess);
public Uni<CallRefResponse> callers(String project, String moduleName, @Nullable String scope) {
return read(CypherQueries.callers(scope), moduleParams(project, moduleName, ANY_SOURCE_FILE),
GraphRepository::toCallRefRow)
.map(GraphRepository::buildCallRefResponse);
}
private static InheritedFunction toInheritedFunction(Record record) {
@@ -351,80 +446,19 @@ public class GraphRepository {
return moduleTree(tx, project, root, maxDepth, false);
}
private static EgoGraphResponse expandEgoGraph(TransactionContext tx, String project, String moduleName,
int depth, String dir, int limit,
boolean traverseOut, boolean traverseIn) {
// Resolve the seed node's stable key (name + sourceFile); prefer a real, non-placeholder node.
List<Record> rootRows = tx.run(CypherQueries.EGO_ROOT, Map.of("project", project, "name", moduleName)).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);
/**
* @param resolveInterfaces item J3 — hop interface callees to their {@code IMPLEMENTED_BY}
* implementation(s); {@code false} keeps the default behavior.
*/
public Uni<CallRefResponse> callees(String project, String moduleName, @Nullable String scope, boolean resolveInterfaces) {
return read(CypherQueries.callees(scope, resolveInterfaces), moduleParams(project, moduleName, ANY_SOURCE_FILE),
GraphRepository::toCallRefRow)
.map(GraphRepository::buildCallRefResponse);
}
/**
* 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)
*/
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, Map.of("project", project, "name", current)).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 + "" + to + "" + edgeKind,
new EgoGraphResponse.GraphEdge(from, to, edgeKind));
if (!nodes.containsKey(neighbor)) {
if (nodes.size() >= limit) {
truncated = true;
continue;
}
nodes.put(neighbor, new EgoGraphResponse.GraphNode(neighbor,
r.get("sourceFile").asString(""), "MODULE",
r.get("unresolved").asBoolean(false), distance));
next.add(neighbor);
}
}
return truncated;
public Uni<List<DbAccess>> dbAccesses(String project, String moduleName, String sourceFile) {
return read(CypherQueries.DB_ACCESSES, moduleParams(project, moduleName, sourceFile),
GraphRepository::toDbAccess);
}
/**
@@ -437,7 +471,8 @@ public class GraphRepository {
*
* @param direction {@code "out"} (callees), {@code "in"} (callers), or {@code "both"}
*/
public Uni<EgoGraphResponse> egoGraph(String project, String moduleName, int depth, String direction, int limit) {
public Uni<EgoGraphResponse> egoGraph(String project, String moduleName, int depth, String direction, int limit,
String sourceFile) {
int clampedDepth = Math.min(Math.max(depth, 1), CALL_TREE_MAX_DEPTH_LIMIT);
int clampedLimit = Math.max(limit, 1);
String dir = switch (direction == null ? "out" : direction.toLowerCase(Locale.ROOT)) {
@@ -449,7 +484,8 @@ public class GraphRepository {
return Uni.createFrom().item(() -> {
try (Session session = driver.session()) {
return session.executeRead((TransactionContext tx) ->
expandEgoGraph(tx, project, moduleName, clampedDepth, dir, clampedLimit, traverseOut, traverseIn));
expandEgoGraph(tx, project, moduleName, clampedDepth, dir, clampedLimit, traverseOut, traverseIn,
sourceFile));
}
});
}
@@ -457,8 +493,9 @@ public class GraphRepository {
/**
* Paginated variant of {@link #dbAccesses(String, String)} for the standalone endpoint.
*/
public Uni<List<DbAccess>> dbAccesses(String project, String moduleName, int limit, int offset) {
return dbAccesses(project, moduleName).map(list -> paginate(list, limit, offset));
public Uni<List<DbAccess>> dbAccesses(String project, String moduleName, int limit, int offset,
String sourceFile) {
return dbAccesses(project, moduleName, sourceFile).map(list -> paginate(list, limit, offset));
}
/**
@@ -546,8 +583,8 @@ public class GraphRepository {
return params;
}
public Uni<List<SqlStatement>> sqlStatements(String project, String moduleName) {
return read(CypherQueries.SQL_STATEMENTS, Map.of("project", project, "name", moduleName),
public Uni<List<SqlStatement>> sqlStatements(String project, String moduleName, String sourceFile) {
return read(CypherQueries.SQL_STATEMENTS, moduleParams(project, moduleName, sourceFile),
GraphRepository::toSqlStatement);
}
@@ -638,7 +675,7 @@ public class GraphRepository {
* @param offset items to skip in each list (0-based)
*/
public Uni<ModuleContext> moduleContext(String project, String moduleName,
@Nullable String include, int limit, int offset) {
@Nullable String include, int limit, int offset, String sourceFile) {
Set<String> sections = parseSections(include);
boolean all = sections.isEmpty();
// Heavy sections (P1-t): full list only when named explicitly; summarized in lean (default)
@@ -646,9 +683,9 @@ public class GraphRepository {
boolean sqlNamed = sections.contains("sqlStatements");
boolean varNamed = sections.contains("variableAccesses");
Uni<ModuleHeader> headerUni = moduleHeader(project, moduleName);
Uni<ModuleHeader> headerUni = moduleHeader(project, moduleName, sourceFile);
Uni<List<FunctionInfo>> functionsUni = all || sections.contains("functions")
? functions(project, moduleName)
? functions(project, moduleName, sourceFile)
: Uni.createFrom().item(List.of());
Uni<CallRefResponse> callersUni = all || sections.contains("callers")
? callers(project, moduleName, null)
@@ -657,15 +694,15 @@ public class GraphRepository {
? callees(project, moduleName, null)
: Uni.createFrom().item(new CallRefResponse(List.of(), List.of()));
Uni<List<DbAccess>> dbAccessesUni = all || sections.contains("dbAccesses")
? dbAccesses(project, moduleName)
? dbAccesses(project, moduleName, sourceFile)
: Uni.createFrom().item(List.of());
// Fetch the heavy lists whenever they are active (named, or default-all for the summary);
// only an explicit whitelist that omits them skips the query.
Uni<List<SqlStatement>> sqlUni = all || sqlNamed
? sqlStatements(project, moduleName)
? sqlStatements(project, moduleName, sourceFile)
: Uni.createFrom().item(List.of());
Uni<List<VariableAccess>> varUni = all || varNamed
? variableAccesses(project, moduleName)
? variableAccesses(project, moduleName, sourceFile)
: Uni.createFrom().item(List.of());
return Uni.combine().all().unis(headerUni, functionsUni, callersUni, calleesUni, dbAccessesUni, sqlUni, varUni)
@@ -694,13 +731,13 @@ public class GraphRepository {
* Triage view of a module (P1-v): reuses the {@code /context} sub-queries but reduces each to
* names/counts, for cheap scanning of many modules.
*/
public Uni<ModuleDigest> moduleDigest(String project, String moduleName) {
Uni<ModuleHeader> headerUni = moduleHeader(project, moduleName);
Uni<List<FunctionInfo>> functionsUni = functions(project, moduleName);
public Uni<ModuleDigest> moduleDigest(String project, String moduleName, String sourceFile) {
Uni<ModuleHeader> headerUni = moduleHeader(project, moduleName, sourceFile);
Uni<List<FunctionInfo>> functionsUni = functions(project, moduleName, sourceFile);
Uni<CallRefResponse> callersUni = callers(project, moduleName, null);
Uni<CallRefResponse> calleesUni = callees(project, moduleName, null);
Uni<List<DbAccess>> dbAccessesUni = dbAccesses(project, moduleName);
Uni<List<ModuleDataStructure>> structuresUni = moduleDataStructures(project, moduleName);
Uni<List<DbAccess>> dbAccessesUni = dbAccesses(project, moduleName, sourceFile);
Uni<List<ModuleDataStructure>> structuresUni = moduleDataStructures(project, moduleName, sourceFile);
return Uni.combine().all().unis(headerUni, functionsUni, callersUni, calleesUni, dbAccessesUni, structuresUni)
.asTuple()
@@ -739,11 +776,11 @@ public class GraphRepository {
.map(list -> list.isEmpty() ? null : list.get(0));
}
private Uni<ModuleHeader> moduleHeader(String project, String moduleName) {
private Uni<ModuleHeader> moduleHeader(String project, String moduleName, String sourceFile) {
return Uni.createFrom().item(() -> {
try (Session session = driver.session()) {
return session.executeRead((TransactionContext tx) -> {
var result = tx.run(CypherQueries.MODULE_SOURCE_FILE, Map.of("project", project, "name", moduleName));
var result = tx.run(CypherQueries.MODULE_SOURCE_FILE, moduleParams(project, moduleName, sourceFile));
if (!result.hasNext()) {
return new ModuleHeader("", null, null, null);
}
@@ -761,13 +798,13 @@ public class GraphRepository {
// Other read queries
// -------------------------------------------------------------------------
public Uni<List<WorkfileAccess>> workfileAccesses(String project, String moduleName) {
return read(CypherQueries.WORKFILE_ACCESSES, Map.of("project", project, "name", moduleName),
public Uni<List<WorkfileAccess>> workfileAccesses(String project, String moduleName, String sourceFile) {
return read(CypherQueries.WORKFILE_ACCESSES, moduleParams(project, moduleName, sourceFile),
GraphRepository::toWorkfileAccess);
}
public Uni<List<VariableAccess>> variableAccesses(String project, String moduleName) {
return read(CypherQueries.VARIABLE_ACCESSES, Map.of("project", project, "name", moduleName),
public Uni<List<VariableAccess>> variableAccesses(String project, String moduleName, String sourceFile) {
return read(CypherQueries.VARIABLE_ACCESSES, moduleParams(project, moduleName, sourceFile),
record -> new VariableAccess(
record.get("function").asString(),
record.get("variable").asString(),
@@ -842,12 +879,12 @@ public class GraphRepository {
}
public Uni<List<DataStructureField>> dbTableColumns(String project, String name) {
return read(CypherQueries.DB_TABLE_COLUMNS, Map.of("project", project, "name", name),
return read(CypherQueries.DB_TABLE_COLUMNS, moduleParams(project, name, ANY_SOURCE_FILE),
GraphRepository::toDataStructureField);
}
public Uni<List<ModuleDataStructure>> moduleDataStructures(String project, String moduleName) {
return read(CypherQueries.MODULE_DATA_STRUCTURES, Map.of("project", project, "name", moduleName),
public Uni<List<ModuleDataStructure>> moduleDataStructures(String project, String moduleName, String sourceFile) {
return read(CypherQueries.MODULE_DATA_STRUCTURES, moduleParams(project, moduleName, sourceFile),
record -> new ModuleDataStructure(
record.get("name").asString(),
record.get("relationship").asString(),
@@ -856,13 +893,13 @@ public class GraphRepository {
record.get("sourceFile").isNull() ? null : record.get("sourceFile").asString()));
}
public Uni<List<PayloadField>> payload(String project, String moduleName) {
return read(CypherQueries.PAYLOAD, Map.of("project", project, "name", moduleName),
public Uni<List<PayloadField>> payload(String project, String moduleName, String sourceFile) {
return read(CypherQueries.PAYLOAD, moduleParams(project, moduleName, sourceFile),
GraphRepository::toPayloadField)
.flatMap(idiom -> !idiom.isEmpty()
? Uni.createFrom().item(idiom)
// item 46b: generic wrapper with no static idiom → derive from its interface PDA.
: read(CypherQueries.PAYLOAD_FROM_PDA, Map.of("project", project, "name", moduleName),
: read(CypherQueries.PAYLOAD_FROM_PDA, moduleParams(project, moduleName, sourceFile),
GraphRepository::toPayloadField));
}
@@ -925,8 +962,9 @@ public class GraphRepository {
/**
* Paginated variant of {@link #workfileAccesses(String, String)} for the standalone endpoint.
*/
public Uni<List<WorkfileAccess>> workfileAccesses(String project, String moduleName, int limit, int offset) {
return workfileAccesses(project, moduleName).map(list -> paginate(list, limit, offset));
public Uni<List<WorkfileAccess>> workfileAccesses(String project, String moduleName, int limit, int offset,
String sourceFile) {
return workfileAccesses(project, moduleName, sourceFile).map(list -> paginate(list, limit, offset));
}
private static EntityColumn toEntityColumn(Record record) {
@@ -960,9 +998,9 @@ public class GraphRepository {
* — the {@code PERFORM} sites (Natural) or cross-class method callers (Java), each with its call
* lines. Uses the same {@link CallRefResponse} shape as the module-level {@link #callers}.
*/
public Uni<CallRefResponse> functionCallers(String project, String moduleName, String function) {
public Uni<CallRefResponse> functionCallers(String project, String moduleName, String function, String sourceFile) {
return read(CypherQueries.FUNCTION_CALLERS,
Map.of("project", project, "name", moduleName, "function", function),
moduleParams(project, moduleName, sourceFile, "function", function),
GraphRepository::toCallRefRow)
.map(GraphRepository::buildCallRefResponse);
}
@@ -993,8 +1031,8 @@ public class GraphRepository {
* Item 28: a module's own {@code sourceFile} (relative to the project root), or {@code null}
* if the module doesn't exist / is an unresolved placeholder.
*/
public Uni<@Nullable String> moduleSourceFile(String project, String moduleName) {
return moduleHeader(project, moduleName).map(h -> h.sourceFile().isEmpty() ? null : h.sourceFile());
public Uni<@Nullable String> moduleSourceFile(String project, String moduleName, String sourceFile) {
return moduleHeader(project, moduleName, sourceFile).map(h -> h.sourceFile().isEmpty() ? null : h.sourceFile());
}
/**
@@ -1448,19 +1486,30 @@ public class GraphRepository {
* exists, it just cannot be analysed.
*/
public Uni<ModuleIngestState> moduleIngestState(String project, String name) {
return moduleIngestState(project, name, ANY_SOURCE_FILE);
}
/**
* Item 115 variant: {@code sourceFile} narrows an ambiguous name to one candidate.
* {@link #ANY_SOURCE_FILE} (the empty string) means "any", so the state reports every real
* candidate and the caller can reject the request as ambiguous.
*/
public Uni<ModuleIngestState> moduleIngestState(String project, String name, String sourceFile) {
return Uni.createFrom().item(() -> {
try (Session session = driver.session()) {
return session.executeRead(tx -> {
var result = tx.run(CypherQueries.MODULE_INGEST_STATE, Map.of("project", project, "name", name));
var result = tx.run(CypherQueries.MODULE_INGEST_STATE,
moduleParams(project, name, sourceFile));
if (!result.hasNext()) {
return new ModuleIngestState(false, null, null, null, null);
return new ModuleIngestState(false, null, null, null, null, List.of());
}
Record record = result.next();
String sourceFile = record.get("sourceFile").isNull() ? "" : record.get("sourceFile").asString();
String found = record.get("sourceFile").isNull() ? "" : record.get("sourceFile").asString();
@Nullable String depth = record.get("depth").isNull() ? null : record.get("depth").asString();
@Nullable String status = record.get("status").isNull() ? null : record.get("status").asString();
@Nullable Long statusAt = record.get("statusAt").isNull() ? null : record.get("statusAt").asLong();
return new ModuleIngestState(!sourceFile.isEmpty(), depth, status, statusAt, sourceFile);
List<String> candidates = record.get("candidates").asList(Values.ofString());
return new ModuleIngestState(!found.isEmpty(), depth, status, statusAt, found, candidates);
});
}
});
@@ -1938,8 +1987,8 @@ public class GraphRepository {
});
}
public Uni<List<DispatchEntry>> dispatchTable(String project, String moduleName) {
return read(CypherQueries.DISPATCH_TABLE, Map.of("project", project, "name", moduleName),
public Uni<List<DispatchEntry>> dispatchTable(String project, String moduleName, String sourceFile) {
return read(CypherQueries.DISPATCH_TABLE, moduleParams(project, moduleName, sourceFile),
record -> new DispatchEntry(
toGuardChain(record.get("chainFields"), record.get("chainValues")),
record.get("guardField").asString(),
@@ -1977,8 +2026,8 @@ public class GraphRepository {
toSites(record.get("sites"))));
}
public Uni<List<FunctionInfo>> functions(String project, String moduleName) {
return read(CypherQueries.MODULE_FUNCTIONS, Map.of("project", project, "name", moduleName),
public Uni<List<FunctionInfo>> functions(String project, String moduleName, String sourceFile) {
return read(CypherQueries.MODULE_FUNCTIONS, moduleParams(project, moduleName, sourceFile),
record -> new FunctionInfo(
record.get("name").asString(),
record.get("sourceFile").asString(""),
@@ -2129,8 +2178,8 @@ public class GraphRepository {
}
}
public Uni<List<EntityColumn>> entityColumns(String project, String name) {
return read(CypherQueries.ENTITY_COLUMNS, Map.of("project", project, "name", name),
public Uni<List<EntityColumn>> entityColumns(String project, String name, String sourceFile) {
return read(CypherQueries.ENTITY_COLUMNS, moduleParams(project, name, sourceFile),
GraphRepository::toEntityColumn);
}
@@ -2222,20 +2271,19 @@ public class GraphRepository {
GraphRepository::toVariableAccessLocation);
}
public Uni<List<InheritedFunction>> moduleFunctions(String project, String name, boolean includeInherited) {
return moduleFunctions(project, name, includeInherited, null);
}
/**
* @param kind roadmap item 33 — optional filter: {@code abstract}/{@code final}/{@code overridable}
*/
public Uni<List<InheritedFunction>> moduleFunctions(String project, String name, boolean includeInherited,
@Nullable String kind) {
@Nullable String kind, String sourceFile) {
String query = includeInherited ? CypherQueries.MODULE_FUNCTIONS_INHERITED : CypherQueries.MODULE_FUNCTIONS_OWN;
// Built by hand rather than via moduleParams because `kind` is nullable and Map.of rejects
// nulls — which is exactly why this site was the one that missed the new parameter.
Map<String, @Nullable Object> params = new java.util.HashMap<>();
params.put("project", project);
params.put("name", name);
params.put("kind", kind);
params.put("sourceFile", sourceFile);
return read(query, params, GraphRepository::toInheritedFunction);
}
@@ -2250,9 +2298,9 @@ public class GraphRepository {
/**
* J4: concrete subclass overrides of the base-class method {@code function} in module {@code name}.
*/
public Uni<List<FunctionOverride>> functionOverrides(String project, String name, String function) {
public Uni<List<FunctionOverride>> functionOverrides(String project, String name, String function, String sourceFile) {
return read(CypherQueries.FUNCTION_OVERRIDES,
Map.of("project", project, "name", name, "function", function),
moduleParams(project, name, sourceFile, "function", function),
GraphRepository::toFunctionOverride);
}
@@ -2263,7 +2311,7 @@ public class GraphRepository {
*/
public Uni<List<BulkFunctionOverride>> functionOverrides(String project, String name) {
return read(CypherQueries.BULK_FUNCTION_OVERRIDES,
Map.of("project", project, "name", name),
moduleParams(project, name, ANY_SOURCE_FILE),
GraphRepository::toBulkFunctionOverride);
}

View File

@@ -2,6 +2,8 @@ package com.agenticcode.neo4jstore.graph;
import org.jspecify.annotations.Nullable;
import java.util.List;
/**
* The ingest state of a module, used by query endpoints to guide an agent on whether a deep ingest
* is still required, and by {@code DeepIngestCoordinator} to coalesce concurrent deep ingests.
@@ -29,12 +31,23 @@ import org.jspecify.annotations.Nullable;
* {@code "INGESTED"}, see {@link IngestStatus}), or {@code null} if unset
* @param statusAt the epoch-millis stamp {@code ingestStatusAt} of the last status transition, or
* {@code null} if unset — used to reclaim a stale {@code INGESTING} left by a crash
* <p>Item 115 adds a <b>fourth</b> state on top of those three: a name can resolve to more than one
* real module. Java makes this ordinary — nested {@code @Nested} test classes, {@code Builder},
* {@code Config}, {@code WorkingStorage} — and measured on one real codebase 8% of all modules were
* only ambiguously addressable. The graph tells them apart by {@code sourceFile}; the {@code name}-keyed
* endpoints could not, and silently answered with the <em>union</em> across unrelated classes. Natural
* is unaffected in practice: colliding identities are skipped at ingest (see the duplicate detection),
* so its persisted module names are unique by construction.
*
* @param sourceFile the node's {@code sourceFile}: {@code null} when no node exists at all,
* {@code ""} for a placeholder, the relative path for a real node
* @param candidates the {@code sourceFile}s of every <b>real</b> (non-placeholder) module sharing this
* name, ascending; empty when the name is absent or only a placeholder. More than
* one entry means the name alone does not identify a module
*/
public record ModuleIngestState(boolean ingested, @Nullable String depth,
@Nullable String status, @Nullable Long statusAt,
@Nullable String sourceFile) {
@Nullable String sourceFile, List<String> candidates) {
/**
* @return whether <em>any</em> {@code MODULE} node exists for the name — real or placeholder.
@@ -53,6 +66,15 @@ public record ModuleIngestState(boolean ingested, @Nullable String depth,
return sourceFile != null && sourceFile.isEmpty();
}
/**
* @return whether the name resolves to more than one real module, so answering by name alone would
* union unrelated modules. Placeholders never count — a placeholder plus one real module is not
* ambiguous, the real one wins, which is how {@code #SUBPROGRAM}-style references keep working.
*/
public boolean ambiguous() {
return candidates.size() > 1;
}
/**
* @return whether the module has been deeply (fully) ingested.
*/

View File

@@ -147,6 +147,27 @@ public final class JavaParser implements LanguageParser {
* or a capitalized bare name treated as a static call ({@code Foo.method()}). Returns
* {@code null} for complex/unresolvable receivers.
*/
/**
* Item 116b: the edge property carrying an unresolved receiver's identifier, and the prefix of the
* throwaway placeholder module it points at until enrichment rewires it. Prefixed so the marker can
* never collide with a real class name, and so the cleanup step can find every leftover.
*/
public static final String UNRESOLVED_FIELD_RECEIVER = "unresolvedFieldReceiver";
public static final String UNRESOLVED_FIELD_RECEIVER_PREFIX = "field:";
/**
* @return whether {@code scope} is a bare lower-case identifier that this class does not declare —
* i.e. it reads like a field rather than a type. Upper-case bare names are static calls and are
* already handled; anything with a declared type resolved before we got here.
*/
private static boolean isProbableFieldReceiver(Expression scope, Map<String, String> declaredTypes) {
if (!scope.isNameExpr()) {
return false;
}
String name = scope.asNameExpr().getNameAsString();
return !name.isEmpty() && Character.isLowerCase(name.charAt(0)) && !declaredTypes.containsKey(name);
}
@Nullable
private static String resolveReceiverClass(Expression scope, Map<String, String> declaredTypes) {
if (!scope.isNameExpr()) {
@@ -702,6 +723,38 @@ public final class JavaParser implements LanguageParser {
return null;
}
/**
* Item 116a: the field types visible to {@code type} from its <em>lexically enclosing</em> classes,
* excluding {@code type}'s own fields (the caller adds those as it emits the field nodes).
*
* <p>Without this a call on a field of the enclosing class resolves to nothing, so a {@code @Nested}
* JUnit class — the standard JUnit 5 layout — contributes no edges at all and the class under test
* reports zero callers. Measured on a real codebase: a service with 53 methods exercised by 185
* {@code @Test} methods answered {@code callers: {}}.
*
* <p>Applied outermost-first so an inner class's own field shadows an enclosing one of the same
* name, which is Java's rule. Not covered: fields inherited from a <em>supertype</em>, which the
* parser cannot see (it parses one file) — that needs the enrichment stage that already resolves
* the type hierarchy for {@code INJECTS}, and is tracked separately.
*/
private static Map<String, String> enclosingFieldTypes(ClassOrInterfaceDeclaration type) {
Deque<ClassOrInterfaceDeclaration> outermostFirst = new ArrayDeque<>();
for (ClassOrInterfaceDeclaration enclosing = type.findAncestor(ClassOrInterfaceDeclaration.class).orElse(null);
enclosing != null;
enclosing = enclosing.findAncestor(ClassOrInterfaceDeclaration.class).orElse(null)) {
outermostFirst.addFirst(enclosing);
}
Map<String, String> types = new HashMap<>();
for (ClassOrInterfaceDeclaration enclosing : outermostFirst) {
for (FieldDeclaration field : enclosing.getFields()) {
for (VariableDeclarator variable : field.getVariables()) {
types.put(variable.getNameAsString(), variable.getTypeAsString());
}
}
}
return types;
}
private static boolean isPlausibleEntityType(String type) {
return !NON_ENTITY_RETURN_TYPES.contains(type) && !NON_DB_RECEIVER_TYPES.contains(type);
}
@@ -1021,8 +1074,12 @@ public final class JavaParser implements LanguageParser {
// Pass 2: emit field/constant nodes (with JPA column metadata where present).
// fieldsByName maps a field/constant name to its node, for READS/WRITES resolution;
// fieldTypes maps a field name to its declared type, for cross-class call resolution.
// Item 116a: fieldTypes starts from the enclosing classes' fields, so a nested class
// resolves a call on a field it inherits lexically (the @Nested/JUnit 5 layout). Only
// fieldTypes is widened — fieldsByName stays this type's own fields, since a field node
// belongs to the class that declares it.
Map<String, AstNode> fieldsByName = new HashMap<>();
Map<String, String> fieldTypes = new HashMap<>();
Map<String, String> fieldTypes = enclosingFieldTypes(type);
for (FieldDeclaration field : type.getFields()) {
boolean isConstant = field.isStatic() && field.isFinal();
@Nullable AnnotationExpr column = annotation(field, "Column").orElse(null);
@@ -1135,6 +1192,20 @@ public final class JavaParser implements LanguageParser {
// callees/call-tree (which traverse from the module) span files uniformly
// with Natural's module-level CALLNAT.
@Nullable String targetClass = resolveReceiverClass(scope, declaredTypes);
if (targetClass == null && isProbableFieldReceiver(scope, declaredTypes)) {
// Item 116b: the receiver names a field this class does not declare — almost
// always one inherited from a supertype, which lives in another file the
// parser never sees. Record the receiver's *name* against a placeholder so
// the enrichment stage, which does know the EXTENDS chain, can resolve it.
// Dropping it here is what left a service with 185 tests reporting no callers.
AstNode marker = referencedModule(referencedModules, nodes,
UNRESOLVED_FIELD_RECEIVER_PREFIX + scope.asNameExpr().getNameAsString());
Map<String, String> props = callProps(call, CallKind.METHOD_CALL);
props.put("calleeMethod", call.getNameAsString());
props.put("callerFn", callableNode.name());
props.put(UNRESOLVED_FIELD_RECEIVER, scope.asNameExpr().getNameAsString());
edges.add(edge(EdgeType.CALLS, typeNode.id(), marker.id(), callLine, null, props));
}
if (targetClass != null) {
AstNode mod = referencedModule(referencedModules, nodes, targetClass);
// J5: carry the invoked method + enclosing function so enrichment can map

View File

@@ -226,6 +226,42 @@ class JavaParserTest {
.filter(n -> n.type() == NodeType.MODULE && n.name().equals("OrderService")).count());
}
/**
* Item 116a. Before the fix a {@code @Nested} class contributed no call edges at all, because
* field types were collected from the declaring type only — so the class under test reported
* zero callers even when every test in the file exercised it.
*/
@Test
void resolvesCallsOnFieldsOfTheEnclosingClass() throws IOException {
LanguageParser.ParseResult result =
parser.parse("NestedCallerTest.java", readFixture("NestedCallerTest.java"));
AstNode inner = findNode(result, NodeType.MODULE, "InnerTests");
assertTrue(hasEdge(result, EdgeType.CALLS, inner, "OrderService", NodeType.MODULE),
"a call on the enclosing class's field must resolve to that field's type");
// The chain is walked, not just one level.
AstNode innermost = findNode(result, NodeType.MODULE, "Innermost");
assertTrue(hasEdge(result, EdgeType.CALLS, innermost, "OrderService", NodeType.MODULE),
"the enclosing chain must be followed past the immediately enclosing class");
}
/**
* The other half of item 116a: widening must not out-shout Java's shadowing rule. An own field
* of the same name wins over the enclosing one, which is why the chain is applied outermost-first.
*/
@Test
void ownFieldShadowsTheEnclosingClassField() throws IOException {
LanguageParser.ParseResult result =
parser.parse("NestedCallerTest.java", readFixture("NestedCallerTest.java"));
AstNode shadowing = findNode(result, NodeType.MODULE, "ShadowingTests");
assertTrue(hasEdge(result, EdgeType.CALLS, shadowing, "AuditLog", NodeType.MODULE),
"the call must resolve to this class's own field type");
assertFalse(hasEdge(result, EdgeType.CALLS, shadowing, "OrderService", NodeType.MODULE),
"the shadowed enclosing field must not leak in");
}
@Test
void parameterShadowingSuppressesFieldEdges() throws IOException {
LanguageParser.ParseResult result = parser.parse("OrderService.java", readFixture("OrderService.java"));

View File

@@ -0,0 +1,40 @@
package com.example.sample;
/**
* Fixture for item 116a — the JUnit 5 {@code @Nested} layout, where the field under test is declared
* in the enclosing class and every call on it happens one level in.
*/
class NestedCallerTest {
private OrderService service;
class InnerTests {
void usesEnclosingField() {
service.placeOrder(1);
}
}
/**
* The enclosing {@code service} must lose against this class's own field of the same name —
* Java's shadowing rule, and the reason the enclosing chain is applied outermost-first.
*/
class ShadowingTests {
private AuditLog service;
void usesOwnField() {
service.record("shadowed");
}
}
class DeeplyNested {
class Innermost {
void stillSeesTheOutermostField() {
service.placeOrder(2);
}
}
}
}

View File

@@ -17,11 +17,18 @@ import {api} from "./client";
*/
export const MODULE_NOT_FOUND = "MODULE_NOT_FOUND";
export const MODULE_NOT_INGESTED = "MODULE_NOT_INGESTED";
/**
* Item 115: the name matches several modules, so the server refused rather than merging them.
* Carries the candidate source files after the marker, separated by `\n`, so the banner can offer
* them without a second request.
*/
export const MODULE_AMBIGUOUS = "MODULE_AMBIGUOUS";
/** True when the error is the server saying this module cannot be analysed, not that a call failed. */
export function isModuleUnavailable(error: unknown): boolean {
return error instanceof Error
&& (error.message === MODULE_NOT_FOUND || error.message === MODULE_NOT_INGESTED);
&& (error.message === MODULE_NOT_FOUND || error.message === MODULE_NOT_INGESTED
|| error.message.startsWith(MODULE_AMBIGUOUS));
}
/**
@@ -31,12 +38,27 @@ export function isModuleUnavailable(error: unknown): boolean {
*
* Mirrors the `STALE_SOURCE` handling in {@link useModuleSource}, which predates this.
*/
function moduleError(status: number, fallback: string): Error {
function moduleError(status: number, body: unknown, fallback: string): Error {
if (status === 404) return new Error(MODULE_NOT_FOUND);
if (status === 409) return new Error(MODULE_NOT_INGESTED);
if (status === 409) {
// Two different 409s now share the status, so the body's `code` decides. Defaulting to
// NOT_INGESTED preserves the item-107 behaviour for every response that predates item 115.
const err = body as { code?: string; details?: { candidates?: string[] } } | undefined;
if (err?.code === "AMBIGUOUS_NAME") {
const candidates = err.details?.candidates ?? [];
return new Error([MODULE_AMBIGUOUS, ...candidates].join("\n"));
}
return new Error(MODULE_NOT_INGESTED);
}
return new Error(fallback);
}
/** @return the candidate source files carried by a {@link MODULE_AMBIGUOUS} error. */
export function ambiguousCandidates(error: unknown): string[] {
if (!(error instanceof Error) || !error.message.startsWith(MODULE_AMBIGUOUS)) return [];
return error.message.split("\n").slice(1);
}
export function useProjects() {
return useQuery({
queryKey: ["projects"],
@@ -81,7 +103,7 @@ export function useModuleContext(project: string | undefined, name: string | und
const {data, error, response} = await api.GET("/api/projects/{project}/modules/{name}/context", {
params: {path: {project: project!, name: name!}},
});
if (error) throw moduleError(response.status, "Failed to load module context");
if (error) throw moduleError(response.status, error, "Failed to load module context");
return data;
},
});
@@ -96,7 +118,7 @@ export function useModuleFunctions(project: string | undefined, name: string | u
const {data, error, response} = await api.GET("/api/projects/{project}/modules/{name}/functions", {
params: {path: {project: project!, name: name!}},
});
if (error) throw moduleError(response.status, "Failed to load functions");
if (error) throw moduleError(response.status, error, "Failed to load functions");
return data;
},
});
@@ -153,7 +175,7 @@ export function useCalls(project: string | undefined, name: string | undefined,
dir === "callers"
? await api.GET("/api/projects/{project}/modules/{name}/callers", {params})
: await api.GET("/api/projects/{project}/modules/{name}/callees", {params});
if (error) throw moduleError(response.status, `Failed to load ${dir}`);
if (error) throw moduleError(response.status, error, `Failed to load ${dir}`);
return data;
},
});
@@ -172,7 +194,7 @@ export function useInternalCallees(project: string | undefined, module: string |
const {data, error, response} = await api.GET("/api/projects/{project}/modules/{name}/callees", {
params: {path: {project: project!, name: module!}, query: {scope: "internal"}},
});
if (error) throw moduleError(response.status, "Failed to load internal callees");
if (error) throw moduleError(response.status, error, "Failed to load internal callees");
return data;
},
});
@@ -197,7 +219,7 @@ export function useFunctionCallers(
"/api/projects/{project}/modules/{name}/functions/{function}/callers",
{params: {path: {project: project!, name: module!, function: fn!}}},
);
if (error) throw moduleError(response.status, "Failed to load function callers");
if (error) throw moduleError(response.status, error, "Failed to load function callers");
return data;
},
});
@@ -253,7 +275,7 @@ export function useModulePayload(project: string | undefined, name: string | und
const {data, error, response} = await api.GET("/api/projects/{project}/modules/{name}/payload", {
params: {path: {project: project!, name: name!}},
});
if (error) throw moduleError(response.status, "Failed to load payload");
if (error) throw moduleError(response.status, error, "Failed to load payload");
return data;
},
});
@@ -267,7 +289,7 @@ export function useModuleDataStructures(project: string | undefined, name: strin
const {data, error, response} = await api.GET("/api/projects/{project}/modules/{name}/data-structures", {
params: {path: {project: project!, name: name!}},
});
if (error) throw moduleError(response.status, "Failed to load data structures");
if (error) throw moduleError(response.status, error, "Failed to load data structures");
return data;
},
});
@@ -296,7 +318,7 @@ export function useDbAccesses(project: string | undefined, name: string | undefi
const {data, error, response} = await api.GET("/api/projects/{project}/modules/{name}/db-accesses", {
params: {path: {project: project!, name: name!}},
});
if (error) throw moduleError(response.status, "Failed to load DB accesses");
if (error) throw moduleError(response.status, error, "Failed to load DB accesses");
return data;
},
});
@@ -310,7 +332,7 @@ export function useSqlStatements(project: string | undefined, name: string | und
const {data, error, response} = await api.GET("/api/projects/{project}/modules/{name}/sql-statements", {
params: {path: {project: project!, name: name!}},
});
if (error) throw moduleError(response.status, "Failed to load SQL statements");
if (error) throw moduleError(response.status, error, "Failed to load SQL statements");
return data;
},
});
@@ -324,7 +346,7 @@ export function useDispatchTable(project: string | undefined, name: string | und
const {data, error, response} = await api.GET("/api/projects/{project}/modules/{name}/dispatch-table", {
params: {path: {project: project!, name: name!}},
});
if (error) throw moduleError(response.status, "Failed to load dispatch table");
if (error) throw moduleError(response.status, error, "Failed to load dispatch table");
return data;
},
});
@@ -471,7 +493,7 @@ export function useImpactCallers(project: string | undefined, name: string | und
const {data, error, response} = await api.GET("/api/projects/{project}/modules/{name}/graph", {
params: {path: {project: project!, name: name!}, query: {direction: "in", depth: 10, limit: 500}},
});
if (error) throw moduleError(response.status, "Failed to load impact");
if (error) throw moduleError(response.status, error, "Failed to load impact");
return data;
},
});
@@ -491,6 +513,6 @@ export async function fetchEgoGraph(
const {data, error, response} = await api.GET("/api/projects/{project}/modules/{name}/graph", {
params: {path: {project, name}, query: {direction, depth, limit}},
});
if (error) throw moduleError(response.status, "Failed to load graph");
if (error) throw moduleError(response.status, error, "Failed to load graph");
return data;
}

View File

@@ -1,4 +1,4 @@
import {MODULE_NOT_INGESTED} from "../api/hooks";
import {ambiguousCandidates, MODULE_AMBIGUOUS, MODULE_NOT_INGESTED} from "../api/hooks";
/**
* Item 107: the honest empty state for a module the server cannot analyse.
@@ -14,6 +14,25 @@ import {MODULE_NOT_INGESTED} from "../api/hooks";
*/
export function ModuleUnavailable({moduleName, error}: { moduleName: string; error: unknown }) {
const notIngested = error instanceof Error && error.message === MODULE_NOT_INGESTED;
const ambiguous = error instanceof Error && error.message.startsWith(MODULE_AMBIGUOUS);
if (ambiguous) {
// Item 115: listing the candidates is the whole point — a bare "ambiguous" would leave the
// reader with no way forward, which is barely better than the silent merge it replaced.
const candidates = ambiguousCandidates(error);
return (
<div className="rounded border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-800
dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">
<span className="font-semibold">
{candidates.length} modules are named {moduleName}.
</span>{" "}
The name alone does not identify one, so nothing is shown rather than a merge of all of
them. Pick one:
<ul className="mt-1 list-disc pl-4 font-mono">
{candidates.map((c) => <li key={c}>{c}</li>)}
</ul>
</div>
);
}
return (
<div
className={

View File

@@ -38,6 +38,11 @@ below. Nothing else is in your write scope.
- `404 NODE_NOT_FOUND` / `404 MODULE_NOT_FOUND` — unknown id / module name.
Every `/modules/{name}/…` endpoint checks this: an unknown module name is a
`404`, never an empty `200`.
- `409 AMBIGUOUS_NAME` — several real modules share this name (ordinary in
Java: nested `@Nested` classes, `Builder`, `WorkingStorage`). `details.candidates`
lists their source files; repeat the request with `?sourceFile=<one of them>`
(`--source-file` in the CLI). Do **not** read this as "not found": the module
exists several times over. Natural names are unique, so this cannot occur there.
- `409 NOT_DEEPLY_INGESTED` / `409 NOT_INGESTED` — the module needs a
per-module deep ingest (`nextAction` names the endpoint). That's **out of
your read-only scope** — report it, don't call it. Not the same as "no

View File

@@ -106,8 +106,28 @@ graph knows three states and each now gets its own status:
|-------------------------------------------------------------------------------|---------------------------------------------------------------|------------------------------------------------------------------------|
| no `MODULE` node for the name | `404 MODULE_NOT_FOUND` | unknown name (typo, or not in this project) |
| **placeholder** — node exists because something calls it, source never parsed | `409` + `{status:"NOT_INGESTED", module, detail, nextAction}` | knowable in principle, not analysed yet |
| **ambiguous** — several real modules share the name (Java) | `409` + `{code:"AMBIGUOUS_NAME", details:{candidates:[...]}}` | the name does not identify a module; pick one |
| real, ingested module | `200` | the body is the answer — an empty body genuinely means "nothing found" |
**Ambiguous names (item 115).** A Java simple name is not unique: nested `@Nested` test classes,
`Builder`, `Config`, `WorkingStorage`. Measured on `pur`, **163 names covering 385 modules (~8%)** were
addressable only ambiguously, and the endpoints used to answer with the *union* across unrelated
classes — `/modules/BrokerHistoryTests/functions` returned 627 functions for a class that has 107.
They now refuse and list the candidates. Repeat the request with `?sourceFile=<candidate>`:
```
GET /modules/Shared/functions → 409 AMBIGUOUS_NAME, candidates ["a/Shared.java","b/Shared.java"]
GET /modules/Shared/functions?sourceFile=a/Shared.java → 200
```
The `ac` CLI takes `--source-file` on every module command. Natural is unaffected: colliding
identities are skipped at ingest, so its module names are unique by construction (`upms` has zero
ambiguous names, `pur` 163).
Two limits worth knowing. Ambiguous names *inside* a traversal (a call tree's neighbours) are still
merged — only the request's root module is disambiguated. And a name skipped at ingest as a duplicate
identity still answers `404`, not `409` (item 114).
The `409` applies to everything derived from the module's **own** source: `digest`, `context`,
`call-tree`, `callees`, `db-accesses`, `workfile-accesses`, `sql-statements`, `functions`,
`functions/overrides`, `functions/{fn}/overrides`, `functions/{fn}/callers`, `data-structures`,

View File

@@ -321,6 +321,197 @@ before. The parked "parallel parse phase" idea was implemented 2026-07-18 (item
## Known bugs
- [x] **115. Module endpoints address Java classes by simple name and silently merge distinct
classes that share one** (found 2026-08-06, `pur` source-vs-API cross-check; **done 2026-08-06**)
**Symptom.** `GET /modules/BrokerHistoryTests/functions` returns **627** functions. The class it
names has **107**. The other 520 belong to four unrelated classes:
```
MATCH (m:MODULE {project:'pur', name:'BrokerHistoryTests'}) RETURN m.sourceFile
→ HistoryCalculationServiceTest.java 84
HistoryComplexServiceTest.java 203
HistoryRecordPageServiceTest.java 128
HistoryRecordServiceTest.java 107
HistoryVariantServiceTest.java 105 = 627
```
Five distinct `@Nested` classes, one per test file, each legitimately its own module — correctly
ingested, correctly distinguished in the graph by `sourceFile`. The **API** collapses them, because
`/modules/{name}/…` matches on `name` alone and the path offers no way to disambiguate (verified
against the OpenAPI spec: the only parameters are `project` and `name`).
**Not a test-only problem.** `WorkingStorage` is six nested classes in six *production* files
(`LastAgentLoopLogic`, `AuthorizationCheckStandardProcessingLogic`, `InitializationService`, …).
Its `digest` reports all six enclosing classes as `CONSTRUCTOR` callers, reading as one shared class
constructed in six places when in truth each file has its own.
**Scale in `pur`: 163 ambiguous names covering 385 modules — ~8% of all 4738 modules.** Worst cases
collide 7-fold (`ProcessTests`, `VermittlerstammTests`). Java makes this normal: nested test classes,
`Builder`, `Config`, `Handler`, `WorkingStorage`.
**Why it matters.** This is worse than a wrong count. Every derived answer — callers, callees,
db-accesses, call-tree — is a union across unrelated classes, presented with no indication that a
merge happened. An agent asking "who uses `WorkingStorage`?" gets six answers for six different
classes as though they were one, and cannot tell.
**Solution sketch.**
1. *Make ambiguity visible before making it addressable.* When a name resolves to more than one
module, answer `409 AMBIGUOUS_NAME` with the candidate `sourceFile`s in `details` rather than
silently unioning. This is the same shape as the 107 fix and stops wrong answers immediately,
at the cost of breaking queries that currently "work".
2. *Then make it addressable.* Accept an optional `sourceFile` (or fully-qualified name) query
parameter on the module endpoints to pick one candidate; `search/identifier` already returns the
`sourceFile` per hit, so an agent has what it needs to disambiguate in one extra call.
3. Natural is unaffected in practice — its module names are file-stem-unique by language
convention, and genuine collisions are already handled (skipped) by the duplicate detection; see
[114], which is the *other* half of this problem.
Not yet decided: whether Java modules should carry the FQN as `name` instead. That would fix
addressing at the root but changes every response body and the whole UI, so it needs its own
proposal rather than being folded in here.
**Delivered.** Both halves together — the guard alone would have made 385 modules in `pur` and 326
in `app` unreachable by name, trading one wrong answer for another.
- `ModuleIngestState` carries `candidates` (the real, non-placeholder `sourceFile`s for the name) and
`ambiguous()`. `MODULE_INGEST_STATE` returns them and honours a `$sourceFile` filter.
- `withModule`/`withIngestedModule` (the item-107 guards) answer `409 AMBIGUOUS_NAME` with the
candidates in `details`. **Guard order matters**: absent → ambiguous → placeholder. A placeholder is
never a candidate, so one real module beside a placeholder is not ambiguous and `#SUBPROGRAM`-style
references keep answering `NOT_INGESTED`.
- `?sourceFile=` on the module endpoints, threaded through **18 Cypher queries** and the repository.
- `ac --source-file` on all 17 module commands, via a picocli `@Mixin` whose `append()` picks `?` or
`&` — several commands add their own parameters after the base path and a fixed `?` emitted two.
- UI: `409` no longer maps blindly to `NOT_INGESTED`; the body's `code` decides, and the banner lists
the candidates. Listing them is the point — a bare "ambiguous" is barely better than the merge.
**Two corrections worth keeping.** First, a line-based inventory found 27 affected Cypher sites; a
block-based one found **33** — the same grep-instead-of-dataflow error as item 111d-1, 20% low.
Second, `EGO_NEIGHBORS_IN/OUT` were patched and then **un**patched: they are keyed on the BFS
frontier's name, not the root's, so filtering them by the root's `sourceFile` would have broken
traversal. Ambiguous *neighbour* names still merge inside a call tree — the same scope boundary
item 107 has, and it is not closed here.
**The runtime hazard predicted in review actually fired.** Cypher rejects an unbound parameter at
runtime, not compile time; `moduleFunctions` built its parameters as a hand-rolled `HashMap` (because
`kind` is nullable and `Map.of` rejects nulls), so it was the one site the central `moduleParams`
helper did not cover, and it returned `500`. Caught by `AmbiguousModuleIT`, which asserts a real
selection (1 method vs 2) rather than just a `200`.
- [x] **116. A method call on a field the calling class does not declare produces no edge** (found
2026-08-06, `pur` source-vs-API cross-check; **done 2026-08-06**)
**Symptom.** `HistoryRecordService` (53 methods, exercised by 185 `@Test` methods) reports
**`callers: {}`** — nobody calls it. The tests call it constantly:
```java
class HistoryRecordServiceTest {
private HistoryRecordService service; // line 33, outer class
@Nested class BrokerHistoryTests {
… service.applyControlCardFilters(…) // line 50, inner class → no edge
```
Neither the class nor the invoked method names appear among `BrokerHistoryTests`' callees; only
receivers that are type names (static calls, constructors) resolve.
**Cause, narrowed by counter-example.** Instance-field calls resolve correctly *within* one class:
`AbstractPartnerLogic.partnerRepository.findFirstByHistorySpOptional(…)` yields
`IPartnerRepository`, confirmed in both directions (33 callers). The failure is specific to the
field being declared in the **lexically enclosing** class — field-type resolution does not cross
that boundary.
**Why it matters.** `@Nested` is the standard JUnit 5 layout, so an entire codebase's test →
production call graph can be missing while the API reports it as empty rather than unknown. "Which
tests cover this service?" and "is this method still used?" both answer wrongly, in the direction
that looks like a clean result.
**The original write-up was too narrow, and the counter-example that produced it did not hold.**
It read as an enclosing-class problem because `AbstractPartnerLogic.partnerRepository.findFirst(…)`
resolved correctly — but that class *declares* the field. `PartnerCommonLogic`, which inherits it,
produces no edge either: the `IPartnerRepository` edge there is `INJECTS` (propagated by
`LINK_INJECTS_TO_SUBCLASSES`), not `METHOD_CALL`. The real rule is **any field not declared in this
very type**, and it splits by what the parser can see:
**116a — lexically enclosing types (done).** `JavaParser` builds `fieldTypes` from the
`findAncestor(ClassOrInterfaceDeclaration)` chain, applied outermost-first so an inner class's own
field shadows an enclosing one. Purely in-file, so no enrichment involved. This is the `@Nested`
case — the standard JUnit 5 layout, which had been costing the entire test→production call graph.
**116b — inherited fields (done).** The parser reads one file and cannot know a supertype's fields,
so it no longer *drops* the call: it records the receiver's identifier on a `field:<name>`
placeholder edge. The new `resolve-inherited-field-receivers` enrichment step walks the `EXTENDS`/
`IMPLEMENTS` chain the graph does know, finds the declaring field, normalises its declared type
(generics and package stripped in Cypher, since modules are keyed on the simple name) and re-points
the edge. `delete-unresolved-field-receivers` then removes the scaffolding, so `field:*` never
reaches the module namespace. Ordered **before** `link-calls-to-implementations`, so a recovered
edge is still eligible for the polymorphic fan-out.
**A bug the test caught that review did not.** The resolver first ended with a global
`... ORDER BY target.sourceFile DESC LIMIT 1`, which collapses the *whole query* to one row — exactly
one marker edge resolved per project. On a one-level hierarchy that looks like success. The
two-hop assertion in `InheritedFieldCallIT` failed and exposed it; the fix collapses per marker edge
via `collect()`.
Still open: receivers that are neither locals, own fields, enclosing fields nor inherited fields —
static imports and chained calls. Those markers are cleaned up rather than resolved.
- [ ] **114. A module skipped as a duplicate identity is indistinguishable from one that does not
exist, and caller/callee lists drop it without a word** (found 2026-08-06, `upms` source-vs-API
cross-check; same failure class as 107, one level deeper)
**Symptom.** `GET /modules/USIX045N/callers` returns two callers. The source has three: the call in
`generated_src/subprogram/JE999.nat:321` is uncommented and real. `JE999` is not in the graph at
all — `digest` → `404 MODULE_NOT_FOUND`, `search/identifier?name=JE999` → `[]`. The `callers`
response carries no hint that anything was left out.
**Cause — a deliberate decision with an undocumented consequence.** `JE999` has two colliding
identities, and `ingestRoot` filters every conflicting path out of `toPersist` rather than silently
picking one file:
```
MODULE JE999 generated_src/subprogram/JE999.nat vs src/manual/program/JE999.nat
DATA_STRUCTURE JE999 …/local_data_area/new/JE999.lda vs …/parameter_data_area/new/JE999.pda
```
Skipping is right — picking arbitrarily would be worse. Verified as the only colliding stem in the
whole walked tree, in both categories, which matches the item-112/113 completion line exactly:
`files=6315, persisted=6311, duplicates=2` — two identities, four skipped files.
**Why it matters.** It creates a third module state that 107 does not model, and reports it as the
first:
| graph state | answer today | truth |
|---|---|---|
| no node at all | `404 MODULE_NOT_FOUND` | correct |
| placeholder (`sourceFile = ""`) | `409 NOT_INGESTED` | correct |
| **skipped as duplicate** | **`404 MODULE_NOT_FOUND`** | exists **twice**, deliberately not ingested |
Worse, the answer is not even stable: a duplicate-skipped module that something *references* gets a
placeholder and answers `409`, while an unreferenced one answers `404`. Nothing calls `JE999`, which
is why it vanished entirely. The same cause produces two different HTTP answers.
The duplicate list *is* computed — it rides in the `IngestSummary` of the ingest/refresh response —
but it is unreachable afterwards: `Duplicate` appears in the OpenAPI schema only inside that
response, and no endpoint exposes it. After a 17-minute deep refresh nobody has that body.
**Solution sketch — two genuinely separate problems; the second is the hard one.**
1. *Make the state visible.* Persist a marker node for each skipped identity (a placeholder carrying
the conflicting paths), so the module endpoints answer `409 DUPLICATE_IDENTITY` with the paths in
`details` instead of `404`. Add `GET /api/projects/{p}/duplicates` so the set is queryable after
the fact rather than only in an ingest response. This alone fixes the misleading *status*.
2. *The missing edges stay missing.* A marker node does **not** restore the `JE999 → USIX045N`
`CALLNAT` edge: that edge only exists if `JE999`'s body is parsed, and it was not. So
`USIX045N/callers` would still be short one entry, just no longer silently. Closing that needs a
decision the graph cannot make alone — e.g. a configurable precedence (`src/manual` over
`generated_src`) that ingests one side and flags the module ambiguous. That is a design question,
not a bug fix, and must not be smuggled in with step 1.
Scope note: fixing only step 1 leaves caller lists incomplete. That is still a strict improvement —
an agent can see that a duplicate exists and go read the sources — but the roadmap entry must not
imply the class of bug is closed, exactly as 107's scope note does.
- [x] **107. Every module endpoint answers `200` with an empty shell for a module that does not exist —
indistinguishable from a real but empty module** (found 2026-08-02, `upms` webservice-layer audit;
contradicts the documented `404 MODULE_NOT_FOUND`) — **done 2026-08-05**