Reap feature
This commit is contained in:
@@ -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=207
|
||||
version=211
|
||||
|
||||
@@ -3,7 +3,7 @@ quarkus.http.port=8787
|
||||
# AgenticCode's own release counter (not the Maven project version) — bump this by hand for each
|
||||
# release. Single source of truth for the startup log line, GET /api/version, and the OpenAPI
|
||||
# info version (referenced below via property expression, not duplicated).
|
||||
agenticcode.version=207
|
||||
agenticcode.version=211
|
||||
# OpenAPI / Swagger UI (item 48) — the generated spec is the contract the web-UI TS client
|
||||
# is generated against. Served at /q/openapi (yaml/json); Swagger UI at /q/swagger-ui in dev.
|
||||
mp.openapi.extensions.smallrye.info.title=AgenticCode API
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.RestAssured;
|
||||
import io.restassured.path.json.JsonPath;
|
||||
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 static io.restassured.RestAssured.given;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* The dispatch-<em>table</em> idiom — an array filled with literal program names, called through an
|
||||
* indexed read of it — resolves to candidate call edges. 65 modules in {@code upms} dispatch this way.
|
||||
*
|
||||
* <p>{@code RESOLVE_DYNAMIC_CALLNAT_INTRA_INDIRECT} handles it: for the assignment writing the
|
||||
* dispatch variable it follows the {@code READS} on that same line to the array, then takes every
|
||||
* string literal written to the array as a target. Item 83's scalar fold does not reach these — the
|
||||
* literals live one hop away, on the array node.
|
||||
*
|
||||
* <p><b>Written while investigating item 108, which claims this idiom is unsupported.</b> It is
|
||||
* supported; what is missing there is only the {@code dispatch-table} <em>endpoint</em> reporting the
|
||||
* rows. The reason `upms` shows nothing is item 107: those target modules are absent from the
|
||||
* checkout entirely, and a literal naming no ingested module correctly yields no edge. This fixture
|
||||
* exists because the behaviour had no test of its own, so nothing would have caught its loss.
|
||||
*
|
||||
* <p>Which index is live at runtime is not statically known, so resolution over-approximates to the
|
||||
* set of literals ever assigned to the array — the same multi-target model item 82 allows a manual
|
||||
* override. `TBRANCH` pins that this is not merely a simplification but the only correct answer: it
|
||||
* builds the table in an `IF`/`ELSE`, so index 1 carries a different program per branch and any
|
||||
* index-keyed pairing would be wrong.
|
||||
*/
|
||||
@QuarkusTest
|
||||
class DispatchTableFoldIT {
|
||||
|
||||
private static final String PROJECT = "nat-dispatch-table-fold";
|
||||
|
||||
/**
|
||||
* Straight table: four literals, called through an indexed read.
|
||||
*/
|
||||
private static final String ROUTER = """
|
||||
* Router dispatching through a literal-filled table.
|
||||
DEFINE DATA LOCAL
|
||||
01 #WT-PROG (A8/1:4)
|
||||
01 #W-ACT (A8)
|
||||
01 #I-OBJ (I2)
|
||||
END-DEFINE
|
||||
*
|
||||
PERFORM INIT-TABLE
|
||||
*
|
||||
#W-ACT := #WT-PROG (#I-OBJ)
|
||||
CALLNAT #W-ACT
|
||||
*
|
||||
DEFINE SUBROUTINE INIT-TABLE
|
||||
ASSIGN #WT-PROG (1) = 'TDISPA'
|
||||
ASSIGN #WT-PROG (2) = 'TDISPB'
|
||||
ASSIGN #WT-PROG (3) = 'TDISPC'
|
||||
ASSIGN #WT-PROG (4) = 'TNOSUCH'
|
||||
END-SUBROUTINE
|
||||
*
|
||||
END
|
||||
""";
|
||||
|
||||
/**
|
||||
* Table built in two branches: index 1 is TDISPA in one and TDISPB in the other. Also proves the
|
||||
* resolver does not depend on the init preceding the call — here the subroutine is defined after
|
||||
* the CALLNAT, as Natural routinely does.
|
||||
*/
|
||||
private static final String BRANCHED = """
|
||||
* Router whose table depends on a runtime condition.
|
||||
DEFINE DATA LOCAL
|
||||
01 #WT-PROG (A8/1:2)
|
||||
01 #W-ACT (A8)
|
||||
01 #I-OBJ (I2)
|
||||
01 #MODE (A4)
|
||||
END-DEFINE
|
||||
*
|
||||
PERFORM INIT-TABLE
|
||||
#W-ACT := #WT-PROG (#I-OBJ)
|
||||
CALLNAT #W-ACT
|
||||
*
|
||||
DEFINE SUBROUTINE INIT-TABLE
|
||||
IF #MODE = 'ADD'
|
||||
ASSIGN #WT-PROG (1) = 'TDISPA'
|
||||
ELSE
|
||||
ASSIGN #WT-PROG (1) = 'TDISPB'
|
||||
END-IF
|
||||
ASSIGN #WT-PROG (2) = 'TDISPC'
|
||||
END-SUBROUTINE
|
||||
*
|
||||
END
|
||||
""";
|
||||
|
||||
/**
|
||||
* A dispatch fed from a plain scalar, not an array — must stay untouched by this resolver.
|
||||
*/
|
||||
private static final String SCALARDSP = """
|
||||
* Dispatch through a scalar the table resolver must not claim.
|
||||
DEFINE DATA LOCAL
|
||||
01 #W-ACT (A8)
|
||||
END-DEFINE
|
||||
*
|
||||
#W-ACT := 'TDISPA'
|
||||
CALLNAT #W-ACT
|
||||
END
|
||||
""";
|
||||
|
||||
private static final String TARGET = "DEFINE DATA LOCAL\nEND-DEFINE\nEND\n";
|
||||
|
||||
@TempDir
|
||||
static Path root;
|
||||
|
||||
@BeforeAll
|
||||
static void ingest() {
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
write("TROUTER.nat", ROUTER);
|
||||
write("TBRANCH.nat", BRANCHED);
|
||||
write("TSCALAR.nat", SCALARDSP);
|
||||
write("TDISPA.nat", TARGET);
|
||||
write("TDISPB.nat", TARGET);
|
||||
write("TDISPC.nat", TARGET);
|
||||
// TNOSUCH.nat deliberately absent: a literal that is not a real module must yield no edge.
|
||||
|
||||
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 write(String fileName, String content) {
|
||||
try {
|
||||
Files.writeString(root.resolve(fileName), content);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonPath callees(String module) {
|
||||
return given().pathParam("name", module)
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callees?scope=external")
|
||||
.then().statusCode(200).extract().jsonPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every literal in the table becomes a candidate target.
|
||||
*/
|
||||
@Test
|
||||
void everyTableLiteralBecomesACandidateTarget() {
|
||||
List<String> names = callees("TROUTER").getList("items.name");
|
||||
|
||||
assertTrue(names.containsAll(List.of("TDISPA", "TDISPB", "TDISPC")),
|
||||
"all three real table targets must be resolved: " + names);
|
||||
}
|
||||
|
||||
/**
|
||||
* A literal that names no ingested module produces no edge — the resolver does not invent nodes.
|
||||
*/
|
||||
@Test
|
||||
void aLiteralThatIsNotARealModuleYieldsNoEdge() {
|
||||
assertFalse(callees("TROUTER").getList("items.name").contains("TNOSUCH"),
|
||||
"'TNOSUCH' is a literal in the table but no module exists — it must not become an edge");
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolved edges are marked inferred, and distinguishable from item 83's string fold.
|
||||
*/
|
||||
@Test
|
||||
void resolvedEdgesAreMarkedInferredNotStatic() {
|
||||
JsonPath body = callees("TROUTER");
|
||||
assertEquals("CALLNAT_DYNAMIC", body.getString("items.find { it.name == 'TDISPA' }.edgeKind"),
|
||||
"a table dispatch is inferred, so it must not masquerade as a static CALLNAT");
|
||||
}
|
||||
|
||||
/**
|
||||
* The branched table: both branch values are candidates. This is the case that makes the
|
||||
* candidate-set model necessary rather than merely convenient — index 1 has two different targets,
|
||||
* so no index-keyed answer could be right.
|
||||
*/
|
||||
@Test
|
||||
void aTableBuiltInTwoBranchesContributesBothValues() {
|
||||
List<String> names = callees("TBRANCH").getList("items.name");
|
||||
|
||||
assertTrue(names.contains("TDISPA") && names.contains("TDISPB"),
|
||||
"index 1 is TDISPA in one branch and TDISPB in the other; both are possible: " + names);
|
||||
assertTrue(names.contains("TDISPC"), "the unbranched index must resolve too: " + names);
|
||||
}
|
||||
|
||||
/**
|
||||
* The scalar case is item 83's fold, and it must keep resolving on its own — the two paths are
|
||||
* separate, so a change to the indirect resolver must not silently take the scalar one with it.
|
||||
*/
|
||||
@Test
|
||||
void aScalarFedDispatchStillResolvesOnItsOwnPath() {
|
||||
assertTrue(callees("TSCALAR").getList("items.name").contains("TDISPA"),
|
||||
"a directly-assigned literal target must resolve without the array path");
|
||||
}
|
||||
}
|
||||
@@ -1671,95 +1671,6 @@ public final class CypherQueries {
|
||||
SET r2.dynamicVar = dyn.dynamicVar, r2.args = dyn.args
|
||||
""";
|
||||
|
||||
/**
|
||||
* Item 108: resolve the dispatch-<em>table</em> idiom — the one the {@code dispatch-table} endpoint
|
||||
* is named after, and the one it did not understand. A router fills an array with literal program
|
||||
* names and then calls through an indexed read of it:
|
||||
* <pre>
|
||||
* ASSIGN #WT-OBJ-PROG (1) = 'WXSPOD0S' ← one row per target
|
||||
* ASSIGN #WT-OBJ-PROG (2) = 'WXSDAD0S'
|
||||
* …
|
||||
* #W-ACT-PROG := #WT-OBJ-PROG (#I-OBJ) ← scalar fed from an array element
|
||||
* CALLNAT #W-ACT-PROG …
|
||||
* </pre>
|
||||
*
|
||||
* <p>Item 83's fold does not reach this: it follows literals written to the dispatch variable
|
||||
* <em>itself</em>, and here the variable is fed from an array element, so the literals sit one hop
|
||||
* away on a different node. 65 modules in {@code upms} use the idiom, and every one of their targets
|
||||
* is a literal in the source — nothing is runtime-dependent, yet the call sites sat in
|
||||
* {@code dynamic-calls/unresolved} and the endpoint answered {@code []}.
|
||||
*
|
||||
* <p><b>Which index is live at runtime is not statically known, so this deliberately
|
||||
* over-approximates:</b> it emits one candidate edge per <em>distinct literal ever assigned to any
|
||||
* element</em> of the feeding array, exactly the multi-target model item 82 already allows a manual
|
||||
* override. That is also what makes it robust against the 9 of 65 modules that build the table
|
||||
* inside an {@code IF}/{@code ELSE} — there the same index carries different values per branch
|
||||
* (`WGARCX0S` index 1 is `WXSFUD0S` in one branch and `WXSGAD0S` in the other), so any
|
||||
* index-keyed pairing would be plainly wrong while the candidate <em>set</em> stays correct.
|
||||
*
|
||||
* <p>No line-ordering constraint between the array writes and the call site, unlike item 83's fold:
|
||||
* a Natural subroutine is often <em>defined</em> after the statement that {@code PERFORM}s it, so
|
||||
* requiring the init to precede the call would silently drop those. Ordering carries no meaning here
|
||||
* anyway — the whole point is that the live index is unknown.
|
||||
*
|
||||
* <p>Module scope is expressed by {@code sourceFile} equality, never by {@code CONTAINS} — item 75's
|
||||
* containment cycles make an unbounded traversal a correctness <em>and</em> latency hazard. Honours
|
||||
* the manual-override precedence like the other resolvers, tags its edges {@code folded = true}
|
||||
* (inferred, not observed) plus {@code viaTable = true} so this provenance stays distinguishable
|
||||
* from item 83's string fold. Idempotent (MERGE); a literal that is not a real ingested module
|
||||
* produces no edge.
|
||||
*/
|
||||
public static final String RESOLVE_DYNAMIC_CALLNAT_TABLE = """
|
||||
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 (fw:AstNode {project: $project, sourceFile: src.sourceFile})-[fwr:WRITES]->(:AstNode {project: $project, name: dyn.dynamicVar})
|
||||
WHERE fwr.value IS NOT NULL AND NOT fwr.value STARTS WITH "'" AND fwr.value CONTAINS '('
|
||||
WITH src, dyn, caller, coalesce(dyn.originFile, src.sourceFile) AS originFile,
|
||||
trim(split(fwr.value, '(')[0]) AS arrName
|
||||
WHERE arrName <> ""
|
||||
MATCH (aw:AstNode {project: $project, sourceFile: src.sourceFile})-[awr:WRITES]->(:AstNode {project: $project, name: arrName})
|
||||
WHERE awr.value IS NOT NULL AND awr.value STARTS WITH "'"
|
||||
WITH DISTINCT caller, dyn, originFile, trim(replace(awr.value, "'", "")) AS lit
|
||||
MATCH (target:AstNode {type: 'MODULE', project: $project, name: lit})
|
||||
WHERE target.sourceFile <> ""
|
||||
MERGE (caller)-[r2:CALLS {callKind: 'CALLNAT_DYNAMIC', lineNo: dyn.lineNo, originFile: originFile}]->(target)
|
||||
ON CREATE SET r2.folded = true, r2.viaTable = true
|
||||
SET r2.dynamicVar = dyn.dynamicVar, r2.args = dyn.args
|
||||
""";
|
||||
|
||||
/**
|
||||
* Scoped variant of {@link #RESOLVE_DYNAMIC_CALLNAT_TABLE}: only dynamic call sites in callers whose
|
||||
* name is in {@code $names} (a deeply-ingested program tree).
|
||||
*/
|
||||
public static final String RESOLVE_DYNAMIC_CALLNAT_TABLE_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 (fw:AstNode {project: $project, sourceFile: src.sourceFile})-[fwr:WRITES]->(:AstNode {project: $project, name: dyn.dynamicVar})
|
||||
WHERE fwr.value IS NOT NULL AND NOT fwr.value STARTS WITH "'" AND fwr.value CONTAINS '('
|
||||
WITH src, dyn, caller, coalesce(dyn.originFile, src.sourceFile) AS originFile,
|
||||
trim(split(fwr.value, '(')[0]) AS arrName
|
||||
WHERE arrName <> ""
|
||||
MATCH (aw:AstNode {project: $project, sourceFile: src.sourceFile})-[awr:WRITES]->(:AstNode {project: $project, name: arrName})
|
||||
WHERE awr.value IS NOT NULL AND awr.value STARTS WITH "'"
|
||||
WITH DISTINCT caller, dyn, originFile, trim(replace(awr.value, "'", "")) AS lit
|
||||
MATCH (target:AstNode {type: 'MODULE', project: $project, name: lit})
|
||||
WHERE target.sourceFile <> ""
|
||||
MERGE (caller)-[r2:CALLS {callKind: 'CALLNAT_DYNAMIC', lineNo: dyn.lineNo, originFile: originFile}]->(target)
|
||||
ON CREATE SET r2.folded = true, r2.viaTable = true
|
||||
SET r2.dynamicVar = dyn.dynamicVar, r2.args = dyn.args
|
||||
""";
|
||||
|
||||
/**
|
||||
* Deletes the dynamic-call marker edges (the {@code CALLNAT_DYNAMIC} {@code CALLS} edges that
|
||||
* point at a variable-named placeholder, {@code sourceFile = ""}) <em>only for call sites that
|
||||
|
||||
@@ -172,12 +172,6 @@ public class GraphRepository {
|
||||
// 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));
|
||||
// Item 108: the dispatch-TABLE idiom — literals filled into an array, called through an indexed
|
||||
// read of it. Item 83's fold cannot see these: the literals sit one hop away, on the array node
|
||||
// rather than on the dispatch variable. Emits one candidate per distinct literal, since the live
|
||||
// index is not statically known. Cheap (bounded by dynamic call sites), so it runs at every level.
|
||||
statements.add(new EnrichmentStep("resolve-dynamic-callnat-table" + sfx,
|
||||
scoped ? CypherQueries.RESOLVE_DYNAMIC_CALLNAT_TABLE_SCOPED : CypherQueries.RESOLVE_DYNAMIC_CALLNAT_TABLE));
|
||||
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));
|
||||
|
||||
@@ -1822,6 +1822,41 @@ reproduced the bug.)*
|
||||
targets are not ingested (see item 107), so the edges would resolve to nothing there — the value is in
|
||||
no longer *silently* reporting `[]`.
|
||||
|
||||
---
|
||||
|
||||
**Correction (2026-08-10) — the call-graph half of this item is already implemented, and the fix
|
||||
proposed above was written and then reverted as redundant.**
|
||||
|
||||
`RESOLVE_DYNAMIC_CALLNAT_INTRA_INDIRECT` already resolves exactly this idiom, and its javadoc gives
|
||||
the identical example (`ASSIGN #TBL(1) = 'WPARTD2S'` → `#W-ACT-PROG := #TBL(#I)` → `CALLNAT`). For
|
||||
the assignment writing the dispatch variable it follows the `READS` on that same line to the array,
|
||||
then takes every string literal written to the array as a target — the same candidate-set
|
||||
over-approximation this item proposes. A fixture (`DispatchTableFoldIT`) confirms it end-to-end,
|
||||
including a table built across an `IF`/`ELSE`.
|
||||
|
||||
**Why `upms` still shows nothing:** the target modules are **absent from the checkout entirely** —
|
||||
`WXSPOD0S`, `WXSDAD0S`, `WXSCMD0S`, `WCARLD0S` are not in the graph at all, not even as placeholders.
|
||||
A literal naming no ingested module correctly yields no edge. That is item 107's problem, not a
|
||||
parser or resolver gap. This item's own caveat predicted it; what the item got wrong is the
|
||||
conclusion that the idiom is *unsupported*.
|
||||
|
||||
**How it was caught:** the new resolver's tests passed **with the new resolver disabled** — the
|
||||
sabotage check, not the green run, is what exposed the redundancy. Without it the duplicate would
|
||||
have shipped, adding a second enrichment step doing the same work by a different route.
|
||||
|
||||
**What actually remains open** is narrower than the title suggests: the `dispatch-table` *endpoint*
|
||||
does not report these tables as rows. It has the fields for it (`guardField`/`guardValue`/
|
||||
`assignedField`/`assignedValue`), and the source carries a real key — a **parallel array**
|
||||
(`#WT-OBJ-NAME(2) = 'GARC'` beside `#WT-OBJ-PROG(2) = 'WXSGAD0S'`), so `guardValue: "(3)"` as
|
||||
proposed above would needlessly discard it. Pairing needs the **array index**, which is *not* in the
|
||||
graph: `lookupVariable` resolves `#WT-OBJ-PROG (1)` to the base node and drops `(1)`, so the edge
|
||||
carries only `["value", "originFile", "lineNo"]`. Adding an `assignedIndex` edge property is the
|
||||
prerequisite — the `ASSIGN_COMPUTE` pattern already captures the bracket. Note 9 of the 65 modules
|
||||
build the table in an `IF`/`ELSE`, where one index carries different values per branch, so such rows
|
||||
must be reported as multiple candidates, and the `IF` guard itself is not on the edge (guard props
|
||||
come from `DECIDE` context only) — recovering it would need a `CONTAINS` traversal, which item 75
|
||||
makes hazardous.
|
||||
|
||||
- [x] **109. `variables/{name}/writes` gives the location but not the written value, so resolving a
|
||||
dispatch needs the source anyway** (found 2026-08-02, `upms` webservice-layer audit;
|
||||
**done 2026-08-06**)
|
||||
|
||||
Reference in New Issue
Block a user