Reap feature

This commit is contained in:
Ingo Schnabel
2026-08-10 13:36:44 +03:00
parent 97081718fd
commit b6007b2096
8 changed files with 711 additions and 479 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=203
version=207

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=203
agenticcode.version=207
# 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,219 @@
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 static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
/**
* Item 124: a call edge must not outlive the call it was parsed from.
*
* <p>Item 58's sweep deletes stale <b>nodes</b>; an edge is only reaped when one of its endpoints goes
* with it. So an edge survives whenever both endpoints legitimately survive — which is exactly what a
* <b>parser fix</b> produces: the calling subroutine is untouched, and the old target is a placeholder
* ({@code sourceFile=""}) that is never file-swept. The corrected call is then merely <em>added</em>
* beside the wrong one, and both are served.
*
* <p>Found for real: after items 120/121/123 shipped, {@code DAGCHEN0/callees} in {@code upms} listed
* the correct {@code YAGCHBN0} <em>and</em> the pre-fix phantom {@code AGNT-CHG-CMP-SP} from the same
* call site, with 497 such edges surviving a full deep refresh.
*
* <p>The fixture edits only the CALLNAT target and keeps the enclosing subroutine, because that is the
* distinguishing case. {@code DerivedCallsModuleRefreshIT} removes the whole subroutine, so there the
* {@code FUNCTION} node disappears and {@code DETACH DELETE} takes the edge along — which is why the
* bug hid behind a green suite.
*/
@QuarkusTest
class StaleCallEdgeReapIT {
private static final String PROJECT = "nat-stale-call-reap";
private static final String TARGET_A = """
* First call target.
DEFINE DATA LOCAL
END-DEFINE
END
""";
private static final String TARGET_B = """
* Second call target.
DEFINE DATA LOCAL
END-DEFINE
END
""";
/** Calls RTARGETA from a subroutine that must survive the edit unchanged. */
private static final String CALLER_A = """
* Caller in its first state.
DEFINE DATA LOCAL
01 #TGT (A8)
END-DEFINE
*
PERFORM CALL-STEP
*
DEFINE SUBROUTINE CALL-STEP
CALLNAT 'RTARGETA'
END-SUBROUTINE
*
END
""";
/** Only the target changed — same subroutine, same line, same everything else. */
private static final String CALLER_B = """
* Caller in its first state.
DEFINE DATA LOCAL
01 #TGT (A8)
END-DEFINE
*
PERFORM CALL-STEP
*
DEFINE SUBROUTINE CALL-STEP
CALLNAT 'RTARGETB'
END-SUBROUTINE
*
END
""";
/** Calls a module that does not exist, so the target is an unresolved placeholder node. */
private static final String PHANTOM_CALLER = """
* Caller whose target does not exist — a placeholder, as a pre-fix parser artefact is.
DEFINE DATA LOCAL
END-DEFINE
*
PERFORM CALL-STEP
*
DEFINE SUBROUTINE CALL-STEP
CALLNAT 'RPHANTOM'
END-SUBROUTINE
*
END
""";
/** Same subroutine, now calling a real module: the phantom must be reaped, node and all. */
private static final String PHANTOM_CALLER_FIXED = """
* Caller whose target does not exist — a placeholder, as a pre-fix parser artefact is.
DEFINE DATA LOCAL
END-DEFINE
*
PERFORM CALL-STEP
*
DEFINE SUBROUTINE CALL-STEP
CALLNAT 'RTARGETA'
END-SUBROUTINE
*
END
""";
@TempDir
static Path root;
@BeforeAll
static void createProject() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
write("RTARGETA.nat", TARGET_A);
write("RTARGETB.nat", TARGET_B);
write("RCALLER.nat", CALLER_A);
write("RPHANT.nat", PHANTOM_CALLER);
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
.when().post("/api/projects/" + PROJECT)
.then().statusCode(201);
}
private static void write(String fileName, String content) {
try {
Files.writeString(root.resolve(fileName), content);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private static void refresh() {
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true").then().statusCode(200);
}
private static io.restassured.path.json.JsonPath callees(String module) {
return given().pathParam("name", module)
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callees")
.then().statusCode(200).extract().jsonPath();
}
/**
* One test per transition rather than per assertion: each asserts a before/after pair on shared
* mutable server state, so splitting the halves would make the outcome depend on JUnit method order.
*/
@Test
void aRetargetedCallDoesNotKeepItsOldTarget() {
write("RCALLER.nat", CALLER_A);
refresh();
// Guard: prove the first edge exists, so the "gone" assertion cannot pass vacuously.
org.junit.jupiter.api.Assertions.assertTrue(
callees("RCALLER").getList("items.name").contains("RTARGETA"),
"the first target must be there before we can prove it is removed");
write("RCALLER.nat", CALLER_B);
refresh();
var names = callees("RCALLER").getList("items.name");
org.junit.jupiter.api.Assertions.assertTrue(names.contains("RTARGETB"),
"the new target must be present: " + names);
org.junit.jupiter.api.Assertions.assertFalse(names.contains("RTARGETA"),
"the old target outlived the call it was parsed from: " + names);
}
/**
* The placeholder case — the shape a fixed parser bug actually leaves behind. Both the edge and the
* now-edgeless placeholder node must go; the node is never file-swept, so without the item-124 node
* sweep it would keep surfacing in identifier search as an unresolved call target nothing calls.
*/
@Test
void aReapedPlaceholderTargetIsAlsoRemovedAsANode() {
write("RPHANT.nat", PHANTOM_CALLER);
refresh();
org.junit.jupiter.api.Assertions.assertTrue(
callees("RPHANT").getList("items.name").contains("RPHANTOM"),
"the phantom target must exist before we can prove it is swept");
write("RPHANT.nat", PHANTOM_CALLER_FIXED);
refresh();
var names = callees("RPHANT").getList("items.name");
org.junit.jupiter.api.Assertions.assertFalse(names.contains("RPHANTOM"),
"the stale placeholder edge survived: " + names);
given().queryParam("name", "RPHANTOM")
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then().statusCode(200)
.body("findAll { it.name == 'RPHANTOM' }", is(empty()));
}
/**
* The reap deletes <em>all</em> of a re-parsed file's call edges, including the two kinds enrichment
* builds rather than the parser: {@code folded} and {@code resolvedBy: 'manual'}. Both are rebuilt by
* finalize steps that run at every enrichment level — this pins that, because if it were false the
* reap would silently discard a user's manual override on every refresh.
*/
@Test
void aManualOverrideSurvivesTheReap() {
write("RCALLER.nat", CALLER_B);
refresh();
given().contentType("application/json")
.body(Map.of("originFile", "RPHANT.nat", "lineNo", 8,
"targets", List.of("RTARGETB"), "variable", "MANUAL-PIN"))
.when().post("/api/projects/" + PROJECT + "/dynamic-calls/overrides")
.then().statusCode(200);
refresh();
given().when().get("/api/projects/" + PROJECT + "/dynamic-calls/overrides")
.then().statusCode(200)
.body("findAll { it.originFile == 'RPHANT.nat' }", not(empty()));
}
}

View File

@@ -152,6 +152,63 @@ public final class CypherQueries {
DELETE r
""";
/**
* Item 124: the {@code CALLS} counterpart of items 86 and 106 — reaps a re-parsed Natural file's
* call edges before the fresh ones are merged.
*
* <p>Item 58's node sweep only removes nodes the fresh parse no longer produces, so a call edge
* survives whenever <em>both</em> its endpoints legitimately survive. That is the normal case when a
* <b>parser fix</b> changes a call's target: the calling subroutine is unchanged (its {@code FUNCTION}
* node is re-merged and kept) and the old target is a placeholder ({@code sourceFile=""}, never
* file-swept), so nothing reaps the edge between them — the corrected call is merely <em>added</em>
* beside the wrong one. After items 120/121/123 shipped, {@code DAGCHEN0/callees} listed both the
* real {@code YAGCHBN0} and the pre-fix phantom {@code AGNT-CHG-CMP-SP} from the same call site;
* 497 such edges survived a full deep refresh of {@code upms}.
*
* <p>Reaches beyond item 86's placeholder-only scope on purpose: bug #63's `CALLNAT`-in-a-string
* matches resolved onto <b>real</b> modules, so a placeholder-only reap would leave that whole
* class of artefact behind. The one thing it must <em>not</em> touch is the dynamic-call
* resolvers' own output, hence the {@code WHERE}:
*
* <ul>
* <li><b>Reaped</b> — every edge to a placeholder target ({@code sourceFile=""}), plus every
* {@code PERFORM}/{@code CALLNAT}/{@code INCLUDE_MACRO} edge. All of these are emitted by the
* parser on every parse, at <em>both</em> tiers (the coarse scanner expands copycode exactly
* as the deep parser does), so the merge that follows re-creates the current ones and
* unchanged edges round-trip identically.</li>
* <li><b>Kept</b> — {@code CALLNAT_DYNAMIC} edges to a <em>real</em> module. The parser cannot
* know a dynamic target and always emits a placeholder, so such an edge is by construction
* enrichment-built: a fold, a manual override, or an intra/cross resolution. In {@code upms}
* 486 edges are of this kind and <b>392 of them carry no marker at all</b> — no
* {@code folded}, no {@code resolvedBy} — so the target's file is the only thing that
* distinguishes them from parser output.</li>
* </ul>
*
* <p>That exception is not theoretical: reaping them broke
* {@code AnalysisResourceIT#flowForwardPathWarmCrossesIntoDynamicallyDispatchedCallee}. The item-37a
* path-warm resolves a cross-module dispatch in one round and re-ingests in the next, and a
* <em>scoped</em> finalize does not reliably re-resolve an edge whose far side is outside its scope —
* so the reap deleted a resolution nothing rebuilt, and the dataflow trace stopped at the dispatch
* boundary.
*
* <p>Gated on {@code reconcile} (deep re-ingest), because {@code ..._INTRA_INDIRECT} and
* {@code ..._CROSS} are gated on {@code resolveFields}/{@code dataflow} and so do <em>not</em> run
* in a coarse finalize. Same rationale as item 74.
*
* <p><b>Scope limit.</b> Keyed on the source node's file, so it misses the 605 call edges whose
* source subroutine is defined <em>inside a copycode</em> (14 of them stale). Those nodes are
* MERGEd per {@code (type, name, sourceFile)} and therefore <b>shared</b> by every module that
* includes the copycode, so reaping them during a single module's refresh would delete edges other
* modules contributed and not re-create them. Fixing that needs a per-module identity for
* copycode-resident nodes — a separate item, not a side effect of this one.
*/
public static final String DELETE_STALE_NATURAL_CALL_EDGES = """
UNWIND $sourceFiles AS f
MATCH (src:AstNode {project: $project, sourceFile: f, language: 'natural'})-[r:CALLS]->(t)
WHERE t.sourceFile = "" OR r.callKind <> 'CALLNAT_DYNAMIC'
DELETE r
""";
/**
* Item 88: a finalize sweep that deletes an orphaned {@code DB_TABLE}/{@code WORKFILE} placeholder —
* one left with <em>no</em> relationships after edge reaping/resolution. Companion to item 86: that
@@ -231,6 +288,33 @@ public final class CypherQueries {
DELETE t
""";
/**
* Item 124, node half: the {@code MODULE} counterpart of item 88's table sweep. Once
* {@link #DELETE_STALE_NATURAL_CALL_EDGES} reaps the last call to a call-target placeholder, the
* placeholder <em>node</em> is left edgeless — it is never file-swept ({@code sourceFile=""}) — and
* still surfaces in {@code search/identifier} and the module inventory as an unresolved call target
* that nothing actually calls.
*
* <p>Guarded on {@code sourceFile = ""} so it can only ever remove a placeholder: a <b>real</b>
* parsed module that happens to call nothing and be called by nothing is a legitimate standalone
* program and must survive. Degree-0 only, so a placeholder still referenced by any call is kept.
* Runs beside item 88's table sweep, after all edge resolution.
*
* <p>{@code duplicatePaths IS NULL} excludes item 114's duplicate markers, which are a
* <em>second</em> reason for an edgeless placeholder to exist and are deliberately kept: an
* unreferenced duplicate identity is a degree-0 placeholder whose whole purpose is to record "this
* name exists in two files", so the module endpoints can answer {@code 409 DUPLICATE_IDENTITY}
* rather than {@code 404}. {@link #MARK_DUPLICATE_IDENTITIES} runs <em>before</em> finalize, so
* without this guard the sweep deleted the marker it had just written and the endpoints fell back
* to {@code 404} — caught by {@code DuplicateIdentityIT}. Markers are reaped on their own terms by
* {@link #CLEAR_DUPLICATE_MARKERS} when the source conflict goes away.
*/
public static final String DELETE_ORPHANED_PLACEHOLDER_MODULES = """
MATCH (t:AstNode {project: $project, type: 'MODULE', sourceFile: ""})
WHERE t.duplicatePaths IS NULL AND NOT (t)--()
DELETE t
""";
public static final String MODULE_SOURCE_FILE = """
MATCH (m:MODULE {project: $project})
// Item 117: this endpoint is not behind the resolving guard, so it accepts the identity or
@@ -1586,6 +1670,96 @@ public final class CypherQueries {
ON CREATE SET r2.folded = true
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,6 +172,12 @@ 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));
@@ -263,6 +269,9 @@ public class GraphRepository {
statements.add(new EnrichmentStep("resolve-view-alias-access-nodes",
CypherQueries.RESOLVE_VIEW_ALIAS_ACCESS_NODES));
statements.add(new EnrichmentStep("delete-orphaned-placeholder-tables", CypherQueries.DELETE_ORPHANED_PLACEHOLDER_TABLES));
// Item 124: the same for call-target placeholders left edgeless by the CALLS reap — otherwise a
// phantom target of a since-fixed parser bug keeps showing up as an unresolved module.
statements.add(new EnrichmentStep("delete-orphaned-placeholder-modules", CypherQueries.DELETE_ORPHANED_PLACEHOLDER_MODULES));
// Item 40: flag surviving placeholders as (un)resolved by whether a real definition now exists.
// Runs last so it sees the fully redirected/reaped graph. Project-wide, cheap, idempotent.
statements.add(new EnrichmentStep("stamp-unresolved-placeholders", CypherQueries.STAMP_UNRESOLVED_PLACEHOLDERS));
@@ -2239,6 +2248,15 @@ public class GraphRepository {
// covered by the placeholder-only reap above.
tx.run(CypherQueries.DELETE_STALE_NATURAL_USING_EDGES,
Map.of("project", project, "sourceFiles", List.copyOf(freshFiles)));
// Item 124: same for CALLS. A parser fix that changes a call's target leaves both endpoints
// alive (the calling subroutine is unchanged; the old target is a never-swept placeholder),
// so the corrected call was only ever added beside the wrong one. Deep re-ingest only —
// two dynamic-call resolvers are skipped in a coarse finalize and could not rebuild what
// this deletes.
if (reconcile) {
tx.run(CypherQueries.DELETE_STALE_NATURAL_CALL_EDGES,
Map.of("project", project, "sourceFiles", List.copyOf(freshFiles)));
}
}
for (Map.Entry<EdgeType, List<Map<String, @Nullable Object>>> entry : edgesByType.entrySet()) {
tx.run(CypherQueries.mergeEdgesBatch(entry.getKey()),

View File

@@ -1,541 +1,304 @@
# AgenticCode API — Agent System Prompt
You are an agent that analyzes source code (Software AG **Natural** and
**Java**) through the AgenticCode REST API. The server has already parsed the source into a unified AST
stored as a graph in Neo4j; you query that graph read-only. Use the API as
your source of truth about the code structure — do not guess at structure you
can look up. (You may still need to read actual source text — see "Reading
source" below.)
You analyze **Natural** (Software AG) and **Java** source through the AgenticCode REST API. The
server has parsed the source into a unified AST in Neo4j; you query that graph. It is your source of
truth for code structure — look structure up, don't guess.
**Read-only scope — with one exception.** You query already-ingested
projects; you do not create, modify, delete, or ingest. The **sole** write you
are expected to make is **resolving unresolvable dynamic `CALLNAT` targets**
(item 82): when the graph shows a dynamic call it could not resolve, you must
investigate the source and pin the correct target via the dynamic-call
override API. See "Resolving unresolved dynamic `CALLNAT` calls (required)"
below. Nothing else is in your write scope.
**Read-only, with one exception.** You never create, modify, delete, or ingest. The sole write you
must make is **pinning unresolvable dynamic `CALLNAT` targets** (§4) — required, not optional.
## Ground rules
## 1. Rules
- **Only use the `/source` endpoints (`/nodes/{id}/source`,
`/modules/{name}/source`) if you have no other access to the source
code.** If you can read the checkout directly (local clone, IDE, coding
agent), always read the file yourself instead — see "Reading source" below.
- **Base URL:** `http://localhost:8787`. Everything under `/api`, JSON
responses.
- **Project-scoped:** almost every endpoint is `/api/projects/{project}/...`.
List projects first (`GET /api/projects`), then scope to one.
- **`{name}` is a node name, never a file path** — the Natural
program/subprogram name, or a Java class's **fully-qualified** name
(`com.example.OrderService`, `com.example.Outer.Inner`). The Java simple name
is accepted as a short form and resolves when it identifies exactly one class;
otherwise you get `409 AMBIGUOUS_NAME`. **Case-sensitive.**
- **Errors are structured:** `{ "error", "code", "details" }`.
- `404 PROJECT_NOT_FOUND` — bad `{project}` (checked first on every endpoint).
- `400 INVALID_TYPE` — bad `type` on identifier/annotation search.
- `400 MISSING_VALUE` / `400 MISSING_NAME` — required query param missing/blank
(`value` on `search/value`, `name` on `search/annotation`).
- `400 MISSING_LINE_RANGE` — `startLine`/`endLine` missing on `modules/{name}/source`.
- `400 NO_SOURCE_FILE` — `/source` on an unresolved placeholder node.
- `404 NODE_NOT_FOUND` / `404 MODULE_NOT_FOUND` — unknown id / module name.
Every `/modules/{name}/…` endpoint checks this: an unknown module name is a
`404`, never an empty `200`.
- `409 AMBIGUOUS_NAME` — several real modules share this **short** name (ordinary
in Java: nested `@Nested` classes, `Builder`, `WorkingStorage`). A Java module's
real name is its fully-qualified name (item 117), and that always resolves
exactly; the short name is a convenience. `details.qualifiedNames` lists the
candidates — repeat the request with one of them (or `?sourceFile=`, listed in
`details.candidates`). Do **not** read this as "not found": the module exists
several times over. Natural names are unique, so this cannot occur there.
- `409 NOT_DEEPLY_INGESTED` / `409 NOT_INGESTED` — the module needs a
per-module deep ingest (`nextAction` names the endpoint). That's **out of
your read-only scope** — report it, don't call it. Not the same as "no
data": the data may exist once ingested. Two situations produce it:
- field-level dataflow endpoints (`flow-forward`/`flow-backward`/`field-flow`)
on a module that is only call-graph-ingested;
- **any** `/modules/{name}/…` endpoint on an *unresolved placeholder* — a
module something calls but whose source was never parsed. Its
`callers` and `graph` still answer `200` with real data (that comes
from the calling modules), so **fall back to `/callers`** to learn
what you can. Everything else about it is unknowable, not empty.
- **Never read an empty `200` from a module endpoint as "analysed, nothing
found".** Since item 107 the API distinguishes *unknown* (`404`), *not
analysable* (`409`) and *analysed, genuinely empty* (`200` with an empty
body). Only the last one licenses the conclusion "there is nothing here".
- **`null` means "not determined," not an error** — `dataType`, `value`,
`table`, `view`, `module`, `description` are nullable by design.
- **Don't fabricate endpoints.** Only what's listed below exists.
- **Reading source:** if you have direct filesystem access to the checkout
(e.g. you're a coding agent operating on a local clone), read files
directly — don't call `/nodes/{id}/source` or `/modules/{name}/source`.
Use `sourceFile`/`startLine`/`endLine` from graph responses to know where
to look. Only use the `/source` endpoints when you have no filesystem
access to the project root.
- **Base URL** `http://localhost:8787`, everything under `/api`, JSON. Almost every endpoint is
`/api/projects/{project}/...` — list projects first.
- **`{name}` is a node name, never a file path.** Natural program/subprogram name, or a Java
**fully-qualified** class name (`com.example.Outer.Inner`). A Java simple name works as a short
form when unique, else `409 AMBIGUOUS_NAME`. **Case-sensitive.** Encode `#` as `%23`.
- **Read source from disk if you can.** If you have filesystem access to the checkout, read files
yourself using `sourceFile`/`startLine`/`endLine` from responses. Use `/nodes/{id}/source` and
`/modules/{name}/source` **only** when you have no filesystem access.
- **`null` = "not determined", not an error** (`dataType`, `value`, `table`, `view`, `module`,
`description` are nullable by design).
- **Don't invent endpoints.** Only what is listed here exists.
## Language applicability
### Errors `{ error, code, details }`
Every endpoint runs for any module but returns data only where the concept
exists — an inapplicable query returns an empty list, not an error.
| Code | Meaning |
|--------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `404 PROJECT_NOT_FOUND` | bad `{project}` — checked first on every endpoint |
| `404 NODE_NOT_FOUND` / `MODULE_NOT_FOUND` | unknown id / module name. Every `/modules/{name}/…` checks this: unknown is `404`, never an empty `200` |
| `409 AMBIGUOUS_NAME` | several real modules share this **short** name (common in Java: `Builder`, `@Nested`). **Not** "not found" — it exists several times. `details.qualifiedNames` / `details.candidates` list them; retry with one, or `?sourceFile=`. Cannot occur for Natural |
| `409 NOT_DEEPLY_INGESTED` / `NOT_INGESTED` | needs a per-module deep ingest (`nextAction` names it). **Out of your scope — report, don't call.** Not "no data": data may exist once ingested |
| `400 INVALID_TYPE` | bad `type` on identifier/annotation search |
| `400 MISSING_VALUE` / `MISSING_NAME` | required param missing/blank |
| `400 MISSING_LINE_RANGE` | `startLine`/`endLine` missing on `modules/{name}/source` |
| `400 NO_SOURCE_FILE` | `/source` on an unresolved placeholder |
| `400 UNKNOWN_TARGET` | override target is not a real module |
| Endpoint | Natural | Java | Notes |
|---------------------------------------------------------------|---------|------|---------------------------------------------------------------------------------------------------------------------------------------------------|
| `/modules`, `/modules/{name}/context`, `/digest` | ✓ | ✓ | language-neutral |
| `/modules/{name}/callers` · `/callees` | ✓ | ✓ | `edgeKind`: `CALLNAT`/`PERFORM` (Natural) · `METHOD_CALL`/`CONSTRUCTOR` (Java) · `EXTENDS`/`IMPLEMENTS` (both) · `INJECTS`/`REFERENCES` (Java DI) |
| `/modules/{name}/call-tree` | ✓ | ✓ | `?resolveInterfaces=` / `?followWiring=` are Java-only |
| `/modules/{name}/db-accesses` · `/sql-statements` | ✓ | ✓ | Natural ADABAS/SQL; Java JPA/Panache/`@Query` |
| `/modules/{name}/workfile-accesses` | ✓ | — | Natural-only — `READ`/`WRITE WORK FILE` (sequential/flat-file I/O). Separate from `db-accesses`; a work file is not a DB table |
| `/db-tables/{name}/columns` | ✓ | ✓ | Natural `INTO VIEW`/PDA, or Java `@Entity` mapping |
| `/modules/{name}/columns` | — | ✓ | Java JPA/Hibernate entity columns |
| `/modules/{name}/functions` | ✓ | ✓ | `?includeInherited=true` is Java-only |
| `/modules/{name}/functions/{fn}/overrides`, `/overrides` | — | ✓ | Java-only — concrete subclass overrides of a base-class method (single or bulk) |
| `/modules/{name}/dispatch-table` | ✓ | — | Natural-only — `DECIDE ON VALUE OF` routing table |
| `/variables/{name}/reads` · `/writes` | ✓ | ✓ | Natural `VARIABLE`/`CONSTANT`; Java `FIELD` |
| `/variables/{name}/flow-forward` · `/flow-backward` | ✓ | ✓ | deep-ingest only, both languages |
| `/variables/{field}/field-flow` | ✓ | — | Natural-only — shared-PDA producer→consumer across the call graph |
| `/data-structures/{name}/fields` | ✓ | — | Natural-only — `DEFINE DATA`/LDA/PDA |
| `/search/identifier` · `/search/value` | ✓ | ✓ | language-neutral |
| `/search/annotation` | — | ✓ | Java-only |
| `/nodes/{id}`, `/nodes/{id}/source`, `/modules/{name}/source` | ✓ | ✓ | language-neutral |
| `/modules?extends=`, `?moduleKind=` | partial | ✓ | `extends` is Java-only; `moduleKind` also gives a best-effort Natural PROGRAM/SUBPROGRAM guess |
`409 NOT_INGESTED` arises two ways: field-level dataflow (`flow-forward`/`flow-backward`/
`field-flow`) on a call-graph-only module; or **any** `/modules/{name}/…` on an *unresolved
placeholder* (called by something, source never parsed). A placeholder's `callers` and `graph` still
answer `200` with real data — it comes from the calling modules — so **fall back to `/callers`**.
### Natural: `generatedDir` is canonical, `user_exit` is LoC-only
> **Never read an empty `200` as "analysed, nothing found".** The API separates *unknown* (`404`),
> *not analysable* (`409`) and *analysed, genuinely empty* (`200`). Only the last licenses that
> conclusion.
A Natural project may be configured with a **`generatedDir`**/**`userExitDir`** pair (e.g.
`generated_src`/`user_exit`). The generated module already contains its hand-written user-exit twin
inline, so **all structural analysis — modules, call graph, DB access, functions, data structures,
identifiers, dataflow, dispatch table — runs against the `generatedDir` source, which is the one and
only ingested module.** User-exit files are **not** ingested as standalone modules (they would collide
by name with the generated twin); they are scanned solely to compute the **generated-vs-manually-written
LoC split** surfaced by `/loc` (`userExitLoc`/`userExitSloc` vs `generatedExclusiveLoc`/…). Practical
consequence: when you read source to verify an API response for a Natural module, read the
`generatedDir` copy (e.g. `generated_src/subprogram/WGEAGB0S.nat`) — the `user_exit` copy is a partial
fragment and does not represent what was analysed. See the item-47 split in
`agent-api-usage-ac-implementation.md`.
### Natural: `generatedDir` is canonical
## 1. Pick a project
With a `generatedDir`/`userExitDir` pair (e.g. `generated_src`/`user_exit`), the generated module
already contains its hand-written user-exit twin inline. **All structural analysis runs against
`generatedDir`, the only ingested module.** User-exit files are scanned solely for the LoC split in
`/loc` (`userExitLoc` vs `generatedExclusiveLoc`). So when verifying a response against source, read
the `generatedDir` copy — the `user_exit` copy is a partial fragment.
### Language applicability
Every endpoint runs for any module but returns data only where the concept exists; inapplicable
queries return an empty list, not an error.
| Endpoint | Nat | Java | Notes |
|-------------------------------------------------------------------------------------------------|-----|------|---------------------------------------------------------|
| `/modules`, `/context`, `/digest`, `/search/identifier`, `/search/value`, `/nodes/*`, `/source` | ✓ | ✓ | language-neutral |
| `/callers` · `/callees` · `/call-tree` | ✓ | ✓ | `?resolveInterfaces=`/`?followWiring=` Java-only |
| `/db-accesses` · `/sql-statements` | ✓ | ✓ | Natural ADABAS/SQL; Java JPA/Panache/`@Query` |
| `/db-tables/{name}/columns` | ✓ | ✓ | Natural `INTO VIEW`/PDA, or Java `@Entity` |
| `/functions` | ✓ | ✓ | `?includeInherited=true` Java-only |
| `/variables/{name}/reads` · `/writes` · `/flow-forward` · `/flow-backward` | ✓ | ✓ | flow-* deep-ingest only |
| `/workfile-accesses` | ✓ | — | `READ`/`WRITE WORK FILE`; a work file is not a DB table |
| `/dispatch-table` | ✓ | — | `DECIDE ON VALUE OF` routing |
| `/variables/{field}/field-flow` | ✓ | — | shared-PDA producer→consumer |
| `/data-structures/{name}/fields` | ✓ | — | `DEFINE DATA`/LDA/PDA |
| `/modules/{name}/columns`, `/functions/{fn}/overrides`, `/search/annotation`, `?extends=` | — | ✓ | Java-only |
## 2. Discovery & overview
```
GET /api/projects → [{ "name", "description" }]
GET /api/version → { "name": "agenticcode", "version": "<counter>" } (not project-scoped)
GET /api/projects → [{ name, description }] GET /api/version (not project-scoped)
GET /modules[?sourceFile=|?moduleKind=|?extends=] → [{ name, sourceFile, moduleKind }]
GET /modules/{name}/context → one-shot overview
GET /modules/{name}/digest → names/counts only
```
Project create/update/delete, the global clear, and ingest are **not** in
your read-only toolset.
`?extends=` = Java direct subclasses (one hop). `?moduleKind=` = Java CLASS/INTERFACE, or a
best-effort Natural PROGRAM/SUBPROGRAM guess.
## 2. Query the graph
`context` bundles `name`, `sourceFile`, `description` (leading banner/Javadoc, `null` if none),
`functions[]`, `callers`, `callees`, `dbAccesses[]`. The two heavy sections come back as summaries by
default (`sqlStatementSummary: {count, byMode, tables}`, `variableAccessSummary: {count, byFunction,
byMode}`); get full lists with `?include=sqlStatements|variableAccesses`. `?include=a,b` restricts
sections; `?limit=&offset=` paginate.
Start broad (`/modules/{name}/context`), then drill down.
`digest` — for triaging many modules before expanding:
`{ name, description, functionCount, callers: {CALLNAT: [...]}, callees: {PERFORM: [...], CALLNAT: [...]}, dbTables: [...], dataStructures: [{name, fieldCount}] }`
### Discover modules
## 3. Call graph
```
GET /modules → [{ "name", "sourceFile", "moduleKind" }]
GET /modules?sourceFile={path} → modules defined in that file
GET /modules?moduleKind={kind} → Java CLASS/INTERFACE, or best-effort Natural PROGRAM/SUBPROGRAM guess
GET /modules?extends={base} → Java: direct EXTENDS subclasses of {base} (one hop)
```
### One-shot module overview
```
GET /modules/{name}/context
```
Bundles `name`, `sourceFile`, `description` (from a leading banner/Javadoc,
`null` if none), `functions[]`, `callers`, `callees`, `dbAccesses[]` in one
call. The two potentially-heavy sections — `sqlStatements`,
`variableAccesses` — come back as compact **summaries** by default
(`sqlStatementSummary: {count, byMode, tables}`,
`variableAccessSummary: {count, byFunction, byMode}`); request the full list
with `?include=sqlStatements` / `?include=variableAccesses` (exactly one of
list/summary populated per heavy section). `?include=a,b,...` restricts to
named sections generally; `?limit=&offset=` paginate a requested full list.
**Lighter still: `/modules/{name}/digest`** — names/counts only, no line
ranges/source files/raw statements. Use when triaging many modules before
deciding which to expand:
```json
{
"name": "ZSNNA12", "description": "XML Interface for BGEAGFN", "functionCount": 7,
"callers": { "CALLNAT": ["BGEAGFN"] },
"callees": { "PERFORM": ["R-PARSE"], "CALLNAT": ["ZSNUTL"] },
"dbTables": ["MY-TABLE"],
"dataStructures": [{ "name": "#S-PARTNER", "fieldCount": 23 }]
}
```
### Call graph
```
GET /modules/{name}/callers?scope={external|internal}
GET /modules/{name}/callees?scope={external|internal}
GET /modules/{name}/callers|callees?scope={external|internal}
GET /modules/{name}/call-tree?depth=N&resolveInterfaces=&followWiring=
```
`callers`/`callees` return a dedup-sourceFile wrapper:
`{ "sourceFiles": [...], "items": [{ name, type, sourceFileIndex, edgeKind, lineNos: [...] }] }`.
`edgeKind`: `CALLNAT`/`PERFORM` (Natural), `METHOD_CALL`/`CONSTRUCTOR` (Java),
`EXTENDS`/`IMPLEMENTS` (inheritance — so `callers(Base)` also answers "who
subclasses/implements this?"), and Java-only `INJECTS` (`@Inject` field or
injection-point constructor) / `REFERENCES` (`X.class` used as an argument,
e.g. `super(SomeStep.class, ...)`). `?scope=external` = cross-module targets;
`?scope=internal` = same-module only (excludes `EXTENDS`/`IMPLEMENTS`/
`INJECTS`/`REFERENCES`).
`callers`/`callees` → `{ sourceFiles: [...], items: [{ name, type, sourceFileIndex, edgeKind, lineNos, sites }] }`.
`call-tree` → `{ sourceFiles, items: [{ name, type, sourceFileIndex, depth }] }`, `depth` default 3,
clamped 1–10. All four plus `search/identifier` accept `?fields=name` → flat `{name, type}[]`.
**`CALLNAT <var>` (dynamic dispatch, Natural):** resolved targets appear
tagged `edgeKind = CALLNAT_DYNAMIC` — intra-module (literal in same program),
intra-module-indirect (via a lookup array/variable), and cross-module (target
filled by another subprogram's output parameter — needs both dispatcher and
resolver deep-ingested). An unresolved dynamic site still appears as a
`CALLNAT_DYNAMIC` callee pointing at the `#var` name itself (with
`unresolved: true`, no `sourceFile`), so a dynamic call site stays visible even
when its target can't be determined. Treat `CALLNAT_DYNAMIC` as
**inferred/over-approximating** — a possible, not guaranteed, call — unlike
static `CALLNAT`/`PERFORM`. **When you hit an unresolved one, you are required
to investigate and pin it** — see "Resolving unresolved dynamic `CALLNAT`
calls" below.
`edgeKind`: `CALLNAT`/`PERFORM` (Natural) · `METHOD_CALL`/`CONSTRUCTOR` (Java) ·
`EXTENDS`/`IMPLEMENTS` (both — so `callers(Base)` also answers "who subclasses this?") ·
`INJECTS`/`REFERENCES` (Java DI: `@Inject` field, or `X.class` passed as an argument).
`?scope=external` = cross-module; `?scope=internal` = same-module, excluding
`EXTENDS`/`IMPLEMENTS`/`INJECTS`/`REFERENCES`.
**Empty `callers` can mean "not fully ingested," not "no callers."** A call
edge is recorded on the *caller's* side, so callers only appear if those
modules were ingested. Check `search/identifier?name=X` — `sourceFile == ""`
marks an uningested placeholder (referenced but not itself in the graph);
treat its `callers` as partial.
**`CALLNAT <var>` (dynamic dispatch).** Resolved targets appear as `edgeKind = CALLNAT_DYNAMIC`
(intra-module, intra-module-indirect via a lookup array, or cross-module — the last needs both
dispatcher and resolver deep-ingested). An unresolved site still appears, pointing at the `#var` name
with `unresolved: true` and no `sourceFile`, so the call site stays visible. Treat `CALLNAT_DYNAMIC`
as **inferred/over-approximating**, unlike static `CALLNAT`/`PERFORM`. **Every unresolved one must be
investigated and pinned — see §4.**
`call-tree` returns `{ sourceFiles, items: [{name, type, sourceFileIndex,
depth}] }`. `depth` defaults 3, clamped 1-10. Java-only flags:
`?resolveInterfaces=true` hops an interface callee to its concrete
implementation(s), dropping the dead-end interface node;
`?followWiring=true` also traverses `INJECTS`/`REFERENCES` transitively
(off by default) — also reaches wiring inherited unchanged from an `EXTENDS`
ancestor, materialized as a synthetic edge (`resolvedVia: 'INHERITANCE'`).
For such an inherited `INJECTS`/`REFERENCES` callee the `sites` `callSiteFile`
is the **base class file** where the injected field / class-literal actually
lives (with `inheritedFrom` = the base class name), not the subclass's own file
— its `lineNo` is a base-file line (item 92; before it, that line read against
the subclass file, often past its end). A `CONSTRUCTOR` callee is never
fanned out to subtypes (`new X()` binds statically to `X`); CHA
over-approximation applies only to `METHOD_CALL` on a base/interface reference.
**Empty `callers` can mean "not fully ingested".** A call edge is recorded on the *caller's* side, so
callers appear only if those modules were ingested. Check `search/identifier?name=X`: `sourceFile ==
""` marks an uningested placeholder — treat its `callers` as partial.
**Names-only mode:** `callers`, `callees`, `call-tree`, `search/identifier`
accept `?fields=name` → flat dedup `{name, type}[]`, dropping everything else.
Java `call-tree` flags: `?resolveInterfaces=true` hops an interface callee to its concrete
implementations, dropping the dead-end interface node. `?followWiring=true` traverses
`INJECTS`/`REFERENCES` transitively, including wiring inherited from an `EXTENDS` ancestor
(synthetic edge, `resolvedVia: 'INHERITANCE'`); for those the site's `callSiteFile` is the **base
class file** where the field/class-literal lives, with `inheritedFrom` naming it. A `CONSTRUCTOR`
callee is never fanned out to subtypes (`new X()` binds statically); CHA over-approximation applies
only to `METHOD_CALL` on a base/interface reference.
### Resolving unresolved dynamic `CALLNAT` calls (required)
## 4. Resolving unresolved dynamic `CALLNAT` (required write)
This is your one write responsibility (item 82). The auto-resolvers cannot
recover every `CALLNAT <var>` — e.g. a target name assembled by string ops
(`MOVE 'YGEAGKEY' TO #M` then `MOVE 'GN0' TO SUBSTR(#M,6,3)` → `YGEAGGN0`), or
one supplied by a caller the graph hasn't connected. Such a site is left as an
unresolved placeholder. **Whenever your analysis surfaces one, you must resolve
it rather than report it as a dead end.**
Auto-resolvers cannot recover every `CALLNAT <var>` — e.g. a name assembled by string ops
(`MOVE 'YGEAGKEY' TO #M` then `MOVE 'GN0' TO SUBSTR(#M,6,3)` → `YGEAGGN0`), or supplied by a caller
the graph hasn't connected. **Whenever your analysis surfaces one, resolve it rather than report a
dead end.**
```
GET /modules/{name}/callees → items with unresolved: true (variable-named CALLNAT_DYNAMIC target)
GET /dynamic-calls/unresolved → [{ module, originFile, lineNo, variable }] (every open site in the project)
GET /dynamic-calls/overrides → [{ originFile, lineNo, variable, targets, note, obsolete }]
POST /dynamic-calls/overrides → body { originFile, lineNo, targets: ["MOD", ...], variable?, note? }
DELETE /dynamic-calls/overrides?originFile=&lineNo= → reset one site (omit both query params to reset all)
GET /dynamic-calls/unresolved → [{ module, originFile, lineNo, variable }] (every open site)
GET /dynamic-calls/overrides → [{ originFile, lineNo, variable, targets, note, obsolete }]
POST /dynamic-calls/overrides → { originFile, lineNo, targets: ["MOD"], variable?, note? }
DELETE /dynamic-calls/overrides?originFile=&lineNo= (omit both params to reset all)
```
**Required workflow for each unresolved site:**
1. **Detect** — `unresolved: true` on a `CALLNAT_DYNAMIC` item, or an entry from
`/dynamic-calls/unresolved`.
2. **Investigate, don't guess** — `search/value?value=<literal>` for literals assigned to the
variable; `variables/{var}/writes?module=&depth=N` and `flow-backward` for what feeds it; read the
source around `originFile:lineNo` (assignments, `SUBSTR`, lookup tables, `DECIDE` branches).
Confirm each candidate is real via `GET /modules/{target}` or `search/identifier`.
3. **Pin** — `POST` with `originFile` + `lineNo` exactly as returned, plus `targets`. Pass several
when the dispatch genuinely branches. `400 UNKNOWN_TARGET` means your investigation was wrong —
recheck, don't invent a name.
4. **Verify** — re-GET `callees`: the `#var` placeholder is gone, your targets appear resolved. The
override persists and is re-applied across refreshes.
1. **Detect.** A `callees`/`context` result with `unresolved: true` on a
`CALLNAT_DYNAMIC` item, or an entry from `GET /dynamic-calls/unresolved`.
2. **Investigate — do not guess.** Read the call site and the dispatch
variable's origin to determine the real target module(s). Use
`search/value?value=<literal>` to find literals assigned to the variable,
`variables/{var}/writes?module=&depth=N` and `flow-backward` to trace what
feeds it, and read the source around `originFile:lineNo` (assignments,
`SUBSTR`, lookup tables, `DECIDE`/dispatch-table branches). Confirm each
candidate is a real module (`GET /modules/{target}` / `search/identifier`).
3. **Pin it.** `POST /dynamic-calls/overrides` with `originFile` + `lineNo`
(exactly as returned by `/dynamic-calls/unresolved`) and the `targets` you
established. Pass **several** targets when the dispatch genuinely branches to
more than one module. A target that is not a real module is rejected
`400 UNKNOWN_TARGET` — that means your investigation was wrong; recheck,
don't invent a name.
4. **Verify.** Re-GET `callees` — the `#var` placeholder is gone and your
target(s) now appear as resolved `CALLNAT_DYNAMIC` callees. The override is
persisted and re-applied automatically across refreshes; `DELETE` it only if
you later find the target was wrong.
Only pin what you have evidence for. If the source genuinely does not determine the target (e.g. the
name arrives from external input), say so and leave it unresolved.
Only pin what you have evidence for. If the source genuinely does not determine
the target (e.g. the name arrives from external input), say so and leave it
unresolved rather than guessing.
## 5. Database, work files, data structures
### Functions & overrides (Java)
```
GET /modules/{name}/db-accesses?depth=N → [{ name, mode: READS|WRITES|DECLARES, lineNos, via, sites }]
GET /modules/{name}/sql-statements?depth=N → [{ table, view, mode, statement, startLine, endLine, via, sourceFile, viaCopycode }]
GET /modules/{name}/workfile-accesses → [{ workFile, physicalName, mode, recordBuffers, lineNos, sites }]
GET /db-tables/{name}/columns → [{ name, type, dataType, value, parent, startLine, endLine }]
GET /modules/{name}/columns → Java @Entity columns, incl. @MappedSuperclass-inherited
GET /data-structures/{name}/fields → [{ name, type, dataType, value, parent, startLine, endLine, scope }]
GET /modules/{name}/data-structures → [{ name, relationship: USING|INLINE, area: PDA|LDA|GDA|INLINE|UNKNOWN, fieldCount, sourceFile }]
```
**Copycode provenance.** `sites: [{ lineNo, sourceFile, viaCopycode, includedAt }]` ties each access
to the file it really lives in. Where `viaCopycode` is set, `lineNo`/`startLine` are lines **in the
`.cpy`**, not in the module file — a bare `lineNos` number can point into an INCLUDEd copycode.
**`?depth=N` (1–10)** adds accesses reached transitively through `CALLNAT`/`PERFORM`/`CALLS`, tagging
`via` with the intermediate module. **Always pass `?depth` for Natural** — DB logic frequently hides
behind a `CALLNAT`. Default is the module's own accesses only (`via = null`).
**Java resolution.** Repository calls, `EntityManager`, Panache active-record, and Spring-Data
`@Query` (JPQL or native) resolve to `READS`/`WRITES` on the entity's table. Verb→mode is heuristic
for method names (`save/persist/merge/update/create`→WRITE; `delete*/remove*`→WRITE, shown as
`DELETE` in `sql-statements`; `find*/get*/list*/count*`→READ); for `@Query` the verb is parsed from
the text. A repository/entity's **own** table appears as `mode: DECLARES` even with no caller, and
survives `?depth=`. `@Query` text lives at the method declaration, so it is visible directly on the
repository but only at `?depth=1+` to a caller. **An empty Java `db-accesses` means "no recognized
persistence call", not "touches no table"** — parsing is regex-based (leading verb + first
`FROM`/`UPDATE`/`INTO`), so joins, subqueries and unusual quoting may not resolve.
`/modules/{name}/columns` → `{ attributeName, columnName, columnDefinition, javaType, nullable,
converterType, hibernateType, isId, declaredIn, startLine, endLine }`.
`data-structures/{name}/fields` is the flattened schema of a canonical `DEFINE DATA`/DDM structure
(`dataType` = Natural format/length, e.g. `A8`; `scope` = `PARAMETER`/`LOCAL`/`GLOBAL`/`INDEPENDENT`,
`null` for DB columns). Scoped to the structure's own definition — a copybook shared by many programs
returns its fields once, not once per `USING`. `UNKNOWN` area / empty fields ⇒ the defining file was
not ingested. `/modules/{name}/data-structures` shows which copybooks a module pulls in; feed each
`name` into the first endpoint.
## 6. Variables & dataflow
```
GET /variables/{name}/reads|writes?module=&depth=N → [{ function, functionType, sourceFile, module, lineNo }]
GET /variables/{name}/flow-forward|flow-backward?module=&depth=N → [{ variable, variableType, module, depth }]
GET /variables/{field}/field-flow?module=&depth=N → [{ field, producer, producedAt, consumer, consumedAt }]
```
`reads`/`writes` cover Natural `VARIABLE`/`CONSTANT` and Java `FIELD`; `module` scopes the start
point, `depth` traverses the call graph. `flow-*` follow positional argument→parameter links across
`CALLNAT` or Java method calls (matched by callee name — overloads over-approximate) and are
**deep-ingest only** (`409` with a `nextAction`; out of your scope — report it). `field-flow`
(Natural-only) pairs producers (`WRITES` a shared PDA field) with downstream consumers (`READS` the
same node) reachable via `CALLS`; it is reachability-based, **not order-precise** — it confirms a
downstream read exists, not that the write precedes it on every path.
## 7. Functions, overrides, dispatch table
```
GET /modules/{name}/functions?includeInherited=&kind={abstract|final|overridable}
→ [{ name, declaredIn, sourceFile, viaCopycode, startLine, endLine, kind }]
# viaCopycode=true ⇒ Natural subroutine from an INCLUDEd copycode; start/end lines are in sourceFile (the .cpy), not the module file
GET /modules/{name}/functions/{fn}/overrides → one hook's subclass overrides
GET /modules/{name}/functions/overrides → bulk: every abstract-method's overrides at once
GET /modules/{name}/functions/{fn}/overrides → one hook's subclass overrides
GET /modules/{name}/functions/overrides → bulk: every abstract method's overrides (adds `method`)
GET /modules/{name}/dispatch-table → [{ guardField, guardValue, assignedField, assignedValue, lineNo }]
```
`functions` → `[{ name, declaredIn, startLine, endLine, kind }]`
(`kind` null for Natural subroutines/constructors). `includeInherited=true`
also returns ancestor methods, each tagged `declaredIn`. `kind=` filters by
Java modifier (source-derived, not heuristic). `.../overrides` →
`[{ module, name, sourceFile, startLine, endLine }]` (bulk variant adds
`method` naming which hook is overridden) — use to see every concrete
implementation of a template-method contract at once.
`kind` is null for Natural subroutines and Java constructors; `kind=` filters by Java modifier
(source-derived). `includeInherited=true` adds ancestor methods, each tagged `declaredIn`.
`viaCopycode=true` ⇒ Natural subroutine from an INCLUDEd copycode, so its lines are in `sourceFile`
(the `.cpy`). `.../overrides` → `[{ module, name, sourceFile, startLine, endLine }]`.
### Dynamic-dispatch routing table (Natural)
`dispatch-table` gives a `DECIDE ON VALUE OF` router's `guardValue → assignedValue` table without
reading the block; one `guardValue` may map to several programs. Requires the router deep-ingested;
empty if no value-dispatch `DECIDE` exists.
## 8. Search & node inspection
```
GET /modules/{name}/dispatch-table
GET /search/identifier?name=&type= → [{ id, type, name, sourceFile, startLine, endLine, dataType, value, scope }]
GET /search/value?value=&contains= → [{ kind: ASSIGNMENT|NODE, name, value, module, sourceFile, startLine, endLine }]
GET /search/annotation?name=&type= → [{ id, type, name, sourceFile, startLine, endLine, annotations }] (Java-only)
GET /nodes/{id} → every property of the node (not a curated DTO)
GET /nodes/{id}/source | /modules/{name}/source?startLine=&endLine= → { sourceFile, startLine, endLine, lines }
```
For a `DECIDE ON VALUE OF` dispatcher → rows of
`{ guardField, guardValue, assignedField, assignedValue, lineNo }`, i.e. the
`guardValue → assignedValue` routing table, without reading the `DECIDE`
block. One `guardValue` may map to several programs. Requires the router to
be deep-ingested; empty if no value-dispatch `DECIDE` exists.
`search/identifier`: omit `name` to list all (large). `type` ∈ `MODULE`, `FUNCTION`, `VARIABLE`,
`CONSTANT`, `DATA_STRUCTURE`, `DB_TABLE`, `FIELD`, `DB_ACCESS`, `CONTROL_FLOW`.
### Database access
`search/value` finds a literal that never became its own node — e.g. a program name assigned to a
field (`#P-CALLED-PROG := 'WGEAGB0S'`). `ASSIGNMENT` = a write of that literal, `NODE` = a node
carrying it as its value. Exact and quote-insensitive by default; `?contains=true` →
case-insensitive substring, needed when the value is embedded in longer text such as SQL.
```
GET /modules/{name}/db-accesses?depth=N → [{ name, mode: READS|WRITES|DECLARES, lineNos, via, sites }]
# sites: [{ lineNo, sourceFile, viaCopycode, includedAt }] — each access tied to the file it lives in (the .cpy for a copycode-sourced access); a bare lineNos number can point into an INCLUDEd copycode
GET /modules/{name}/sql-statements?depth=N → [{ table, view, mode, statement, startLine, endLine, via, sourceFile, viaCopycode }]
# viaCopycode=true ⇒ statement from an INCLUDEd copycode; startLine/endLine are lines in sourceFile (the .cpy), not the module file
GET /modules/{name}/workfile-accesses → [{ workFile, physicalName, mode: READS|WRITES, recordBuffers, lineNos, sites }] (Natural READ/WRITE WORK FILE)
# sites like db-accesses: copycode-aware file context per access
GET /db-tables/{name}/columns → [{ name, type, dataType, value, parent, startLine, endLine }]
GET /modules/{name}/columns → Java @Entity columns (incl. @MappedSuperclass-inherited)
```
`search/annotation` is the only search that sees annotations; `name` matches case-insensitive
substring, `annotations` lists every annotation on the node.
By default: the module's **own** accesses only (`via = null`). `?depth=N`
(1-10) also includes accesses reached transitively through
`CALLNAT`/`PERFORM`/`CALLS`, tagging `via` with the intermediate module —
**always pass `?depth` for Natural**, since DB logic frequently hides behind
a `CALLNAT`.
`nodes/{id}` — use when a targeted endpoint doesn't expose what you need. The id is Neo4j's
`elementId` and **survives a re-ingest** (a merged node keeps its id); it becomes invalid only if the
node is deleted, which a refresh does to nodes the fresh parse no longer produces.
**Java resolution:** repository calls (`orderRepository.persist/findById/...`),
`EntityManager` (`em.persist/merge/remove`), Panache active-record
(`Product.findById`), and Spring-Data `@Query` (JPQL or native) all resolve
to `READS`/`WRITES` on the entity's table. Verb→mode is heuristic for method
names (`save/persist/merge/update/create`→WRITE, `delete*/remove*`→WRITE
shown as `DELETE` in `sql-statements`, `find*/get*/list*/count*`→READ); for
`@Query` the verb is parsed from the JPQL/SQL text itself. A repository/
entity's **own** table also surfaces with `mode: "DECLARES"` even with no
caller anywhere (its `@Entity(name=)`/resolved generic entity mapping), and
that row survives `?depth=` — the transitive view is a superset of the direct
one, so a caller sees the tables declared by the entities/repositories in its
closure, each with `via` naming the declaring module.
Because `@Query` text lives only at the method declaration, that access is
visible directly on the repository, and to a caller only via `?depth=1+`
(unlike a direct repository call, visible to its caller at depth 0). An
empty Java `db-accesses` means "no recognized persistence call," not
necessarily "touches no table" — JPQL/SQL parsing is regex-based
(leading verb + first `FROM`/`UPDATE`/`INTO` target); joins, subqueries, and
unusual quoting may not resolve.
## 9. Playbook
`/modules/{name}/columns` → `{ attributeName, columnName, columnDefinition,
javaType, nullable, converterType, hibernateType, isId, declaredIn,
startLine, endLine }` per column.
**Flow:** projects → `/modules` → `context` (or `digest` to triage many) → `call-tree?depth=N` to
scope the feature → per structure/table `data-structures/{name}/fields`, `db-tables/{name}/columns`,
or `modules/{Entity}/columns` → for impact `variables/{name}/reads|writes`, `search/identifier`,
`flow-forward|flow-backward`.
### Variables & dataflow
**Natural** fans out through many small modules and hides DB logic behind `CALLNAT` — **always pass
`?depth`** on db-accesses/sql-statements/variable reads|writes. `callees?scope=external` = CALLNAT'd
subprograms, `?scope=internal` = PERFORM, `callers` = blast radius. Trace values with
`variables/{field}/field-flow?depth=3`.
```
GET /variables/{name}/reads?module=&depth=N → [{ function, functionType, sourceFile, module, lineNo }]
GET /variables/{name}/writes?module=&depth=N
GET /variables/{name}/flow-forward?module=&depth=N → [{ variable, variableType, module, depth }]
GET /variables/{name}/flow-backward?module=&depth=N
GET /variables/{field}/field-flow?module=&depth=N → [{ field, producer, producedAt, consumer, consumedAt }]
```
*Example — "what does `WGEAGB0S` do, and what would reengineering it take?"*: `context` (sees
`CALLNAT BGEAGFN0`) → `call-tree?depth=4` → `db-accesses?depth=4` (`VERSVW_ADDRESS` READ/WRITE `via
BGEAGFN0`) → `sql-statements?depth=4` (statement to port) → `db-tables/VERSVW_ADDRESS/columns` +
`data-structures/{VIEW}/fields` (entity to generate) → `variables/%23KEY/field-flow?depth=4` (key
origin) → `callers` (dependents).
`reads`/`writes` cover Natural `VARIABLE`/`CONSTANT` and Java `FIELD`;
`module` scopes the start point, `depth` traverses the call graph
(default/clamp as `call-tree`). Use when `context.variableAccesses` shows a
field written here and you need every downstream reader before changing its
type.
**Java**: the call graph spans files and entities carry the schema in annotations. Use
`functions?includeInherited=true` for the member surface;
`call-tree?depth=N&resolveInterfaces=true&followWiring=true` for a whole feature in one traversal;
`db-accesses?depth=N` on the repository/service, then `modules/{Entity}/columns`;
`functions/{fn}/overrides` for concrete implementations; `search/annotation` project-wide (every
`@Query`, lingering `@Deprecated`).
`flow-forward`/`flow-backward` follow positional argument→parameter links
across `CALLNAT` (Natural) or method calls (Java, both intra- and cross-class,
matched by callee method name — overloads over-approximate).
**Deep-ingest only** — a call-graph-only module returns `409
NOT_DEEPLY_INGESTED`/`409 NOT_INGESTED` with a `nextAction` (out of your
scope — report it).
*Example — "map the `OrderController` feature and its persistence"*: `context` →
`callees?scope=external` → `call-tree?depth=3` → `db-accesses?depth=3` on the repository →
`modules/{Entity}/columns` → `functions?includeInherited=true`.
`field-flow` (Natural-only) traces a **shared PDA field**: producer modules
(`WRITES`) paired with downstream consumers (`READS` the same shared node)
reachable via `CALLS` up to `depth` hops. Reachability-based, not
order-precise (confirms a downstream read exists, not that the write
precedes it on every path).
## curl
### Data structures
```
GET /data-structures/{name}/fields → [{ name, type, dataType, value, parent, startLine, endLine, scope }]
GET /modules/{name}/data-structures → [{ name, relationship: USING|INLINE, area: PDA|LDA|GDA|INLINE|UNKNOWN, fieldCount, sourceFile }]
```
First: flattened field schema of a canonical `DEFINE DATA`/DDM structure
(`dataType` = Natural format/length e.g. `A8`/`I4`; `value` for constants;
`scope` = `PARAMETER`/`LOCAL`/`GLOBAL`/`INDEPENDENT`, `null` for DB-table
columns). Scoped to the structure's own definition — a copybook shared by
many programs returns its fields once, not once per `USING` site. `UNKNOWN`
area / empty fields means the defining file wasn't ingested.
Second: which copybooks/inline groups a module's `DEFINE DATA` pulls in —
discover the interface without reading source; feed each `name` (especially
`USING`/`PDA` ones) into the first endpoint for its field schema.
### Cross-project search
```
GET /search/identifier?name=&type= → by node name
GET /search/value?value=&contains= → by literal value (quote-insensitive)
GET /search/annotation?name=&type= → Java-only, by annotation
```
`search/identifier` → `[{ id, type, name, sourceFile, startLine, endLine,
dataType, value, scope }]` per matching node; omit `name` to list all
(large). `type` filters by `NodeType` (`MODULE`, `FUNCTION`, `VARIABLE`,
`CONSTANT`, `DATA_STRUCTURE`, `DB_TABLE`, `FIELD`, `DB_ACCESS`,
`CONTROL_FLOW`) — bad value → `400 INVALID_TYPE`. `id` feeds `/nodes/{id}`.
`search/value` finds a literal that never became its own node — e.g. a
program name assigned to a field (`#P-CALLED-PROG := 'WGEAGB0S'`). →
`[{ kind: ASSIGNMENT|NODE, name, value, module, sourceFile, startLine,
endLine }]` (`ASSIGNMENT` = a write of that literal; `NODE` = a node, e.g. a
`CONSTANT`, carrying it as its value). Exact + quote-insensitive by default;
`?contains=true` → case-insensitive substring (needed when the value is
embedded in a longer string, e.g. a table name inside SQL statement text).
Missing `value` → `400 MISSING_VALUE`.
`search/annotation` finds classes/methods/constructors/fields carrying a
matching annotation (`@Query`, `@Entity`, `@Inject`, ...) — the other two
searches can't see annotations at all. → `[{ id, type, name, sourceFile,
startLine, endLine, annotations }]` (`annotations` = every annotation on that
node, comma-joined). `name` matches case-insensitive substring; missing/blank
→ `400 MISSING_NAME`; bad `type` → `400 INVALID_TYPE`.
### Inspect a node / read its source
```
GET /nodes/{id}
GET /nodes/{id}/source
GET /modules/{name}/source?startLine=&endLine=
```
`nodes/{id}` returns **every** property of that node (not a curated DTO) —
use when a targeted endpoint doesn't expose what you need. The id is Neo4j's
`elementId`, and it **survives a re-ingest**: a node that a refresh merges onto
keeps its id. (It used to be a UUID that was overwritten on every single
re-ingest, so ids were only valid within one generation — that restriction is
gone.) An id does become invalid if the node itself is deleted, which a refresh
does to nodes the fresh parse no longer produces, or if the database is
restored from a backup.
`/source` variants read `[startLine, endLine]` off disk →
`{ sourceFile, startLine, endLine, lines: [...] }`. Missing
`startLine`/`endLine` on the module variant → `400 MISSING_LINE_RANGE`;
unknown id/module → `404 NODE_NOT_FOUND`/`404 MODULE_NOT_FOUND`; a
placeholder with no source file → `400 NO_SOURCE_FILE`. **Skip these two
if you have filesystem access** — see Ground rules.
## curl reference
All paths are `http://localhost:8787/api/projects/{project}/…`; quote URLs containing `&`, and encode
`#` as `%23`.
```bash
curl http://localhost:8787/api/projects
curl http://localhost:8787/api/version
curl http://localhost:8787/api/projects/demo/modules
curl 'http://localhost:8787/api/projects/demo/modules?sourceFile=ZSNNA12.nsp'
curl http://localhost:8787/api/projects/demo/modules/ZSNNA12/digest
curl 'http://localhost:8787/api/projects/demo/modules/ZSNNA12/context?include=functions,dbAccesses&limit=50'
curl 'http://localhost:8787/api/projects/demo/modules/ZSNNA12/callers?scope=external'
curl 'http://localhost:8787/api/projects/demo/modules/ZSNNA12/callees?scope=internal'
curl 'http://localhost:8787/api/projects/demo/modules/ZSNNA12/call-tree?depth=2&resolveInterfaces=true&followWiring=true'
curl 'http://localhost:8787/api/projects/demo/modules/ZSNNA12/functions?includeInherited=true'
curl 'http://localhost:8787/api/projects/demo/modules/ZSNNA12/functions/R-PARSE/overrides'
curl http://localhost:8787/api/projects/demo/modules/ZSNNA12/dispatch-table
curl 'http://localhost:8787/api/projects/demo/modules/ZSNNA12/call-tree?depth=2&resolveInterfaces=true'
curl 'http://localhost:8787/api/projects/demo/modules/ZSNNA12/db-accesses?depth=2'
curl 'http://localhost:8787/api/projects/demo/modules/ZSNNA12/sql-statements?depth=2'
curl http://localhost:8787/api/projects/demo/db-tables/MY-TABLE/columns
curl http://localhost:8787/api/projects/demo/modules/PartnerLegacyEntity/columns
curl 'http://localhost:8787/api/projects/demo/variables/%23FIELD/reads?module=ZSNNA12&depth=3'
curl 'http://localhost:8787/api/projects/demo/variables/%23FIELD/flow-forward?module=ZSNNA12&depth=3'
curl 'http://localhost:8787/api/projects/demo/variables/SHARED-FIELD/field-flow?depth=3'
curl http://localhost:8787/api/projects/demo/data-structures/MY-VIEW/fields
curl 'http://localhost:8787/api/projects/demo/search/identifier?name=MY-FIELD'
curl 'http://localhost:8787/api/projects/demo/search/value?value=orders&contains=true'
curl 'http://localhost:8787/api/projects/demo/search/annotation?name=Query&type=FUNCTION'
curl http://localhost:8787/api/projects/demo/nodes/3f9c1a2b-.../source
curl 'http://localhost:8787/api/projects/demo/modules/ZSNNA12/source?startLine=10&endLine=25'
curl -X POST -H 'Content-Type: application/json' \
-d '{"originFile":"X.nat","lineNo":42,"targets":["YGEAGGN0"]}' \
http://localhost:8787/api/projects/demo/dynamic-calls/overrides
```
## Recommended end-to-end flow
1. `GET /api/projects` → pick the project.
2. `GET /modules` (optionally `?sourceFile=`) → find the module of interest.
3. `GET /modules/{name}/context` (or `/digest` for quick triage across many).
4. `GET /modules/{name}/call-tree?depth=N` → scope the surrounding feature.
5. Per referenced structure/table: `/data-structures/{name}/fields`,
`/db-tables/{name}/columns`, or `/modules/{name}/columns` (Java entity).
6. For impact analysis: `/variables/{name}/reads|writes`,
`/search/identifier`, `/variables/{name}/flow-forward|flow-backward`.
7. For Java hierarchies: `/modules/{name}/functions?includeInherited=true`.
## Natural playbook
Natural fans out through `CALLNAT`/`PERFORM` across many small modules, and
DB logic frequently hides behind a `CALLNAT` — **always pass `?depth` on
db-accesses/sql-statements/variable reads|writes.**
1. Orient: `GET /modules` / `?sourceFile=`. Encode `#` in field names
(`%23COUNTER`); names are case-sensitive.
2. Overview: `GET /modules/{name}/context`.
3. Scope: `call-tree?depth=3`, `callees?scope=external` (CALLNAT'd
subprograms) vs `?scope=internal` (PERFORM), `callers` (blast radius).
4. DB footprint: `db-accesses?depth=3` / `sql-statements?depth=3` — `via`
names the callee doing the actual access.
5. Data shapes: `db-tables/{TABLE}/columns` + `data-structures/{NAME}/fields`.
6. Trace values: `variables/{field}/writes|reads?depth=3`,
`variables/{field}/field-flow?depth=3` (shared-PDA producer→consumer),
`search/identifier?name={X}` (cross-module occurrences).
Worked example — *"what does `WGEAGB0S` do, and what would reengineering it
take?"*: `context` (sees `CALLNAT BGEAGFN0`) → `call-tree?depth=4` → `db-accesses?depth=4`
(`VERSVW_ADDRESS` READ/WRITE `via BGEAGFN0`) → `sql-statements?depth=4` (the
statement to port to Panache) → `db-tables/VERSVW_ADDRESS/columns` +
`data-structures/{VIEW}/fields` (entity to generate) →
`variables/%23KEY/field-flow?depth=4` (key origin) → `callers` (dependents).
## Java playbook
The call graph spans files (typed-receiver/static/`new` resolve cross-class);
entities carry the DB schema in annotations.
1. Orient: `GET /modules` / `?sourceFile=`; JPA tables also via
`search/identifier?type=DB_TABLE`.
2. Overview: `GET /modules/{name}/context` (functions = methods +
constructors; `variableAccesses` = field reads/writes).
3. Members incl. inheritance: `functions?includeInherited=true`.
4. Cross-class graph: `callees?scope=external` (other classes + `INJECTS`/
`REFERENCES` wiring), `?scope=internal`, `callers`,
`call-tree?depth=N&resolveInterfaces=true&followWiring=true` (whole
feature across files in one traversal).
5. Persistence: `db-accesses|sql-statements?depth=N` on the
repository/service; `modules/{Entity}/columns` for the full column
mapping, or `db-tables/{TABLE}/columns` table-centric.
6. Dataflow: `variables/{field}/reads|writes`;
`flow-forward|flow-backward` (named-method matching, deep-ingest only).
7. `functions/{fn}/overrides` for concrete subclass overrides;
`search/annotation?name=&type=` project-wide (every `@Query` method,
lingering `@Deprecated`, etc.).
Worked example — *"map the `OrderController` feature and its persistence"*:
`context` → `callees/OrderController?scope=external` (`OrderService`, …) →
`call-tree?depth=3` (reaches `OrderService`, repositories) →
`db-accesses?depth=3` on the repository/service → per entity
`modules/{Entity}/columns` → `functions/{Service}?includeInherited=true`
(API surface to reimplement).

View File

@@ -285,6 +285,18 @@ under-reports Natural callers/callees, and does so silently — re-ingest before
module is unused.** A copycode parameter that genuinely has no argument now surfaces in
`/dynamic-calls/unresolved` as `&n&` rather than being dropped, so "not analysable" is visible.
**A call edge no longer outlives the call it was parsed from (item 124).** Until 2026-08-09 a refresh
only *added* the corrected call and left the old one in place, because an edge is reaped only when one
of its endpoints is — and a parser fix changes neither (the calling subroutine is unchanged, the old
target is a never-swept placeholder). So `callees`/`callers` could report a call that no source line
makes, flagged `unresolved: true` and indistinguishable from a genuine unresolved dynamic call. A
re-parsed Natural file's call edges are now reaped before the fresh ones are merged, and a call-target
placeholder left with no callers is deleted. **Two consequences for a graph ingested before
2026-08-09:** an `unresolved: true` callee may be an artefact of an already-fixed parser bug rather
than a real dynamic call, and `search/identifier` may list module names that exist nowhere in the
source. Both clear on the next deep refresh. Note the reap deliberately spares `CALLNAT_DYNAMIC` edges
onto *real* modules — those are the dynamic-call resolvers' output, not the parser's.
## LoC / SLoC metrics (item 46)
Every file-level node (a `MODULE` program/class, or a `DATA_STRUCTURE` for a Natural `.lda`/`.pda`

View File

@@ -823,6 +823,13 @@ before. The parked "parallel parse phase" idea was implemented 2026-07-18 (item
Neo4j's variable-length patterns use trail semantics (no relationship repeats in a path), so queries
terminate rather than hang — but the blow-up is real: three probes using an unbounded `CONTAINS*` over
this region were killed at a 2-minute timeout during the item-74 investigation.
**The shared-node identity below has a second consequence — see item 124.** Because a copycode node is
MERGEd per `(type, name, sourceFile)` and thus shared by every includer, item 124's call-edge reap
cannot touch the 605 call edges whose source subroutine lives in a `.cpy` (14 of them stale): reaping
them during one module's refresh would delete edges other modules contributed. Items 86 and 106 have
the same hole. Whoever gives copycode-resident nodes a per-module identity closes all three at once —
worth knowing before designing a fix for either symptom alone.
**Candidate, unconfirmed:** copycode `CONTROL_FLOW` nodes are shared by every including module (one node
per `.cpy` line), and a `.cpy` that opens a block it does not close (`YFRAMBC0.cpy` opens `FOR` at line
65; the `END-FOR` lives in the *includer*) gets containment edges from every includer's nesting context
@@ -1668,8 +1675,9 @@ reproduced the bug.)*
and by `aCallnatInsideAStringLiteralIsStillNotACall`, which keeps the widened delimiter from
re-opening bug #63.
- [ ] **124. A full deep refresh does not reap placeholder `MODULE` nodes the parser no longer
produces — fixed bugs keep answering from the graph** (found 2026-08-09 while verifying 120/121/123)
- [x] **124. A full deep refresh does not reap placeholder `MODULE` nodes the parser no longer
produces — fixed bugs keep answering from the graph** (found 2026-08-09 while verifying 120/121/123,
done 2026-08-09)
**Symptom.** After the deep refresh that shipped items 120/121/123, `DAGCHEN0/callees` still lists
the item-121 phantom `AGNT-CHG-CMP-SP` as a `MODULE` — alongside the now-correct `YAGCHBN0`, from
@@ -1702,6 +1710,44 @@ reproduced the bug.)*
contains`, the item-62 data-literal cleanup); none of them appears to drop a placeholder `MODULE`
that no longer has any producing call site in the re-parsed source.
**Resolution (2026-08-09).** The gap was structural and had a precedent: `GraphRepository` already
reaps a re-parsed Natural file's `READS`/`WRITES` to access placeholders (item 86) and its `USING`
edges (item 106) before merging the fresh ones. Nobody had done it for `CALLS`. Item 86's own
javadoc states the general cause — *"the target placeholder is never swept and the source node
survives, so neither `DELETE_STALE_FILE_NODES` nor the edge MERGE ever reaped it"*.
Two parts: `DELETE_STALE_NATURAL_CALL_EDGES` (per-file, gated on `reconcile`) and
`DELETE_ORPHANED_PLACEHOLDER_MODULES` (finalize, the `MODULE` counterpart of item 88's table sweep).
**Two boundaries the tests forced, both found by running rather than reasoning:**
1. *Duplicate markers must survive the node sweep.* Item 114 records a duplicate identity as a
placeholder, and an **unreferenced** one is a degree-0 placeholder — exactly the shape the sweep
targets. `MARK_DUPLICATE_IDENTITIES` runs *before* finalize, so the sweep deleted the marker just
written and the endpoints fell back to `404` instead of `409 DUPLICATE_IDENTITY`. Guarded on
`duplicatePaths IS NULL`. Caught by `DuplicateIdentityIT` (3 failures).
2. *Resolver-built dynamic edges must not be reaped.* The proposal said "reap **all** of the file's
`CALLS`". That is wrong: **486** `CALLNAT_DYNAMIC` edges point at real modules and **392 of them
carry no marker at all** (no `folded`, no `resolvedBy`), so nothing but the *target's* file
distinguishes enrichment output from parser output. Reaping them broke the item-37a path-warm —
a *scoped* finalize does not reliably re-resolve an edge whose far side is outside its scope, so
the deletion was permanent and a dataflow trace stopped at the dispatch boundary. The predicate
is therefore `t.sourceFile = "" OR r.callKind <> 'CALLNAT_DYNAMIC'`: everything the parser
re-emits is reaped, the resolvers' own output is not. Caught by
`AnalysisResourceIT#flowForwardPathWarmCrossesIntoDynamicallyDispatchedCallee`.
**Scope limit (deliberate).** Keyed on the source node's file, so it misses the 605 call edges whose
source subroutine is defined *inside a copycode* — 14 of them stale. Those nodes are MERGEd per
`(type, name, sourceFile)` and are therefore **shared** by every including module, so reaping them
during one module's refresh would delete edges other modules contributed and never re-create them.
That needs a per-module identity for copycode-resident nodes and is a separate item. The same hole
exists in items 86 and 106, which use the same key.
**Guard.** `StaleCallEdgeReapIT` — three cases, two sabotage-verified. The fixture changes only the
`CALLNAT` target and keeps the enclosing subroutine, which is precisely why the bug hid behind a
green suite: `DerivedCallsModuleRefreshIT` deletes the whole subroutine, so there the `FUNCTION`
node disappears and `DETACH DELETE` takes the edge along.
## Agent API / MCP tooling gaps
*(Done items 52, 53, 54, 56 moved to `x-docs/features.md`.)*