Reap feature

This commit is contained in:
Ingo Schnabel
2026-08-14 10:25:39 +03:00
parent b6007b2096
commit cf5d5ea821
6 changed files with 243 additions and 97 deletions

View File

@@ -4,4 +4,4 @@
server.url=http://localhost:8787
# Stamped by manage-ac.sh (stamp_cli_version) from ac-code-server's agenticcode.version
# at build time. "dev" means this jar wasn't built via manage-ac.sh.
version=207
version=211

View File

@@ -3,7 +3,7 @@ quarkus.http.port=8787
# AgenticCode's own release counter (not the Maven project version) — bump this by hand for each
# release. Single source of truth for the startup log line, GET /api/version, and the OpenAPI
# info version (referenced below via property expression, not duplicated).
agenticcode.version=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

View File

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

View File

@@ -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

View File

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

View File

@@ -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**)