This commit is contained in:
Ingo Schnabel
2026-07-27 08:55:37 +02:00
parent 364bcb2932
commit c8ab0274c3
19 changed files with 494 additions and 52 deletions

View File

@@ -21,6 +21,9 @@ final class SearchIdentifierCommand extends AbstractProjectCommand {
@Option(names = "--module", description = "Scope to nodes of this module (by name)") @Option(names = "--module", description = "Scope to nodes of this module (by name)")
@Nullable String module; @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)") @Option(names = "--source-file", description = "Scope to this exact source file (relative path)")
@Nullable String sourceFile; @Nullable String sourceFile;
@@ -37,6 +40,7 @@ final class SearchIdentifierCommand extends AbstractProjectCommand {
path = appendQuery(path, "name", name); path = appendQuery(path, "name", name);
path = appendQuery(path, "type", type); path = appendQuery(path, "type", type);
path = appendQuery(path, "module", module); path = appendQuery(path, "module", module);
path = appendQuery(path, "priorityModule", priorityModule);
path = appendQuery(path, "sourceFile", sourceFile); path = appendQuery(path, "sourceFile", sourceFile);
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset); path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
return printResponse(apiClient().get(path)); return printResponse(apiClient().get(path));

View File

@@ -4,4 +4,4 @@
server.url=http://localhost:8787 server.url=http://localhost:8787
# Stamped by manage-ac.sh (stamp_cli_version) from ac-code-server's agenticcode.version # 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. # at build time. "dev" means this jar wasn't built via manage-ac.sh.
version=108 version=112

View File

