New features

This commit is contained in:
Ingo Schnabel
2026-08-18 12:54:39 +03:00
parent 3309afe0e1
commit 0e54cc1589
11 changed files with 308 additions and 58 deletions

View File

@@ -12,9 +12,12 @@ import picocli.CommandLine.Parameters;
@Command(name = "search-identifier", mixinStandardHelpOptions = true, description = "Search for an identifier across all modules, or list all identifiers")
final class SearchIdentifierCommand extends AbstractProjectCommand {
@Parameters(index = "0", arity = "0..1", description = "Identifier name; a leading Natural sigil (# & +) is ignored (omit to list all identifiers)")
@Parameters(index = "0", arity = "0..1", description = "Identifier name — a Java type's short or fully-qualified form; a leading Natural sigil (# & +) is ignored (omit to list all identifiers)")
@Nullable String name;
@Option(names = "--contains", description = "Match names that contain the given name (case-insensitive) instead of equalling it; requires a name")
boolean contains;
@Option(names = "--type", description = "Node type filter (MODULE, FUNCTION, VARIABLE, DATA_STRUCTURE, DB_TABLE)")
@Nullable String type;
@@ -42,6 +45,9 @@ final class SearchIdentifierCommand extends AbstractProjectCommand {
path = appendQuery(path, "module", module);
path = appendQuery(path, "priorityModule", priorityModule);
path = appendQuery(path, "sourceFile", sourceFile);
if (contains) {
path = appendQuery(path, "contains", "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=215
version=217

View File

@@ -771,6 +771,7 @@ public class AnalysisResource {
@GET
@Path("/search/identifier")
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = IdentifierMatch.class)))
@APIResponse(responseCode = "400", description = "Unknown node 'type', or 'contains' without a 'name'.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> searchIdentifier(@PathParam("project") String project, @QueryParam("name") @Nullable String name,
@QueryParam("type") @Nullable String type,
@@ -780,8 +781,17 @@ public class AnalysisResource {
@QueryParam("module") @Nullable String module,
@Parameter(description = "Do not filter, but pin this module's matches to the front so its local declaration survives the limit when a name recurs across many modules.")
@QueryParam("priorityModule") @Nullable String priorityModule,
@Parameter(description = "Match names that contain 'name' (case-insensitive) instead of equalling it, as on /search/value. Matches a Java type's fully-qualified name as well as its short form, so a package fragment also hits. Requires a non-blank 'name'.")
@QueryParam("contains") @Nullable Boolean contains,
@QueryParam("limit") @Nullable Integer limit, @QueryParam("offset") @Nullable Integer offset,
@QueryParam("fields") @Nullable String fields) {
// Item 125: a substring search for nothing is a full node dump, and was previously the
// silent outcome — the parameter did not exist, so JAX-RS dropped it and the caller read the
// resulting [] as "no such identifier".
if (Boolean.TRUE.equals(contains) && (name == null || name.isBlank())) {
return Uni.createFrom().item(ProjectResource.error(Response.Status.BAD_REQUEST, "MISSING_NAME",
"Query parameter 'name' is required when 'contains' is true"));
}
if (type != null) {
try {
NodeType.valueOf(type.toUpperCase());
@@ -792,7 +802,8 @@ public class AnalysisResource {
}
return withFanoutWarm(project,
() -> graphRepository.searchIdentifier(project, name,
type != null ? type.toUpperCase() : null, sourceFile, module, priorityModule, effectiveLimit(limit), effectiveOffset(offset)),
type != null ? type.toUpperCase() : null, sourceFile, module, priorityModule,
Boolean.TRUE.equals(contains), effectiveLimit(limit), effectiveOffset(offset)),
matches -> matches.stream().map(IdentifierMatch::sourceFile).filter(sf -> !sf.isEmpty()).distinct().toList(),
matches -> namesOnly(fields) ? ok(identifierNames(matches)) : ok(matches));
}

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=215
agenticcode.version=217
# 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

@@ -95,7 +95,7 @@ class CoalescingIT {
assertTrue(state.isFull(), "the module is FULL after concurrent deep ingests");
assertEquals(IngestStatus.INGESTED.name(), state.status());
List<IdentifierMatch> realModules = graphRepository.searchIdentifier(PROJECT, MODULE, "MODULE", null, null, null, 50, 0)
List<IdentifierMatch> realModules = graphRepository.searchIdentifier(PROJECT, MODULE, "MODULE", null, null, null, false, 50, 0)
.await().indefinitely().stream()
.filter(m -> !m.sourceFile().isEmpty())
.toList();

View File

@@ -0,0 +1,165 @@
package com.agenticcode.codeserver.api;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
/**
* Item 125: a Java type declaration must be findable through {@code search/identifier} by its
* <b>short</b> name, not only by the fully-qualified identity the graph stores (item 117), and
* {@code ?contains=} must do what it does on {@code /search/value} instead of being dropped.
*
* <p>The bug this pins down was not "types are not indexed" — they were, under their FQN — but that
* the short form an agent actually types answered {@code []}, which is indistinguishable from "no
* such name". The match now also carries {@code simpleName} and {@code moduleKind}, so "is this a
* type or a method?" is answered by the search itself.
*/
@QuarkusTest
class IdentifierTypeDeclarationIT {
private static final String PROJECT = "identifier-type-decl-project";
private static final String FQN = "com.example.idt.PartnerUpdateLogic";
@TempDir
static Path root;
@BeforeAll
static void ingestFixtures() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
Path pkg = root.resolve("src/main/java/com/example/idt");
writeSource(pkg, "PartnerUpdateLogic.java", """
package com.example.idt;
public class PartnerUpdateLogic {
private String partnerName;
public void updatePartner() {
this.partnerName = "x";
}
}
""");
writeSource(pkg, "PartnerReadPort.java", """
package com.example.idt;
public interface PartnerReadPort {
String read();
}
""");
given()
.contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "java", null, null))
.when().post("/api/projects/" + PROJECT)
.then()
.statusCode(201);
given()
.when().post("/api/projects/" + PROJECT + "/refresh?deep=true")
.then()
.statusCode(200);
}
private static void writeSource(Path dir, String fileName, String content) {
try {
Files.createDirectories(dir);
Files.writeString(dir.resolve(fileName), content);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private static io.restassured.specification.RequestSpecification search() {
return given().queryParam("type", "MODULE");
}
@Test
void theShortNameFindsTheTypeDeclaration() {
search()
.queryParam("name", "PartnerUpdateLogic")
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then()
.statusCode(200)
.body("name", hasItem(FQN))
.body("find { it.name == '" + FQN + "' }.simpleName", equalTo("PartnerUpdateLogic"))
.body("find { it.name == '" + FQN + "' }.moduleKind", equalTo("CLASS"));
}
@Test
void theQualifiedNameStillFindsIt() {
search()
.queryParam("name", FQN)
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then()
.statusCode(200)
.body("name", hasItem(FQN));
}
@Test
void moduleKindDistinguishesAnInterfaceFromAClass() {
search()
.queryParam("name", "PartnerReadPort")
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then()
.statusCode(200)
.body("moduleKind", everyItem(equalTo("INTERFACE")));
}
@Test
void containsMatchesASubstringOfTheShortNameCaseInsensitively() {
search()
.queryParam("name", "partnerupd").queryParam("contains", true)
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then()
.statusCode(200)
.body("name", hasItem(FQN));
}
@Test
void containsAlsoMatchesThePackageFragmentOfTheQualifiedName() {
search()
.queryParam("name", "com.example.idt").queryParam("contains", true)
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then()
.statusCode(200)
.body("name", hasItems(FQN, "com.example.idt.PartnerReadPort"));
}
@Test
void withoutContainsASubstringIsNotAMatch() {
search()
.queryParam("name", "PartnerUpd")
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then()
.statusCode(200)
.body("$", hasSize(0));
}
@Test
void aMethodMatchCarriesNoSimpleNameOrModuleKind() {
given()
.queryParam("name", "updatePartner")
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then()
.statusCode(200)
.body("find { it.type == 'FUNCTION' }.simpleName", nullValue())
.body("find { it.type == 'FUNCTION' }.moduleKind", nullValue());
}
@Test
void containsWithoutANameIsRejectedRatherThanDumpingEveryNode() {
given()
.queryParam("contains", true)
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then()
.statusCode(400)
.body("code", equalTo("MISSING_NAME"));
}
}

View File

@@ -2386,8 +2386,22 @@ public final class CypherQueries {
// when a name recurs across many modules. ORDER BY makes the page deterministic (it was not before).
public static final String SEARCH_IDENTIFIER = """
MATCH (n:AstNode {project: $project})
// Item 125: a Java type declaration's identity is its FQN (item 117), so matching n.name
// alone made a class findable only by its fully-qualified name — the short form an agent
// actually types answered [], which reads as "no such name" rather than "wrong form of the
// name". n.simpleName is matched alongside it. It is null for Natural, where the
// sigil-stripped n.name remains the only identity, so nothing changes there.
WITH n, (CASE WHEN left(n.name, 1) IN ['#', '&', '+'] THEN substring(n.name, 1) ELSE n.name END) AS bare
// $contains (item 125) switches the name predicate to a case-insensitive substring match,
// as /search/value already does — "every identifier containing 'upd'" was unanswerable.
// It matches the FQN too, so a package fragment also hits: over-matching is visible and
// filterable (?type=MODULE, moduleKind), silence is not.
WHERE ($name IS NULL OR
(CASE WHEN left(n.name, 1) IN ['#', '&', '+'] THEN substring(n.name, 1) ELSE n.name END) = $name)
CASE WHEN $contains
THEN toLower(bare) CONTAINS toLower($name)
OR (n.simpleName IS NOT NULL AND toLower(n.simpleName) CONTAINS toLower($name))
ELSE bare = $name OR n.simpleName = $name
END)
AND ($type IS NULL OR n.type = $type)
AND ($sourceFile IS NULL OR n.sourceFile = $sourceFile)
AND ($module IS NULL OR EXISTS {
@@ -2402,8 +2416,18 @@ public final class CypherQueries {
} THEN 0 ELSE 1 END AS pinRank
RETURN elementId(n) AS id, n.type AS type, n.name AS name, n.sourceFile AS sourceFile,
n.startLine AS startLine, n.endLine AS endLine,
n.dataType AS dataType, n.value AS value, n.scope AS scope, n.unresolved AS unresolved
n.dataType AS dataType, n.value AS value, n.scope AS scope, n.unresolved AS unresolved,
// Item 125: the short form and the declaration kind (CLASS/INTERFACE/ENUM/RECORD,
// PROGRAM/SUBPROGRAM for Natural), so a caller can tell a type declaration from a
// method or a variable without a second round trip. Null for non-MODULE nodes.
n.simpleName AS simpleName, n.moduleKind AS moduleKind
ORDER BY pinRank ASC, n.sourceFile ASC, n.startLine ASC
// $scanCap bounds an unindexed CONTAINS over a large project: it is offset+limit, i.e.
// exactly the rows the caller's page can need, and Integer.MAX_VALUE when the caller asked
// for everything (limit <= 0). Slicing [offset, offset+limit) out of the first offset+limit
// ordered rows is identical to slicing it out of the full ordered list, so paginate()'s
// semantics — including "limit <= 0 means unlimited" — are untouched.
LIMIT $scanCap
""";
/**
* Item 78: deletes a project's {@code AstNode}s <b>in batches</b>, leaving its {@code (:Project)}

View File

@@ -1081,19 +1081,37 @@ public class GraphRepository {
}
/**
* Paginated variant of {@link #searchIdentifier(String, String, String, String, String, String)} for the endpoint.
* Paginated variant of {@link #searchIdentifier(String, String, String, String, String, String, boolean, int)}
* for the endpoint.
*
* @param contains item 125: match names that <em>contain</em> {@code identifierName}
* (case-insensitive) instead of equalling it
*/
public Uni<List<IdentifierMatch>> searchIdentifier(String project, @Nullable String identifierName,
@Nullable String type, @Nullable String sourceFile,
@Nullable String module, @Nullable String priorityModule,
int limit, int offset) {
return searchIdentifier(project, identifierName, type, sourceFile, module, priorityModule)
boolean contains, int limit, int offset) {
// Item 125: fetch at most the rows this page can consume, so an unindexed CONTAINS over a
// large project cannot materialise its whole match set here. paginate() then slices exactly
// as before — including its "limit <= 0 means unlimited" rule, which is why the cap is
// Integer.MAX_VALUE in that case rather than 0.
int scanCap = limit > 0 ? (int) Math.min((long) Math.max(offset, 0) + limit, Integer.MAX_VALUE)
: Integer.MAX_VALUE;
return searchIdentifier(project, identifierName, type, sourceFile, module, priorityModule, contains, scanCap)
.map(list -> paginate(list, limit, offset));
}
public Uni<List<IdentifierMatch>> searchIdentifier(String project, @Nullable String identifierName,
@Nullable String type, @Nullable String sourceFile,
@Nullable String module, @Nullable String priorityModule) {
return searchIdentifier(project, identifierName, type, sourceFile, module, priorityModule,
false, Integer.MAX_VALUE);
}
public Uni<List<IdentifierMatch>> searchIdentifier(String project, @Nullable String identifierName,
@Nullable String type, @Nullable String sourceFile,
@Nullable String module, @Nullable String priorityModule,
boolean contains, int scanCap) {
Map<String, @Nullable Object> params = new java.util.HashMap<>();
params.put("project", project);
// Sigil-insensitive: strip a leading Natural sigil (# user, & AIV, + GDA) so a search for
@@ -1106,6 +1124,9 @@ public class GraphRepository {
// Pin this module's matches to the front so they survive limit/paginate truncation when a
// name recurs across many modules (the caller's local declaration would otherwise be lost).
params.put("priorityModule", priorityModule);
// Item 125: substring vs exact name match, and the row cap the query's LIMIT reads.
params.put("contains", contains);
params.put("scanCap", scanCap);
return read(CypherQueries.SEARCH_IDENTIFIER, params, record -> new IdentifierMatch(
record.get("id").asString(),
NodeType.valueOf(record.get("type").asString()),
@@ -1116,7 +1137,9 @@ public class GraphRepository {
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(),
record.get("unresolved").isNull() ? null : record.get("unresolved").asBoolean()));
record.get("unresolved").isNull() ? null : record.get("unresolved").asBoolean(),
record.get("simpleName").isNull() ? null : record.get("simpleName").asString(),
record.get("moduleKind").isNull() ? null : record.get("moduleKind").asString()));
}
/**

View File

@@ -13,8 +13,16 @@ import org.jspecify.annotations.Nullable;
* @param unresolved item 40: {@code true} when this match is an unresolved reference placeholder
* ({@code sourceFile} is blank and no real definition of the name is ingested);
* {@code false} for a resolved placeholder, {@code null} for a normal (real) node
* @param simpleName item 125: a Java type declaration's short form, where {@code name} is its
* fully-qualified identity (item 117). {@code null} for every non-{@code MODULE} node and
* for Natural modules, whose {@code name} already is the short form
* @param moduleKind item 125: the declaration kind of a {@code MODULE} match — {@code CLASS},
* {@code INTERFACE}, {@code ENUM}, {@code RECORD} for Java, {@code PROGRAM},
* {@code SUBPROGRAM} for Natural — so "is this name a type or a method?" is answered by
* the search itself rather than by a follow-up call. {@code null} for non-{@code MODULE} nodes
*/
public record IdentifierMatch(String id, NodeType type, String name, String sourceFile, int startLine, int endLine,
@Nullable String dataType, @Nullable String value, @Nullable String scope,
@Nullable Boolean unresolved) {
@Nullable Boolean unresolved, @Nullable String simpleName,
@Nullable String moduleKind) {
}

View File

@@ -673,31 +673,31 @@ origins (`http://localhost:5173`, `http://localhost:4173`) — extend the
## Endpoint quick reference
| Endpoint | Use for |
|----------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `GET /modules?sourceFile=&moduleKind=&extends=` | List/filter modules; map a source file to its module name(s). Each row carries `loc`/`sloc` (item 46) and `ingestStatus`/`ingestDepth` (item 50) for status badges without a per-module round trip |
| `GET /loc?language=&sourceFile=` | Per-language LoC/SLoC rollup (fileCount/loc/sloc) + project total; each file counted once (item 46). For a generated/user_exit project also `userExitLoc`/`userExitSloc` + `generatedExclusiveLoc`/`generatedExclusiveSloc` (item 47) |
| `GET /modules/{name}/digest` | Tiny triage view before deciding which modules to expand |
| `GET /modules/{name}/context` | One-shot overview: functions, callers, callees, DB accesses, SQL/variable summaries (`?include=` for full lists) |
| `GET /modules/{name}/callers` \| `/callees` | Direct callers/callees incl. `EXTENDS`/`IMPLEMENTS`/`INJECTS`/`REFERENCES`. `callers` `scope`: **`external` (default)** = modules that call this one (CALLNAT/inheritance), **rolled up to the calling MODULE**: a call made from inside a subroutine/method is attributed to its owning module (never the calling `FUNCTION` node), and repeated call sites from one caller collapse to a single row whose `sites` list every line — symmetric with how `callees` anchors its source side. `internal` = the module's own subroutines' `PERFORM` wiring (function-level). The default is external-only, module-typed only, and never lists the module as its own caller (no `MODULE→MODULE` self-loop); use `scope=internal` or `/functions/{fn}/callers` for intra-module / function-level wiring. `callees` is unchanged (default lists both external CALLNAT and internal PERFORM targets) |
| `GET /modules/{name}/functions/{function}/callers` | **FUNCTION-level callers** (item 52): who `PERFORM`s (Natural) or calls (Java cross-class) a specific subroutine/method, with call-site `lineNos`. Finer-grained than the module-level `/callers` (which is module→module). Same `CallRefResponse` shape. CLI `ac function-callers <module> <function>` |
| `GET /modules/{name}/call-tree?depth=` | Transitive call graph to scope a feature
| `GET /modules/{name}/reaches?target=A,B,C&direction=up\|down&depth=` | **Item 110 — "can A reach B, and how?"** Returns `{reachable, paths, truncated}` with one witness route per reached target (module names, source→target). `direction=down` (default): paths from this module to each target. `up`: paths from each target to this module. The counterpart to `call-tree`, which only walks downward and returns a closure without routes — one audit hand-rolled this as ~100 `/callers` requests. **`reachable: false` means "no path over known edges", not "no path"**: the traversal runs on resolved module calls, so a route through an unresolved dynamic `CALLNAT` (item 82) is invisible. Bounded by `depth` (item 75: the call graph has cycles). CLI `ac reaches <module> --target A,B --direction up` |
| `GET /duplicates` | **Item 114 — identities skipped at ingest** because they exist in more than one file (`{name, kind, paths}`, paths relative to the project root). These are *not* in `/modules`; asking for one by name gives `409 DUPLICATE_IDENTITY`. Their own calls are absent from the graph, so caller lists elsewhere can be short. CLI `ac duplicates` | |
| `GET /dynamic-calls/unresolved` \| `/overrides` · `POST`/`DELETE /overrides` | **Manual dynamic-`CALLNAT` overrides (item 82).** `unresolved` lists open `CALLNAT <var>` sites `{module, originFile, lineNo, variable}`; `POST /overrides {originFile, lineNo, targets[], variable?, note?}` pins a site to real module(s) (applied at once, persisted across refreshes, `400 UNKNOWN_TARGET` for a non-module); `DELETE /overrides?originFile=&lineNo=` resets one site (omit both = all) and restores the placeholder inline; `GET /overrides` lists them with an `obsolete` flag. CLI `ac dynamic-calls unresolved\|overrides\|set\|reset` |
| `GET /modules/{name}/graph?direction=&depth=&limit=` | Ego graph (item 49): bounded module-level call neighbourhood as **nodes + edges** (unlike call-tree). `direction` = `out`/`in`/`both`; `limit` caps nodes (BFS order) and sets `truncated`; unresolved targets carry `unresolved=true` + empty `sourceFile`. CLI `ac ego-graph` |
| `GET /modules/{name}/db-accesses` \| `/sql-statements` | DB tables + mode, raw statement text (pass `?depth=` for Natural). **`db-accesses`/`workfile-accesses` return every row when no `limit` is given (item 103)** — they used to default to 50, and since the response is a bare array with no total and no `truncated` flag the cut was invisible: `WGEAGB0S?depth=10` returned 50 of 64 rows and hid 7 tables outright. An explicit `limit` is still honoured exactly. `db-accesses` items carry **`sites: [{lineNo, sourceFile, viaCopycode, includedAt}]`** (+ kept `lineNos`); `sql-statements` items carry **`sourceFile`** + **`viaCopycode`** — so a copycode-sourced access (e.g. `SELECT … FROM SYSIBM-SYSDUMMY1` in `USIX043C.cpy`) reports the `.cpy` line, not a bare number that reads as a host-file line |
| `GET /modules/{name}/workfile-accesses` | Natural **work files** (sequential/flat-file I/O — `READ`/`WRITE WORK FILE n`), the work-file analogue of `db-accesses` (item 84): `[{workFile, physicalName, mode: READS\|WRITES, recordBuffers, lineNos, sites}]`, aggregated per work-file number + mode. `sites: [{lineNo, sourceFile, viaCopycode, includedAt}]` gives each access its file context (copycode-aware), like `db-accesses`. `physicalName` comes from a `DEFINE WORK FILE n '<name>'`, else `null`. **Kept separate from `db-accesses`** — a work file is not an ADABAS/SQL table (fixes a former bug where `READ WORK FILE` created a phantom `DB_TABLE 'WORK'`). CLI `ac workfile-accesses <module>` |
| `GET /modules/{name}/data-structures` | Which copybooks/inline groups a module uses. A `USING <member>` binds by **member (file) name**, never by a level-1 record inside the file (item 100) — before that, `WGEAGB0S USING W-WIF-A2` reported `old/W-WIF-A7.pda` (whose level-1 record is a copy-pasted `1W-WIF-A2`), and a data area with several level-1 records and none named after the member (`VLAYERLA.lda`, `USIX020L.lda`) resolved to nothing at all (`sourceFile: null`, `area: UNKNOWN`, `fieldCount: 0`) although the file was ingested. One row per resolved definition, `(name, sourceFile)` (item 102) — never one row blending an arbitrary file with another definition's `fieldCount` |
| `GET /modules/{name}/payload` | Natural XML wire-payload contract: `{tag, field, direction, source, lineNo, sourceFile}` — static `ADD-XML-LINE` idiom (`source=IDIOM`, item 45) or derived from the wrapper's interface PDA (`source=PDA`, item 46b). `sourceFile` is the file `lineNo` refers to (module for IDIOM, PDA for PDA) |
| `GET /modules/{name}/dispatch-table` | Natural `DECIDE ON VALUE OF` routing table |
| `GET /modules/{name}/functions?kind=` \| `/functions/{fn}/overrides` \| `/functions/overrides` | Method list, modifier filter (Java), subclass overrides (single/bulk). Each item carries **`sourceFile`** + **`viaCopycode`** (item 84): a Natural subroutine pulled in via `INCLUDE` reports the **copycode** file and `viaCopycode:true`, so its `startLine`/`endLine` are read as offsets into that copycode — **not** into the including module's own file (which is shorter). `viaCopycode:false` = declared inline. Always `false` for Java |
| `GET /data-structures/{name}/fields` \| `/db-tables/{name}/columns` \| `/modules/{name}/columns` | Field/column schemas for DTO/entity generation. Every field carries **`sourceFile`** (item 101). When a structure name resolves to several definitions (42 level-1 names recur across `upms` data areas), the **member root** — the definition whose file basename equals the name, i.e. what a `USING <member>` binds to — wins; **`?sourceFile=`** pins a specific one. Before item 101 the definitions were silently unioned: `W-WIF-A2` returned 15 fields, the merge of `W-WIF-A2.pda` (5) and `W-WIF-A7.pda` (10), a layout that exists nowhere |
| `GET /variables/{name}/reads` \| `/writes` \| `/flow-forward` \| `/flow-backward` \| `/field-flow` | Impact analysis and dataflow tracing |
| `GET /search/identifier` \| `/search/value` \| `/search/annotation` | Cross-project lookup by name / literal value / annotation. `search/identifier` matches the **exact** declared name but is **sigil-insensitive**: a leading Natural sigil (`#` user, `&` AIV, `+` GDA) is ignored on both sides, so `name=K-OUT-MAX` finds the declared `#K-OUT-MAX` (and vice-versa). Optional **scope** filters `sourceFile=<relpath>` and `module=<name>` (item 53) narrow the match to one file / one module — use them to pinpoint a module-local declaration when a name recurs across dozens of modules (the result is otherwise paginated and the local one may fall off the page). To keep the **full cross-project list** yet still guarantee a given module's own declaration is on the first page, pass `priorityModule=<name>` instead of `module=`: it does not filter, but pins that module's matches to the front (ahead of the otherwise `sourceFile`-ordered rest) so they survive the `limit`. This is what the web UI's click-to-identify sends for the open module. CLI `ac search-identifier --module --priority-module --source-file --type` accept the same filters. **Latency (item 105):** a lookup whose hits lie in a Natural data area used to take 60-75 s — every fan-out query deep-ingested the surfaced `.lda`/`.pda`, which can never reach `FULL` (a data area yields no `MODULE` node), so it was re-warmed on every call and each warm dragged a whole-project finalize behind it. Data areas are now excluded from the fan-out warm; they have no deep tier to gain |
| `GET /search/source?regex=&limit=&ignoreCase=` (`ac search-source`) | Regex **grep over module source text** (item 54): `{module, sourceFile, lineNo, line}` hits + `truncated`. Case-insensitive by default. Complements `/search/identifier` (declared names) — use for code patterns (statements, table names, literals) |
| `GET /nodes/{id}` | Every property of one node (when a curated DTO is missing something) |
| `GET /nodes/{id}/source` \| `/modules/{name}/source` \| `/source?file=` | Source text — **only when you have no other access to the source** (you always do in this repo, see "Reading source in this repo" above). `/modules/{name}/source` returns the **whole file** when the line range is omitted (M1), or a `[startLine,endLine]` slice when both are given. `/source?file=<relpath>` (CLI `ac file-source`) serves a file by **relative path** rather than module name — for files that aren't standalone modules, e.g. a Natural data area (PDA/LDA) USING'd by a module, whose field line numbers refer to that file. Same whole-file/range + stale-source semantics; the client-supplied path is rejected (`400 INVALID_SOURCE_FILE`) if it escapes the project root |
| Endpoint | Use for |
|----------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `GET /modules?sourceFile=&moduleKind=&extends=` | List/filter modules; map a source file to its module name(s). Each row carries `loc`/`sloc` (item 46) and `ingestStatus`/`ingestDepth` (item 50) for status badges without a per-module round trip |
| `GET /loc?language=&sourceFile=` | Per-language LoC/SLoC rollup (fileCount/loc/sloc) + project total; each file counted once (item 46). For a generated/user_exit project also `userExitLoc`/`userExitSloc` + `generatedExclusiveLoc`/`generatedExclusiveSloc` (item 47) |
| `GET /modules/{name}/digest` | Tiny triage view before deciding which modules to expand |
| `GET /modules/{name}/context` | One-shot overview: functions, callers, callees, DB accesses, SQL/variable summaries (`?include=` for full lists) |
| `GET /modules/{name}/callers` \| `/callees` | Direct callers/callees incl. `EXTENDS`/`IMPLEMENTS`/`INJECTS`/`REFERENCES`. `callers` `scope`: **`external` (default)** = modules that call this one (CALLNAT/inheritance), **rolled up to the calling MODULE**: a call made from inside a subroutine/method is attributed to its owning module (never the calling `FUNCTION` node), and repeated call sites from one caller collapse to a single row whose `sites` list every line — symmetric with how `callees` anchors its source side. `internal` = the module's own subroutines' `PERFORM` wiring (function-level). The default is external-only, module-typed only, and never lists the module as its own caller (no `MODULE→MODULE` self-loop); use `scope=internal` or `/functions/{fn}/callers` for intra-module / function-level wiring. `callees` is unchanged (default lists both external CALLNAT and internal PERFORM targets) |
| `GET /modules/{name}/functions/{function}/callers` | **FUNCTION-level callers** (item 52): who `PERFORM`s (Natural) or calls (Java cross-class) a specific subroutine/method, with call-site `lineNos`. Finer-grained than the module-level `/callers` (which is module→module). Same `CallRefResponse` shape. CLI `ac function-callers <module> <function>` |
| `GET /modules/{name}/call-tree?depth=` | Transitive call graph to scope a feature
| `GET /modules/{name}/reaches?target=A,B,C&direction=up\|down&depth=` | **Item 110 — "can A reach B, and how?"** Returns `{reachable, paths, truncated}` with one witness route per reached target (module names, source→target). `direction=down` (default): paths from this module to each target. `up`: paths from each target to this module. The counterpart to `call-tree`, which only walks downward and returns a closure without routes — one audit hand-rolled this as ~100 `/callers` requests. **`reachable: false` means "no path over known edges", not "no path"**: the traversal runs on resolved module calls, so a route through an unresolved dynamic `CALLNAT` (item 82) is invisible. Bounded by `depth` (item 75: the call graph has cycles). CLI `ac reaches <module> --target A,B --direction up` |
| `GET /duplicates` | **Item 114 — identities skipped at ingest** because they exist in more than one file (`{name, kind, paths}`, paths relative to the project root). These are *not* in `/modules`; asking for one by name gives `409 DUPLICATE_IDENTITY`. Their own calls are absent from the graph, so caller lists elsewhere can be short. CLI `ac duplicates` | |
| `GET /dynamic-calls/unresolved` \| `/overrides` · `POST`/`DELETE /overrides` | **Manual dynamic-`CALLNAT` overrides (item 82).** `unresolved` lists open `CALLNAT <var>` sites `{module, originFile, lineNo, variable}`; `POST /overrides {originFile, lineNo, targets[], variable?, note?}` pins a site to real module(s) (applied at once, persisted across refreshes, `400 UNKNOWN_TARGET` for a non-module); `DELETE /overrides?originFile=&lineNo=` resets one site (omit both = all) and restores the placeholder inline; `GET /overrides` lists them with an `obsolete` flag. CLI `ac dynamic-calls unresolved\|overrides\|set\|reset` |
| `GET /modules/{name}/graph?direction=&depth=&limit=` | Ego graph (item 49): bounded module-level call neighbourhood as **nodes + edges** (unlike call-tree). `direction` = `out`/`in`/`both`; `limit` caps nodes (BFS order) and sets `truncated`; unresolved targets carry `unresolved=true` + empty `sourceFile`. CLI `ac ego-graph` |
| `GET /modules/{name}/db-accesses` \| `/sql-statements` | DB tables + mode, raw statement text (pass `?depth=` for Natural). **`db-accesses`/`workfile-accesses` return every row when no `limit` is given (item 103)** — they used to default to 50, and since the response is a bare array with no total and no `truncated` flag the cut was invisible: `WGEAGB0S?depth=10` returned 50 of 64 rows and hid 7 tables outright. An explicit `limit` is still honoured exactly. `db-accesses` items carry **`sites: [{lineNo, sourceFile, viaCopycode, includedAt}]`** (+ kept `lineNos`); `sql-statements` items carry **`sourceFile`** + **`viaCopycode`** — so a copycode-sourced access (e.g. `SELECT … FROM SYSIBM-SYSDUMMY1` in `USIX043C.cpy`) reports the `.cpy` line, not a bare number that reads as a host-file line |
| `GET /modules/{name}/workfile-accesses` | Natural **work files** (sequential/flat-file I/O — `READ`/`WRITE WORK FILE n`), the work-file analogue of `db-accesses` (item 84): `[{workFile, physicalName, mode: READS\|WRITES, recordBuffers, lineNos, sites}]`, aggregated per work-file number + mode. `sites: [{lineNo, sourceFile, viaCopycode, includedAt}]` gives each access its file context (copycode-aware), like `db-accesses`. `physicalName` comes from a `DEFINE WORK FILE n '<name>'`, else `null`. **Kept separate from `db-accesses`** — a work file is not an ADABAS/SQL table (fixes a former bug where `READ WORK FILE` created a phantom `DB_TABLE 'WORK'`). CLI `ac workfile-accesses <module>` |
| `GET /modules/{name}/data-structures` | Which copybooks/inline groups a module uses. A `USING <member>` binds by **member (file) name**, never by a level-1 record inside the file (item 100) — before that, `WGEAGB0S USING W-WIF-A2` reported `old/W-WIF-A7.pda` (whose level-1 record is a copy-pasted `1W-WIF-A2`), and a data area with several level-1 records and none named after the member (`VLAYERLA.lda`, `USIX020L.lda`) resolved to nothing at all (`sourceFile: null`, `area: UNKNOWN`, `fieldCount: 0`) although the file was ingested. One row per resolved definition, `(name, sourceFile)` (item 102) — never one row blending an arbitrary file with another definition's `fieldCount` |
| `GET /modules/{name}/payload` | Natural XML wire-payload contract: `{tag, field, direction, source, lineNo, sourceFile}` — static `ADD-XML-LINE` idiom (`source=IDIOM`, item 45) or derived from the wrapper's interface PDA (`source=PDA`, item 46b). `sourceFile` is the file `lineNo` refers to (module for IDIOM, PDA for PDA) |
| `GET /modules/{name}/dispatch-table` | Natural `DECIDE ON VALUE OF` routing table |
| `GET /modules/{name}/functions?kind=` \| `/functions/{fn}/overrides` \| `/functions/overrides` | Method list, modifier filter (Java), subclass overrides (single/bulk). Each item carries **`sourceFile`** + **`viaCopycode`** (item 84): a Natural subroutine pulled in via `INCLUDE` reports the **copycode** file and `viaCopycode:true`, so its `startLine`/`endLine` are read as offsets into that copycode — **not** into the including module's own file (which is shorter). `viaCopycode:false` = declared inline. Always `false` for Java |
| `GET /data-structures/{name}/fields` \| `/db-tables/{name}/columns` \| `/modules/{name}/columns` | Field/column schemas for DTO/entity generation. Every field carries **`sourceFile`** (item 101). When a structure name resolves to several definitions (42 level-1 names recur across `upms` data areas), the **member root** — the definition whose file basename equals the name, i.e. what a `USING <member>` binds to — wins; **`?sourceFile=`** pins a specific one. Before item 101 the definitions were silently unioned: `W-WIF-A2` returned 15 fields, the merge of `W-WIF-A2.pda` (5) and `W-WIF-A7.pda` (10), a layout that exists nowhere |
| `GET /variables/{name}/reads` \| `/writes` \| `/flow-forward` \| `/flow-backward` \| `/field-flow` | Impact analysis and dataflow tracing |
| `GET /search/identifier` \| `/search/value` \| `/search/annotation` | Cross-project lookup by name / literal value / annotation. `search/identifier` matches the **exact** declared name but is **sigil-insensitive**: a leading Natural sigil (`#` user, `&` AIV, `+` GDA) is ignored on both sides, so `name=K-OUT-MAX` finds the declared `#K-OUT-MAX` (and vice-versa). **Item 125:** a Java **type declaration** is matched by its **short name** as well as by the fully-qualified identity the graph stores (item 117) — `name=PartnerUpdateLogic` finds `com.example.PartnerUpdateLogic`; before this it answered `[]`, which reads as "no such name". Every match carries `simpleName` and `moduleKind` (`CLASS`/`INTERFACE`/`ENUM`/`RECORD`, `PROGRAM`/`SUBPROGRAM` for Natural), both `null` for non-`MODULE` hits — so "is this name a type or a method?" needs no second call. `contains=true` (CLI `--contains`) switches to a case-insensitive **substring** match, as on `/search/value`; it was previously accepted and silently dropped. It matches the FQN too, so a package fragment also hits — filter with `type=MODULE`/`moduleKind` if that is noise. `contains` without a `name` is `400 MISSING_NAME` (a substring search for nothing is a full node dump). The substring scan is **unindexed**: it is bounded to `offset+limit` rows, so keep a `limit` on large projects. Optional **scope** filters `sourceFile=<relpath>` and `module=<name>` (item 53) narrow the match to one file / one module — use them to pinpoint a module-local declaration when a name recurs across dozens of modules (the result is otherwise paginated and the local one may fall off the page). To keep the **full cross-project list** yet still guarantee a given module's own declaration is on the first page, pass `priorityModule=<name>` instead of `module=`: it does not filter, but pins that module's matches to the front (ahead of the otherwise `sourceFile`-ordered rest) so they survive the `limit`. This is what the web UI's click-to-identify sends for the open module. CLI `ac search-identifier --module --priority-module --source-file --type --contains` accept the same filters. **Latency (item 105):** a lookup whose hits lie in a Natural data area used to take 60-75 s — every fan-out query deep-ingested the surfaced `.lda`/`.pda`, which can never reach `FULL` (a data area yields no `MODULE` node), so it was re-warmed on every call and each warm dragged a whole-project finalize behind it. Data areas are now excluded from the fan-out warm; they have no deep tier to gain |
| `GET /search/source?regex=&limit=&ignoreCase=` (`ac search-source`) | Regex **grep over module source text** (item 54): `{module, sourceFile, lineNo, line}` hits + `truncated`. Case-insensitive by default. Complements `/search/identifier` (declared names) — use for code patterns (statements, table names, literals) |
| `GET /nodes/{id}` | Every property of one node (when a curated DTO is missing something) |
| `GET /nodes/{id}/source` \| `/modules/{name}/source` \| `/source?file=` | Source text — **only when you have no other access to the source** (you always do in this repo, see "Reading source in this repo" above). `/modules/{name}/source` returns the **whole file** when the line range is omitted (M1), or a `[startLine,endLine]` slice when both are given. `/source?file=<relpath>` (CLI `ac file-source`) serves a file by **relative path** rather than module name — for files that aren't standalone modules, e.g. a Natural data area (PDA/LDA) USING'd by a module, whose field line numbers refer to that file. Same whole-file/range + stale-source semantics; the client-supplied path is rejected (`400 INVALID_SOURCE_FILE`) if it escapes the project root |
Full endpoint list, request params, and response field details:
`x-docs/agent-api-system-prompt.md`.

View File

@@ -38,11 +38,15 @@ tracks only items that are still open.
## High priority — Agent API gaps found in the UPMS→PUR reengineering (2026-08-18)
Six gaps hit during a week of daily agent use on `upms` / `pur` / `app`, ordered by the time they
Six gaps reported after a week of daily agent use on `upms` / `pur` / `app`, ordered by the time they
cost. All probes below were run against a freshly refreshed graph and are reproducible as written.
Items 1 and 3 make the API return a *wrong* answer rather than a missing one, which is why they lead.
Items 125 and 127 were the two said to make the API return a *wrong* answer rather than a missing
one, which is why they led the list. **125 is fixed (2026-08-18); 127 was retracted the same day —
its probe was not ambiguous, so the answer had been correct all along (see the retraction below).**
That leaves **126, 128, 129, 130** open.
- [ ] **125. `search/identifier` does not index type declarations — and silently ignores `contains`**
- [x] **125. `search/identifier` did not match a type declaration's short name — and silently ignored `contains`** (
fixed 2026-08-18)
**Symptom.** The class is in the graph, but the search endpoint cannot find it.
```
@@ -60,10 +64,22 @@ Items 1 and 3 make the API return a *wrong* answer rather than a missing one, wh
rather than a match or an error. "Every identifier containing `upd`" was the central question of
a rename pass and could not be answered at all.
**Fix.** Index type declarations (class, interface, enum, record, and Natural data structures)
in the same identifier index, with a `type` discriminator so callers can filter. Honour
`contains` here as it is honoured on `/search/value`, or reject it with 400 rather than
returning an empty result that reads like "no such name".
**Diagnosis (2026-08-18).** The reported cause was wrong in a way worth recording: type
declarations **are** indexed — `?name=com.agenticcode.neo4jstore.graph.GraphRepository` returns the
`MODULE` node. `SEARCH_IDENTIFIER` matched `n.name` alone, and a Java module's `name` is its FQN
(item 117) while the short form lives in `n.simpleName`. So the index was complete and only the
*short* key was missing — which produced exactly the reported symptom. `contains` was not a
parameter of the endpoint at all, so JAX-RS dropped it silently.
**Delivered.** `SEARCH_IDENTIFIER` matches the sigil-stripped `n.name` **or** `n.simpleName`;
`?contains=true` (CLI `ac search-identifier --contains`) switches both to a case-insensitive
substring match as on `/search/value`, and `contains` without a `name` is `400 MISSING_NAME`
rather than a full node dump. Each `IdentifierMatch` now carries `simpleName` and `moduleKind`
(`CLASS`/`INTERFACE`/`ENUM`/`RECORD`, `PROGRAM`/`SUBPROGRAM` for Natural, `null` for non-`MODULE`
hits) — the discriminator the item asked for. The unindexed substring scan is bounded to
`offset+limit` rows via a `$scanCap` on the query's `LIMIT`, chosen so `paginate()`'s existing
"`limit <= 0` means unlimited" rule is untouched. Covered by `IdentifierTypeDeclarationIT`
(8 tests); `x-docs/agent-api-usage-ac-implementation.md` updated.
- [ ] **126. `/projects` carries no ingest metadata, so "not found" is never evidence**
@@ -82,21 +98,18 @@ Items 1 and 3 make the API return a *wrong* answer rather than a missing one, wh
language/parser version per project. Ideally every response carries the project's `ingestedAt`,
so a stale answer is visible at the point of use rather than only on the project listing.
- [ ] **127. An ambiguous simple module name is resolved silently to one candidate**
- *Not a bug — retracted 2026-08-18 (was #127): "an ambiguous simple module name is resolved
silently to one candidate".* The probe picked a name that is **not** ambiguous: `pur` holds exactly
**one** module with `simpleName = Builder` (`…KeyBasedIterator.Builder`) out of 5,119, so the
`200` was the correct answer. Item 115's refusal works and was verified on names that really do
collide — `GET /pur/modules/AuthorizationInterceptor/context` and `/pur/modules/VoidConverter/context`
both answer `409 AMBIGUOUS_NAME` with `candidates` **and** `qualifiedNames` in `details`. The guard
is `withModule`/`withIngestedModule`, which reject on `state.ambiguous()`; `MODULE_INGEST_STATE`
collects every real candidate by `name` **or** `simpleName`, so nested classes are covered.
**Symptom.**
```
GET /pur/modules/Builder/context → 200, com.uniqagroup.pur.common.batch.base.reader
.key_based.KeyBasedIterator.Builder
```
Expected `409 AMBIGUOUS_NAME` (which the API guide documents as ordinary for nested classes in
`pur`). Instead one candidate is served with no indication that others exist. Whether `pur` in
fact holds more than one `Builder` could not be checked from the API — item 125 removes the only
endpoint that would list them, which is part of the point.
**Fix.** Either raise `409` with the candidate FQNs in `details`, or answer `200` with a
`candidates` array alongside the resolved module. A silently chosen match is the failure mode
that is hardest to notice downstream, because the response looks entirely normal.
The item's one genuine complaint — *"whether `pur` in fact holds more than one `Builder` could not
be checked from the API"* — was real, and is what **item 125** now answers:
`GET /pur/search/identifier?name=Builder&type=MODULE` lists every module sharing a short name.
- [ ] **128. No endpoint for "every reference site of this symbol"**