Return 409 NOT_INGESTED

This commit is contained in:
Ingo Schnabel
2026-08-05 12:19:59 +02:00
parent 2c56eea161
commit ea654d0ce5
18 changed files with 1217 additions and 228 deletions

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=151
version=156

View File

@@ -114,8 +114,8 @@ public class AnalysisResource {
}
private static Response deepIngestRequired(String project, String module, ModuleIngestState state) {
String status = state.exists() ? "NOT_DEEPLY_INGESTED" : "NOT_INGESTED";
String detail = state.exists()
String status = state.ingested() ? "NOT_DEEPLY_INGESTED" : "NOT_INGESTED";
String detail = state.ingested()
? "Module '" + module + "' has only its call graph ingested; field-level dataflow requires a deep ingest."
: "Module '" + module + "' is not ingested in project '" + project + "'. Ingest the call graph and/or deep-ingest it.";
return Response.status(Response.Status.CONFLICT)
@@ -195,32 +195,23 @@ public class AnalysisResource {
return offset != null ? Math.max(offset, 0) : 0;
}
@GET
@Path("/modules/{name}/sql-statements")
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = SqlStatement.class)))
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> sqlStatements(@PathParam("project") String project, @PathParam("name") String name,
@QueryParam("depth") @Nullable Integer depth) {
return withProject(project, () -> {
if (depth != null && depth > 0) {
return graphRepository.sqlStatementsTransitive(project, name, depth).map(this::ok);
}
return graphRepository.sqlStatements(project, name).map(this::ok);
});
private static Response moduleNotFound(String project, String name) {
return ProjectResource.error(Response.Status.NOT_FOUND, "MODULE_NOT_FOUND",
"No module '" + name + "' in project '" + project + "'");
}
@GET
@Path("/modules/{name}/context")
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = ModuleContext.class)))
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.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) {
int effectiveLimit = limit != null ? Math.max(limit, 0) : 0;
int effectiveOffset = offset != null ? Math.max(offset, 0) : 0;
return withProject(project, () -> graphRepository.moduleContext(project, name, include, effectiveLimit, effectiveOffset)
.map(context -> Response.ok(context).build()));
/**
* 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.
*/
private static Response notIngested(String project, String name) {
return Response.status(Response.Status.CONFLICT)
.entity(new DeepIngestRequired("NOT_INGESTED", name,
"Module '" + name + "' is referenced by project '" + project + "' but its source is not "
+ "ingested — any result here would be empty because nothing was analysed, not "
+ "because nothing was found. Use /callers to see who references it.",
new NextAction("POST", "/api/projects/" + project + "/ingest/" + name)))
.build();
}
private static List<NameRef> callRefNames(CallRefResponse resp) {
@@ -406,47 +397,82 @@ public class AnalysisResource {
return withProject(project, () -> graphRepository.dbTableColumns(project, name).map(this::ok));
}
@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)))
public Uni<Response> sqlStatements(@PathParam("project") String project, @PathParam("name") String name,
@QueryParam("depth") @Nullable Integer depth) {
return withIngestedModule(project, name, () -> {
if (depth != null && depth > 0) {
return graphRepository.sqlStatementsTransitive(project, name, depth).map(this::ok);
}
return graphRepository.sqlStatements(project, name).map(this::ok);
});
}
@GET
@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)))
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) {
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)
.map(context -> Response.ok(context).build()));
}
@GET
@Path("/modules/{name}/columns")
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = EntityColumn.class)))
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.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 withProject(project, () -> graphRepository.entityColumns(project, name).map(this::ok));
return withIngestedModule(project, name, () -> graphRepository.entityColumns(project, name).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 not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.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 withProject(project, () -> graphRepository.moduleDataStructures(project, name).map(this::ok));
return withIngestedModule(project, name, () -> graphRepository.moduleDataStructures(project, name).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 not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.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 withProject(project, () -> graphRepository.payload(project, name).map(this::ok));
return withIngestedModule(project, name, () -> graphRepository.payload(project, name).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 not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.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 withProject(project, () -> graphRepository.dispatchTable(project, name).map(this::ok));
return withIngestedModule(project, name, () -> graphRepository.dispatchTable(project, name).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 not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.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> moduleFunctions(@PathParam("project") String project, @PathParam("name") String name,
@QueryParam("includeInherited") @Nullable Boolean includeInherited,
@QueryParam("kind") @Nullable String kind) {
boolean inherited = includeInherited != null && includeInherited;
return withProject(project, () -> graphRepository.moduleFunctions(project, name, inherited, kind).map(this::ok));
return withIngestedModule(project, name, () -> graphRepository.moduleFunctions(project, name, inherited, kind).map(this::ok));
}
/**
@@ -457,33 +483,10 @@ public class AnalysisResource {
@GET
@Path("/modules/{name}/functions/overrides")
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = BulkFunctionOverride.class)))
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.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 withProject(project, () -> graphRepository.functionOverrides(project, name).map(this::ok));
}
@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 not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> functionOverrides(@PathParam("project") String project, @PathParam("name") String name,
@PathParam("function") String function) {
return withProject(project, () -> graphRepository.functionOverrides(project, name, function).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
* module-granularity {@code /callers}.
*/
@GET
@Path("/modules/{name}/functions/{function}/callers")
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = CallRefResponse.class)))
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> functionCallers(@PathParam("project") String project, @PathParam("name") String name,
@PathParam("function") String function) {
return withProject(project, () -> graphRepository.functionCallers(project, name, function)
.map(resp -> Response.ok(resp).build()));
return withIngestedModule(project, name, () -> graphRepository.functionOverrides(project, name).map(this::ok));
}
private static List<NameRef> identifierNames(List<IdentifierMatch> matches) {
@@ -558,12 +561,13 @@ public class AnalysisResource {
}
@GET
@Path("/modules/{name}/digest")
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = ModuleDigest.class)))
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> moduleDigest(@PathParam("project") String project, @PathParam("name") String name) {
return withProject(project, () -> graphRepository.moduleDigest(project, name)
.map(digest -> Response.ok(digest).build()));
@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
@@ -578,16 +582,43 @@ public class AnalysisResource {
AnalysisResource::stepModules);
}
/**
* 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
* module-granularity {@code /callers}.
*/
@GET
@Path("/modules/{name}/functions/{function}/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)))
@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)
.map(resp -> Response.ok(resp).build()));
}
@GET
@Path("/modules/{name}/digest")
@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)
.map(digest -> Response.ok(digest).build()));
}
@GET
@Path("/modules/{name}/callees")
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = CallRefResponse.class)))
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.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 it has no known callees.", content = @Content(schema = @Schema(implementation = DeepIngestRequired.class)))
public Uni<Response> callees(@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("resolveInterfaces") @Nullable Boolean resolveInterfaces) {
return withProject(project, () -> graphRepository.callees(project, name, scope, effectiveLimit(limit), effectiveOffset(offset),
return withIngestedModule(project, name, () -> graphRepository.callees(project, name, scope, effectiveLimit(limit), effectiveOffset(offset),
resolveInterfaces != null && resolveInterfaces)
.map(resp -> namesOnly(fields)
? ok(callRefNames(resp))
@@ -597,13 +628,14 @@ public class AnalysisResource {
@GET
@Path("/modules/{name}/db-accesses")
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = DbAccess.class)))
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.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> dbAccesses(@PathParam("project") String project, @PathParam("name") String name,
@QueryParam("depth") @Nullable Integer depth,
@QueryParam("limit") @Nullable Integer limit, @QueryParam("offset") @Nullable Integer offset) {
int effLimit = uncappedLimit(limit);
int effOffset = effectiveOffset(offset);
return withProject(project, () -> {
return withIngestedModule(project, name, () -> {
if (depth != null && depth > 0) {
return graphRepository.dbAccessesTransitive(project, name, depth, effLimit, effOffset).map(this::ok);
}
@@ -614,77 +646,32 @@ public class AnalysisResource {
@GET
@Path("/modules/{name}/workfile-accesses")
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = WorkfileAccess.class)))
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.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> workfileAccesses(@PathParam("project") String project, @PathParam("name") String name,
@QueryParam("limit") @Nullable Integer limit, @QueryParam("offset") @Nullable Integer offset) {
int effLimit = uncappedLimit(limit);
int effOffset = effectiveOffset(offset);
return withProject(project, () -> graphRepository.workfileAccesses(project, name, effLimit, effOffset).map(this::ok));
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 not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.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 withFanoutWarm(project,
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());
}
/**
* 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.
*/
@GET
@Path("/modules/{name}/callers")
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = CallRefResponse.class)))
@APIResponse(responseCode = "404", description = "Project 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 withFanoutWarm(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",
description = "Item 49: a bounded call-graph neighbourhood (nodes + edges) around one module, "
+ "for the web UI's interactive sub-graph. Module-granularity; keyed on name+sourceFile.")
@APIResponse(responseCode = "200", description = "The bounded neighbourhood.",
content = @Content(schema = @Schema(implementation = EgoGraphResponse.class)))
@APIResponse(responseCode = "404", description = "Project not found.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> egoGraph(@PathParam("project") String project, @PathParam("name") String name,
@Parameter(description = "Max call hops from the module (clamped to the call-tree max).")
@QueryParam("depth") @Nullable Integer depth,
@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) {
int effectiveDepth = depth != null ? depth : defaultCallTreeDepth;
String dir = direction != null ? direction : "out";
int effectiveLimit = limit != null ? limit : DEFAULT_PAGE_LIMIT;
return withProject(project, () -> graphRepository.egoGraph(project, name, effectiveDepth, dir, effectiveLimit)
.map(resp -> Response.ok(resp).build()));
resp -> namesOnly(fields) ? ok(callTreeNames(resp)) : Response.ok(resp).build()));
}
@GET
@@ -798,28 +785,34 @@ 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}/source")
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = SourceSnippet.class)))
@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) {
// 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;
if (!wholeFile && (startLine == null || endLine == null)) {
return Uni.createFrom().item(ProjectResource.error(Response.Status.BAD_REQUEST, "MISSING_LINE_RANGE",
"Supply both 'startLine' and 'endLine', or neither (whole file)"));
}
int sl = startLine != null ? startLine : 1;
int el = endLine != null ? endLine : Integer.MAX_VALUE;
return withProject(project, () -> graphRepository.moduleSourceFile(project, name).flatMap(sourceFile -> sourceFile == null
? Uni.createFrom().item(ProjectResource.error(Response.Status.NOT_FOUND, "MODULE_NOT_FOUND",
"No module '" + name + "' in project '" + project + "'"))
: wholeFile
? sourceSnippetWhole(project, sourceFile)
: sourceSnippet(project, sourceFile, sl, el)));
@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) {
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()));
}
@GET
@@ -860,6 +853,104 @@ public class AnalysisResource {
"Project '" + project + "' does not exist")));
}
@GET
@Path("/modules/{name}/graph")
@Operation(summary = "Ego graph around a module",
description = "Item 49: a bounded call-graph neighbourhood (nodes + edges) around one module, "
+ "for the web UI's interactive sub-graph. Module-granularity; keyed on name+sourceFile.")
@APIResponse(responseCode = "200", description = "The bounded neighbourhood.",
content = @Content(schema = @Schema(implementation = EgoGraphResponse.class)))
@APIResponse(responseCode = "404", description = "Project or module not found.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> egoGraph(@PathParam("project") String project, @PathParam("name") String name,
@Parameter(description = "Max call hops from the module (clamped to the call-tree max).")
@QueryParam("depth") @Nullable Integer depth,
@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) {
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)
.map(resp -> Response.ok(resp).build()));
}
@GET
@Path("/modules/{name}/source")
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = SourceSnippet.class)))
@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) {
// 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;
if (!wholeFile && (startLine == null || endLine == null)) {
return Uni.createFrom().item(ProjectResource.error(Response.Status.BAD_REQUEST, "MISSING_LINE_RANGE",
"Supply both 'startLine' and 'endLine', or neither (whole file)"));
}
int sl = startLine != null ? startLine : 1;
int el = endLine != null ? endLine : Integer.MAX_VALUE;
// 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
? Uni.createFrom().item(moduleNotFound(project, name))
: wholeFile
? sourceSnippetWhole(project, sourceFile)
: sourceSnippet(project, sourceFile, sl, el)));
}
/**
* Item 107: runs {@code action} only if the project exists <em>and</em> the graph knows a
* {@code MODULE} node for {@code name} — real or placeholder — otherwise
* {@code 404 MODULE_NOT_FOUND}.
*
* <p>Before this guard every {@code /modules/{name}/…} endpoint answered {@code 200} with an
* all-zeros shell for a name typed at random, byte-identical to a real module's answer. That is
* not merely an ugly response: an agent asking "does this module reach the commission
* calculation?" read the empty {@code 200} as <em>"analysed, nothing found"</em> when the truth
* was <em>"not analysable"</em>.
*
* <p>Use this for endpoints whose answer is derived from the <em>calling</em> modules' source and
* is therefore genuine even for a placeholder ({@code /callers}, {@code /graph}). Endpoints that
* 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))));
}
/**
* Item 107: like {@link #withModule}, but additionally rejects an unresolved <b>placeholder</b>
* module (a node that exists only because something calls it — its source was never parsed) with
* {@code 409 NOT_INGESTED} and an actionable {@code nextAction}.
*
* <p>For such a module everything derived from its own source — functions, DB accesses, data
* structures, dispatch table, callees, call tree — is structurally empty, so a {@code 200} would
* be exactly the "looks complete but isn't" answer this item exists to remove. Nothing is lost by
* refusing: the deep-ingest warm on {@code call-tree}/{@code callers} is seeded from the source
* 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();
}));
}
/**
* Looks up {@code sourceFile}'s stored content hash (item 41), then reads its {@code [startLine,
* endLine]} slice with a stale check (see {@link #withSnippet}).
@@ -944,9 +1035,21 @@ public class AnalysisResource {
private <T> Uni<Response> withFanoutWarm(String project, Supplier<Uni<T>> query,
Function<T, Collection<String>> surfaced,
Function<T, Response> render) {
return withProject(project, () -> query.get().flatMap(first ->
return withProject(project, () -> fanoutWarm(project, query, surfaced, render));
}
/**
* The guard-free body of {@link #withFanoutWarm}. Split out for item 107 so a module-scoped
* endpoint can wrap the same warm-and-rerun logic in {@link #withModule} /
* {@link #withIngestedModule} instead of the project-only guard — and so the module check runs
* <b>once, before</b> the first query rather than again on the warm-triggered re-run.
*/
private <T> Uni<Response> fanoutWarm(String project, Supplier<Uni<T>> query,
Function<T, Collection<String>> surfaced,
Function<T, Response> render) {
return query.get().flatMap(first ->
deepIngestCoordinator.ensureDeepMany(project, surfaced.apply(first)).flatMap(changed ->
changed ? query.get().map(render) : Uni.createFrom().item(render.apply(first)))));
changed ? query.get().map(render) : Uni.createFrom().item(render.apply(first))));
}
/**

View File

@@ -375,7 +375,7 @@ public class DeepIngestCoordinator {
if (state.isFull()) {
return;
}
if (!state.exists()) {
if (!state.ingested()) {
// No coarse node to carry a durable status; the monitor still prevents an intra-JVM
// duplicate. Ingest best-effort and stop.
ingestClaimed(project, module);

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=151
agenticcode.version=156
# 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

@@ -71,7 +71,7 @@ class DeepIngestStatusIT {
void durableIngestStatusLifecycle() {
// After a call-graph ingest the module exists but is not deeply ingested.
ModuleIngestState initial = state();
assertTrue(initial.exists(), "call-graph ingest should create a real module node");
assertTrue(initial.ingested(), "call-graph ingest should create a real module node");
assertFalse(initial.isFull(), "call-graph ingest is not a deep (FULL) ingest");
assertEquals(IngestStatus.NOT_INGESTED.name(), initial.status());

View File

@@ -0,0 +1,192 @@
package com.agenticcode.codeserver.api;
import com.agenticcode.neo4jstore.graph.GraphRepository;
import com.agenticcode.neo4jstore.graph.ModuleIngestState;
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.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
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.equalTo;
import static org.hamcrest.Matchers.hasItem;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Item 107: a {@code /modules/{name}/…} endpoint must never invent an empty module.
*
* <p>Before this guard, a name typed at random and a real-but-empty module produced byte-identical
* {@code 200}s, so an agent read <em>"not analysable"</em> as <em>"analysed, nothing found"</em>.
* The graph knows three states and each now gets its own answer:
* <ul>
* <li>absent → {@code 404 MODULE_NOT_FOUND}</li>
* <li>unresolved placeholder (referenced by a caller, source never parsed) →
* {@code 409 NOT_INGESTED}, except on {@code /callers} and {@code /graph}, whose data comes
* from the <em>calling</em> modules and is genuine</li>
* <li>real, ingested module → {@code 200} (regression guard: the guards must not break the
* happy path)</li>
* </ul>
*/
@QuarkusTest
class ModuleNotFoundIT {
private static final String PROJECT = "item107-module-not-found";
/**
* A real, parsed module.
*/
private static final String REAL = "MNF_REAL";
/**
* Referenced by {@link #REAL} but never present on disk — the graph holds only a placeholder.
*/
private static final String GHOST = "MNF_GHOST";
/**
* Not in the graph at all.
*/
private static final String ABSENT = "NOSUCHMOD123";
@TempDir
static Path root;
@Inject
GraphRepository graphRepository;
/**
* Endpoints whose answer is derived from the module's <b>own</b> source: {@code 404} when the
* module is absent, {@code 409} when it is a placeholder.
*/
static List<String> sourceDependentEndpoints() {
return List.of("digest", "context", "call-tree", "callees", "db-accesses", "workfile-accesses",
"sql-statements", "functions", "functions/overrides", "functions/SOME-FN/overrides",
"functions/SOME-FN/callers", "data-structures", "dispatch-table", "payload", "columns");
}
/**
* Endpoints answerable from the <b>calling</b> modules' source: {@code 404} when the module is
* absent, but {@code 200} for a placeholder — the data is real.
*/
static List<String> callerDerivedEndpoints() {
return List.of("callers", "graph");
}
@BeforeAll
static void createProject() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
// MNF_REAL calls MNF_GHOST, whose source is deliberately absent from the root: the call-graph
// enrichment resolves the CALLNAT onto a placeholder MODULE node (sourceFile = "").
write(REAL + ".nat", """
DEFINE DATA
LOCAL
1 #CLIENT (A8)
END-DEFINE
*
CALLNAT '""" + GHOST + """
' #CLIENT
*
END
""");
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
.when().post("/api/projects/" + PROJECT)
.then().statusCode(201);
}
private static void write(String fileName, String content) {
try {
Files.write(root.resolve(fileName), content.getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private static String path(String module, String endpoint) {
return "/api/projects/" + PROJECT + "/modules/" + module + "/" + endpoint;
}
/**
* The premise the placeholder cases rest on. Asserted separately so a change in how unresolved
* {@code CALLNAT} targets are stored fails here with a clear message, rather than silently
* turning every {@code 409} assertion below into a vacuous {@code 404}.
*/
@Test
void fixtureProducesTheThreeStates() {
ModuleIngestState real = state(REAL);
assertTrue(real.present(), REAL + " must be in the graph");
assertTrue(real.ingested(), REAL + " must be a real, parsed node");
assertFalse(real.placeholder(), REAL + " must not be a placeholder");
ModuleIngestState ghost = state(GHOST);
assertTrue(ghost.present(), GHOST + " must exist as a node — something calls it");
assertTrue(ghost.placeholder(), GHOST + " must be an unresolved placeholder (sourceFile = \"\")");
assertFalse(ghost.ingested(), GHOST + " source is absent, so it is not ingested");
assertFalse(state(ABSENT).present(), ABSENT + " must not be in the graph at all");
}
@ParameterizedTest
@MethodSource({"sourceDependentEndpoints", "callerDerivedEndpoints"})
void absentModuleIs404(String endpoint) {
given().when().get(path(ABSENT, endpoint))
.then().statusCode(404)
.body("code", equalTo("MODULE_NOT_FOUND"));
}
@ParameterizedTest
@MethodSource("sourceDependentEndpoints")
void placeholderModuleIs409(String endpoint) {
given().when().get(path(GHOST, endpoint))
.then().statusCode(409)
.body("status", equalTo("NOT_INGESTED"))
.body("module", equalTo(GHOST))
.body("nextAction.method", equalTo("POST"))
.body("nextAction.path", equalTo("/api/projects/" + PROJECT + "/ingest/" + GHOST));
}
@ParameterizedTest
@MethodSource("callerDerivedEndpoints")
void placeholderModuleStays200WhereTheDataIsReal(String endpoint) {
given().when().get(path(GHOST, endpoint)).then().statusCode(200);
}
/**
* The placeholder's callers are not merely {@code 200} — they carry the real answer, which is why
* this endpoint is exempt from the {@code 409} and why that response points callers here.
*/
@Test
void placeholderCallersCarryRealData() {
given().when().get(path(GHOST, "callers"))
.then().statusCode(200)
.body("items.name", hasItem(REAL));
}
@ParameterizedTest
@MethodSource({"sourceDependentEndpoints", "callerDerivedEndpoints"})
void realModuleStillAnswers200(String endpoint) {
given().when().get(path(REAL, endpoint)).then().statusCode(200);
}
/**
* An unknown project still wins over the module check — the outer guard runs first.
*/
@Test
void unknownProjectStillReportsProjectNotFound() {
given().when().get("/api/projects/no-such-project-107/modules/" + REAL + "/digest")
.then().statusCode(404)
.body("code", equalTo("PROJECT_NOT_FOUND"));
}
private ModuleIngestState state(String module) {
return graphRepository.moduleIngestState(PROJECT, module).await().indefinitely();
}
}

View File

@@ -65,7 +65,7 @@ class Tier1IndexIT {
void tier1ScanIndexesModulesReferencesAndIdentifiers() {
// Modules exist from the coarse scan alone, at the shallow (call-graph / not-ingested) tier.
ModuleIngestState caller = state("DF_CALLER");
assertTrue(caller.exists(), "Tier-1 scan creates a real module node on create");
assertTrue(caller.ingested(), "Tier-1 scan creates a real module node on create");
assertFalse(caller.isFull(), "Tier-1 is a coarse scan, not a deep (FULL) ingest");
assertEquals(IngestStatus.NOT_INGESTED.name(), caller.status(), "coarse-scanned module is NOT_INGESTED");

View File

@@ -1423,7 +1423,10 @@ public class GraphRepository {
/**
* @return the ingest state of module {@code name}: whether a real (non-placeholder) node exists
* and its {@link IngestDepth}. Used by query endpoints to tell an agent whether a deep ingest is
* still required before deep (field/DB/dataflow) data is available.
* still required before deep (field/DB/dataflow) data is available, and (item 107) to tell an
* absent module apart from an unresolved placeholder — see {@link ModuleIngestState}. A missing
* {@code sourceFile} column is normalised to {@code ""}, i.e. treated as a placeholder: the node
* exists, it just cannot be analysed.
*/
public Uni<ModuleIngestState> moduleIngestState(String project, String name) {
return Uni.createFrom().item(() -> {
@@ -1431,14 +1434,14 @@ public class GraphRepository {
return session.executeRead(tx -> {
var result = tx.run(CypherQueries.MODULE_INGEST_STATE, Map.of("project", project, "name", name));
if (!result.hasNext()) {
return new ModuleIngestState(false, null, null, null);
return new ModuleIngestState(false, null, null, null, null);
}
Record record = result.next();
boolean real = !record.get("sourceFile").isNull() && !record.get("sourceFile").asString().isEmpty();
String sourceFile = 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(real, depth, status, statusAt);
return new ModuleIngestState(!sourceFile.isEmpty(), depth, status, statusAt, sourceFile);
});
}
});

View File

@@ -6,23 +6,58 @@ import org.jspecify.annotations.Nullable;
* 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.
*
* @param exists whether a real (non-placeholder, {@code sourceFile != ""}) {@code MODULE} node
* exists for the name
* @param depth the stored {@code ingestDepth} ({@code "CALL_GRAPH"}/{@code "FULL"}), or {@code null}
* if unset (treated as not deeply ingested)
* @param status the stored {@code ingestStatus} ({@code "NOT_INGESTED"}/{@code "INGESTING"}/
* {@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 107: the graph knows <b>three</b> states for a module name, and query endpoints must tell
* them apart — conflating them is what let every {@code /modules/{name}/…} endpoint answer {@code 200}
* with an all-zeros shell for a name that exists nowhere:
* <ul>
* <li><b>absent</b> — no {@code MODULE} node at all ({@link #present()} is {@code false}); the name
* is a typo or simply unknown. Endpoints answer {@code 404 MODULE_NOT_FOUND}.</li>
* <li><b>placeholder</b> — a node exists because something calls it, but its source was never
* parsed ({@link #placeholder()}). Anything derived from the module's <em>own</em> source is
* structurally empty, so endpoints answer {@code 409 NOT_INGESTED} rather than "analysed,
* nothing found". Data derived from the <em>calling</em> modules (notably {@code /callers})
* is genuine and still served.</li>
* <li><b>ingested</b> — a real, parsed node ({@link #ingested()}), possibly only to
* {@code CALL_GRAPH} depth (see {@link #isFull()}).</li>
* </ul>
*
* @param ingested whether a real (non-placeholder, {@code sourceFile != ""}) {@code MODULE} node
* exists for the name — i.e. its source was actually parsed
* @param depth the stored {@code ingestDepth} ({@code "CALL_GRAPH"}/{@code "FULL"}), or {@code null}
* if unset (treated as not deeply ingested)
* @param status the stored {@code ingestStatus} ({@code "NOT_INGESTED"}/{@code "INGESTING"}/
* {@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
* @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
*/
public record ModuleIngestState(boolean exists, @Nullable String depth,
@Nullable String status, @Nullable Long statusAt) {
public record ModuleIngestState(boolean ingested, @Nullable String depth,
@Nullable String status, @Nullable Long statusAt,
@Nullable String sourceFile) {
/**
* @return whether <em>any</em> {@code MODULE} node exists for the name — real or placeholder.
* The negation is the {@code 404 MODULE_NOT_FOUND} case: the name is unknown to the graph.
*/
public boolean present() {
return sourceFile != null;
}
/**
* @return whether the only node for this name is an unresolved placeholder: referenced by a
* caller, but its source never parsed. Distinct from {@link #present()} being {@code false} —
* the module is known to exist, it just cannot be analysed.
*/
public boolean placeholder() {
return sourceFile != null && sourceFile.isEmpty();
}
/**
* @return whether the module has been deeply (fully) ingested.
*/
public boolean isFull() {
return exists && IngestDepth.FULL.name().equals(depth);
return ingested && IngestDepth.FULL.name().equals(depth);
}
/**
@@ -31,7 +66,7 @@ public record ModuleIngestState(boolean exists, @Nullable String depth,
* missing stamp is treated as stale (reclaimable), never as freshly ingesting.
*/
public boolean isIngesting(long staleBeforeMillis) {
return exists && IngestStatus.INGESTING.name().equals(status)
return ingested && IngestStatus.INGESTING.name().equals(status)
&& statusAt != null && statusAt >= staleBeforeMillis;
}
}

View File

@@ -6,6 +6,37 @@ import {api} from "./client";
* (name, sourceFile) stable identity — node ids are regenerated on re-ingest, so we never key on them.
*/
/**
* Item 107: the server no longer answers `200` with an empty shell for a module it cannot analyse.
* A module endpoint now returns `404 MODULE_NOT_FOUND` (no such module) or `409 NOT_INGESTED` (an
* unresolved placeholder — referenced by a caller, but its own source was never parsed).
*
* These are the message strings the panels test for. Kept as sentinels rather than prose so a panel
* can tell "this module cannot be analysed" apart from "the request failed", and render one banner
* instead of a wall of red — see {@link moduleError}.
*/
export const MODULE_NOT_FOUND = "MODULE_NOT_FOUND";
export const MODULE_NOT_INGESTED = "MODULE_NOT_INGESTED";
/** 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);
}
/**
* Maps a failed module-endpoint response to an Error, promoting the two item-107 statuses to the
* sentinels above. Every module hook throws through here so the distinction survives to the UI;
* `fallback` covers everything else (network, 5xx) and keeps the old per-panel message.
*
* Mirrors the `STALE_SOURCE` handling in {@link useModuleSource}, which predates this.
*/
function moduleError(status: number, fallback: string): Error {
if (status === 404) return new Error(MODULE_NOT_FOUND);
if (status === 409) return new Error(MODULE_NOT_INGESTED);
return new Error(fallback);
}
export function useProjects() {
return useQuery({
queryKey: ["projects"],
@@ -47,10 +78,10 @@ export function useModuleContext(project: string | undefined, name: string | und
enabled: !!project && !!name,
queryKey: ["context", project, name],
queryFn: async () => {
const {data, error} = await api.GET("/api/projects/{project}/modules/{name}/context", {
const {data, error, response} = await api.GET("/api/projects/{project}/modules/{name}/context", {
params: {path: {project: project!, name: name!}},
});
if (error) throw new Error("Failed to load module context");
if (error) throw moduleError(response.status, "Failed to load module context");
return data;
},
});
@@ -62,10 +93,10 @@ export function useModuleFunctions(project: string | undefined, name: string | u
enabled: !!project && !!name,
queryKey: ["functions", project, name],
queryFn: async () => {
const {data, error} = await api.GET("/api/projects/{project}/modules/{name}/functions", {
const {data, error, response} = await api.GET("/api/projects/{project}/modules/{name}/functions", {
params: {path: {project: project!, name: name!}},
});
if (error) throw new Error("Failed to load functions");
if (error) throw moduleError(response.status, "Failed to load functions");
return data;
},
});
@@ -118,11 +149,11 @@ export function useCalls(project: string | undefined, name: string | undefined,
queryKey: [dir, project, name],
queryFn: async () => {
const params = {path: {project: project!, name: name!}, query: {scope: "external"}};
const {data, error} =
const {data, error, response} =
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 new Error(`Failed to load ${dir}`);
if (error) throw moduleError(response.status, `Failed to load ${dir}`);
return data;
},
});
@@ -138,10 +169,10 @@ export function useInternalCallees(project: string | undefined, module: string |
enabled: enabled && !!project && !!module,
queryKey: ["callees-internal", project, module],
queryFn: async () => {
const {data, error} = await api.GET("/api/projects/{project}/modules/{name}/callees", {
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 new Error("Failed to load internal callees");
if (error) throw moduleError(response.status, "Failed to load internal callees");
return data;
},
});
@@ -162,11 +193,11 @@ export function useFunctionCallers(
enabled: enabled && !!project && !!module && !!fn,
queryKey: ["function-callers", project, module, fn],
queryFn: async () => {
const {data, error} = await api.GET(
const {data, error, response} = await api.GET(
"/api/projects/{project}/modules/{name}/functions/{function}/callers",
{params: {path: {project: project!, name: module!, function: fn!}}},
);
if (error) throw new Error("Failed to load function callers");
if (error) throw moduleError(response.status, "Failed to load function callers");
return data;
},
});
@@ -219,10 +250,10 @@ export function useModulePayload(project: string | undefined, name: string | und
enabled: !!project && !!name,
queryKey: ["payload", project, name],
queryFn: async () => {
const {data, error} = await api.GET("/api/projects/{project}/modules/{name}/payload", {
const {data, error, response} = await api.GET("/api/projects/{project}/modules/{name}/payload", {
params: {path: {project: project!, name: name!}},
});
if (error) throw new Error("Failed to load payload");
if (error) throw moduleError(response.status, "Failed to load payload");
return data;
},
});
@@ -233,10 +264,10 @@ export function useModuleDataStructures(project: string | undefined, name: strin
enabled: !!project && !!name,
queryKey: ["data-structures", project, name],
queryFn: async () => {
const {data, error} = await api.GET("/api/projects/{project}/modules/{name}/data-structures", {
const {data, error, response} = await api.GET("/api/projects/{project}/modules/{name}/data-structures", {
params: {path: {project: project!, name: name!}},
});
if (error) throw new Error("Failed to load data structures");
if (error) throw moduleError(response.status, "Failed to load data structures");
return data;
},
});
@@ -262,10 +293,10 @@ export function useDbAccesses(project: string | undefined, name: string | undefi
enabled: !!project && !!name,
queryKey: ["db-accesses", project, name],
queryFn: async () => {
const {data, error} = await api.GET("/api/projects/{project}/modules/{name}/db-accesses", {
const {data, error, response} = await api.GET("/api/projects/{project}/modules/{name}/db-accesses", {
params: {path: {project: project!, name: name!}},
});
if (error) throw new Error("Failed to load DB accesses");
if (error) throw moduleError(response.status, "Failed to load DB accesses");
return data;
},
});
@@ -276,10 +307,10 @@ export function useSqlStatements(project: string | undefined, name: string | und
enabled: !!project && !!name,
queryKey: ["sql-statements", project, name],
queryFn: async () => {
const {data, error} = await api.GET("/api/projects/{project}/modules/{name}/sql-statements", {
const {data, error, response} = await api.GET("/api/projects/{project}/modules/{name}/sql-statements", {
params: {path: {project: project!, name: name!}},
});
if (error) throw new Error("Failed to load SQL statements");
if (error) throw moduleError(response.status, "Failed to load SQL statements");
return data;
},
});
@@ -290,10 +321,10 @@ export function useDispatchTable(project: string | undefined, name: string | und
enabled: !!project && !!name,
queryKey: ["dispatch-table", project, name],
queryFn: async () => {
const {data, error} = await api.GET("/api/projects/{project}/modules/{name}/dispatch-table", {
const {data, error, response} = await api.GET("/api/projects/{project}/modules/{name}/dispatch-table", {
params: {path: {project: project!, name: name!}},
});
if (error) throw new Error("Failed to load dispatch table");
if (error) throw moduleError(response.status, "Failed to load dispatch table");
return data;
},
});
@@ -437,10 +468,10 @@ export function useImpactCallers(project: string | undefined, name: string | und
enabled: !!project && !!name,
queryKey: ["impact-callers", project, name],
queryFn: async () => {
const {data, error} = await api.GET("/api/projects/{project}/modules/{name}/graph", {
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 new Error("Failed to load impact");
if (error) throw moduleError(response.status, "Failed to load impact");
return data;
},
});
@@ -457,9 +488,9 @@ export async function fetchEgoGraph(
depth: number,
limit: number,
) {
const {data, error} = await api.GET("/api/projects/{project}/modules/{name}/graph", {
const {data, error, response} = await api.GET("/api/projects/{project}/modules/{name}/graph", {
params: {path: {project, name}, query: {direction, depth, limit}},
});
if (error) throw new Error("Failed to load graph");
if (error) throw moduleError(response.status, "Failed to load graph");
return data;
}

View File

@@ -203,7 +203,9 @@ export interface paths {
/** Data Structure Fields */
get: {
parameters: {
query?: never;
query?: {
sourceFile?: string;
};
header?: never;
path: {
name: string;
@@ -289,6 +291,178 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/projects/{project}/dynamic-calls/overrides": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List manual dynamic-CALLNAT overrides (item 82), each flagged obsolete if auto-resolution has since caught up. */
get: {
parameters: {
query?: never;
header?: never;
path: {
project: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DynamicCallOverride"][];
};
};
/** @description Project not found. */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
put?: never;
/** Pin a dynamic CALLNAT call site (originFile+lineNo) to one or more target modules (item 82); applied immediately. */
post: {
parameters: {
query?: never;
header?: never;
path: {
project: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["DynamicCallOverrideRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DynamicCallOverride"];
};
};
/** @description Missing field or unknown target module. */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Project not found. */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
/** Reset dynamic-CALLNAT overrides (item 82): one call site via originFile+lineNo, or all when both are omitted; restores the unresolved placeholder inline. */
delete: {
parameters: {
query?: {
lineNo?: number;
originFile?: string;
};
header?: never;
path: {
project: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ResetResult"];
};
};
/** @description Project not found. */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects/{project}/dynamic-calls/unresolved": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List unresolved dynamic CALLNAT call sites (item 82) a human/agent can pin a target to. */
get: {
parameters: {
query?: never;
header?: never;
path: {
project: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UnresolvedDynamicCall"][];
};
};
/** @description Project not found. */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects/{project}/loc": {
parameters: {
query?: never;
@@ -424,7 +598,7 @@ export interface paths {
"application/json": components["schemas"]["CallTreeResponse"];
};
};
/** @description Project not found. */
/** @description Project or module not found. */
404: {
headers: {
[name: string]: unknown;
@@ -433,6 +607,15 @@ export interface paths {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @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. */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeepIngestRequired"];
};
};
};
};
put?: never;
@@ -478,7 +661,7 @@ export interface paths {
"application/json": components["schemas"]["CallRefResponse"];
};
};
/** @description Project not found. */
/** @description Project or module not found. */
404: {
headers: {
[name: string]: unknown;
@@ -487,6 +670,15 @@ export interface paths {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Module is an unresolved placeholder — its source is not ingested, so it has no known callees. */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeepIngestRequired"];
};
};
};
};
put?: never;
@@ -531,7 +723,7 @@ export interface paths {
"application/json": components["schemas"]["CallRefResponse"];
};
};
/** @description Project not found. */
/** @description Project or module not found. */
404: {
headers: {
[name: string]: unknown;
@@ -579,7 +771,7 @@ export interface paths {
"application/json": components["schemas"]["EntityColumn"][];
};
};
/** @description Project not found. */
/** @description Project or module not found. */
404: {
headers: {
[name: string]: unknown;
@@ -588,6 +780,15 @@ export interface paths {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Module is an unresolved placeholder — its source is not ingested. */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeepIngestRequired"];
};
};
};
};
put?: never;
@@ -631,7 +832,7 @@ export interface paths {
"application/json": components["schemas"]["ModuleContext"];
};
};
/** @description Project not found. */
/** @description Project or module not found. */
404: {
headers: {
[name: string]: unknown;
@@ -640,6 +841,15 @@ export interface paths {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Module is an unresolved placeholder — its source is not ingested. */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeepIngestRequired"];
};
};
};
};
put?: never;
@@ -679,7 +889,7 @@ export interface paths {
"application/json": components["schemas"]["ModuleDataStructure"][];
};
};
/** @description Project not found. */
/** @description Project or module not found. */
404: {
headers: {
[name: string]: unknown;
@@ -688,6 +898,15 @@ export interface paths {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Module is an unresolved placeholder — its source is not ingested. */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeepIngestRequired"];
};
};
};
};
put?: never;
@@ -731,7 +950,7 @@ export interface paths {
"application/json": components["schemas"]["DbAccess"][];
};
};
/** @description Project not found. */
/** @description Project or module not found. */
404: {
headers: {
[name: string]: unknown;
@@ -740,6 +959,15 @@ export interface paths {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Module is an unresolved placeholder — its source is not ingested. */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeepIngestRequired"];
};
};
};
};
put?: never;
@@ -779,7 +1007,7 @@ export interface paths {
"application/json": components["schemas"]["ModuleDigest"];
};
};
/** @description Project not found. */
/** @description Project or module not found. */
404: {
headers: {
[name: string]: unknown;
@@ -788,6 +1016,15 @@ export interface paths {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Module is an unresolved placeholder — its source is not ingested. Its callers are still knowable via /callers. */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeepIngestRequired"];
};
};
};
};
put?: never;
@@ -827,7 +1064,7 @@ export interface paths {
"application/json": components["schemas"]["DispatchEntry"][];
};
};
/** @description Project not found. */
/** @description Project or module not found. */
404: {
headers: {
[name: string]: unknown;
@@ -836,6 +1073,15 @@ export interface paths {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Module is an unresolved placeholder — its source is not ingested. */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeepIngestRequired"];
};
};
};
};
put?: never;
@@ -878,7 +1124,7 @@ export interface paths {
"application/json": components["schemas"]["InheritedFunction"][];
};
};
/** @description Project not found. */
/** @description Project or module not found. */
404: {
headers: {
[name: string]: unknown;
@@ -887,6 +1133,15 @@ export interface paths {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Module is an unresolved placeholder — its source is not ingested. */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeepIngestRequired"];
};
};
};
};
put?: never;
@@ -926,7 +1181,7 @@ export interface paths {
"application/json": components["schemas"]["BulkFunctionOverride"][];
};
};
/** @description Project not found. */
/** @description Project or module not found. */
404: {
headers: {
[name: string]: unknown;
@@ -935,6 +1190,15 @@ export interface paths {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Module is an unresolved placeholder — its source is not ingested. */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeepIngestRequired"];
};
};
};
};
put?: never;
@@ -975,7 +1239,7 @@ export interface paths {
"application/json": components["schemas"]["CallRefResponse"];
};
};
/** @description Project not found. */
/** @description Project or module not found. */
404: {
headers: {
[name: string]: unknown;
@@ -984,6 +1248,15 @@ export interface paths {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Module is an unresolved placeholder — its source is not ingested, so it has no FUNCTION nodes. */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeepIngestRequired"];
};
};
};
};
put?: never;
@@ -1024,7 +1297,7 @@ export interface paths {
"application/json": components["schemas"]["FunctionOverride"][];
};
};
/** @description Project not found. */
/** @description Project or module not found. */
404: {
headers: {
[name: string]: unknown;
@@ -1033,6 +1306,15 @@ export interface paths {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Module is an unresolved placeholder — its source is not ingested. */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeepIngestRequired"];
};
};
};
};
put?: never;
@@ -1082,7 +1364,7 @@ export interface paths {
"application/json": components["schemas"]["EgoGraphResponse"];
};
};
/** @description Project not found. */
/** @description Project or module not found. */
404: {
headers: {
[name: string]: unknown;
@@ -1130,7 +1412,7 @@ export interface paths {
"application/json": components["schemas"]["PayloadField"][];
};
};
/** @description Project not found. */
/** @description Project or module not found. */
404: {
headers: {
[name: string]: unknown;
@@ -1139,6 +1421,15 @@ export interface paths {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Module is an unresolved placeholder — its source is not ingested. */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeepIngestRequired"];
};
};
};
};
put?: never;
@@ -1231,7 +1522,7 @@ export interface paths {
"application/json": components["schemas"]["SqlStatement"][];
};
};
/** @description Project not found. */
/** @description Project or module not found. */
404: {
headers: {
[name: string]: unknown;
@@ -1240,6 +1531,75 @@ export interface paths {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Module is an unresolved placeholder — its source is not ingested. */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeepIngestRequired"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects/{project}/modules/{name}/workfile-accesses": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Workfile Accesses */
get: {
parameters: {
query?: {
limit?: number;
offset?: number;
};
header?: never;
path: {
name: string;
project: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["WorkfileAccess"][];
};
};
/** @description Project or module not found. */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Module is an unresolved placeholder — its source is not ingested. */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeepIngestRequired"];
};
};
};
};
put?: never;
@@ -1346,6 +1706,64 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/projects/{project}/recreate": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Recreate */
post: {
parameters: {
query?: {
deep?: boolean;
};
header?: never;
path: {
project: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["IngestSummary"];
};
};
/** @description The project's stored root no longer resolves; nothing was deleted. */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Project not found. */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects/{project}/refresh": {
parameters: {
query?: never;
@@ -2017,6 +2435,15 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
AccessSite: {
/** Format: int32 */
lineNo?: number;
sourceFile?: string;
viaCopycode?: boolean;
/** Format: int32 */
includedAt?: number;
includePath?: components["schemas"]["IncludeStep"][];
};
AggregatedCallRef: {
name?: string;
type?: components["schemas"]["NodeType"];
@@ -2024,6 +2451,7 @@ export interface components {
sourceFileIndex?: number;
edgeKind?: string;
sites?: components["schemas"]["CallSite"][];
unresolved?: boolean;
};
AnnotationMatch: {
id?: string;
@@ -2058,6 +2486,7 @@ export interface components {
viaCopycode?: string;
/** Format: int32 */
includedAt?: number;
includePath?: components["schemas"]["IncludeStep"][];
};
CallTreeItem: {
name?: string;
@@ -2083,6 +2512,7 @@ export interface components {
/** Format: int32 */
endLine?: number;
scope?: string;
sourceFile?: string;
};
DataStructureRef: {
name?: string;
@@ -2101,6 +2531,13 @@ export interface components {
mode?: string;
lineNos?: number[];
via?: string;
sites?: components["schemas"]["AccessSite"][];
};
DeepIngestRequired: {
status?: string;
module?: string;
detail?: string;
nextAction?: components["schemas"]["NextAction"];
};
DispatchEntry: {
guards?: components["schemas"]["DispatchGuard"][];
@@ -2121,6 +2558,25 @@ export interface components {
kind?: components["schemas"]["NodeType"];
paths?: string[];
};
DynamicCallOverride: {
originFile?: string;
/** Format: int32 */
lineNo?: number;
variable?: string;
targets?: string[];
note?: string;
createdBy?: string;
createdAt?: string;
obsolete?: boolean;
};
DynamicCallOverrideRequest: {
originFile?: string;
/** Format: int32 */
lineNo?: number;
targets?: string[];
variable?: string;
note?: string;
};
/** @description A bounded call-graph neighbourhood (nodes + edges) around one module. */
EgoGraphResponse: {
root?: string;
@@ -2168,6 +2624,7 @@ export interface components {
};
FunctionInfo: {
name?: string;
sourceFile?: string;
/** Format: int32 */
startLine?: number;
/** Format: int32 */
@@ -2211,6 +2668,11 @@ export interface components {
scope?: string;
unresolved?: boolean;
};
IncludeStep: {
sourceFile?: string;
/** Format: int32 */
lineNo?: number;
};
IngestSummary: {
/** Format: int32 */
ingested?: number;
@@ -2223,6 +2685,8 @@ export interface components {
InheritedFunction: {
name?: string;
declaredIn?: string;
sourceFile?: string;
viaCopycode?: boolean;
/** Format: int32 */
startLine?: number;
/** Format: int32 */
@@ -2296,8 +2760,12 @@ export interface components {
ingestStatus?: string;
ingestDepth?: string;
};
NextAction: {
method?: string;
path?: string;
};
/** @enum {string} */
NodeType: "MODULE" | "FUNCTION" | "VARIABLE" | "DATA_STRUCTURE" | "DB_TABLE" | "CONSTANT" | "FIELD" | "DB_ACCESS" | "CONTROL_FLOW" | "PAYLOAD_FIELD";
NodeType: "MODULE" | "FUNCTION" | "VARIABLE" | "DATA_STRUCTURE" | "DB_TABLE" | "CONSTANT" | "FIELD" | "DB_ACCESS" | "WORKFILE" | "WORKFILE_ACCESS" | "CONTROL_FLOW" | "PAYLOAD_FIELD";
PayloadField: {
tag?: string;
field?: string;
@@ -2341,6 +2809,10 @@ export interface components {
generatedDir?: string;
userExitDir?: string;
};
ResetResult: {
/** Format: int32 */
removed?: number;
};
/** @description A regex match in a module's source: module, file, line number, line text. */
SourceMatch: {
module?: string;
@@ -2373,6 +2845,8 @@ export interface components {
/** Format: int32 */
endLine?: number;
via?: string;
sourceFile?: string;
viaCopycode?: boolean;
};
SqlStatementSummary: {
/** Format: int32 */
@@ -2390,6 +2864,13 @@ export interface components {
maxNodes?: number;
hint?: string;
};
UnresolvedDynamicCall: {
module?: string;
originFile?: string;
/** Format: int32 */
lineNo?: number;
variable?: string;
};
ValueMatch: {
kind?: string;
name?: string;
@@ -2435,6 +2916,14 @@ export interface components {
name?: string;
version?: string;
};
WorkfileAccess: {
workFile?: string;
physicalName?: string;
mode?: string;
recordBuffers?: string[];
lineNos?: number[];
sites?: components["schemas"]["AccessSite"][];
};
};
responses: never;
parameters: never;

View File

@@ -1,5 +1,6 @@
import {useState} from "react";
import {
isModuleUnavailable,
useDataStructureFields,
useDbAccesses,
useDispatchTable,
@@ -28,6 +29,12 @@ interface DossierProps extends Props {
* port a Natural module to Java. Read-only consumer of existing endpoints.
*/
export function MigrationDossier({project, moduleName, moduleSourceFile, onOpenLine, onOpenFileLine}: DossierProps) {
// Item 107: every section below queries a different module endpoint, so an unknown or un-ingested
// module used to paint five independent "failed to load" errors. ModuleView already shows one
// banner for that case — render nothing here rather than repeating it five times. Any other
// failure still surfaces per section, since it can affect one section and not the others.
const {error} = useModulePayload(project, moduleName);
if (isModuleUnavailable(error)) return null;
return (
<div className="space-y-6 p-4 text-sm">
<PayloadSection project={project} moduleName={moduleName} onOpenFileLine={onOpenFileLine}/>

View File

@@ -1,10 +1,13 @@
import {useModuleContext} from "../api/hooks";
import {isModuleUnavailable, useModuleContext} from "../api/hooks";
import type {ModuleInfo} from "../api/client";
import {ModuleNotes} from "./ModuleNotes";
/** Overview tab: read-only summary from GET /context (description, functions, calls, DB tables). */
export function ModuleOverview({project, module}: { project: string; module: ModuleInfo }) {
const {data, isLoading, isError} = useModuleContext(project, module.name);
const {data, isLoading, isError, error} = useModuleContext(project, module.name);
// Item 107: an unknown or un-ingested module is reported once by ModuleView's banner — don't
// repeat it here as a generic failure, which would read as "the request broke".
const unavailable = isModuleUnavailable(error);
const callees = data?.callees?.items?.map((i) => i.name).filter(Boolean) ?? [];
const callers = data?.callers?.items?.map((i) => i.name).filter(Boolean) ?? [];
@@ -14,7 +17,7 @@ export function ModuleOverview({project, module}: { project: string; module: Mod
return (
<div className="space-y-4 p-4 text-sm">
{isLoading && <p className="text-neutral-400">loading context…</p>}
{isError && <p className="text-red-500">failed to load context</p>}
{isError && !unavailable && <p className="text-red-500">failed to load context</p>}
{data && (
<>
{data.description && <p className="text-neutral-600 dark:text-neutral-300">{data.description}</p>}

View File

@@ -0,0 +1,41 @@
import {MODULE_NOT_INGESTED} from "../api/hooks";
/**
* Item 107: the honest empty state for a module the server cannot analyse.
*
* <p>The API used to answer `200` with an all-zeros body for both a misspelt name and a module whose
* source was never parsed, so every panel rendered "none" — indistinguishable from a module that
* genuinely has no DB access, no callees and no data structures. It now answers `404` / `409`, and
* this banner says which, once, instead of each panel independently reporting a failure.
*
* <p>`NOT_INGESTED` is not an error: the module is real and something calls it, its source just is
* not in the graph. Its callers are still knowable, which is why the Calls and Graph tabs keep
* working and the copy points there.
*/
export function ModuleUnavailable({moduleName, error}: { moduleName: string; error: unknown }) {
const notIngested = error instanceof Error && error.message === MODULE_NOT_INGESTED;
return (
<div
className={
"rounded border px-3 py-2 text-xs " +
(notIngested
? "border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200"
: "border-red-300 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-200")
}
>
{notIngested ? (
<>
<span className="font-semibold">{moduleName} is not ingested.</span>{" "}
Something in this project calls it, but its source is not in the graph — so there is
nothing to analyse here, rather than nothing to find. Its callers are still accurate:
see the Calls and Graph tabs.
</>
) : (
<>
<span className="font-semibold">No module {moduleName} in this project.</span>{" "}
The name is unknown to the graph.
</>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import {lazy, Suspense, useState} from "react";
import type {ModuleInfo} from "../api/client";
import {useIngestNeighborhood, useRefreshModule} from "../api/hooks";
import {isModuleUnavailable, useIngestNeighborhood, useModuleContext, useRefreshModule} from "../api/hooks";
import {StatusBadge} from "./StatusBadge";
import {ModuleOverview} from "./ModuleOverview";
import {SourceView} from "./SourceView";
@@ -11,6 +11,7 @@ import {MigrationDossier} from "./MigrationDossier";
import {DataFlowView} from "./DataFlowView";
import {ImpactView} from "./ImpactView";
import {SourceOutline} from "./SourceOutline";
import {ModuleUnavailable} from "./ModuleUnavailable";
// Lazy-loaded (M2): keeps Sigma/WebGL out of the initial bundle.
const GraphView = lazy(() => import("./GraphView"));
@@ -69,6 +70,9 @@ export function ModuleView({
const refresh = useRefreshModule(project);
const ingestNeighborhood = useIngestNeighborhood(project);
const [identify, setIdentify] = useState<{ name: string; line: number } | null>(null);
// Item 107: /context stands in for "can this module be analysed at all" — it is the broadest
// module endpoint and the one the Overview tab already fetches, so this costs no extra request.
const {error: contextError} = useModuleContext(project, module.name);
return (
<div className="relative flex h-full flex-col border-l border-neutral-200 dark:border-neutral-800">
@@ -118,6 +122,19 @@ export function ModuleView({
</nav>
<div className="min-h-0 flex-1 overflow-auto">
{/*
Item 107: one banner for the whole view when the server says this module cannot be
analysed (404) or was never ingested (409), instead of every tab and every dossier
section independently reporting a failure. Rendered above the tab body rather than
in place of it: for a placeholder the Calls and Graph tabs still hold real data,
because those come from the CALLING modules' source.
useModuleContext shares React Query's cache with ModuleOverview — no extra request.
*/}
{isModuleUnavailable(contextError) && (
<div className="p-4 pb-0">
<ModuleUnavailable moduleName={module.name!} error={contextError}/>
</div>
)}
{tab === "overview" && <ModuleOverview project={project} module={module}/>}
{tab === "source" && (
<div className="flex h-full min-h-0">

View File

@@ -36,11 +36,23 @@ below. Nothing else is in your write scope.
- `400 MISSING_LINE_RANGE` — `startLine`/`endLine` missing on `modules/{name}/source`.
- `400 NO_SOURCE_FILE` — `/source` on an unresolved placeholder node.
- `404 NODE_NOT_FOUND` / `404 MODULE_NOT_FOUND` — unknown id / module name.
- `409 NOT_DEEPLY_INGESTED` / `409 NOT_INGESTED` — field-level dataflow
endpoints only (`flow-forward`/`flow-backward`/`field-flow`): 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 data": the data may exist once deep-ingested.
Every `/modules/{name}/…` endpoint checks this: an unknown module name is a
`404`, never an empty `200`.
- `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
data": the data may exist once ingested. Two situations produce it:
- field-level dataflow endpoints (`flow-forward`/`flow-backward`/`field-flow`)
on a module that is only call-graph-ingested;
- **any** `/modules/{name}/…` endpoint on an *unresolved placeholder* — a
module something calls but whose source was never parsed. Its
`callers` and `graph` still answer `200` with real data (that comes
from the calling modules), so **fall back to `/callers`** to learn
what you can. Everything else about it is unknowable, not empty.
- **Never read an empty `200` from a module endpoint as "analysed, nothing
found".** Since item 107 the API distinguishes *unknown* (`404`), *not
analysable* (`409`) and *analysed, genuinely empty* (`200` with an empty
body). Only the last one licenses the conclusion "there is nothing here".
- **`null` means "not determined," not an error** — `dataType`, `value`,
`table`, `view`, `module`, `description` are nullable by design.
- **Don't fabricate endpoints.** Only what's listed below exists.

View File

@@ -94,6 +94,38 @@ node's properties — so an agent can tell a dangling/dynamic
reference apart from a resolved one. In `callers`/`callees` such targets already appear as entries
with a blank `sourceFile`.
### Querying a module endpoint: three answers, not one (item 107)
Every `GET /api/projects/{p}/modules/{name}/…` endpoint used to answer `200` with an all-zeros shell
for a name that exists nowhere in the graph, byte-identical to a real-but-empty module's answer. That
is not cosmetic: `call-tree?depth=4` returning `200` with 0 modules reads as **"analysed, nothing
found"** when the truth is **"not analysable"** — the module's source was never in the checkout. The
graph knows three states and each now gets its own status:
| state | answer | what it means |
|-------------------------------------------------------------------------------|---------------------------------------------------------------|------------------------------------------------------------------------|
| 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 |
| real, ingested module | `200` | the body is the answer — an empty body genuinely means "nothing found" |
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`,
`dispatch-table`, `payload`, `columns`.
`callers` and `graph` stay `200` for a placeholder — their data comes from the **calling** modules'
source and is genuine. When you get a `409`, **fall back to `/callers`**: it is the one honest answer
available for a module whose own source is missing. (`digest` no longer surfaces those callers, since
its other fields would all be structurally zero.)
`/modules/{name}/source` is unchanged: it already answered `404 MODULE_NOT_FOUND` for both an absent
module and a placeholder, since there is no source to serve either way.
**Scope limit — this guards the *root* module of a request only.** A `call-tree` that traverses *into*
placeholder targets still reports that subtree as empty without flagging it, so a dispatcher whose
targets are all placeholders still returns `200` with a silently truncated tree. Cross-check the
targets you care about individually (a `409` tells you it is unanalysed) — see item 103.
**Data literals are not call targets (item 62).** A `CALLNAT <bareword>` whose target is really a data
value — a browse key reaching the call site through a copycode/macro argument — used to leave a
permanent unresolved placeholder callee. Enrichment now reaps such a placeholder when it has no Natural

View File

@@ -77,9 +77,9 @@ before. The parked "parallel parse phase" idea was implemented 2026-07-18 (item
## Known bugs
- [ ] **107. Every module endpoint answers `200` with an empty shell for a module that does not exist —
- [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`)
contradicts the documented `404 MODULE_NOT_FOUND`) — **done 2026-08-05**
**Symptom.** Measured against the live server, `upms`:
```
@@ -103,13 +103,37 @@ before. The parked "parallel parse phase" idea was implemented 2026-07-18 (item
that trusts the `200` concludes "these paths trigger no commission processing"; the honest answer is
"unknown". Same failure mode as item 103: an incomplete answer that looks complete.
**Fix.** Return `404 MODULE_NOT_FOUND` when no `MODULE` node exists for `(project, name)` — the check
`search/identifier` already performs. If a node exists but its source is not ingested (placeholder,
`sourceFile = ""`), that is a *different* state and deserves an explicit marker in the payload
(`placeholder: true` / `sourceFile: null`) rather than an all-zeros body. Worth checking which other
`/modules/{name}/…` endpoints share the shell (`functions`, `db-accesses`, `data-structures`,
`dispatch-table` all plausibly do — `dispatch-table` returning `[]` for a non-existent module is
currently indistinguishable from item 108's real gap).
**Fix (as implemented 2026-08-05).** `ModuleIngestState` now carries `sourceFile`, so the three graph
states are separable: `present()` (any node), `placeholder()` (node exists, `sourceFile = ""`),
`ingested()` (real parsed node; the old `exists()`, renamed). Two guards in `AnalysisResource`
replace the project-only `withProject` on every `/modules/{name}/…` endpoint:
- `withModule` → `404 MODULE_NOT_FOUND` when no node exists at all.
- `withIngestedModule` → the same `404`, plus `409 {status:"NOT_INGESTED", module, detail, nextAction}`
for a placeholder, reusing the existing `DeepIngestRequired` record.
Applied to all 15 endpoints whose answer comes 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`,
`dispatch-table`, `payload`, `columns`). `callers` and `graph` get the `404` but stay `200` for a
placeholder — their data comes from the *calling* modules and is genuine, so the `409` copy points
callers there. `/modules/{name}/source` was already correct. Covered by `ModuleNotFoundIT` (54 cases,
including an explicit assertion that the fixture really produces all three states). `ac-ui` maps both
statuses to one banner instead of ~8 per-panel failures; the CLI needed no change (`printResponse`
already prints any body and exits 1).
**Deviation from the fix sketched above.** The placeholder case returns `409` rather than a
`placeholder: true` / `sourceFile: null` payload marker. A marker cannot be attached to the
array-shaped responses (`db-accesses`, `functions`, `dispatch-table`, …), so the marker approach
would have fixed the object-shaped endpoints only and left the rest indistinguishable — exactly the
gap this item is about. The `409` is uniform, needs no DTO changes, and carries an actionable
`nextAction`.
**Scope limit — the class of bug is NOT closed.** This guards only the **root** module of a request.
A `call-tree` that traverses *into* placeholder targets still reports that subtree as empty without
flagging it, so the original `WPOLIX0S`-style dispatcher whose 13 targets are all placeholders still
returns `200` with a silently truncated tree. Callers must still cross-check individual targets (each
now answers `409`). Flagging unanalysable nodes *inside* a traversal result is item 103's territory.
- [ ] **75. `CONTAINS` is not acyclic — 22 self-loops and 162 two-cycles in `upms`**
(found 2026-07-17 while root-causing item 74; **cause NOT established — do not treat the notes below as