Bug fixes
This commit is contained in:
@@ -16,6 +16,9 @@ final class RestEndpointsCommand extends AbstractProjectCommand {
|
||||
@Option(names = "--module", description = "Only the endpoints declared by this class (identity or short name)")
|
||||
@Nullable String module;
|
||||
|
||||
@Option(names = "--count-only", description = "Print only how many rows match, instead of the rows themselves")
|
||||
boolean countOnly;
|
||||
|
||||
@Option(names = "--limit", description = "Max items to return (default: all)")
|
||||
int limit = -1;
|
||||
|
||||
@@ -26,6 +29,9 @@ final class RestEndpointsCommand extends AbstractProjectCommand {
|
||||
public Integer call() throws Exception {
|
||||
try {
|
||||
String path = appendQuery(projectPath() + "/rest-endpoints", "module", module);
|
||||
if (countOnly) {
|
||||
path = appendQuery(path, "countOnly", "true");
|
||||
}
|
||||
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
|
||||
return printResponse(apiClient().get(path));
|
||||
} catch (IllegalStateException e) {
|
||||
|
||||
@@ -22,6 +22,9 @@ final class SearchReferencesCommand extends AbstractProjectCommand {
|
||||
description = "Narrow to one kind: CALL, IMPORT, TYPE, ANNOTATION, EXTENDS, IMPLEMENTS, INJECTS, CLASS_LITERAL, INCLUDE")
|
||||
@Nullable String kind;
|
||||
|
||||
@Option(names = "--count-only", description = "Print only how many rows match, instead of the rows themselves")
|
||||
boolean countOnly;
|
||||
|
||||
@Option(names = "--limit", description = "Max items to return")
|
||||
int limit = -1;
|
||||
|
||||
@@ -33,6 +36,9 @@ final class SearchReferencesCommand extends AbstractProjectCommand {
|
||||
try {
|
||||
String path = appendQuery(projectPath() + "/search/references", "name", name);
|
||||
path = appendQuery(path, "kind", kind);
|
||||
if (countOnly) {
|
||||
path = appendQuery(path, "countOnly", "true");
|
||||
}
|
||||
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
|
||||
return printResponse(apiClient().get(path));
|
||||
} catch (IllegalStateException e) {
|
||||
|
||||
@@ -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=245
|
||||
version=247
|
||||
|
||||
@@ -784,10 +784,14 @@ public class AnalysisResource {
|
||||
public Uni<Response> restEndpoints(@PathParam("project") String project,
|
||||
@Parameter(description = "Narrow to the endpoints declared by one class (identity or short name).")
|
||||
@QueryParam("module") @Nullable String module,
|
||||
@Parameter(description = "Item 135: report only how many endpoints match, without the rows.")
|
||||
@QueryParam("countOnly") @Nullable Boolean countOnly,
|
||||
@QueryParam("limit") @Nullable Integer limit,
|
||||
@QueryParam("offset") @Nullable Integer offset) {
|
||||
return withProject(project, () -> graphRepository.restEndpoints(project, module,
|
||||
uncappedLimit(limit), effectiveOffset(offset)).map(this::ok));
|
||||
int effLimit = isCountOnly(countOnly) ? 1 : uncappedLimit(limit);
|
||||
return withProject(project, () -> graphRepository.restEndpointsPage(project, module,
|
||||
effLimit, effectiveOffset(offset))
|
||||
.map(page -> isCountOnly(countOnly) ? countOnly(page) : paged(page)));
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -800,6 +804,8 @@ public class AnalysisResource {
|
||||
@QueryParam("name") @Nullable String name,
|
||||
@Parameter(description = "Narrow to one kind: CALL, IMPORT, TYPE, ANNOTATION, EXTENDS, IMPLEMENTS, INJECTS, CLASS_LITERAL, INCLUDE.")
|
||||
@QueryParam("kind") @Nullable String kind,
|
||||
@Parameter(description = "Item 135: report only how many reference sites match, without the rows.")
|
||||
@QueryParam("countOnly") @Nullable Boolean countOnly,
|
||||
@QueryParam("limit") @Nullable Integer limit,
|
||||
@QueryParam("offset") @Nullable Integer offset) {
|
||||
if (name == null || name.isBlank()) {
|
||||
@@ -812,10 +818,11 @@ public class AnalysisResource {
|
||||
"Unknown reference kind '" + kind + "'; expected one of " + REFERENCE_KINDS));
|
||||
}
|
||||
return withFanoutWarm(project,
|
||||
() -> graphRepository.searchReferences(project, name, upperKind,
|
||||
effectiveLimit(limit), effectiveOffset(offset)),
|
||||
sites -> sites.stream().map(ReferenceSite::sourceFile).filter(sf -> !sf.isEmpty()).distinct().toList(),
|
||||
sites -> ok(sites));
|
||||
() -> graphRepository.searchReferencesPage(project, name, upperKind,
|
||||
countAwareLimit(countOnly, limit), effectiveOffset(offset)),
|
||||
page -> page.rows().stream().map(ReferenceSite::sourceFile)
|
||||
.filter(sf -> !sf.isEmpty()).distinct().toList(),
|
||||
page -> isCountOnly(countOnly) ? countOnly(page) : paged(page));
|
||||
}
|
||||
|
||||
@GET
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.ext.ExceptionMapper;
|
||||
import jakarta.ws.rs.ext.Provider;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Item 136: makes an <em>unexpected</em> failure look like every other error this API returns —
|
||||
* {@code { "error": ..., "code": "INTERNAL_ERROR", "details": {} }} — instead of Quarkus's default
|
||||
* plain-text error page.
|
||||
*
|
||||
* <p>Why it matters: the API's whole contract is "errors are structured JSON with a {@code code} to
|
||||
* branch on". A {@code Uncoercible: Cannot coerce NULL to Java int} escaping the identifier search
|
||||
* broke that promise at exactly the moment a client most needs a machine-readable answer — it got an
|
||||
* HTML-ish body with no {@code code} at all, and no way to tell a server fault from a bad request.
|
||||
*
|
||||
* <p><b>Pass-through is load-bearing.</b> JAX-RS picks the most specific mapper for an exception, and
|
||||
* {@code Throwable} is the least specific one there is: without the {@link WebApplicationException}
|
||||
* branch below, this mapper would also swallow every {@code 404}/{@code 405}/{@code 415} the runtime
|
||||
* raises and the deliberate statuses built by {@link ProjectResource#error}, turning correct answers
|
||||
* into {@code 500}s across the board.
|
||||
*
|
||||
* <p>The error id in the message is the correlation handle: the full stack trace goes to the server
|
||||
* log under the same id, and never into the response — a client has no use for it and a stack trace
|
||||
* is not something to hand out.
|
||||
*/
|
||||
@Provider
|
||||
public class ApiExceptionMapper implements ExceptionMapper<Throwable> {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(ApiExceptionMapper.class);
|
||||
|
||||
@Override
|
||||
public Response toResponse(Throwable exception) {
|
||||
// A deliberate status (404 PROJECT_NOT_FOUND, 400 MISSING_NAME, 409 STALE_SOURCE, and the
|
||||
// runtime's own routing failures) already carries its response. Hand it back untouched.
|
||||
if (exception instanceof WebApplicationException webApplicationException) {
|
||||
return webApplicationException.getResponse();
|
||||
}
|
||||
String errorId = java.util.UUID.randomUUID().toString();
|
||||
LOG.errorf(exception, "Unhandled failure, error id %s", errorId);
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.type(MediaType.APPLICATION_JSON)
|
||||
.entity(ErrorResponse.of("INTERNAL_ERROR",
|
||||
"Unexpected server error (error id " + errorId + "); see the server log for details",
|
||||
Map.of("errorId", errorId)))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -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=245
|
||||
agenticcode.version=247
|
||||
# OpenAPI / Swagger UI (item 48) — the generated spec is the contract the web-UI TS client
|
||||
# is generated against. Served at /q/openapi (yaml/json); Swagger UI at /q/swagger-ui in dev.
|
||||
mp.openapi.extensions.smallrye.info.title=AgenticCode API
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Item 136: an unexpected failure must still leave the server as structured JSON, and a deliberate
|
||||
* one must pass through untouched.
|
||||
*
|
||||
* <p>The pass-through half is the one worth testing hardest: {@code Throwable} is the least specific
|
||||
* exception type there is, so without it this mapper would convert every {@code 404}/{@code 400} the
|
||||
* API answers today into a {@code 500}.
|
||||
*/
|
||||
class ApiExceptionMapperTest {
|
||||
|
||||
private final ApiExceptionMapper mapper = new ApiExceptionMapper();
|
||||
|
||||
@Test
|
||||
void anUnexpectedFailureBecomesAStructuredInternalError() {
|
||||
Response response = mapper.toResponse(new IllegalStateException("boom"));
|
||||
|
||||
assertEquals(500, response.getStatus());
|
||||
ErrorResponse body = (ErrorResponse) response.getEntity();
|
||||
assertEquals("INTERNAL_ERROR", body.code());
|
||||
assertTrue(body.details().containsKey("errorId"), "the correlation id belongs in details");
|
||||
assertFalse(body.error().contains("boom"),
|
||||
"the exception message may name internals and must not be echoed to the client");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDeliberateNotFoundIsHandedBackUnchanged() {
|
||||
Response built = ProjectResource.error(Response.Status.NOT_FOUND, "PROJECT_NOT_FOUND", "no such project");
|
||||
|
||||
Response response = mapper.toResponse(new WebApplicationException(built));
|
||||
|
||||
assertEquals(404, response.getStatus());
|
||||
assertEquals("PROJECT_NOT_FOUND", ((ErrorResponse) response.getEntity()).code());
|
||||
}
|
||||
|
||||
/**
|
||||
* The runtime's own routing failures are {@code WebApplicationException}s too and must keep their
|
||||
* status — an unknown path stays a 404, not a 500.
|
||||
*/
|
||||
@Test
|
||||
void aRoutingFailureKeepsItsStatus() {
|
||||
assertEquals(404, mapper.toResponse(new NotFoundException()).getStatus());
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,33 @@ class DuplicateIdentityIT {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 136: the duplicate marker is a node like any other, and every node must carry
|
||||
* {@code startLine}/{@code endLine}. {@code MARK_DUPLICATE_IDENTITIES} was the one node-creating
|
||||
* query that set neither, and the identifier search's row mapper coerced them unconditionally — so
|
||||
* a page deep enough to reach a marker faulted with an unstructured 500 instead of returning rows.
|
||||
*/
|
||||
@Test
|
||||
void aDuplicateMarkerCarriesLinesAndDoesNotFaultTheIdentifierSearch() {
|
||||
given().queryParam("name", "DUPE").queryParam("limit", 500)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/identifier")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("findAll { it.name == 'DUPE' }.startLine", everyItem(notNullValue()))
|
||||
.body("findAll { it.name == 'DUPE' }.endLine", everyItem(notNullValue()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The same page, fetched whole. Pins the actual reported symptom — a 500 several hundred rows in —
|
||||
* rather than only the property that caused it.
|
||||
*/
|
||||
@Test
|
||||
void aFullIdentifierPageOverTheWholeProjectDoesNotFault() {
|
||||
given().queryParam("name", "E").queryParam("contains", true).queryParam("limit", 500)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/identifier")
|
||||
.then().statusCode(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* The unreferenced duplicate: used to answer 404, i.e. "no such module".
|
||||
*/
|
||||
|
||||
@@ -10,6 +10,7 @@ import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
@@ -27,6 +28,10 @@ class SearchTruncationIT {
|
||||
|
||||
private static final String PROJECT = "search-truncation-project";
|
||||
private static final int CLASSES = 60;
|
||||
/**
|
||||
* Item 135: more endpoints than the default page of 50, so paging them is a real question.
|
||||
*/
|
||||
private static final int ENDPOINTS = 60;
|
||||
|
||||
@TempDir
|
||||
static Path root;
|
||||
@@ -41,13 +46,27 @@ class SearchTruncationIT {
|
||||
public @interface Marked {
|
||||
}
|
||||
""");
|
||||
// Item 135: one project type every Thing imports and declares a field of, so the reference
|
||||
// search has a set larger than the default page to page over. It lives in its own package on
|
||||
// purpose: a same-package type needs no import, and TypeResolver cannot turn the bare simple
|
||||
// name into an identity, so no reference edge is emitted for it at all (documented on
|
||||
// JavaParser#addReferenceEdges).
|
||||
write(root.resolve("src/main/java/com/example/support"), "Support.java", """
|
||||
package com.example.support;
|
||||
|
||||
public class Support {
|
||||
}
|
||||
""");
|
||||
for (int i = 0; i < CLASSES; i++) {
|
||||
write(pkg, "Thing" + i + ".java", """
|
||||
package com.example.many;
|
||||
|
||||
import com.example.support.Support;
|
||||
|
||||
@Marked
|
||||
public class Thing%d {
|
||||
private String shared = "repeated-literal";
|
||||
private Support support;
|
||||
|
||||
public String shared() {
|
||||
return shared;
|
||||
@@ -55,6 +74,26 @@ class SearchTruncationIT {
|
||||
}
|
||||
""".formatted(i));
|
||||
}
|
||||
// Item 135: a REST surface to page over. Separate classes from the Thing fixture above so the
|
||||
// annotation and identifier counts it pins stay untouched.
|
||||
for (int i = 0; i < ENDPOINTS; i++) {
|
||||
write(pkg, "Endpoint" + i + "Resource.java", """
|
||||
package com.example.many;
|
||||
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
|
||||
@Path("/endpoint%d")
|
||||
public class Endpoint%dResource {
|
||||
|
||||
@GET
|
||||
@Path("/list")
|
||||
public String list() {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
""".formatted(i, i));
|
||||
}
|
||||
given()
|
||||
.contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "java", null, null))
|
||||
@@ -150,6 +189,110 @@ class SearchTruncationIT {
|
||||
.header(AnalysisResource.TRUNCATED, notNullValue());
|
||||
}
|
||||
|
||||
// --- item 135: the two endpoints item 131 forgot -------------------------------------------
|
||||
|
||||
/**
|
||||
* The failure this item is about: {@code search/references} capped at the default 50 and said
|
||||
* nothing at all — no total, no truncation flag. A rename scoped from that page would have missed
|
||||
* every site past the fiftieth and looked complete doing it.
|
||||
*/
|
||||
@Test
|
||||
void theReferenceSearchReportsItsTotalAndTruncation() {
|
||||
int all = given().queryParam("name", "Support").queryParam("limit", 500)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/references")
|
||||
.jsonPath().getList("$").size();
|
||||
org.junit.jupiter.api.Assertions.assertTrue(all > 50,
|
||||
"the fixture must produce more references than one default page, got " + all);
|
||||
given().queryParam("name", "Support")
|
||||
.when().get("/api/projects/" + PROJECT + "/search/references")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("size()", equalTo(50))
|
||||
.header(AnalysisResource.TRUNCATED, equalTo("true"))
|
||||
.header(AnalysisResource.TOTAL_COUNT, equalTo(String.valueOf(all)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theReferenceSearchCountOnlyAgreesWithTheFullFetch() {
|
||||
int rows = given().queryParam("name", "Support").queryParam("limit", 500)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/references")
|
||||
.jsonPath().getList("$").size();
|
||||
given().queryParam("name", "Support").queryParam("countOnly", true)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/references")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("count", equalTo(rows));
|
||||
}
|
||||
|
||||
/**
|
||||
* The count must not inherit {@code $scanCap} — the same trap {@link #theTotalExceedsTheRowsItDescribes}
|
||||
* pins for the annotation search.
|
||||
*/
|
||||
@Test
|
||||
void theReferenceTotalExceedsTheRowsItDescribes() {
|
||||
int rows = given().queryParam("name", "Support").queryParam("limit", 5)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/references")
|
||||
.jsonPath().getList("$").size();
|
||||
long total = Long.parseLong(given().queryParam("name", "Support").queryParam("limit", 5)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/references")
|
||||
.header(AnalysisResource.TOTAL_COUNT));
|
||||
org.junit.jupiter.api.Assertions.assertEquals(5, rows);
|
||||
org.junit.jupiter.api.Assertions.assertTrue(total > rows,
|
||||
"total (" + total + ") must exceed the returned rows (" + rows + ")");
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code rest-endpoints} defaults to an uncapped limit, so it never lost rows — but it was equally
|
||||
* silent about how many there are. The header has to be there either way.
|
||||
*/
|
||||
@Test
|
||||
void theRestEndpointListReportsItsTotalWhenComplete() {
|
||||
given().when().get("/api/projects/" + PROJECT + "/rest-endpoints")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("size()", equalTo(ENDPOINTS))
|
||||
.header(AnalysisResource.TRUNCATED, equalTo("false"))
|
||||
.header(AnalysisResource.TOTAL_COUNT, equalTo(String.valueOf(ENDPOINTS)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aCappedRestEndpointPageSaysItWasCut() {
|
||||
given().queryParam("limit", 10)
|
||||
.when().get("/api/projects/" + PROJECT + "/rest-endpoints")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("size()", equalTo(10))
|
||||
.header(AnalysisResource.TRUNCATED, equalTo("true"))
|
||||
.header(AnalysisResource.TOTAL_COUNT, equalTo(String.valueOf(ENDPOINTS)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theRestEndpointCountOnlyWorks() {
|
||||
given().queryParam("countOnly", true)
|
||||
.when().get("/api/projects/" + PROJECT + "/rest-endpoints")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("count", equalTo(ENDPOINTS))
|
||||
.body("$", not(hasKey("rows")));
|
||||
}
|
||||
|
||||
/**
|
||||
* Paging has to actually move: two consecutive pages of one must not be the same row. A count
|
||||
* header on a page that never advances would be worse than no header, because it would look right.
|
||||
*/
|
||||
@Test
|
||||
void consecutiveReferencePagesAreDisjoint() {
|
||||
// Compared as whole rows, not by file: one class contributes both an IMPORT and a TYPE
|
||||
// reference, so two adjacent rows legitimately share a sourceFile.
|
||||
Map<String, ?> first = given().queryParam("name", "Support").queryParam("limit", 1).queryParam("offset", 0)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/references")
|
||||
.jsonPath().getMap("[0]");
|
||||
Map<String, ?> second = given().queryParam("name", "Support").queryParam("limit", 1).queryParam("offset", 1)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/references")
|
||||
.jsonPath().getMap("[0]");
|
||||
org.junit.jupiter.api.Assertions.assertNotEquals(first, second);
|
||||
}
|
||||
|
||||
/**
|
||||
* A page shorter than the limit is provably the end of the set, so no count query runs and the
|
||||
* total is arithmetic — this pins that the cheap path reports the same numbers as the counted one.
|
||||
|
||||
@@ -462,7 +462,16 @@ public final class CypherQueries {
|
||||
public static final String MARK_DUPLICATE_IDENTITIES = """
|
||||
UNWIND $duplicates AS d
|
||||
MERGE (n:AstNode {type: d.type, name: d.name, sourceFile: '', project: $project, ownerModule: ''})
|
||||
SET n:$(d.type), n.duplicatePaths = d.paths
|
||||
SET n:$(d.type), n.duplicatePaths = d.paths,
|
||||
// Item 136: every AstNode must carry startLine/endLine (CLAUDE.md), and this MERGE was the
|
||||
// one node-creating query that did not. A marker has no line — 0 is a convention, not a
|
||||
// truth — but the alternative, letting these two properties be null on a handful of nodes,
|
||||
// forces null handling into every row mapper in the project; one of them missed it and
|
||||
// faulted `search/identifier` with an unstructured 500 once a page reached row 487.
|
||||
// coalesce, because the MERGE key is deliberately the same as MERGE_NODES': when something
|
||||
// already references the skipped identity, marker and placeholder are one node and the
|
||||
// placeholder's real lines must survive.
|
||||
n.startLine = coalesce(n.startLine, 0), n.endLine = coalesce(n.endLine, 0)
|
||||
""";
|
||||
|
||||
/**
|
||||
@@ -2206,7 +2215,7 @@ public final class CypherQueries {
|
||||
* is excluded. Path segments are joined with exactly one {@code /}, and a class with no
|
||||
* {@code @Path} contributes nothing to the prefix rather than a literal "null".
|
||||
*/
|
||||
public static final String REST_ENDPOINTS = """
|
||||
private static final String REST_ENDPOINTS_CORE = """
|
||||
MATCH (m:AstNode {project: $project, type: 'MODULE'})-[:CONTAINS]->(f:AstNode {type: 'FUNCTION'})
|
||||
WHERE f.httpMethod IS NOT NULL
|
||||
AND ($module IS NULL OR m.name = $module OR m.simpleName = $module)
|
||||
@@ -2234,7 +2243,13 @@ public final class CypherQueries {
|
||||
// DISTINCT is load-bearing: the graph can hold more than one CONTAINS edge between the
|
||||
// same module and function (see item 75), which multiplied every such endpoint into
|
||||
// identical rows — 183 of 436 on `pur`.
|
||||
RETURN DISTINCT f.httpMethod AS httpMethod, path AS path,
|
||||
""";
|
||||
|
||||
/**
|
||||
* The row projection; {@link #REST_ENDPOINTS_COUNT} must stay distinct over the same columns.
|
||||
*/
|
||||
private static final String REST_ENDPOINTS_ROW = """
|
||||
DISTINCT f.httpMethod AS httpMethod, path AS path,
|
||||
m.name AS module, m.simpleName AS moduleSimpleName, f.name AS handler,
|
||||
f.sourceFile AS sourceFile, f.startLine AS startLine,
|
||||
// A @RegisterRestClient interface declares a call this application *makes*, not one
|
||||
@@ -2242,10 +2257,24 @@ public final class CypherQueries {
|
||||
// backwards; they are flagged rather than dropped, because "who calls out to what"
|
||||
// is a real question too.
|
||||
coalesce(m.annotations, '') CONTAINS 'RegisterRestClient' AS outbound
|
||||
""";
|
||||
|
||||
public static final String REST_ENDPOINTS = REST_ENDPOINTS_CORE + "RETURN " + REST_ENDPOINTS_ROW + """
|
||||
ORDER BY path, httpMethod
|
||||
LIMIT $scanCap
|
||||
""";
|
||||
|
||||
/**
|
||||
* Item 135: the endpoint count. Distinct over the full projected column set, not just
|
||||
* {@code (module, handler)}: the narrower key would be equal here only by accident of the data
|
||||
* model, and a count that disagrees with the page it describes is worse than none. Being distinct
|
||||
* also makes it immune to item 75's duplicate {@code CONTAINS} edges — which it masks exactly as
|
||||
* the row query does; item 75 is still open.
|
||||
*/
|
||||
public static final String REST_ENDPOINTS_COUNT = REST_ENDPOINTS_CORE + "WITH " + REST_ENDPOINTS_ROW + """
|
||||
RETURN count(*) AS total
|
||||
""";
|
||||
|
||||
private static String flowQuery(boolean forward, int maxDepth) {
|
||||
String path = forward
|
||||
? "(v)-[:ARG_TO_PARAM*1..%d]->(t:AstNode)"
|
||||
@@ -2515,7 +2544,7 @@ public final class CypherQueries {
|
||||
* {@code $kind} narrows to one reference kind; {@code $scanCap} bounds the row set exactly as in
|
||||
* {@link #SEARCH_IDENTIFIER}.
|
||||
*/
|
||||
public static final String SEARCH_REFERENCES = """
|
||||
private static final String SEARCH_REFERENCES_CORE = """
|
||||
MATCH (t:AstNode {project: $project})
|
||||
WHERE (t.name = $name OR t.simpleName = $name) AND t.type IN ['MODULE', 'DATA_STRUCTURE']
|
||||
MATCH (s:AstNode {project: $project})-[r]->(t)
|
||||
@@ -2535,11 +2564,30 @@ public final class CypherQueries {
|
||||
OPTIONAL MATCH (owner:AstNode {project: $project, type: 'MODULE'})-[:CONTAINS]->(s)
|
||||
WITH s, r, t, kind,
|
||||
CASE WHEN s.type = 'MODULE' THEN s.name ELSE owner.name END AS inModule
|
||||
RETURN DISTINCT s.sourceFile AS sourceFile, coalesce(r.lineNo, s.startLine) AS lineNo,
|
||||
""";
|
||||
|
||||
/**
|
||||
* The row projection: {@link #SEARCH_REFERENCES_COUNT} must stay distinct over the same columns.
|
||||
*/
|
||||
private static final String SEARCH_REFERENCES_ROW = """
|
||||
DISTINCT s.sourceFile AS sourceFile, coalesce(r.lineNo, s.startLine) AS lineNo,
|
||||
kind AS kind, inModule AS inModule, t.name AS target
|
||||
""";
|
||||
|
||||
public static final String SEARCH_REFERENCES = SEARCH_REFERENCES_CORE + "RETURN " + SEARCH_REFERENCES_ROW + """
|
||||
ORDER BY sourceFile ASC, lineNo ASC, kind ASC
|
||||
LIMIT $scanCap
|
||||
""";
|
||||
|
||||
/**
|
||||
* Item 135: how many reference sites the search <em>would</em> return. Distinct over exactly the
|
||||
* columns {@link #SEARCH_REFERENCES_ROW} projects — a narrower key would count fewer rows than the
|
||||
* page delivers. Deliberately carries no {@code $scanCap}, for the reason spelled out on
|
||||
* {@link #SEARCH_IDENTIFIER_COUNT}: a capped count equals the page size and reports nothing.
|
||||
*/
|
||||
public static final String SEARCH_REFERENCES_COUNT = SEARCH_REFERENCES_CORE + "WITH " + SEARCH_REFERENCES_ROW + """
|
||||
RETURN count(*) AS total
|
||||
""";
|
||||
public static final String SEARCH_IDENTIFIER = SEARCH_IDENTIFIER_CORE + """
|
||||
WITH n, CASE WHEN $priorityModule IS NOT NULL AND EXISTS {
|
||||
MATCH (pm:MODULE {project: $project})
|
||||
|
||||
@@ -1170,8 +1170,11 @@ public class GraphRepository {
|
||||
NodeType.valueOf(record.get("type").asString()),
|
||||
record.get("name").asString(),
|
||||
record.get("sourceFile").asString(),
|
||||
record.get("startLine").asInt(),
|
||||
record.get("endLine").asInt(),
|
||||
// Item 136: asInt() on a NULL throws Uncoercible and escaped as an unstructured 500.
|
||||
// Item 114's duplicate markers were created without lines; that is fixed at the write
|
||||
// side too, but a row mapper must never be the thing that faults an endpoint.
|
||||
intOrZero(record, "startLine"),
|
||||
intOrZero(record, "endLine"),
|
||||
record.get("dataType").isNull() ? null : record.get("dataType").asString(),
|
||||
record.get("value").isNull() ? null : record.get("value").asString(),
|
||||
record.get("scope").isNull() ? null : record.get("scope").asString(),
|
||||
@@ -1481,11 +1484,27 @@ public class GraphRepository {
|
||||
record.get("moduleSimpleName").isNull() ? null : record.get("moduleSimpleName").asString(),
|
||||
record.get("handler").asString(),
|
||||
record.get("sourceFile").asString(),
|
||||
record.get("startLine").asInt(),
|
||||
intOrZero(record, "startLine"),
|
||||
!record.get("outbound").isNull() && record.get("outbound").asBoolean()))
|
||||
.map(list -> paginate(list, limit, offset));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 135: the REST surface as a {@link Page}. Item 131 shipped without this endpoint, so the
|
||||
* answer carried no total at all. With the default (uncapped) limit no count query runs — see
|
||||
* {@link #withTotal}.
|
||||
*/
|
||||
public Uni<Page<RestEndpoint>> restEndpointsPage(String project, @Nullable String module,
|
||||
int limit, int offset) {
|
||||
return restEndpoints(project, module, limit, offset)
|
||||
.flatMap(rows -> withTotal(rows, limit, offset, () -> {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("project", project);
|
||||
params.put("module", module);
|
||||
return count(CypherQueries.REST_ENDPOINTS_COUNT, params);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 128: every reference site of {@code name} — imports, declared type positions, annotation
|
||||
* usages, calls, inheritance and wiring — with a {@code kind} discriminator per row.
|
||||
@@ -1510,6 +1529,23 @@ public class GraphRepository {
|
||||
.map(list -> paginate(list, limit, offset));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 135: the reference search as a {@link Page}. This is the one that actually lost rows — it
|
||||
* capped at the default 50 with no signal whatsoever, which is precisely the failure item 131 was
|
||||
* written to remove.
|
||||
*/
|
||||
public Uni<Page<ReferenceSite>> searchReferencesPage(String project, String name, @Nullable String kind,
|
||||
int limit, int offset) {
|
||||
return searchReferences(project, name, kind, limit, offset)
|
||||
.flatMap(rows -> withTotal(rows, limit, offset, () -> {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("project", project);
|
||||
params.put("name", name);
|
||||
params.put("kind", kind);
|
||||
return count(CypherQueries.SEARCH_REFERENCES_COUNT, params);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 129: {@code sourceFile -> sourceHash} for the whole project, the input to a
|
||||
* {@code changedOnly} refresh's skip decision. A file missing from this map has no stored hash and
|
||||
|
||||
@@ -20,7 +20,8 @@ must make is **pinning unresolvable dynamic `CALLNAT` targets** (§4) — requir
|
||||
- **`null` = "not determined", not an error** (`dataType`, `value`, `table`, `view`, `module`,
|
||||
`description` are nullable by design).
|
||||
- **Paginated endpoints return 50 rows by default — and now say so (item 131).** The body is still a
|
||||
bare JSON array, but the three search endpoints send `X-AC-Total-Count` and `X-AC-Truncated`
|
||||
bare JSON array, but the three search endpoints — plus `search/references` and `rest-endpoints`
|
||||
since item 135 — send `X-AC-Total-Count` and `X-AC-Truncated`
|
||||
headers, so a cut answer is detectable without a second call. **Read those headers before claiming a
|
||||
result set is complete**, or ask the counting question directly with `?countOnly=true`, which
|
||||
returns `{"count": n}`. `search/annotation?name=Immutable` returns 50 rows with
|
||||
@@ -262,7 +263,10 @@ case-insensitive substring, needed when the value is embedded in longer text suc
|
||||
substring, `annotations` lists every annotation on the node.
|
||||
|
||||
**All three take `?limit=&offset=` and default to `limit=50`** — and all three now report
|
||||
`X-AC-Total-Count` / `X-AC-Truncated` and accept `?countOnly=true` (item 131). In practice
|
||||
`X-AC-Total-Count` / `X-AC-Truncated` and accept `?countOnly=true` (item 131). So do
|
||||
`search/references` and `rest-endpoints`, which item 131 missed and item 135 added: `search/references`
|
||||
had been capping at 50 with no signal at all, which is the one case here where a page was actually
|
||||
losing rows silently. In practice
|
||||
`search/identifier` and `search/value` stay well under the cap, so it bites on `search/annotation`,
|
||||
whose result sets run into the thousands (`@Column` on `pur`: 3 630) — where `countOnly` answers a
|
||||
completeness question for a few bytes instead of ~250 k tokens. The total costs a second query only
|
||||
|
||||
@@ -71,14 +71,15 @@ own slicing. Copycode/INCLUDE slices are raw pre-expansion file text.
|
||||
|
||||
## Truncation is now visible on the search endpoints (item 131)
|
||||
|
||||
`search/identifier`, `search/value` and `search/annotation` send two headers with every answer:
|
||||
`search/identifier`, `search/value`, `search/annotation`, `search/references` and
|
||||
`rest-endpoints` send two headers with every answer:
|
||||
|
||||
| Header | Meaning |
|
||||
|--------------------|---------------------------------------------------------|
|
||||
| `X-AC-Total-Count` | how many rows match in total, ignoring `limit`/`offset` |
|
||||
| `X-AC-Truncated` | `true` when this page leaves some out |
|
||||
|
||||
and all three accept **`?countOnly=true`** (CLI `--count-only`), returning `{"count": n}` instead of
|
||||
and all five accept **`?countOnly=true`** (CLI `--count-only`), returning `{"count": n}` instead of
|
||||
rows — a completeness question is a counting question, and `@Column` on `pur` is 3 630 rows ≈ 250 k
|
||||
tokens if you ask for them.
|
||||
|
||||
@@ -89,6 +90,13 @@ page as the whole set, recording that 17 entities had lost `@Immutable` when **z
|
||||
against all 114 rows of `Tables_meta.csv`: 94 non-writable carry it, 20 writable do not, no
|
||||
deviations. The finding cost a day and was pure artefact of the cut.
|
||||
|
||||
**`search/references` and `rest-endpoints` joined this late (item 135, 2026-08-20).** Item 131 was
|
||||
written about the annotation search and both were overlooked. `search/references` was the damaging
|
||||
one: it capped at the default 50 and said nothing at all, so a rename scoped from that page missed
|
||||
every site past the fiftieth and looked complete doing it. `rest-endpoints` defaults to an uncapped
|
||||
limit and so never lost rows, but it was equally silent about how many there are. Both were found by
|
||||
`x-scripts/verify-api.sh` on its first run, not by a test.
|
||||
|
||||
Two mechanics worth knowing: the total costs a **second query only when the page comes back full**
|
||||
(a short page is provably the end, so the total is arithmetic), and a total that divides evenly by
|
||||
`limit` makes the last full page report `truncated` with the next page empty — one wasted call, never
|
||||
@@ -97,7 +105,7 @@ body into `jq` stays clean.
|
||||
|
||||
## REST surface and scope headers (item 130)
|
||||
|
||||
`GET /api/projects/{p}/rest-endpoints?module=&limit=&offset=` (CLI `ac rest-endpoints`) lists
|
||||
`GET /api/projects/{p}/rest-endpoints?module=&countOnly=&limit=&offset=` (CLI `ac rest-endpoints`) lists
|
||||
`{httpMethod, path, module, moduleSimpleName, handler, sourceFile, startLine}` — the **composed**
|
||||
path (class-level `@Path` + method-level `@Path`), so "which code runs for `POST /partners`" is one
|
||||
call. Previously the two halves had to be joined by hand from two `/search/annotation` calls, because
|
||||
@@ -130,7 +138,7 @@ keep this off the request's critical path, and the ingest path invalidates that
|
||||
|
||||
## Every reference site of a name (item 128)
|
||||
|
||||
`GET /api/projects/{p}/search/references?name=&kind=&limit=&offset=` (CLI `ac references <name>`)
|
||||
`GET /api/projects/{p}/search/references?name=&kind=&countOnly=&limit=&offset=` (CLI `ac references <name>`)
|
||||
returns `{sourceFile, lineNo, kind, inModule, target}` per **mention** of a type — not just per call:
|
||||
|
||||
| `kind` | Where it comes from |
|
||||
@@ -861,6 +869,45 @@ origins (`http://localhost:5173`, `http://localhost:4173`) — extend the
|
||||
Full endpoint list, request params, and response field details:
|
||||
`x-docs/agent-api-system-prompt.md`.
|
||||
|
||||
## Errors are structured JSON — always (item 136)
|
||||
|
||||
Every failure now answers `{ "error": ..., "code": ..., "details": {} }`, including the ones nobody
|
||||
planned for: an unhandled exception is mapped to `500 INTERNAL_ERROR` with an `errorId` in `details`
|
||||
that matches the stack trace in the server log (the trace itself is never in the response). Before
|
||||
this, an unexpected fault escaped as a plain-text Quarkus error page with no `code` to branch on —
|
||||
which is exactly the moment a client most needs a machine-readable answer. Deliberate statuses
|
||||
(`PROJECT_NOT_FOUND`, `MISSING_NAME`, `STALE_SOURCE`, the runtime's own routing 404s) pass through
|
||||
unchanged.
|
||||
|
||||
The fault that exposed this: `search/identifier` coerced `startLine`/`endLine` unconditionally, and
|
||||
item 114's duplicate markers were the one kind of node created without them, so any page long enough
|
||||
to reach a marker (row 487 on `ac`) died. Both halves are fixed — the markers now carry lines, and the
|
||||
row mapper no longer trusts that they will.
|
||||
|
||||
## Verifying the API after a deploy (item 134)
|
||||
|
||||
After `./manage-ac.sh deploy` (or `./rebuild-and-refresh.sh`), run:
|
||||
|
||||
```bash
|
||||
./x-scripts/verify-api.sh # defaults to project 'ac'
|
||||
./x-scripts/verify-api.sh -p upms # any ingested project
|
||||
AC_SERVER_URL=http://host:8787 ./x-scripts/verify-api.sh
|
||||
```
|
||||
|
||||
It answers one question in ~10 s: *does the server that is running right now still return
|
||||
plausible data over the real graph?* Exit 0 = all green, 1 = at least one check failed. Every line is
|
||||
`PASS`, `FAIL` or `SKIP`; `SKIP` means the endpoint family does not apply to that project (a pure
|
||||
Natural project has no `rest-endpoints`, a leaf module has no callees).
|
||||
|
||||
What it covers: `/api/version` and the project list; the item-130 scope headers
|
||||
(`X-AC-Exclude-Dirs`, `X-AC-Ingested-At`, `X-AC-Ingest-Incomplete` — a `true` there means a refresh
|
||||
was aborted and every later answer is drawn from a half-updated graph); the item-131 paging contract
|
||||
on the search endpoints; per-family data plausibility; and the structured-error negative cases.
|
||||
|
||||
What it is **not**: a substitute for `mvn test`. The integration tests pin semantics; this pins
|
||||
"the deployed thing is not obviously broken". A green run is not a quality gate. All assertions are
|
||||
invariants, never fixed counts — counts move with every refresh.
|
||||
|
||||
## Missing capability?
|
||||
|
||||
If the API/CLI genuinely cannot answer a question (not just
|
||||
|
||||
@@ -382,6 +382,95 @@ its probe was not ambiguous, so the answer had been correct all along (see the r
|
||||
|
||||
## Follow-ups found while closing 125-130 (2026-08-19)
|
||||
|
||||
- [x] **134. A post-deploy API smoke test** (2026-08-20)
|
||||
|
||||
**Why.** Items 129 and 130 both shipped with bugs that no test caught and that only manual live
|
||||
probing exposed: `//file` in a REST path, 183 duplicate rows of 436, an inherited JAX-RS `@Path`
|
||||
collapsing to `POST /`, `?paths=pom.xml` being accepted as a source file. The integration tests pin
|
||||
semantics against fixtures; nothing checked the *deployed* server against the *real* graph.
|
||||
|
||||
**Delivered.** `x-scripts/verify-api.sh` — `curl` + `python3` only, no Maven, no Docker, ~10s
|
||||
against `ac`/`app`/`pur`, ~25s against `upms`. `./x-scripts/verify-api.sh [-p <project>]`, exit 0/1,
|
||||
one `PASS`/`FAIL`/`SKIP` line per check. Five groups: reachability and version; the item-130 scope
|
||||
and freshness headers; the item-131 paging contract (`X-AC-Total-Count` numeric, `countOnly` agrees
|
||||
with the full total, `limit=1` sets `X-AC-Truncated`, consecutive offsets are disjoint); data
|
||||
plausibility per endpoint family; and the negative cases (structured `PROJECT_NOT_FOUND` /
|
||||
`MODULE_NOT_FOUND`, and the item-129 `?paths=pom.xml` regression).
|
||||
|
||||
**Deliberate limits.** Every assertion is an invariant (`> 0`, no duplicates, header present,
|
||||
required field set) — never a fixed row count, because counts move with each refresh and differ per
|
||||
project. Endpoint families that a project legitimately lacks (`rest-endpoints` in a pure Natural
|
||||
project, an annotation search with no hits, a leaf module with no callees) report `SKIP`, not `FAIL`.
|
||||
The run is read-only apart from `refresh?paths=pom.xml`, which resolves no file and starts no ingest.
|
||||
The duplicate-row check on `rest-endpoints` cannot surface item 75 — that query already applies
|
||||
`DISTINCT`; it only guards against the `DISTINCT` being dropped again. This is **not** a test
|
||||
replacement and must not be treated as a quality gate.
|
||||
|
||||
**It paid for itself on the first run** — three real defects, now items 135 and 136 below.
|
||||
|
||||
- [x] **135. `search/references` and `rest-endpoints` were left out of item 131 — they still truncate silently** (
|
||||
2026-08-20)
|
||||
|
||||
**Symptom.** `verify-api.sh` reports on all four projects: `search/references: X-AC-Total-Count is
|
||||
numeric — got '' (status=200)`, same for `rest-endpoints`. Neither response carries
|
||||
`X-AC-Total-Count` or `X-AC-Truncated`.
|
||||
|
||||
**Cause.** Both handlers in `AnalysisResource` return `ok(...)` on a plain `List` instead of
|
||||
`paged(...)` on a `Page<>`. `searchReferences` uses `effectiveLimit(limit)`, so it caps at the
|
||||
default 50 with no signal at all — exactly the failure item 131 set out to remove. `restEndpoints`
|
||||
uses `uncappedLimit(limit)`, so it does not lose rows today, but it is equally silent about how many
|
||||
there are.
|
||||
|
||||
**Delivered.** Both queries split into `*_CORE` + a shared row projection + a `*_COUNT` that is
|
||||
`WITH DISTINCT <the same columns> RETURN count(*)` — the same column set as the row projection, on
|
||||
purpose: a narrower distinct key would count rows the page never delivers. Neither count carries
|
||||
`$scanCap`, for the reason already pinned on `SEARCH_IDENTIFIER_COUNT`. `restEndpointsPage` /
|
||||
`searchReferencesPage` go through the existing `withTotal(...)`, so the count query runs only when
|
||||
the page comes back full — and with `rest-endpoints`' default uncapped limit it never runs at all.
|
||||
Both endpoints gained `?countOnly=true`, both CLI commands `--count-only`; `warnIfTruncated` already
|
||||
sat in `printResponse`, so the stderr note started working the moment the headers appeared.
|
||||
`SearchTruncationIT` grew from 8 to 15 tests.
|
||||
|
||||
**One thing the fixture taught us:** a reference to a type in the *same package* produces no edge at
|
||||
all — there is no import, and `TypeResolver` cannot turn the bare simple name into an identity. The
|
||||
test fixture had to put the referenced class in its own package to have anything to page over. That
|
||||
limit is documented on `JavaParser#addReferenceEdges` and is worth knowing before scoping a rename
|
||||
inside a single package.
|
||||
|
||||
- [x] **136. `search/identifier` faults with an unstructured 500 on a deep page** (2026-08-20)
|
||||
|
||||
**Symptom.** `GET /api/projects/ac/search/identifier?name=e&contains=true&limit=500` answers
|
||||
**500** with a plain-text Quarkus error page. `limit=300` is fine, so it is not the limit itself but
|
||||
which rows land in the page. Server log: `org.neo4j.driver.exceptions.value.Uncoercible: Cannot
|
||||
coerce NULL to Java int`.
|
||||
|
||||
**Cause.** The identifier row mapper coerces `startLine`/`endLine` unconditionally, and at least one
|
||||
node in `ac` carries NULL there. Only reachable past ~300 rows, which is why no test and no manual
|
||||
probe hit it.
|
||||
|
||||
**Two defects, not one.** Beyond the mapping bug, the response violated the API principle that errors
|
||||
are structured JSON — an agent got an HTML-ish body with no `code` to branch on.
|
||||
|
||||
**Delivered.** Three changes, because one alone would have been a patch over a symptom:
|
||||
1. **Cause.** `MARK_DUPLICATE_IDENTITIES` (item 114) was the only node-creating query that set
|
||||
neither `startLine` nor `endLine`. It now sets both via `coalesce(n.startLine, 0)` — `coalesce`
|
||||
because its `MERGE` key is deliberately `MERGE_NODES`', so marker and reference placeholder can be
|
||||
one node whose real lines must survive. `0` is a convention, not a truth: a marker has no line.
|
||||
The alternative — letting two properties be null on a handful of nodes — pushes null handling into
|
||||
every row mapper in the project, and that is exactly how this bug happened.
|
||||
2. **Defence.** The identifier row mapper uses the existing `intOrZero(record, key)` helper. A row
|
||||
mapper must never be the thing that faults an endpoint. The other `asInt()` call sites were
|
||||
reviewed and left alone: they read `FUNCTION`/`FIELD`/SQL rows that always carry lines, and
|
||||
churning 50 call sites would have been a different, larger change.
|
||||
3. **Contract.** New `ApiExceptionMapper` (`@Provider`, `ExceptionMapper<Throwable>`) returns
|
||||
`500 INTERNAL_ERROR` with an `errorId` in `details` matching the logged stack trace, which is not
|
||||
echoed to the client. The `WebApplicationException` pass-through is load-bearing — `Throwable` is
|
||||
the least specific mapper there is, and without it every deliberate 404/400/409 would have become
|
||||
a 500. `ApiExceptionMapperTest` pins both directions.
|
||||
|
||||
**Not retroactive.** The write-side fix takes effect on the next ingest; the 11 broken nodes in `ac`
|
||||
stay until then. The mapper hardening makes the endpoint correct immediately, refresh or not.
|
||||
|
||||
- [ ] **132. `changedOnly` has a permanent re-parse floor: a file with no hashed shell is always "changed"**
|
||||
|
||||
**Symptom.** Two consecutive `POST /ac/refresh?changedOnly=true` runs with nothing edited in between:
|
||||
|
||||
353
x-scripts/verify-api.sh
Executable file
353
x-scripts/verify-api.sh
Executable file
@@ -0,0 +1,353 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# verify-api.sh — post-deploy smoke test for the AgenticCode REST API.
|
||||
#
|
||||
# Usage:
|
||||
# ./x-scripts/verify-api.sh [-p <project>] # default project: ac
|
||||
# AC_SERVER_URL=http://host:8787 ./x-scripts/verify-api.sh -p pur
|
||||
#
|
||||
# Exit 0 = every check passed, 1 = at least one failed, 2 = usage/precondition error.
|
||||
#
|
||||
# What this IS: a check that the *deployed* server, against the *real* graph, still answers
|
||||
# plausibly — the failure class that only ever showed up in manual live probing ('//file' in
|
||||
# REST paths, duplicated rows, an inherited @Path collapsing to 'POST /', ?paths=pom.xml
|
||||
# being accepted). It runs in well under a minute and touches no build tooling.
|
||||
#
|
||||
# What this is NOT: a replacement for the integration tests. Those pin semantics; this pins
|
||||
# "the thing we just deployed is not obviously broken". Never treat a green run here as a
|
||||
# quality gate.
|
||||
#
|
||||
# Assertions are INVARIANTS (> 0, no duplicates, header present, required field set), never
|
||||
# fixed row counts — counts move with every refresh and differ per project.
|
||||
#
|
||||
# Mutation: the run is read-only except for one call, `refresh?paths=pom.xml`, which by
|
||||
# design resolves no source file and therefore starts no ingest. It does invalidate the
|
||||
# project metadata cache. Nothing else writes.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SERVER="${AC_SERVER_URL:-http://localhost:8787}"
|
||||
API="$SERVER/api"
|
||||
PROJECT="ac"
|
||||
|
||||
usage() { echo "Usage: $0 [-p <project>]" >&2; exit 2; }
|
||||
while getopts ":p:h" opt; do
|
||||
case "$opt" in
|
||||
p) PROJECT="$OPTARG" ;;
|
||||
h) usage ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
command -v curl >/dev/null 2>&1 || { echo "verify-api.sh: curl is required." >&2; exit 2; }
|
||||
command -v python3 >/dev/null 2>&1 || { echo "verify-api.sh: python3 is required." >&2; exit 2; }
|
||||
|
||||
PASSED=0
|
||||
FAILED=0
|
||||
START=$(date +%s)
|
||||
|
||||
GREEN=$'\033[0;32m'; RED=$'\033[0;31m'; BOLD=$'\033[1m'; DIM=$'\033[2m'; OFF=$'\033[0m'
|
||||
|
||||
pass() { PASSED=$((PASSED + 1)); printf ' %sPASS%s %-52s %s%s%s\n' "$GREEN" "$OFF" "$1" "$DIM" "${2:-}" "$OFF"; }
|
||||
fail() { FAILED=$((FAILED + 1)); printf ' %sFAIL%s %-52s %s\n' "$RED" "$OFF" "$1" "${2:-}"; }
|
||||
group() { printf '\n%s%s%s\n' "$BOLD" "$1" "$OFF"; }
|
||||
|
||||
# check <name> <condition-exit-code> <detail>
|
||||
check() {
|
||||
if [[ "$2" -eq 0 ]]; then pass "$1" "${3:-}"; else fail "$1" "${3:-}"; fi
|
||||
}
|
||||
|
||||
BODY=$(mktemp); HEAD=$(mktemp)
|
||||
trap 'rm -f "$BODY" "$HEAD"' EXIT
|
||||
|
||||
# get <path...> -> sets STATUS, body in $BODY, headers in $HEAD
|
||||
get() {
|
||||
STATUS=$(curl -s -m 30 -o "$BODY" -D "$HEAD" -w '%{http_code}' "$@" 2>/dev/null)
|
||||
[[ -n "$STATUS" ]] || STATUS=000
|
||||
}
|
||||
|
||||
hdr() { grep -i "^$1:" "$HEAD" | head -1 | cut -d' ' -f2- | tr -d '\r'; }
|
||||
|
||||
# py <expr-script> — runs python3 against the body; exit 0 means the assertion held.
|
||||
py() { python3 -c "$1" "$BODY" "${@:2}" 2>/dev/null; }
|
||||
|
||||
printf '%sverify-api.sh%s server=%s project=%s\n' "$BOLD" "$OFF" "$SERVER" "$PROJECT"
|
||||
|
||||
# --- 1. reachability & version ------------------------------------------------------------
|
||||
group "1. Reachability & version"
|
||||
|
||||
get "$API/version"
|
||||
check "GET /api/version returns 200" "$([[ $STATUS == 200 ]] && echo 0 || echo 1)" "status=$STATUS"
|
||||
if [[ $STATUS == 200 ]]; then
|
||||
VERSION=$(py 'import json,sys; d=json.load(open(sys.argv[1])); v=str(d.get("version","")); print(v); sys.exit(0 if v else 1)')
|
||||
check "version is non-empty" $? "version=$VERSION"
|
||||
else
|
||||
echo " server unreachable at $SERVER — is the stack deployed (./manage-ac.sh deploy)?" >&2
|
||||
fi
|
||||
|
||||
get "$API/projects"
|
||||
check "GET /api/projects returns 200" "$([[ $STATUS == 200 ]] && echo 0 || echo 1)" "status=$STATUS"
|
||||
py 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if any(p.get("name")==sys.argv[2] for p in d) else 1)' "$PROJECT"
|
||||
check "project '$PROJECT' is listed" $?
|
||||
|
||||
if [[ $FAILED -gt 0 ]]; then
|
||||
echo
|
||||
echo "Aborting: the server is not usable, later checks would only add noise." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- 2. scope & freshness headers (item 130) ----------------------------------------------
|
||||
group "2. Scope & freshness headers (item 130)"
|
||||
|
||||
get "$API/projects/$PROJECT/modules?limit=1"
|
||||
EXCL=$(hdr X-AC-Exclude-Dirs); AT=$(hdr X-AC-Ingested-At); INC=$(hdr X-AC-Ingest-Incomplete)
|
||||
check "X-AC-Exclude-Dirs present" "$([[ -n $EXCL ]] && echo 0 || echo 1)" "$EXCL"
|
||||
check "X-AC-Ingested-At present" "$([[ -n $AT ]] && echo 0 || echo 1)" "$AT"
|
||||
check "X-AC-Ingest-Incomplete=false" "$([[ $INC == false ]] && echo 0 || echo 1)" \
|
||||
"$([[ $INC == false ]] || echo "got '$INC' — a refresh was aborted or is running")"
|
||||
|
||||
# --- 3. paging contract (item 131) --------------------------------------------------------
|
||||
group "3. Paging contract (item 131)"
|
||||
|
||||
# Endpoints that carry X-AC-Total-Count, each with a query that yields rows in any project.
|
||||
paging_check() {
|
||||
local label="$1" path="$2"
|
||||
|
||||
get "$API/projects/$PROJECT/$path"
|
||||
local total; total=$(hdr X-AC-Total-Count)
|
||||
if ! [[ $total =~ ^[0-9]+$ ]]; then
|
||||
fail "$label: X-AC-Total-Count is numeric" "got '$total' (status=$STATUS)"
|
||||
return
|
||||
fi
|
||||
pass "$label: X-AC-Total-Count is numeric" "total=$total"
|
||||
|
||||
if [[ $total -eq 0 ]]; then
|
||||
printf ' %sSKIP%s %-52s %s%s%s\n' "$DIM" "$OFF" "$label: paging (no rows to page)" "$DIM" "total=0" "$OFF"
|
||||
return
|
||||
fi
|
||||
|
||||
# countOnly must report the same total without returning the page.
|
||||
get "$API/projects/$PROJECT/$(printf %s "$path" | sed 's/?/?countOnly=true\&/')"
|
||||
local co; co=$(hdr X-AC-Total-Count)
|
||||
check "$label: countOnly agrees with full total" \
|
||||
"$([[ $co == "$total" ]] && echo 0 || echo 1)" "countOnly=$co full=$total"
|
||||
|
||||
# limit=1 must flag truncation whenever more than one row exists.
|
||||
get "$API/projects/$PROJECT/$(printf %s "$path" | sed 's/?/?limit=1\&/')"
|
||||
local tr; tr=$(hdr X-AC-Truncated)
|
||||
local want=false; [[ $total -gt 1 ]] && want=true
|
||||
check "$label: limit=1 sets X-AC-Truncated=$want" \
|
||||
"$([[ $tr == "$want" ]] && echo 0 || echo 1)" "truncated=$tr total=$total"
|
||||
|
||||
# Two pages of 1 must be disjoint — the offset actually moves.
|
||||
local p0 p1
|
||||
get "$API/projects/$PROJECT/$(printf %s "$path" | sed 's/?/?limit=1\&offset=0\&/')"
|
||||
p0=$(py 'import json,sys; d=json.load(open(sys.argv[1])); print(json.dumps(d[0],sort_keys=True) if d else "")')
|
||||
get "$API/projects/$PROJECT/$(printf %s "$path" | sed 's/?/?limit=1\&offset=1\&/')"
|
||||
p1=$(py 'import json,sys; d=json.load(open(sys.argv[1])); print(json.dumps(d[0],sort_keys=True) if d else "")')
|
||||
if [[ $total -gt 1 ]]; then
|
||||
check "$label: offset=0 and offset=1 are disjoint" \
|
||||
"$([[ -n $p0 && -n $p1 && $p0 != "$p1" ]] && echo 0 || echo 1)"
|
||||
fi
|
||||
}
|
||||
|
||||
paging_check "search/identifier" "search/identifier?name=e&contains=true"
|
||||
|
||||
# A deep page must not blow up: a row with a NULL startLine used to escape as an unstructured
|
||||
# 500 ("Cannot coerce NULL to Java int") that only appears past the first few hundred rows.
|
||||
get "$API/projects/$PROJECT/search/identifier?name=e&contains=true&limit=500"
|
||||
check "search/identifier: a 500-row page does not fault" \
|
||||
"$([[ $STATUS == 200 ]] && echo 0 || echo 1)" "status=$STATUS"
|
||||
paging_check "search/annotation" "search/annotation?name=ApplicationScoped"
|
||||
paging_check "search/value" "search/value?value=project"
|
||||
paging_check "search/references" "search/references?name=Logger"
|
||||
paging_check "rest-endpoints" "rest-endpoints?"
|
||||
|
||||
# --- 4. data plausibility -----------------------------------------------------------------
|
||||
group "4. Data plausibility"
|
||||
|
||||
# rest-endpoints: the concrete bugs that only real data exposed.
|
||||
get "$API/projects/$PROJECT/rest-endpoints?limit=500"
|
||||
if [[ $STATUS == 200 ]]; then
|
||||
# A pure Natural project declares no HTTP endpoints — absence is correct there, not a defect.
|
||||
if ! py 'import json,sys; sys.exit(0 if json.load(open(sys.argv[1])) else 1)'; then
|
||||
printf ' %sSKIP%s %-52s %s%s%s\n' "$DIM" "$OFF" "rest-endpoints (project declares none)" \
|
||||
"$DIM" "0 rows — expected for a non-JAX-RS project" "$OFF"
|
||||
REST_ROWS=0
|
||||
else
|
||||
pass "rest-endpoints returns rows"
|
||||
REST_ROWS=1
|
||||
fi
|
||||
if [[ $REST_ROWS == 1 ]]; then
|
||||
py 'import json,sys; d=json.load(open(sys.argv[1])); bad=[e["path"] for e in d if "//" in e["path"]]; print(*bad[:3]); sys.exit(1 if bad else 0)'
|
||||
check "no path contains '//'" $?
|
||||
py 'import json,sys; d=json.load(open(sys.argv[1])); bad=[e["path"] for e in d if not e["path"].startswith("/")]; print(*bad[:3]); sys.exit(1 if bad else 0)'
|
||||
check "every path starts with '/'" $?
|
||||
# NOTE: the query already applies DISTINCT, so this cannot surface item 75's duplicate
|
||||
# CONTAINS edges — it only guards against that DISTINCT being dropped again.
|
||||
py 'import json,sys
|
||||
d=json.load(open(sys.argv[1]))
|
||||
k=[(e["httpMethod"],e["path"],e["module"],e["handler"]) for e in d]
|
||||
dup=len(k)-len(set(k)); print("duplicates:",dup); sys.exit(1 if dup else 0)'
|
||||
check "no duplicate endpoint rows" $?
|
||||
py 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if all("outbound" in e for e in d) else 1)'
|
||||
check "every row carries the outbound flag" $?
|
||||
py 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if any(e["httpMethod"]=="GET" and e["path"]=="/api/version" for e in d) else 1)'
|
||||
SELF=$?
|
||||
if [[ $PROJECT == ac ]]; then
|
||||
check "the server's own GET /api/version is found" $SELF
|
||||
fi
|
||||
fi
|
||||
else
|
||||
fail "rest-endpoints returns 200" "status=$STATUS"
|
||||
fi
|
||||
|
||||
# Pick a module that actually exists in this project, then exercise the module endpoints.
|
||||
get "$API/projects/$PROJECT/modules?limit=200"
|
||||
CANDIDATES=$(py 'import json,sys
|
||||
d=json.load(open(sys.argv[1]))
|
||||
c=[m for m in d if m.get("ingestDepth")=="FULL"] or d
|
||||
print("\n".join(m["name"] for m in c[:20]))')
|
||||
# Prefer a module that actually calls something — a leaf would make the callees check vacuous.
|
||||
MODULE=""; CALLEE_MODULE=""
|
||||
while IFS= read -r cand; do
|
||||
[[ -n $cand ]] || continue
|
||||
[[ -n $MODULE ]] || MODULE="$cand"
|
||||
E=$(python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1],safe=""))' "$cand")
|
||||
get "$API/projects/$PROJECT/modules/$E/callees?limit=20"
|
||||
if [[ $STATUS == 200 ]] && py 'import json,sys; sys.exit(0 if json.load(open(sys.argv[1])).get("items") else 1)'; then
|
||||
CALLEE_MODULE="$cand"; break
|
||||
fi
|
||||
done <<< "$CANDIDATES"
|
||||
check "a FULL-ingested module is available" "$([[ -n $MODULE ]] && echo 0 || echo 1)" "module=$MODULE"
|
||||
|
||||
nonempty_rows() { # <label> <path> — 200 and a non-empty array
|
||||
get "$API/projects/$PROJECT/$2"
|
||||
if [[ $STATUS != 200 ]]; then fail "$1" "status=$STATUS"; return; fi
|
||||
py 'import json,sys; d=json.load(open(sys.argv[1])); print(len(d),"rows"); sys.exit(0 if d else 1)'
|
||||
check "$1" $? ""
|
||||
}
|
||||
|
||||
fields_set() { # <label> <path> <field...> — 200 and every row has the fields
|
||||
get "$API/projects/$PROJECT/$2"
|
||||
if [[ $STATUS != 200 ]]; then fail "$1" "status=$STATUS"; return; fi
|
||||
py 'import json,sys
|
||||
d=json.load(open(sys.argv[1]))
|
||||
rows=d if isinstance(d,list) else [d]
|
||||
miss={f for r in rows for f in sys.argv[2:] if r.get(f) is None}
|
||||
print("missing:",",".join(sorted(miss)) or "-")
|
||||
sys.exit(1 if miss else 0)' "${@:3}"
|
||||
check "$1" $? ""
|
||||
}
|
||||
|
||||
if [[ -n $MODULE ]]; then
|
||||
ENC=$(python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1],safe=""))' "$MODULE")
|
||||
# callees answers with the shared file-index envelope {sourceFiles, items}: every item must
|
||||
# name a callee and every sourceFileIndex must resolve into the sourceFiles table.
|
||||
if [[ -z $CALLEE_MODULE ]]; then
|
||||
printf ' %sSKIP%s %-52s %s%s%s\n' "$DIM" "$OFF" "callees: sourceFileIndex resolves" \
|
||||
"$DIM" "no module among the first 20 has callees" "$OFF"
|
||||
else
|
||||
CENC=$(python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1],safe=""))' "$CALLEE_MODULE")
|
||||
get "$API/projects/$PROJECT/modules/$CENC/callees?limit=20"
|
||||
if [[ $STATUS == 200 ]]; then
|
||||
py 'import json,sys
|
||||
d=json.load(open(sys.argv[1]))
|
||||
files=d.get("sourceFiles",[]); items=d.get("items",[])
|
||||
def idx(i):
|
||||
v = i.get("sourceFileIndex")
|
||||
return v if isinstance(v, int) else -1
|
||||
bad=[i.get("name") for i in items if not i.get("name") or not (0 <= idx(i) < len(files))]
|
||||
print(len(items),"callees,",len(files),"files; bad:",bad[:3] or "-")
|
||||
sys.exit(1 if (not items or bad) else 0)'
|
||||
check "callees: every item resolves its sourceFileIndex" $? "via $CALLEE_MODULE"
|
||||
else
|
||||
fail "callees returns 200" "status=$STATUS"
|
||||
fi
|
||||
fi
|
||||
fields_set "functions rows carry name/startLine" "modules/$ENC/functions?limit=20" name startLine
|
||||
get "$API/projects/$PROJECT/modules/$ENC/context"
|
||||
check "context returns 200" "$([[ $STATUS == 200 ]] && echo 0 || echo 1)" "status=$STATUS"
|
||||
get "$API/projects/$PROJECT/modules/$ENC/digest"
|
||||
check "digest returns 200" "$([[ $STATUS == 200 ]] && echo 0 || echo 1)" "status=$STATUS"
|
||||
get "$API/projects/$PROJECT/modules/$ENC/graph"
|
||||
check "graph returns 200" "$([[ $STATUS == 200 ]] && echo 0 || echo 1)" "status=$STATUS"
|
||||
get "$API/projects/$PROJECT/modules/$ENC/source"
|
||||
check "source returns 200 (not STALE_SOURCE)" "$([[ $STATUS == 200 ]] && echo 0 || echo 1)" \
|
||||
"$([[ $STATUS == 200 ]] || echo "status=$STATUS — the graph is behind disk, refresh needed")"
|
||||
fi
|
||||
|
||||
nonempty_rows "modules returns rows" "modules?limit=5"
|
||||
nonempty_rows "loc returns rows" "loc?limit=5"
|
||||
# search/source answers {regex, truncated, matches} — not a bare array.
|
||||
get "$API/projects/$PROJECT/search/source?regex=project&limit=5"
|
||||
if [[ $STATUS == 200 ]]; then
|
||||
py 'import json,sys
|
||||
d=json.load(open(sys.argv[1]))
|
||||
m=d.get("matches",[])
|
||||
bad=[x for x in m if not x.get("sourceFile") or not x.get("lineNo")]
|
||||
print(len(m),"matches; truncated:",d.get("truncated"))
|
||||
sys.exit(1 if (not m or bad) else 0)'
|
||||
check "search/source matches carry sourceFile/lineNo" $? ""
|
||||
else
|
||||
fail "search/source returns 200" "status=$STATUS"
|
||||
fi
|
||||
|
||||
# A node id from a live result must resolve through /nodes/{id} and /nodes/{id}/source.
|
||||
# Pick a RESOLVED hit — an unresolved placeholder has no source file by design and would
|
||||
# legitimately answer 400 NO_SOURCE_FILE.
|
||||
SEED="${MODULE:-e}"
|
||||
get "$API/projects/$PROJECT/search/identifier?name=$(python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1],safe=""))' "$SEED")&contains=true&limit=200"
|
||||
NODE=$(py 'import json,sys
|
||||
d=json.load(open(sys.argv[1]))
|
||||
r=[n for n in d if n.get("sourceFile") and not n.get("unresolved")]
|
||||
print(r[0]["id"] if r else "")')
|
||||
if [[ -n $NODE ]]; then
|
||||
get "$API/projects/$PROJECT/nodes/$NODE"
|
||||
check "a live node id resolves via /nodes/{id}" "$([[ $STATUS == 200 ]] && echo 0 || echo 1)" "status=$STATUS"
|
||||
get "$API/projects/$PROJECT/nodes/$NODE/source"
|
||||
check "/nodes/{id}/source returns 200" "$([[ $STATUS == 200 ]] && echo 0 || echo 1)" "status=$STATUS"
|
||||
else
|
||||
fail "search/identifier yields a node id"
|
||||
fi
|
||||
|
||||
# --- 5. negative cases --------------------------------------------------------------------
|
||||
group "5. Negative cases"
|
||||
|
||||
structured_404() { # <label> <path> <expected-code>
|
||||
get "$API/projects/$2"
|
||||
if [[ $STATUS != 404 ]]; then fail "$1" "status=$STATUS (expected 404)"; return; fi
|
||||
py 'import json,sys
|
||||
d=json.load(open(sys.argv[1]))
|
||||
ok = d.get("code")==sys.argv[2] and d.get("error") and isinstance(d.get("details"),dict)
|
||||
print("code="+str(d.get("code")))
|
||||
sys.exit(0 if ok else 1)' "$3"
|
||||
check "$1" $? ""
|
||||
}
|
||||
|
||||
structured_404 "unknown project -> 404 PROJECT_NOT_FOUND" "nope-does-not-exist/modules" PROJECT_NOT_FOUND
|
||||
structured_404 "unknown module -> 404 MODULE_NOT_FOUND" "$PROJECT/modules/ZZZ-DOES-NOT-EXIST/callers" MODULE_NOT_FOUND
|
||||
|
||||
# Item 129 regression: a non-source path must come back unresolved, not be silently ingested.
|
||||
STATUS=$(curl -s -m 30 -o "$BODY" -D "$HEAD" -w '%{http_code}' -X POST \
|
||||
"$API/projects/$PROJECT/refresh?paths=pom.xml" 2>/dev/null)
|
||||
if [[ $STATUS == 200 ]]; then
|
||||
py 'import json,sys
|
||||
d=json.load(open(sys.argv[1]))
|
||||
u=d.get("unresolved") or []
|
||||
print("unresolved:",u, "persisted:", d.get("filesPersisted"))
|
||||
sys.exit(0 if "pom.xml" in u and not d.get("filesPersisted") else 1)'
|
||||
check "refresh?paths=pom.xml is unresolved, nothing ingested" $? ""
|
||||
else
|
||||
fail "refresh?paths=pom.xml returns 200" "status=$STATUS"
|
||||
fi
|
||||
|
||||
# --- summary ------------------------------------------------------------------------------
|
||||
ELAPSED=$(( $(date +%s) - START ))
|
||||
printf '\n%s' "$BOLD"
|
||||
if [[ $FAILED -eq 0 ]]; then
|
||||
printf '%sAll %d checks passed%s (%ss, server v%s, project %s)\n' "$GREEN" "$PASSED" "$OFF" "$ELAPSED" "${VERSION:-?}" "$PROJECT"
|
||||
exit 0
|
||||
fi
|
||||
printf '%s%d passed, %d FAILED%s (%ss, server v%s, project %s)\n' "$RED" "$PASSED" "$FAILED" "$OFF" "$ELAPSED" "${VERSION:-?}" "$PROJECT"
|
||||
exit 1
|
||||
Reference in New Issue
Block a user