Bug fixes

This commit is contained in:
Ingo Schnabel
2026-08-20 17:11:16 +03:00
parent 4b8a441566
commit 7adf7e54e4
15 changed files with 893 additions and 22 deletions

View File

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

View File

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

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=245
version=247

View File

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

View File

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

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

View File

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

View File

@@ -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".
*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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