UI tests
This commit is contained in:
@@ -21,6 +21,9 @@ final class SearchIdentifierCommand extends AbstractProjectCommand {
|
||||
@Option(names = "--module", description = "Scope to nodes of this module (by name)")
|
||||
@Nullable String module;
|
||||
|
||||
@Option(names = "--priority-module", description = "Pin this module's matches to the front (no filtering) so its local declaration survives the limit when a name recurs across modules")
|
||||
@Nullable String priorityModule;
|
||||
|
||||
@Option(names = "--source-file", description = "Scope to this exact source file (relative path)")
|
||||
@Nullable String sourceFile;
|
||||
|
||||
@@ -37,6 +40,7 @@ final class SearchIdentifierCommand extends AbstractProjectCommand {
|
||||
path = appendQuery(path, "name", name);
|
||||
path = appendQuery(path, "type", type);
|
||||
path = appendQuery(path, "module", module);
|
||||
path = appendQuery(path, "priorityModule", priorityModule);
|
||||
path = appendQuery(path, "sourceFile", sourceFile);
|
||||
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
|
||||
return printResponse(apiClient().get(path));
|
||||
|
||||
@@ -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=108
|
||||
version=112
|
||||
|
||||
@@ -745,6 +745,8 @@ public class AnalysisResource {
|
||||
@QueryParam("sourceFile") @Nullable String sourceFile,
|
||||
@Parameter(description = "Scope to nodes belonging to this module (by name); resolves the module's source file.")
|
||||
@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,
|
||||
@QueryParam("limit") @Nullable Integer limit, @QueryParam("offset") @Nullable Integer offset,
|
||||
@QueryParam("fields") @Nullable String fields) {
|
||||
if (type != null) {
|
||||
@@ -757,7 +759,7 @@ public class AnalysisResource {
|
||||
}
|
||||
return withFanoutWarm(project,
|
||||
() -> graphRepository.searchIdentifier(project, name,
|
||||
type != null ? type.toUpperCase() : null, sourceFile, module, effectiveLimit(limit), effectiveOffset(offset)),
|
||||
type != null ? type.toUpperCase() : null, sourceFile, module, priorityModule, effectiveLimit(limit), effectiveOffset(offset)),
|
||||
matches -> matches.stream().map(IdentifierMatch::sourceFile).filter(sf -> !sf.isEmpty()).distinct().toList(),
|
||||
matches -> namesOnly(fields) ? ok(identifierNames(matches)) : ok(matches));
|
||||
}
|
||||
|
||||
@@ -475,7 +475,7 @@ public class McpQueryTools {
|
||||
.map(support::ok));
|
||||
}
|
||||
|
||||
@Tool(name = "search_identifier", description = "Find identifiers across a project by exact name and optional node type (MODULE, FUNCTION, VARIABLE, DATA_STRUCTURE, DB_TABLE). Name match is sigil-insensitive: a leading Natural sigil (# user, & AIV, + GDA) is ignored on both sides, so 'K-OUT-MAX' matches the declared '#K-OUT-MAX'. Optionally scope to one source file or one module (by name) to pinpoint the local declaration when a name recurs across modules.")
|
||||
@Tool(name = "search_identifier", description = "Find identifiers across a project by exact name and optional node type (MODULE, FUNCTION, VARIABLE, DATA_STRUCTURE, DB_TABLE). Name match is sigil-insensitive: a leading Natural sigil (# user, & AIV, + GDA) is ignored on both sides, so 'K-OUT-MAX' matches the declared '#K-OUT-MAX'. Optionally scope to one source file or one module (by name) to pinpoint the local declaration when a name recurs across modules; or use priorityModule to keep the global list but pin one module's matches to the front so they survive the limit.")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> searchIdentifier(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@@ -483,6 +483,7 @@ public class McpQueryTools {
|
||||
@ToolArg(description = "Optional node type filter", required = false) @Nullable String type,
|
||||
@ToolArg(description = "Scope to this exact source file (relative path)", required = false) @Nullable String sourceFile,
|
||||
@ToolArg(description = "Scope to nodes of this module (by name)", required = false) @Nullable String module,
|
||||
@ToolArg(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", required = false) @Nullable String priorityModule,
|
||||
@ToolArg(description = "Page size (default 50)", required = false) @Nullable Integer limit,
|
||||
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) {
|
||||
if (type != null) {
|
||||
@@ -494,7 +495,7 @@ public class McpQueryTools {
|
||||
}
|
||||
return withFanoutWarm(project,
|
||||
() -> graphRepository.searchIdentifier(project, name,
|
||||
type != null ? type.toUpperCase() : null, sourceFile, module, effectiveLimit(limit), effectiveOffset(offset)),
|
||||
type != null ? type.toUpperCase() : null, sourceFile, module, priorityModule, effectiveLimit(limit), effectiveOffset(offset)),
|
||||
matches -> matches.stream().map(IdentifierMatch::sourceFile).filter(sf -> !sf.isEmpty()).distinct().toList());
|
||||
}
|
||||
|
||||
|
||||
@@ -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 MCP
|
||||
# 'version' tool/server-info (referenced below via property expression, not duplicated).
|
||||
agenticcode.version=108
|
||||
agenticcode.version=112
|
||||
|
||||
# MCP server (HTTP/SSE transport) — tools exposed at http://<host>:8787/mcp/sse
|
||||
quarkus.mcp.server.server-info.name=agenticcode
|
||||
|
||||
@@ -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, 50, 0)
|
||||
List<IdentifierMatch> realModules = graphRepository.searchIdentifier(PROJECT, MODULE, "MODULE", null, null, null, 50, 0)
|
||||
.await().indefinitely().stream()
|
||||
.filter(m -> !m.sourceFile().isEmpty())
|
||||
.toList();
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
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 java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
|
||||
/**
|
||||
* Item 83 — constant-fold a string-<em>assembled</em> dynamic {@code CALLNAT} target. A dispatcher
|
||||
* builds the callee name from a base literal plus a {@code SUBSTR} overlay
|
||||
* ({@code MOVE 'YABALKEY' TO #GETSHORT-MODUL} then {@code MOVE 'GN0' TO SUBSTR(#GETSHORT-MODUL,6,3)}
|
||||
* then {@code CALLNAT #GETSHORT-MODUL}); the fold enricher must resolve that site to the assembled
|
||||
* module {@code YABALGN0}. A manual override (item 82) present when the caller is (re)ingested must
|
||||
* still win over the fold.
|
||||
*/
|
||||
@QuarkusTest
|
||||
class DynamicCallnatFoldIT {
|
||||
|
||||
private static final String PROJECT = "nat-dynamic-fold";
|
||||
private static final String YABALGN0 = """
|
||||
DEFINE DATA
|
||||
LOCAL
|
||||
01 #A (A8)
|
||||
END-DEFINE
|
||||
*
|
||||
END
|
||||
""";
|
||||
private static final String OTHERMOD = """
|
||||
DEFINE DATA
|
||||
LOCAL
|
||||
01 #B (A8)
|
||||
END-DEFINE
|
||||
*
|
||||
END
|
||||
""";
|
||||
@TempDir
|
||||
static Path root;
|
||||
|
||||
/**
|
||||
* Base literal 'YABALKEY' (A8), then a SUBSTR overlay of 'GN0' at position 6, length 3, assembles
|
||||
* 'YABALGN0' — YABAL[KEY] with KEY overwritten by GN0 → CALLNAT #GETSHORT-MODUL targets YABALGN0.
|
||||
* {@code #V} keeps a distinct base name per caller only cosmetically; the fold logic is identical.
|
||||
*/
|
||||
private static String foldCaller(String comment) {
|
||||
return """
|
||||
* %s
|
||||
DEFINE DATA
|
||||
LOCAL
|
||||
01 #GETSHORT-MODUL (A8)
|
||||
END-DEFINE
|
||||
*
|
||||
MOVE 'YABALKEY' TO #GETSHORT-MODUL
|
||||
MOVE 'GN0' TO SUBSTR( #GETSHORT-MODUL ,6,3)
|
||||
CALLNAT #GETSHORT-MODUL
|
||||
*
|
||||
END
|
||||
""".formatted(comment);
|
||||
}
|
||||
|
||||
@BeforeAll
|
||||
static void createProject() {
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
write("FOLDCALL.nat", foldCaller("Item 83: string-assembled dynamic CALLNAT target."));
|
||||
write("FOLDOVR.nat", foldCaller("Item 83: same pattern, used for the override-precedence test."));
|
||||
write("YABALGN0.nat", YABALGN0);
|
||||
write("OTHERMOD.nat", OTHERMOD);
|
||||
|
||||
given().contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
|
||||
.when().post("/api/projects/" + PROJECT)
|
||||
.then().statusCode(201);
|
||||
|
||||
deepRefresh();
|
||||
}
|
||||
|
||||
private static void deepRefresh() {
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true").then().statusCode(200);
|
||||
}
|
||||
|
||||
private static void write(String fileName, String content) {
|
||||
try {
|
||||
Files.writeString(root.resolve(fileName), content);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The lineNo + interned source file of module {@code caller}'s external call site to {@code callee}.
|
||||
*/
|
||||
private static Object[] callSite(String caller, String callee) {
|
||||
var callees = given()
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/" + caller + "/callees?scope=external")
|
||||
.then().statusCode(200).extract();
|
||||
List<String> sourceFiles = callees.path("sourceFiles");
|
||||
List<Map<String, Object>> sites = callees.path("items.find { it.name == '" + callee + "' }.sites");
|
||||
Map<String, Object> site = sites.get(0);
|
||||
int line = ((Number) Objects.requireNonNull(site.get("lineNo"))).intValue();
|
||||
String file = sourceFiles.get(((Number) Objects.requireNonNull(site.get("callSiteFileIndex"))).intValue());
|
||||
return new Object[]{line, file};
|
||||
}
|
||||
|
||||
@Test
|
||||
void foldedDynamicCallnatResolvesToAssembledTarget() {
|
||||
// The base literal + SUBSTR overlay fold to YABALGN0, which is a real module → a resolved
|
||||
// CALLNAT_DYNAMIC edge, so the assembled target appears among FOLDCALL's external callees.
|
||||
given()
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/FOLDCALL/callees?scope=external")
|
||||
.then().statusCode(200)
|
||||
.body("items.name", hasItem("YABALGN0"))
|
||||
.body("items.find { it.name == 'YABALGN0' }.edgeKind", equalTo("CALLNAT_DYNAMIC"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void manualOverrideWinsOverFold() {
|
||||
// Precedence (item 83): a human/agent override present when the caller is (re)ingested must win
|
||||
// over the fold. FOLDOVR initially folds to YABALGN0; pin the site to OTHERMOD, then re-ingest
|
||||
// FOLDOVR (content changed, CALLNAT line preserved) so the fold re-evaluates with the override
|
||||
// in place — it must step aside: OTHERMOD appears, the auto-folded YABALGN0 does not.
|
||||
Object[] site = callSite("FOLDOVR", "YABALGN0");
|
||||
int line = (int) site[0];
|
||||
String file = (String) site[1];
|
||||
|
||||
given().contentType("application/json")
|
||||
.body(Map.of("originFile", file, "lineNo", line, "targets", List.of("OTHERMOD"),
|
||||
"variable", "#GETSHORT-MODUL"))
|
||||
.when().post("/api/projects/" + PROJECT + "/dynamic-calls/overrides")
|
||||
.then().statusCode(200);
|
||||
|
||||
// Change FOLDOVR's content (a different trailing comment) so its sourceHash changes and the
|
||||
// refresh re-ingests it, discarding the stale fold edge and re-running the fold with the override.
|
||||
write("FOLDOVR.nat", foldCaller("Item 83: override-precedence test (touched to force re-ingest)."));
|
||||
deepRefresh();
|
||||
|
||||
given()
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/FOLDOVR/callees?scope=external")
|
||||
.then().statusCode(200)
|
||||
.body("items.name", hasItem("OTHERMOD"))
|
||||
.body("items.name", not(hasItem("YABALGN0")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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.*;
|
||||
|
||||
/**
|
||||
* Characterization of {@code search/identifier?priorityModule=…} (item: current-module prioritization).
|
||||
* A variable name declared LOCAL in several modules must, under a caller's {@code limit}, keep the
|
||||
* global list yet surface the requested module's own declaration first so it is not truncated away.
|
||||
* Modules are named so {@code PRIO_ZZZ_TARGET} sorts last by source file — without prioritization it
|
||||
* falls outside {@code limit=2}; {@code priorityModule} must pull it into the page.
|
||||
*/
|
||||
@QuarkusTest
|
||||
class IdentifierPriorityModuleIT {
|
||||
|
||||
private static final String PROJECT = "prio-test-project";
|
||||
private static final String NAME = "#SHARED-FLD";
|
||||
|
||||
@TempDir
|
||||
static Path root;
|
||||
|
||||
@BeforeAll
|
||||
static void ingestFixtures() {
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
for (String module : new String[]{"PRIO_AAA", "PRIO_BBB", "PRIO_CCC", "PRIO_ZZZ_TARGET"}) {
|
||||
writeSource(module + ".nat", """
|
||||
DEFINE DATA
|
||||
LOCAL
|
||||
1 #SHARED-FLD (A8)
|
||||
END-DEFINE
|
||||
*
|
||||
END
|
||||
""");
|
||||
}
|
||||
given()
|
||||
.contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", 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(String fileName, String content) {
|
||||
try {
|
||||
Files.writeString(root.resolve(fileName), content);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutPriorityModuleTheLastSortingModuleIsTruncatedByTheLimit() {
|
||||
given()
|
||||
.queryParam("name", NAME).queryParam("limit", 2)
|
||||
.when().get("/api/projects/" + PROJECT + "/search/identifier")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("$", hasSize(2))
|
||||
.body("sourceFile", not(hasItem("PRIO_ZZZ_TARGET.nat")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void priorityModulePinsItsMatchIntoThePageDespiteTheLimit() {
|
||||
given()
|
||||
.queryParam("name", NAME).queryParam("limit", 2).queryParam("priorityModule", "PRIO_ZZZ_TARGET")
|
||||
.when().get("/api/projects/" + PROJECT + "/search/identifier")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("$", hasSize(2))
|
||||
.body("sourceFile", hasItem("PRIO_ZZZ_TARGET.nat"))
|
||||
// Pinned match is first; the rest of the page is filled from the global list.
|
||||
.body("sourceFile[0]", org.hamcrest.Matchers.equalTo("PRIO_ZZZ_TARGET.nat"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void priorityModuleDoesNotFilterOutOtherModules() {
|
||||
// Unlike module=, priorityModule keeps the global result set — a large limit still returns all four.
|
||||
given()
|
||||
.queryParam("name", NAME).queryParam("limit", 50).queryParam("priorityModule", "PRIO_ZZZ_TARGET")
|
||||
.when().get("/api/projects/" + PROJECT + "/search/identifier")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("$", hasSize(4))
|
||||
.body("name", everyItem(org.hamcrest.Matchers.equalTo(NAME)));
|
||||
}
|
||||
}
|
||||
@@ -1035,7 +1035,7 @@ public final class CypherQueries {
|
||||
WHERE dyn.callKind = 'CALLNAT_DYNAMIC' AND src.sourceFile <> ""
|
||||
MATCH (caller:AstNode {type: 'MODULE', project: $project, sourceFile: src.sourceFile})
|
||||
MATCH (w:AstNode {project: $project, sourceFile: src.sourceFile})-[wr:WRITES]->(v:AstNode {project: $project, name: dyn.dynamicVar})
|
||||
WHERE wr.value IS NOT NULL AND wr.value STARTS WITH "'"
|
||||
WHERE wr.value IS NOT NULL AND wr.value STARTS WITH "'" AND wr.substrPos IS NULL
|
||||
WITH DISTINCT caller, dyn, trim(replace(wr.value, "'", "")) AS lit,
|
||||
coalesce(dyn.originFile, src.sourceFile) AS originFile
|
||||
MATCH (target:AstNode {type: 'MODULE', project: $project, name: lit})
|
||||
@@ -1109,7 +1109,7 @@ public final class CypherQueries {
|
||||
OR EXISTS { (caller)-[:INCLUDES]->(inc:AstNode) WHERE inc.sourceFile = cv.sourceFile })
|
||||
MATCH (cv)-[:ARG_TO_PARAM*1..5]->(param:AstNode {project: $project})
|
||||
MATCH (pw:AstNode)-[wr:WRITES]->(param)
|
||||
WHERE wr.value IS NOT NULL AND wr.value STARTS WITH "'"
|
||||
WHERE wr.value IS NOT NULL AND wr.value STARTS WITH "'" AND wr.substrPos IS NULL
|
||||
WITH DISTINCT caller, dyn, trim(replace(wr.value, "'", "")) AS lit,
|
||||
coalesce(dyn.originFile, src.sourceFile) AS originFile
|
||||
MATCH (target:AstNode {type: 'MODULE', project: $project, name: lit})
|
||||
@@ -1128,7 +1128,7 @@ public final class CypherQueries {
|
||||
MATCH (src:AstNode {project: $project, sourceFile: caller.sourceFile})-[dyn:CALLS]->(ph:AstNode {project: $project, type: 'MODULE', sourceFile: ""})
|
||||
WHERE dyn.callKind = 'CALLNAT_DYNAMIC'
|
||||
MATCH (w:AstNode {project: $project, sourceFile: caller.sourceFile})-[wr:WRITES]->(v:AstNode {project: $project, name: dyn.dynamicVar})
|
||||
WHERE wr.value IS NOT NULL AND wr.value STARTS WITH "'"
|
||||
WHERE wr.value IS NOT NULL AND wr.value STARTS WITH "'" AND wr.substrPos IS NULL
|
||||
WITH DISTINCT caller, dyn, trim(replace(wr.value, "'", "")) AS lit,
|
||||
coalesce(dyn.originFile, src.sourceFile) AS originFile
|
||||
MATCH (target:AstNode {type: 'MODULE', project: $project, name: lit})
|
||||
@@ -1136,6 +1136,97 @@ public final class CypherQueries {
|
||||
MERGE (caller)-[r2:CALLS {callKind: 'CALLNAT_DYNAMIC', lineNo: dyn.lineNo, originFile: originFile}]->(target)
|
||||
SET r2.dynamicVar = dyn.dynamicVar, r2.args = dyn.args
|
||||
""";
|
||||
|
||||
/**
|
||||
* Item 83: constant-fold a string-<em>assembled</em> dynamic {@code CALLNAT <var>} target. Some
|
||||
* dispatchers build the callee name from a base literal plus one or more {@code SUBSTR} overlays —
|
||||
* e.g. {@code MOVE 'YABALKEY' TO #M} then {@code MOVE 'GN0' TO SUBSTR(#M,6,3)} then
|
||||
* {@code CALLNAT #M} → {@code YABALGN0}. The base assignment and each overlay are parsed as
|
||||
* {@code WRITES} edges to {@code #M}; the overlay writes carry {@code substrPos}/{@code substrLen}
|
||||
* (1-based). This step takes the <em>last</em> full-var literal written before the call site as the
|
||||
* base, applies the overlays whose line is between that base and the call site (ordered by line) via
|
||||
* {@code left}/{@code substring}, and MERGEs a resolved {@code CALLNAT_DYNAMIC} edge to the folded
|
||||
* module — tagged {@code folded = true} (provenance: constant-assembled, inferred). Over-approximates
|
||||
* like the other resolvers; a folded name that is not a real module produces no edge.
|
||||
*
|
||||
* <p>Precedence: a call site that already has a manual override ({@link #APPLY_MANUAL_DYNAMIC_CALLNAT})
|
||||
* is skipped, so a human/agent override still wins over an auto-fold — the fold's non-manual edge
|
||||
* would otherwise make {@code apply-manual} treat the site as already resolved. Module scope is
|
||||
* expressed by {@code sourceFile} equality (item-75-safe), like {@link #RESOLVE_DYNAMIC_CALLNAT_INTRA}.
|
||||
* Cheap (bounded by dynamic call sites), so it runs in call-graph mode too. Idempotent (MERGE).
|
||||
*/
|
||||
public static final String RESOLVE_DYNAMIC_CALLNAT_FOLD = """
|
||||
MATCH (src:AstNode {project: $project})-[dyn:CALLS]->(ph:AstNode {project: $project, type: 'MODULE', sourceFile: ""})
|
||||
WHERE dyn.callKind = 'CALLNAT_DYNAMIC' AND src.sourceFile <> ""
|
||||
AND NOT EXISTS {
|
||||
MATCH (o:DynamicCallOverride {project: $project})
|
||||
WHERE o.originFile = coalesce(dyn.originFile, src.sourceFile) AND o.lineNo = dyn.lineNo
|
||||
}
|
||||
MATCH (caller:AstNode {type: 'MODULE', project: $project, sourceFile: src.sourceFile})
|
||||
MATCH (bw:AstNode {project: $project, sourceFile: src.sourceFile})-[bwr:WRITES]->(:AstNode {project: $project, name: dyn.dynamicVar})
|
||||
WHERE bwr.value IS NOT NULL AND bwr.value STARTS WITH "'" AND bwr.substrPos IS NULL
|
||||
AND bwr.lineNo < dyn.lineNo
|
||||
WITH src, dyn, caller, coalesce(dyn.originFile, src.sourceFile) AS originFile, bwr
|
||||
ORDER BY bwr.lineNo DESC
|
||||
WITH src, dyn, caller, originFile, head(collect(bwr)) AS baseWr
|
||||
WITH src, dyn, caller, originFile, baseWr, trim(replace(baseWr.value, "'", "")) AS baseLit
|
||||
OPTIONAL MATCH (ow:AstNode {project: $project, sourceFile: src.sourceFile})-[owr:WRITES]->(:AstNode {project: $project, name: dyn.dynamicVar})
|
||||
WHERE owr.substrPos IS NOT NULL AND owr.value IS NOT NULL AND owr.value STARTS WITH "'"
|
||||
AND toInteger(owr.substrPos) >= 1
|
||||
AND owr.lineNo > baseWr.lineNo AND owr.lineNo < dyn.lineNo
|
||||
WITH caller, dyn, originFile, baseLit, owr
|
||||
ORDER BY owr.lineNo
|
||||
WITH caller, dyn, originFile, baseLit, collect(owr) AS ovs
|
||||
WHERE size(ovs) > 0
|
||||
WITH caller, dyn, originFile,
|
||||
reduce(acc = baseLit, o IN ovs |
|
||||
left(acc, toInteger(o.substrPos) - 1) + trim(replace(o.value, "'", "")) +
|
||||
substring(acc, toInteger(o.substrPos) - 1 + toInteger(o.substrLen))) AS folded
|
||||
MATCH (target:AstNode {type: 'MODULE', project: $project, name: folded})
|
||||
WHERE target.sourceFile <> ""
|
||||
MERGE (caller)-[r2:CALLS {callKind: 'CALLNAT_DYNAMIC', lineNo: dyn.lineNo, originFile: originFile}]->(target)
|
||||
ON CREATE SET r2.folded = true
|
||||
SET r2.dynamicVar = dyn.dynamicVar, r2.args = dyn.args
|
||||
""";
|
||||
|
||||
/**
|
||||
* Scoped variant of {@link #RESOLVE_DYNAMIC_CALLNAT_FOLD}: only dynamic call sites in callers whose
|
||||
* name is in {@code $names} (a deeply-ingested program tree).
|
||||
*/
|
||||
public static final String RESOLVE_DYNAMIC_CALLNAT_FOLD_SCOPED = """
|
||||
UNWIND $names AS mn
|
||||
MATCH (caller:AstNode {type: 'MODULE', project: $project, name: mn})
|
||||
MATCH (src:AstNode {project: $project, sourceFile: caller.sourceFile})-[dyn:CALLS]->(ph:AstNode {project: $project, type: 'MODULE', sourceFile: ""})
|
||||
WHERE dyn.callKind = 'CALLNAT_DYNAMIC'
|
||||
AND NOT EXISTS {
|
||||
MATCH (o:DynamicCallOverride {project: $project})
|
||||
WHERE o.originFile = coalesce(dyn.originFile, src.sourceFile) AND o.lineNo = dyn.lineNo
|
||||
}
|
||||
MATCH (bw:AstNode {project: $project, sourceFile: caller.sourceFile})-[bwr:WRITES]->(:AstNode {project: $project, name: dyn.dynamicVar})
|
||||
WHERE bwr.value IS NOT NULL AND bwr.value STARTS WITH "'" AND bwr.substrPos IS NULL
|
||||
AND bwr.lineNo < dyn.lineNo
|
||||
WITH src, dyn, caller, coalesce(dyn.originFile, src.sourceFile) AS originFile, bwr
|
||||
ORDER BY bwr.lineNo DESC
|
||||
WITH src, dyn, caller, originFile, head(collect(bwr)) AS baseWr
|
||||
WITH src, dyn, caller, originFile, baseWr, trim(replace(baseWr.value, "'", "")) AS baseLit
|
||||
OPTIONAL MATCH (ow:AstNode {project: $project, sourceFile: caller.sourceFile})-[owr:WRITES]->(:AstNode {project: $project, name: dyn.dynamicVar})
|
||||
WHERE owr.substrPos IS NOT NULL AND owr.value IS NOT NULL AND owr.value STARTS WITH "'"
|
||||
AND toInteger(owr.substrPos) >= 1
|
||||
AND owr.lineNo > baseWr.lineNo AND owr.lineNo < dyn.lineNo
|
||||
WITH caller, dyn, originFile, baseLit, owr
|
||||
ORDER BY owr.lineNo
|
||||
WITH caller, dyn, originFile, baseLit, collect(owr) AS ovs
|
||||
WHERE size(ovs) > 0
|
||||
WITH caller, dyn, originFile,
|
||||
reduce(acc = baseLit, o IN ovs |
|
||||
left(acc, toInteger(o.substrPos) - 1) + trim(replace(o.value, "'", "")) +
|
||||
substring(acc, toInteger(o.substrPos) - 1 + toInteger(o.substrLen))) AS folded
|
||||
MATCH (target:AstNode {type: 'MODULE', project: $project, name: folded})
|
||||
WHERE target.sourceFile <> ""
|
||||
MERGE (caller)-[r2:CALLS {callKind: 'CALLNAT_DYNAMIC', lineNo: dyn.lineNo, originFile: originFile}]->(target)
|
||||
ON CREATE SET r2.folded = true
|
||||
SET r2.dynamicVar = dyn.dynamicVar, r2.args = dyn.args
|
||||
""";
|
||||
/**
|
||||
* Deletes the dynamic-call marker edges (the {@code CALLNAT_DYNAMIC} {@code CALLS} edges that
|
||||
* point at a variable-named placeholder, {@code sourceFile = ""}) <em>only for call sites that
|
||||
@@ -1202,6 +1293,26 @@ public final class CypherQueries {
|
||||
* obsolete. Idempotent (MERGE). Runs after the auto dynamic-CALLNAT resolvers and before
|
||||
* {@link #DELETE_DYNAMIC_CALLNAT_PLACEHOLDER_EDGES}.
|
||||
*/
|
||||
/**
|
||||
* Item 83 precedence: a manual override must win over an auto-<em>fold</em>. The
|
||||
* {@link #RESOLVE_DYNAMIC_CALLNAT_FOLD} guard already stops the fold from (re-)creating an edge at a
|
||||
* site that carries a {@code :DynamicCallOverride}, but a folded edge minted by an <em>earlier</em>
|
||||
* finalize (before the override was set) survives a non-wiping refresh — enrichment edges are not
|
||||
* reconciled away when the caller file changes. This step deletes any such stale
|
||||
* {@code folded = true} edge at an override's {@code (originFile, lineNo)}, so
|
||||
* {@link #APPLY_MANUAL_DYNAMIC_CALLNAT} — which skips sites that still have a non-manual auto edge —
|
||||
* can then apply the override. Runs immediately before apply-manual. Only {@code folded} edges are
|
||||
* removed; a direct-literal/indirect/cross resolution keeps item-82's "auto wins" precedence.
|
||||
* Project-wide, idempotent, a no-op when there are no overrides.
|
||||
*/
|
||||
public static final String DELETE_FOLDED_OVERRIDDEN_DYNAMIC_CALLNAT = """
|
||||
MATCH (o:DynamicCallOverride {project: $project})
|
||||
MATCH (caller:AstNode {type: 'MODULE', project: $project})-[r:CALLS {callKind: 'CALLNAT_DYNAMIC', lineNo: o.lineNo}]->(:AstNode {type: 'MODULE', project: $project})
|
||||
WHERE coalesce(r.originFile, caller.sourceFile) = o.originFile
|
||||
AND r.folded = true AND coalesce(r.resolvedBy, '') <> 'manual'
|
||||
DELETE r
|
||||
""";
|
||||
|
||||
public static final String APPLY_MANUAL_DYNAMIC_CALLNAT = """
|
||||
MATCH (o:DynamicCallOverride {project: $project})
|
||||
UNWIND o.targets AS tname
|
||||
@@ -1347,7 +1458,7 @@ public final class CypherQueries {
|
||||
OR EXISTS { (caller)-[:INCLUDES]->(inc:AstNode) WHERE inc.sourceFile = cv.sourceFile })
|
||||
MATCH (cv)-[:ARG_TO_PARAM*1..5]->(param:AstNode {project: $project})
|
||||
MATCH (pw:AstNode)-[wr:WRITES]->(param)
|
||||
WHERE wr.value IS NOT NULL AND wr.value STARTS WITH "'"
|
||||
WHERE wr.value IS NOT NULL AND wr.value STARTS WITH "'" AND wr.substrPos IS NULL
|
||||
WITH DISTINCT caller, dyn, trim(replace(wr.value, "'", "")) AS lit,
|
||||
coalesce(dyn.originFile, src.sourceFile) AS originFile
|
||||
MATCH (target:AstNode {type: 'MODULE', project: $project, name: lit})
|
||||
@@ -1788,6 +1899,9 @@ public final class CypherQueries {
|
||||
f.dataType AS value, m.name AS parent, f.startLine AS startLine, f.endLine AS endLine, null AS scope
|
||||
""";
|
||||
|
||||
// $module is a hard filter (return only that module's nodes); $priorityModule instead only
|
||||
// pins that module's matches to the front so they survive a caller's limit/paginate truncation
|
||||
// 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})
|
||||
WHERE ($name IS NULL OR
|
||||
@@ -1798,9 +1912,14 @@ public final class CypherQueries {
|
||||
MATCH (mod:AstNode {type: 'MODULE', name: $module, project: $project})
|
||||
WHERE mod.sourceFile = n.sourceFile
|
||||
})
|
||||
WITH n, CASE WHEN $priorityModule IS NOT NULL AND EXISTS {
|
||||
MATCH (pm:AstNode {type: 'MODULE', name: $priorityModule, project: $project})
|
||||
WHERE pm.sourceFile = n.sourceFile
|
||||
} THEN 0 ELSE 1 END AS pinRank
|
||||
RETURN n.id 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
|
||||
ORDER BY pinRank ASC, n.sourceFile ASC, n.startLine ASC
|
||||
""";
|
||||
/**
|
||||
* Item 78: deletes a project's {@code AstNode}s <b>in batches</b>, leaving its {@code (:Project)}
|
||||
|
||||
@@ -137,6 +137,12 @@ public class GraphRepository {
|
||||
// Resolve intra-module dynamic CALLNAT <var> sites to real CALLS edges (cheap, all modes).
|
||||
statements.add(new EnrichmentStep("resolve-dynamic-callnat-intra" + sfx,
|
||||
scoped ? CypherQueries.RESOLVE_DYNAMIC_CALLNAT_INTRA_SCOPED : CypherQueries.RESOLVE_DYNAMIC_CALLNAT_INTRA));
|
||||
// Item 83: constant-fold string-assembled dynamic CALLNAT targets (base literal + SUBSTR
|
||||
// overlays → folded module name). Cheap (bounded by dynamic call sites), all modes. Skips sites
|
||||
// with a manual override so item-82 overrides still win. Runs right after the direct-literal
|
||||
// resolver and before apply-manual/placeholder cleanup.
|
||||
statements.add(new EnrichmentStep("resolve-dynamic-callnat-fold" + sfx,
|
||||
scoped ? CypherQueries.RESOLVE_DYNAMIC_CALLNAT_FOLD_SCOPED : CypherQueries.RESOLVE_DYNAMIC_CALLNAT_FOLD));
|
||||
if (resolveFields) {
|
||||
statements.add(new EnrichmentStep("resolve-dynamic-callnat-intra-indirect" + sfx,
|
||||
scoped ? CypherQueries.RESOLVE_DYNAMIC_CALLNAT_INTRA_INDIRECT_SCOPED : CypherQueries.RESOLVE_DYNAMIC_CALLNAT_INTRA_INDIRECT));
|
||||
@@ -193,6 +199,9 @@ public class GraphRepository {
|
||||
// cleanup below, so a manually-resolved marker is flagged manualHidden (kept for inline reset)
|
||||
// rather than deleted. Project-wide in both modes; idempotent (MERGE); a no-op when there are
|
||||
// no overrides. The :DynamicCallOverride nodes it reads survived the refresh (not :AstNode).
|
||||
// Item 83: a manual override wins over an auto-fold — drop a stale folded edge at an overridden
|
||||
// site so apply-manual (next) can pin the human/agent target. Runs right before apply-manual.
|
||||
statements.add(new EnrichmentStep("delete-folded-overridden-dynamic-callnat", CypherQueries.DELETE_FOLDED_OVERRIDDEN_DYNAMIC_CALLNAT));
|
||||
statements.add(new EnrichmentStep("apply-manual-dynamic-callnat", CypherQueries.APPLY_MANUAL_DYNAMIC_CALLNAT));
|
||||
// Drop the now-unresolved dynamic-call markers (placeholder edges); resolved edges remain.
|
||||
statements.add(new EnrichmentStep("delete-dynamic-callnat-placeholders" + sfx,
|
||||
@@ -932,17 +941,19 @@ public class GraphRepository {
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated variant of {@link #searchIdentifier(String, String, String, String, String)} for the endpoint.
|
||||
* Paginated variant of {@link #searchIdentifier(String, String, String, String, String, String)} for the endpoint.
|
||||
*/
|
||||
public Uni<List<IdentifierMatch>> searchIdentifier(String project, @Nullable String identifierName,
|
||||
@Nullable String type, @Nullable String sourceFile,
|
||||
@Nullable String module, int limit, int offset) {
|
||||
return searchIdentifier(project, identifierName, type, sourceFile, module).map(list -> paginate(list, limit, offset));
|
||||
@Nullable String module, @Nullable String priorityModule,
|
||||
int limit, int offset) {
|
||||
return searchIdentifier(project, identifierName, type, sourceFile, module, priorityModule)
|
||||
.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 module, @Nullable String priorityModule) {
|
||||
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
|
||||
@@ -952,6 +963,9 @@ public class GraphRepository {
|
||||
// Item 53: optional scope filters — narrow to one file, or to all nodes of a named module.
|
||||
params.put("sourceFile", sourceFile);
|
||||
params.put("module", module);
|
||||
// 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);
|
||||
return read(CypherQueries.SEARCH_IDENTIFIER, params, record -> new IdentifierMatch(
|
||||
record.get("id").asString(),
|
||||
NodeType.valueOf(record.get("type").asString()),
|
||||
|
||||
@@ -96,6 +96,12 @@ public final class NaturalParser implements LanguageParser {
|
||||
// Leading option keywords between the file number and the record buffer operand(s).
|
||||
private static final Pattern WORKFILE_BUFFER_LEAD = Pattern.compile("(?i)^(?:ONCE\\s+|RECORD\\s+|VARIABLE\\s+)+");
|
||||
private static final Pattern MOVE_STATEMENT = Pattern.compile("(?i)^\\s*MOVE\\s+(?:ROUNDED\\s+)?(\\S+)\\s+TO\\s+(.+)$");
|
||||
// Item 83: a MOVE whose target is a SUBSTR(...) overlay — MOVE '<lit>' TO SUBSTR(<var>, <pos>, <len>).
|
||||
// Captures the base variable and the 1-based position/length so the fold enricher can overlay the
|
||||
// literal onto the base value. Tolerates the spaces Natural allows inside the parentheses
|
||||
// (e.g. `SUBSTR( #GETSHORT-MODUL ,6,3)`). Only the 3-arg form (pos+len) is captured.
|
||||
private static final Pattern SUBSTR_TARGET =
|
||||
Pattern.compile("(?i)^SUBSTR\\s*\\(\\s*(\\S+?)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*\\)$");
|
||||
private static final Pattern MOVE_EXOTIC =
|
||||
Pattern.compile("(?i)\\b(BY\\s+NAME|BY\\s+POSITION|EDITED|SUBSTRING|ALL|ENCODED|NORMALIZED|JUSTIFIED)\\b");
|
||||
// The target group allows an optional subscript (e.g. `#TBL (1)` or `#TBL(1)`) so subscripted
|
||||
@@ -1362,7 +1368,23 @@ public final class NaturalParser implements LanguageParser {
|
||||
}
|
||||
String assignedValue = moveMatcher.group(1).trim();
|
||||
Map<String, String> moveGuard = guardProps(controlFlowStack, decideSubject, decideValue, decideValues);
|
||||
for (String targetOperand : moveMatcher.group(2).trim().split("\\s+")) {
|
||||
String moveTargets = moveMatcher.group(2).trim();
|
||||
// Item 83: MOVE '<lit>' TO SUBSTR(<var>,<pos>,<len>) overlays the literal onto a slice of
|
||||
// <var>. Record it as a single WRITES to the base variable, carrying the slice position and
|
||||
// length so the dynamic-CALLNAT fold enricher can reconstruct the assembled target name.
|
||||
Matcher substrTarget = SUBSTR_TARGET.matcher(moveTargets);
|
||||
if (substrTarget.matches()) {
|
||||
AstNode target = lookupVariable(scope, substrTarget.group(1), lineNo, true);
|
||||
if (target != null) {
|
||||
Map<String, String> substrGuard =
|
||||
moveGuard != null ? new LinkedHashMap<>(moveGuard) : new LinkedHashMap<>();
|
||||
substrGuard.put("substrPos", substrTarget.group(2));
|
||||
substrGuard.put("substrLen", substrTarget.group(3));
|
||||
edges.add(edge(EdgeType.WRITES, caller, target.id(), lineNo, assignedValue, substrGuard));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (String targetOperand : moveTargets.split("\\s+")) {
|
||||
AstNode target = lookupVariable(scope, targetOperand, lineNo, true);
|
||||
if (target != null) {
|
||||
edges.add(edge(EdgeType.WRITES, caller, target.id(), lineNo, assignedValue, moveGuard));
|
||||
|
||||
@@ -192,14 +192,19 @@ export function useSourceSearch(project: string | undefined, regex: string | und
|
||||
});
|
||||
}
|
||||
|
||||
/** Name-based identifier lookup (M1 click-to-identify). Disabled until a name is given. */
|
||||
export function useIdentifierSearch(project: string | undefined, name: string | undefined) {
|
||||
/**
|
||||
* Name-based identifier lookup (M1 click-to-identify). Disabled until a name is given.
|
||||
* priorityModule pins the open module's own declaration to the front so it survives the limit
|
||||
* when the name recurs across many modules (otherwise the "this module" group comes up empty).
|
||||
*/
|
||||
export function useIdentifierSearch(project: string | undefined, name: string | undefined,
|
||||
priorityModule?: string) {
|
||||
return useQuery({
|
||||
enabled: !!project && !!name,
|
||||
queryKey: ["identifier", project, name],
|
||||
queryKey: ["identifier", project, name, priorityModule],
|
||||
queryFn: async () => {
|
||||
const {data, error} = await api.GET("/api/projects/{project}/search/identifier", {
|
||||
params: {path: {project: project!}, query: {name: name!, limit: 25}},
|
||||
params: {path: {project: project!}, query: {name: name!, limit: 25, priorityModule}},
|
||||
});
|
||||
if (error) throw new Error("Lookup failed");
|
||||
return data;
|
||||
|
||||
@@ -1517,6 +1517,8 @@ export interface paths {
|
||||
module?: string;
|
||||
name?: string;
|
||||
offset?: number;
|
||||
/** @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. */
|
||||
priorityModule?: string;
|
||||
/** @description Scope to nodes in this exact source file (relative path). */
|
||||
sourceFile?: string;
|
||||
type?: string;
|
||||
|
||||
@@ -51,7 +51,7 @@ export function IdentifierPopover({
|
||||
onOpenFlow,
|
||||
onClose
|
||||
}: Props) {
|
||||
const {data, isLoading, isError} = useIdentifierSearch(project, name);
|
||||
const {data, isLoading, isError} = useIdentifierSearch(project, name, currentModuleName);
|
||||
const matches = useMemo(() => data ?? [], [data]);
|
||||
const [autoJumped, setAutoJumped] = useState(false);
|
||||
|
||||
|
||||
@@ -67,6 +67,11 @@ this work.
|
||||
|
||||
## 2. Tooling: AgenticCode first
|
||||
|
||||
**The full usage guide for AgenticCode is `/artefacts/agent-api-system-prompt.md`** (project root) — every
|
||||
endpoint, its parameters, its response shape, and the semantics behind them. It is the authority; read it
|
||||
before using the API in anger, and consult it whenever a response does not look the way you expected. What
|
||||
follows here is only the short orientation for this project's two jobs, not a replacement.
|
||||
|
||||
**Priority: MCP tools → REST API → `ac` CLI → grep/Explore.** Use `mcp__agenticcode__*` first. If an MCP
|
||||
call fails with a session/protocol error, fall back to the REST endpoint
|
||||
(`GET /api/projects/{project}/modules/{name}/...`) — do not let one broken MCP call push you to grep.
|
||||
|
||||
@@ -31,7 +31,7 @@ Perform a **very deep analysis of all agentic API endpoints**, in two tiers:
|
||||
`db-accesses`, `functions`, `data-structures`, `dispatch-table`, `digest`, `context`,
|
||||
`call-tree`, `sql-statements`, `graph`.
|
||||
2. **Read the Natural source manually** (the module plus its `USING` data areas / copycodes) and
|
||||
verify each response field-by-field: call graph, DB accesses (READ/WRITE mode), variable
|
||||
verify each response field-by-f.manield: call graph, DB accesses (READ/WRITE mode), variable
|
||||
reads/writes, field/placeholder resolution, dispatch table. Note the sources are ISO-8859
|
||||
encoded — use `grep -a` / an encoding-aware reader.
|
||||
3. **Broad-sweep cross-checks (WGEAGB0S's complete call tree).** Enumerate the full transitive
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
# Recreate the standard test projects (idempotent: delete then create).
|
||||
# Use after (re)starting the server to get a known baseline for ingest/perf testing.
|
||||
#
|
||||
# ./scripts/restore-test-projects.sh # recreate all three
|
||||
# Snapshot of the live `GET /api/projects` state (regenerate from there if it drifts).
|
||||
#
|
||||
# ./scripts/restore-test-projects.sh # recreate all projects
|
||||
# BASE=http://localhost:8787 ./scripts/restore-test-projects.sh
|
||||
#
|
||||
# After running, ingest as needed, e.g.:
|
||||
@@ -11,20 +13,21 @@
|
||||
set -euo pipefail
|
||||
BASE="${BASE:-http://localhost:8787}"
|
||||
|
||||
# name | server-side root | excludeDirs (JSON array)
|
||||
# name | JSON create body (language is required; generatedDir/userExitDir set together or not at all)
|
||||
create() {
|
||||
local name="$1" root="$2" excludes="$3"
|
||||
local name="$1" body="$2"
|
||||
echo "== $name =="
|
||||
curl -s -o /dev/null -w " delete: %{http_code}\n" -X DELETE "$BASE/api/projects/$name" || true
|
||||
curl -s -o /dev/null -w " create: %{http_code}\n" -X POST "$BASE/api/projects/$name" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"root\":\"$root\",\"excludeDirs\":$excludes}"
|
||||
-d "$body"
|
||||
}
|
||||
|
||||
create app "/home/ingo/deve/uniqa/uniqa-upms-app" '["test","target"]'
|
||||
create pur "/home/ingo/deve/uniqa/pur-sources/backend" '["test","target"]'
|
||||
create upms "/home/ingo/deve/tools/conqat/system/src/250401_UMPS/src/pur-analysis/pur-legacy" '["user_exit"]'
|
||||
create ac "/home/ingo/deve/agenticCode" '["test","target"]'
|
||||
create ac '{"root":"/home/ingo/deve/agenticCode","excludeDirs":["target"],"language":"java"}'
|
||||
create app '{"root":"/home/ingo/deve/uniqa/uniqa-upms-app","excludeDirs":["test","target"],"language":"java"}'
|
||||
create pur '{"root":"/home/ingo/deve/uniqa/pur-sources/backend","excludeDirs":["test","target"],"language":"java"}'
|
||||
create upms '{"root":"/home/ingo/deve/tools/conqat/system/src/250401_UMPS/src/pur-analysis/pur-legacy","excludeDirs":[],"language":"natural","generatedDir":"generated_src","userExitDir":"user_exit"}'
|
||||
|
||||
echo "Done. Current projects:"
|
||||
curl -s "$BASE/api/projects"
|
||||
echo
|
||||
@@ -487,29 +487,29 @@ 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. MCP `function_callers`, CLI `ac function-callers <module> <function>` |
|
||||
| `GET /modules/{name}/call-tree?depth=` | Transitive call graph to scope a feature |
|
||||
| `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. MCP `list_unresolved_dynamic_calls`/`list_dynamic_call_overrides`/`set_dynamic_call_override`/`reset_dynamic_call_override`, 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`. MCP `ego_graph`, CLI `ac ego-graph` |
|
||||
| `GET /modules/{name}/db-accesses` \| `/sql-statements` | DB tables + mode, raw statement text (pass `?depth=` for Natural). `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'`). MCP `workfile_accesses`, CLI `ac workfile-accesses <module>` |
|
||||
| `GET /modules/{name}/data-structures` | Which copybooks/inline groups a module uses |
|
||||
| `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 |
|
||||
| `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). MCP `search_identifier` / CLI `ac search-identifier --module --source-file --type` accept the same filters |
|
||||
| `GET /search/source?regex=&limit=&ignoreCase=` (MCP `search_source`, `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). `module_source` returns the **whole file** when the line range is omitted (M1), or a `[startLine,endLine]` slice when both are given. `/source?file=<relpath>` (MCP `file_source`, 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. MCP `function_callers`, CLI `ac function-callers <module> <function>` |
|
||||
| `GET /modules/{name}/call-tree?depth=` | Transitive call graph to scope a feature |
|
||||
| `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. MCP `list_unresolved_dynamic_calls`/`list_dynamic_call_overrides`/`set_dynamic_call_override`/`reset_dynamic_call_override`, 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`. MCP `ego_graph`, CLI `ac ego-graph` |
|
||||
| `GET /modules/{name}/db-accesses` \| `/sql-statements` | DB tables + mode, raw statement text (pass `?depth=` for Natural). `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'`). MCP `workfile_accesses`, CLI `ac workfile-accesses <module>` |
|
||||
| `GET /modules/{name}/data-structures` | Which copybooks/inline groups a module uses |
|
||||
| `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 |
|
||||
| `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. MCP `search_identifier` / CLI `ac search-identifier --module --priority-module --source-file --type` accept the same filters |
|
||||
| `GET /search/source?regex=&limit=&ignoreCase=` (MCP `search_source`, `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). `module_source` returns the **whole file** when the line range is omitted (M1), or a `[startLine,endLine]` slice when both are given. `/source?file=<relpath>` (MCP `file_source`, 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`.
|
||||
|
||||
@@ -283,6 +283,17 @@ wrong answer, found by the 2026-07-17 `VMULTMN4` audit.)*
|
||||
REST auto-serializes the records; MCP returns the same DTOs; the CLI is a JSON passthrough — all in sync.
|
||||
IT `WorkfileAndCopycodeFunctionIT#dbAccessSiteNamesTheCopycodeFileForCopycodeSourcedAccess` (host `FIND`
|
||||
vs copycode `FIND` → `sites[0].sourceFile`/`viaCopycode` distinguish the two).
|
||||
- [x] **91. `search/identifier?priorityModule=` pins the caller's module into the page; deterministic order**
|
||||
(2026-07-26, UI click-to-identify test). Click-to-identify sent `search/identifier?name=&limit=25`, but the
|
||||
query had **no `ORDER BY`** and paginated in incidental index order, so for a name declared in >25 modules
|
||||
(e.g. `#I-LINE-LEV`, 192 declarations) the open module's own declaration was truncated away and the popover
|
||||
falsely reported "0 in this module". Fix: `SEARCH_IDENTIFIER` gains `$priorityModule` — it does **not** filter
|
||||
(unlike `module=`) but computes a `pinRank` (0 for that module's file, else 1) and `ORDER BY pinRank,
|
||||
sourceFile, startLine`, so the local match survives the `limit` while the global list is preserved; ordering
|
||||
is now deterministic (it was undefined before). Delivered across REST (`priorityModule`), MCP
|
||||
`search_identifier`, CLI `--priority-module`, and the UI hook. IT `IdentifierPriorityModuleIT` (four modules
|
||||
sharing one LOCAL field, `PRIO_ZZZ_TARGET` sorts last: excluded at `limit=2` without the pin, first in the
|
||||
page with it, and the full set still returned at a large limit — i.e. no filtering).
|
||||
- [x] **90. `DELETE` no longer mis-parsed as a table write** (2026-07-19, second WGEAGB0S deep API audit,
|
||||
Finding 5). Natural DML `DELETE [(label)]` deletes the current record of the enclosing READ/FIND loop
|
||||
and names **no** view, and the `EXAMINE … DELETE [FIRST]` clause is not a DELETE statement at all — but
|
||||
|
||||
Reference in New Issue
Block a user