@@ -745,6 +745,8 @@ public class AnalysisResource {
@QueryParam("sourceFile") @Nullable String sourceFile, @QueryParam("sourceFile") @Nullable String sourceFile,
@Parameter(description = "Scope to nodes belonging to this module (by name); resolves the module's source file.") @Parameter(description = "Scope to nodes belonging to this module (by name); resolves the module's source file.")
@QueryParam("module") @Nullable String module, @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("limit") @Nullable Integer limit, @QueryParam("offset") @Nullable Integer offset,
@QueryParam("fields") @Nullable String fields) { @QueryParam("fields") @Nullable String fields) {
if (type != null) { if (type != null) {
@@ -757,7 +759,7 @@ public class AnalysisResource {
} }
return withFanoutWarm(project, return withFanoutWarm(project,
() -> graphRepository.searchIdentifier(project, name, () -> 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 -> matches.stream().map(IdentifierMatch::sourceFile).filter(sf -> !sf.isEmpty()).distinct().toList(),
matches -> namesOnly(fields) ? ok(identifierNames(matches)) : ok(matches)); matches -> namesOnly(fields) ? ok(identifierNames(matches)) : ok(matches));
} }

View File

@@ -475,7 +475,7 @@ public class McpQueryTools {
.map(support::ok)); .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 @Blocking
public Uni<ToolResponse> searchIdentifier( public Uni<ToolResponse> searchIdentifier(
@ToolArg(description = "Project name") String project, @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 = "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 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 = "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 size (default 50)", required = false) @Nullable Integer limit,
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) { @ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) {
if (type != null) { if (type != null) {
@@ -494,7 +495,7 @@ public class McpQueryTools {
} }
return withFanoutWarm(project, return withFanoutWarm(project,
() -> graphRepository.searchIdentifier(project, name, () -> 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 -> matches.stream().map(IdentifierMatch::sourceFile).filter(sf -> !sf.isEmpty()).distinct().toList());
} }

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 # 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 # 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). # '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 # MCP server (HTTP/SSE transport) — tools exposed at http://<host>:8787/mcp/sse
quarkus.mcp.server.server-info.name=agenticcode quarkus.mcp.server.server-info.name=agenticcode

View File

@@ -95,7 +95,7 @@ class CoalescingIT {
assertTrue(state.isFull(), "the module is FULL after concurrent deep ingests"); assertTrue(state.isFull(), "the module is FULL after concurrent deep ingests");
assertEquals(IngestStatus.INGESTED.name(), state.status()); 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() .await().indefinitely().stream()
.filter(m -> !m.sourceFile().isEmpty()) .filter(m -> !m.sourceFile().isEmpty())
.toList(); .toList();

View File

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

View File

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

View File

@@ -1035,7 +1035,7 @@ public final class CypherQueries {
WHERE dyn.callKind = 'CALLNAT_DYNAMIC' AND src.sourceFile <> "" WHERE dyn.callKind = 'CALLNAT_DYNAMIC' AND src.sourceFile <> ""
MATCH (caller:AstNode {type: 'MODULE', project: $project, sourceFile: 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}) 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, WITH DISTINCT caller, dyn, trim(replace(wr.value, "'", "")) AS lit,
coalesce(dyn.originFile, src.sourceFile) AS originFile coalesce(dyn.originFile, src.sourceFile) AS originFile
MATCH (target:AstNode {type: 'MODULE', project: $project, name: lit}) 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 }) OR EXISTS { (caller)-[:INCLUDES]->(inc:AstNode) WHERE inc.sourceFile = cv.sourceFile })
MATCH (cv)-[:ARG_TO_PARAM*1..5]->(param:AstNode {project: $project}) MATCH (cv)-[:ARG_TO_PARAM*1..5]->(param:AstNode {project: $project})
MATCH (pw:AstNode)-[wr:WRITES]->(param) 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, WITH DISTINCT caller, dyn, trim(replace(wr.value, "'", "")) AS lit,
coalesce(dyn.originFile, src.sourceFile) AS originFile coalesce(dyn.originFile, src.sourceFile) AS originFile
MATCH (target:AstNode {type: 'MODULE', project: $project, name: lit}) 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: ""}) MATCH (src:AstNode {project: $project, sourceFile: caller.sourceFile})-[dyn:CALLS]->(ph:AstNode {project: $project, type: 'MODULE', sourceFile: ""})
WHERE dyn.callKind = 'CALLNAT_DYNAMIC' WHERE dyn.callKind = 'CALLNAT_DYNAMIC'
MATCH (w:AstNode {project: $project, sourceFile: caller.sourceFile})-[wr:WRITES]->(v:AstNode {project: $project, name: dyn.dynamicVar}) 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, WITH DISTINCT caller, dyn, trim(replace(wr.value, "'", "")) AS lit,
coalesce(dyn.originFile, src.sourceFile) AS originFile coalesce(dyn.originFile, src.sourceFile) AS originFile
MATCH (target:AstNode {type: 'MODULE', project: $project, name: lit}) 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) MERGE (caller)-[r2:CALLS {callKind: 'CALLNAT_DYNAMIC', lineNo: dyn.lineNo, originFile: originFile}]->(target)
SET r2.dynamicVar = dyn.dynamicVar, r2.args = dyn.args 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 * 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 * 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 * obsolete. Idempotent (MERGE). Runs after the auto dynamic-CALLNAT resolvers and before
* {@link #DELETE_DYNAMIC_CALLNAT_PLACEHOLDER_EDGES}. * {@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 = """ public static final String APPLY_MANUAL_DYNAMIC_CALLNAT = """
MATCH (o:DynamicCallOverride {project: $project}) MATCH (o:DynamicCallOverride {project: $project})
UNWIND o.targets AS tname UNWIND o.targets AS tname
@@ -1347,7 +1458,7 @@ public final class CypherQueries {
OR EXISTS { (caller)-[:INCLUDES]->(inc:AstNode) WHERE inc.sourceFile = cv.sourceFile }) OR EXISTS { (caller)-[:INCLUDES]->(inc:AstNode) WHERE inc.sourceFile = cv.sourceFile })
MATCH (cv)-[:ARG_TO_PARAM*1..5]->(param:AstNode {project: $project}) MATCH (cv)-[:ARG_TO_PARAM*1..5]->(param:AstNode {project: $project})
MATCH (pw:AstNode)-[wr:WRITES]->(param) 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, WITH DISTINCT caller, dyn, trim(replace(wr.value, "'", "")) AS lit,
coalesce(dyn.originFile, src.sourceFile) AS originFile coalesce(dyn.originFile, src.sourceFile) AS originFile
MATCH (target:AstNode {type: 'MODULE', project: $project, name: lit}) 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 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 = """ public static final String SEARCH_IDENTIFIER = """
MATCH (n:AstNode {project: $project}) MATCH (n:AstNode {project: $project})
WHERE ($name IS NULL OR WHERE ($name IS NULL OR
@@ -1798,9 +1912,14 @@ public final class CypherQueries {
MATCH (mod:AstNode {type: 'MODULE', name: $module, project: $project}) MATCH (mod:AstNode {type: 'MODULE', name: $module, project: $project})
WHERE mod.sourceFile = n.sourceFile 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, 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.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
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)} * Item 78: deletes a project's {@code AstNode}s <b>in batches</b>, leaving its {@code (:Project)}

View File

@@ -137,6 +137,12 @@ public class GraphRepository {
// Resolve intra-module dynamic CALLNAT <var> sites to real CALLS edges (cheap, all modes). // Resolve intra-module dynamic CALLNAT <var> sites to real CALLS edges (cheap, all modes).
statements.add(new EnrichmentStep("resolve-dynamic-callnat-intra" + sfx, statements.add(new EnrichmentStep("resolve-dynamic-callnat-intra" + sfx,
scoped ? CypherQueries.RESOLVE_DYNAMIC_CALLNAT_INTRA_SCOPED : CypherQueries.RESOLVE_DYNAMIC_CALLNAT_INTRA)); 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) { if (resolveFields) {
statements.add(new EnrichmentStep("resolve-dynamic-callnat-intra-indirect" + sfx, statements.add(new EnrichmentStep("resolve-dynamic-callnat-intra-indirect" + sfx,
scoped ? CypherQueries.RESOLVE_DYNAMIC_CALLNAT_INTRA_INDIRECT_SCOPED : CypherQueries.RESOLVE_DYNAMIC_CALLNAT_INTRA_INDIRECT)); 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) // 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 // 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). // 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)); 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. // Drop the now-unresolved dynamic-call markers (placeholder edges); resolved edges remain.
statements.add(new EnrichmentStep("delete-dynamic-callnat-placeholders" + sfx, 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, public Uni<List<IdentifierMatch>> searchIdentifier(String project, @Nullable String identifierName,
@Nullable String type, @Nullable String sourceFile, @Nullable String type, @Nullable String sourceFile,
@Nullable String module, int limit, int offset) { @Nullable String module, @Nullable String priorityModule,
return searchIdentifier(project, identifierName, type, sourceFile, module).map(list -> paginate(list, limit, offset)); 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, public Uni<List<IdentifierMatch>> searchIdentifier(String project, @Nullable String identifierName,
@Nullable String type, @Nullable String sourceFile, @Nullable String type, @Nullable String sourceFile,
@Nullable String module) { @Nullable String module, @Nullable String priorityModule) {
Map<String, @Nullable Object> params = new java.util.HashMap<>(); Map<String, @Nullable Object> params = new java.util.HashMap<>();
params.put("project", project); params.put("project", project);
// Sigil-insensitive: strip a leading Natural sigil (# user, & AIV, + GDA) so a search for // 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. // Item 53: optional scope filters — narrow to one file, or to all nodes of a named module.
params.put("sourceFile", sourceFile); params.put("sourceFile", sourceFile);
params.put("module", module); 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( return read(CypherQueries.SEARCH_IDENTIFIER, params, record -> new IdentifierMatch(
record.get("id").asString(), record.get("id").asString(),
NodeType.valueOf(record.get("type").asString()), NodeType.valueOf(record.get("type").asString()),

View File

@@ -96,6 +96,12 @@ public final class NaturalParser implements LanguageParser {
// Leading option keywords between the file number and the record buffer operand(s). // 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 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+(.+)$"); 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 = private static final Pattern MOVE_EXOTIC =
Pattern.compile("(?i)\\b(BY\\s+NAME|BY\\s+POSITION|EDITED|SUBSTRING|ALL|ENCODED|NORMALIZED|JUSTIFIED)\\b"); 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 // 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(); String assignedValue = moveMatcher.group(1).trim();
Map<String, String> moveGuard = guardProps(controlFlowStack, decideSubject, decideValue, decideValues); 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); AstNode target = lookupVariable(scope, targetOperand, lineNo, true);
if (target != null) { if (target != null) {
edges.add(edge(EdgeType.WRITES, caller, target.id(), lineNo, assignedValue, moveGuard)); edges.add(edge(EdgeType.WRITES, caller, target.id(), lineNo, assignedValue, moveGuard));

View File

@@ -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({ return useQuery({
enabled: !!project && !!name, enabled: !!project && !!name,
queryKey: ["identifier", project, name], queryKey: ["identifier", project, name, priorityModule],
queryFn: async () => { queryFn: async () => {
const {data, error} = await api.GET("/api/projects/{project}/search/identifier", { 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"); if (error) throw new Error("Lookup failed");
return data; return data;

View File

@@ -1517,6 +1517,8 @@ export interface paths {
module?: string; module?: string;
name?: string; name?: string;
offset?: number; 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). */ /** @description Scope to nodes in this exact source file (relative path). */
sourceFile?: string; sourceFile?: string;
type?: string; type?: string;

View File

@@ -51,7 +51,7 @@ export function IdentifierPopover({
onOpenFlow, onOpenFlow,
onClose onClose
}: Props) { }: Props) {
const {data, isLoading, isError} = useIdentifierSearch(project, name); const {data, isLoading, isError} = useIdentifierSearch(project, name, currentModuleName);
const matches = useMemo(() => data ?? [], [data]); const matches = useMemo(() => data ?? [], [data]);
const [autoJumped, setAutoJumped] = useState(false); const [autoJumped, setAutoJumped] = useState(false);

View File

@@ -67,6 +67,11 @@ this work.
## 2. Tooling: AgenticCode first ## 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 **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 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. (`GET /api/projects/{project}/modules/{name}/...`) — do not let one broken MCP call push you to grep.

View File

@@ -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`, `db-accesses`, `functions`, `data-structures`, `dispatch-table`, `digest`, `context`,
`call-tree`, `sql-statements`, `graph`. `call-tree`, `sql-statements`, `graph`.
2. **Read the Natural source manually** (the module plus its `USING` data areas / copycodes) and 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 reads/writes, field/placeholder resolution, dispatch table. Note the sources are ISO-8859
encoded — use `grep -a` / an encoding-aware reader. encoded — use `grep -a` / an encoding-aware reader.
3. **Broad-sweep cross-checks (WGEAGB0S's complete call tree).** Enumerate the full transitive 3. **Broad-sweep cross-checks (WGEAGB0S's complete call tree).** Enumerate the full transitive

View File

@@ -2,7 +2,9 @@
# Recreate the standard test projects (idempotent: delete then create). # Recreate the standard test projects (idempotent: delete then create).
# Use after (re)starting the server to get a known baseline for ingest/perf testing. # 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 # BASE=http://localhost:8787 ./scripts/restore-test-projects.sh
# #
# After running, ingest as needed, e.g.: # After running, ingest as needed, e.g.:
@@ -11,20 +13,21 @@
set -euo pipefail set -euo pipefail
BASE="${BASE:-http://localhost:8787}" 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() { create() {
local name="$1" root="$2" excludes="$3" local name="$1" body="$2"
echo "== $name ==" 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 " 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" \ curl -s -o /dev/null -w " create: %{http_code}\n" -X POST "$BASE/api/projects/$name" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d "{\"root\":\"$root\",\"excludeDirs\":$excludes}" -d "$body"
} }
create app "/home/ingo/deve/uniqa/uniqa-upms-app" '["test","target"]' create ac '{"root":"/home/ingo/deve/agenticCode","excludeDirs":["target"],"language":"java"}'
create pur "/home/ingo/deve/uniqa/pur-sources/backend" '["test","target"]' create app '{"root":"/home/ingo/deve/uniqa/uniqa-upms-app","excludeDirs":["test","target"],"language":"java"}'
create upms "/home/ingo/deve/tools/conqat/system/src/250401_UMPS/src/pur-analysis/pur-legacy" '["user_exit"]' create pur '{"root":"/home/ingo/deve/uniqa/pur-sources/backend","excludeDirs":["test","target"],"language":"java"}'
create ac "/home/ingo/deve/agenticCode" '["test","target"]' 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:" echo "Done. Current projects:"
curl -s "$BASE/api/projects" curl -s "$BASE/api/projects"
echo echo

View File

@@ -487,29 +487,29 @@ origins (`http://localhost:5173`, `http://localhost:4173`) — extend the
## Endpoint quick reference ## Endpoint quick reference
| Endpoint | Use for | | 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 /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 /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}/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}/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}/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}/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 /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 /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}/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}/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}/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}/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}/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}/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 /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 /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 /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/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 /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}` | 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 | | `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: Full endpoint list, request params, and response field details:
`x-docs/agent-api-system-prompt.md`. `x-docs/agent-api-system-prompt.md`.

View File

@@ -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. REST auto-serializes the records; MCP returns the same DTOs; the CLI is a JSON passthrough — all in sync.
IT `WorkfileAndCopycodeFunctionIT#dbAccessSiteNamesTheCopycodeFileForCopycodeSourcedAccess` (host `FIND` IT `WorkfileAndCopycodeFunctionIT#dbAccessSiteNamesTheCopycodeFileForCopycodeSourcedAccess` (host `FIND`
vs copycode `FIND` → `sites[0].sourceFile`/`viaCopycode` distinguish the two). 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, - [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 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 and names **no** view, and the `EXAMINE … DELETE [FIRST]` clause is not a DELETE statement at all — but