diff --git a/ac-cli/src/main/java/com/agenticcode/cli/SearchIdentifierCommand.java b/ac-cli/src/main/java/com/agenticcode/cli/SearchIdentifierCommand.java index c5be7cc..6f044d5 100644 --- a/ac-cli/src/main/java/com/agenticcode/cli/SearchIdentifierCommand.java +++ b/ac-cli/src/main/java/com/agenticcode/cli/SearchIdentifierCommand.java @@ -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)); diff --git a/ac-cli/src/main/resources/agenticcode.properties b/ac-cli/src/main/resources/agenticcode.properties index 04a07cc..36a41ff 100644 --- a/ac-cli/src/main/resources/agenticcode.properties +++ b/ac-cli/src/main/resources/agenticcode.properties @@ -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 diff --git a/ac-code-server/src/main/java/com/agenticcode/codeserver/api/AnalysisResource.java b/ac-code-server/src/main/java/com/agenticcode/codeserver/api/AnalysisResource.java index 7b99775..d360005 100644 --- a/ac-code-server/src/main/java/com/agenticcode/codeserver/api/AnalysisResource.java +++ b/ac-code-server/src/main/java/com/agenticcode/codeserver/api/AnalysisResource.java @@ -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)); } diff --git a/ac-code-server/src/main/java/com/agenticcode/codeserver/mcp/McpQueryTools.java b/ac-code-server/src/main/java/com/agenticcode/codeserver/mcp/McpQueryTools.java index 4957b15..aa3c501 100644 --- a/ac-code-server/src/main/java/com/agenticcode/codeserver/mcp/McpQueryTools.java +++ b/ac-code-server/src/main/java/com/agenticcode/codeserver/mcp/McpQueryTools.java @@ -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 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()); } diff --git a/ac-code-server/src/main/resources/application.properties b/ac-code-server/src/main/resources/application.properties index 4e9ffed..cdea5ba 100644 --- a/ac-code-server/src/main/resources/application.properties +++ b/ac-code-server/src/main/resources/application.properties @@ -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://:8787/mcp/sse quarkus.mcp.server.server-info.name=agenticcode diff --git a/ac-code-server/src/test/java/com/agenticcode/codeserver/api/CoalescingIT.java b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/CoalescingIT.java index 0f296f7..1c9dfa9 100644 --- a/ac-code-server/src/test/java/com/agenticcode/codeserver/api/CoalescingIT.java +++ b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/CoalescingIT.java @@ -95,7 +95,7 @@ class CoalescingIT { assertTrue(state.isFull(), "the module is FULL after concurrent deep ingests"); assertEquals(IngestStatus.INGESTED.name(), state.status()); - List realModules = graphRepository.searchIdentifier(PROJECT, MODULE, "MODULE", null, null, 50, 0) + List realModules = graphRepository.searchIdentifier(PROJECT, MODULE, "MODULE", null, null, null, 50, 0) .await().indefinitely().stream() .filter(m -> !m.sourceFile().isEmpty()) .toList(); diff --git a/ac-code-server/src/test/java/com/agenticcode/codeserver/api/DynamicCallnatFoldIT.java b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/DynamicCallnatFoldIT.java new file mode 100644 index 0000000..2b9ed70 --- /dev/null +++ b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/DynamicCallnatFoldIT.java @@ -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-assembled 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 sourceFiles = callees.path("sourceFiles"); + List> sites = callees.path("items.find { it.name == '" + callee + "' }.sites"); + Map 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"))); + } +} diff --git a/ac-code-server/src/test/java/com/agenticcode/codeserver/api/IdentifierPriorityModuleIT.java b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/IdentifierPriorityModuleIT.java new file mode 100644 index 0000000..de13a78 --- /dev/null +++ b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/IdentifierPriorityModuleIT.java @@ -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))); + } +} diff --git a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/CypherQueries.java b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/CypherQueries.java index daa3bdc..2c425fa 100644 --- a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/CypherQueries.java +++ b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/CypherQueries.java @@ -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-assembled dynamic {@code CALLNAT } 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 last 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. + * + *

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 = ""}) 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-fold. 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 earlier + * 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 in batches, leaving its {@code (:Project)} diff --git a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/GraphRepository.java b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/GraphRepository.java index 58989dc..da75139 100644 --- a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/GraphRepository.java +++ b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/GraphRepository.java @@ -137,6 +137,12 @@ public class GraphRepository { // Resolve intra-module dynamic CALLNAT 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> 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> searchIdentifier(String project, @Nullable String identifierName, @Nullable String type, @Nullable String sourceFile, - @Nullable String module) { + @Nullable String module, @Nullable String priorityModule) { Map 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()), diff --git a/ac-parser-natural/src/main/java/com/agenticcode/parsernatural/NaturalParser.java b/ac-parser-natural/src/main/java/com/agenticcode/parsernatural/NaturalParser.java index 473bf0c..a74d182 100644 --- a/ac-parser-natural/src/main/java/com/agenticcode/parsernatural/NaturalParser.java +++ b/ac-parser-natural/src/main/java/com/agenticcode/parsernatural/NaturalParser.java @@ -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 '' TO SUBSTR(, , ). + // 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 moveGuard = guardProps(controlFlowStack, decideSubject, decideValue, decideValues); - for (String targetOperand : moveMatcher.group(2).trim().split("\\s+")) { + String moveTargets = moveMatcher.group(2).trim(); + // Item 83: MOVE '' TO SUBSTR(,,) overlays the literal onto a slice of + // . 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 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)); diff --git a/ac-ui/src/api/hooks.ts b/ac-ui/src/api/hooks.ts index 4cfc6a5..51c298e 100644 --- a/ac-ui/src/api/hooks.ts +++ b/ac-ui/src/api/hooks.ts @@ -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; diff --git a/ac-ui/src/api/schema.ts b/ac-ui/src/api/schema.ts index 1105d7b..a36c8f6 100644 --- a/ac-ui/src/api/schema.ts +++ b/ac-ui/src/api/schema.ts @@ -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; diff --git a/ac-ui/src/components/IdentifierPopover.tsx b/ac-ui/src/components/IdentifierPopover.tsx index bcd1177..47be4f3 100644 --- a/ac-ui/src/components/IdentifierPopover.tsx +++ b/ac-ui/src/components/IdentifierPopover.tsx @@ -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); diff --git a/prompts/CLAUDE.md b/prompts/CLAUDE.md index 8059aeb..0c16e30 100644 --- a/prompts/CLAUDE.md +++ b/prompts/CLAUDE.md @@ -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. diff --git a/prompts/wgeagb0s-deep-api-audit.md b/prompts/wgeagb0s-deep-api-audit.md index 07fee01..8edc3d2 100644 --- a/prompts/wgeagb0s-deep-api-audit.md +++ b/prompts/wgeagb0s-deep-api-audit.md @@ -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 diff --git a/scripts/restore-test-projects.sh b/scripts/restore-projects.sh similarity index 54% rename from scripts/restore-test-projects.sh rename to scripts/restore-projects.sh index 119df7e..3f3350d 100755 --- a/scripts/restore-test-projects.sh +++ b/scripts/restore-projects.sh @@ -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 diff --git a/x-docs/mcp-api-usage-ac-implementation.md b/x-docs/mcp-api-usage-ac-implementation.md index 14552e8..e040f91 100644 --- a/x-docs/mcp-api-usage-ac-implementation.md +++ b/x-docs/mcp-api-usage-ac-implementation.md @@ -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 ` | -| `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 ` 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 ''`, 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 ` | -| `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=` and `module=` (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=` (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 ` | +| `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 ` 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 ''`, 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 ` | +| `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=` and `module=` (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=` 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=` (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`. diff --git a/x-docs/roadmap.md b/x-docs/roadmap.md index be28e0d..39e83c0 100644 --- a/x-docs/roadmap.md +++ b/x-docs/roadmap.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