Bug fixes

This commit is contained in:
Ingo Schnabel
2026-07-27 16:20:42 +02:00
parent 4e1798eb02
commit d4b14a3b6d
14 changed files with 668 additions and 198 deletions

View File

@@ -67,21 +67,23 @@ flowchart TD
**Prerequisites:** Java 21+, Maven 3.9+, Docker (with the Compose plugin). Node 20+ only if you want to run the web UI.
The repo ships a `docker-compose.yml` (Neo4j 5 + the `ac-code-server` container) and a `manage-ac.sh` wrapper.
The repo ships a `docker-compose.yml` (Neo4j 5 + `ac-code-server` + `ac-ui`) and a `manage-ac.sh` wrapper.
```bash
git clone https://github.com/your-org/agenticcode.git
cd agenticcode
# Build all modules, start Neo4j + ac-code-server, and install the `ac` CLI launcher
# Build all modules, start Neo4j + ac-code-server + ac-ui, and install the `ac` CLI launcher
./manage-ac.sh deploy
```
`./manage-ac.sh deploy` builds the project, brings the Compose stack up (leaving an already-running Neo4j untouched),
and installs the `ac` launcher to `~/.local/bin/ac`.
serves the web UI at `http://localhost:5174`, and installs the `ac` launcher to `~/.local/bin/ac`. See
[`manage-ac.sh`](#manage-acsh--the-stack-manager) below for all commands.
Once up:
- **Web UI** — `http://localhost:5174`
- **REST API** — `http://localhost:8787/api`
- **MCP endpoint** — `http://localhost:8787/mcp/sse` (HTTP/SSE transport)
- **OpenAPI / health** — `http://localhost:8787/q/openapi`, `http://localhost:8787/q/health`
@@ -110,17 +112,52 @@ Re-run `ac refresh` after the sources change; it reconciles per file (unchanged
`ac refresh <MODULE> -p upms` deep-ingests one module plus its transitive `CALLNAT`/`PERFORM` dependency tree (lazy
Tier-2).
### `manage-ac.sh` subcommands
### `manage-ac.sh` — the stack manager
| Command | Action |
|-------------|----------------------------------------------------------|
| `deploy` | Build, (re)start the stack, install/refresh the `ac` CLI |
| `restart` | Recreate the `ac-code-server` container |
| `stop` | Stop the `ac-code-server` container (Neo4j left running) |
| `down` | Stop and remove the whole Compose stack |
| `status` | Show container status |
| `cli` | Rebuild + reinstall only the `ac` CLI |
| `logs [-f]` | Tail server logs (`-f` to follow) |
`manage-ac.sh` builds and runs the whole stack (Neo4j + `ac-code-server` + `ac-ui`) via docker-compose and installs the
`ac` CLI. Run it with no argument (or `help`) to print the command list — a bare invocation deliberately does **not**
deploy, since a full deploy bumps the version and rebuilds everything.
```bash
./manage-ac.sh <command>
```
| Command | What it does |
|-------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `deploy` | Full deploy: bump version, `mvn clean install`, rebuild + restart `ac-code-server`, rebuild + start `ac-ui`, install/refresh the `ac` CLI. Neo4j is left running if already up. |
| `restart` | Restart `ac-code-server` only — **no build**. Also the way to abort a long server-side job (a deep refresh keeps running after its HTTP client is killed). |
| `stop` | Stop `ac-code-server` only; Neo4j and `ac-ui` keep running. |
| `down` | Stop the whole stack, Neo4j included. **The graph volume is kept** (never `down -v`). |
| `status` | Show containers, the answering server version, and the ingested projects — works even when the stack is down. |
| `cli` | Build and (re)install `ac` only — no Docker involved. |
| `ui` | Rebuild + restart `ac-ui` only (npm build runs inside Docker; no local Node needed). |
| `logs [-f]` | Last 200 lines of server logs; `-f` to follow. |
The server answers on `http://localhost:8787`, and the **Dockerized UI on `http://localhost:5174`** (distinct from the
local Vite dev server on 5173). The version bump lives in the Maven build, so every `deploy` (a full `install`) bumps
`agenticcode.version` and re-stamps the CLI; `mvn test`/`compile`/`quarkus:dev` do not.
### `rebuild-and-refresh.sh` — redeploy then deep-refresh
A one-shot convenience script that redeploys the server and re-ingests the given project(s) from scratch — use it after
code changes that affect parsing or enrichment, so the graph reflects the new build. **One or more project names are
required** (there is no default; running it with no argument prints usage and exits).
```bash
./rebuild-and-refresh.sh upms # one project
./rebuild-and-refresh.sh upms pur # several, refreshed in order
```
It runs the full sequence, blocking until done: **stop** the server → **`manage-ac.sh deploy`** (version bump +
server/UI rebuild) → **wait** for `http://localhost:8787/api/projects` to answer (timeout `READY_TIMEOUT`, default 300
s) → **deep-refresh** each named project synchronously → print per-project timings and ring the terminal bell (and
`notify-send` if available).
Overridable via env: `AC` (CLI launcher, default `ac`), `AC_SERVER_URL` (default `http://localhost:8787`),
`READY_TIMEOUT`.
> A deep refresh is long and mutates the graph — **don't interrupt it once running**; the earlier enrichment steps are
> already committed, so an aborted refresh leaves the graph half-updated.
### Dev mode (hot reload)
@@ -228,6 +265,9 @@ legacy Natural/Java and planning migrations.
### Run it
If you ran `./manage-ac.sh deploy` (or `./manage-ac.sh ui`), the UI is **already built and served in Docker
at `http://localhost:5174`** — no local Node needed. For front-end development, run the Vite dev server instead:
```bash
cd ac-ui
npm install

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=113
version=119

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 MCP
# 'version' tool/server-info (referenced below via property expression, not duplicated).
agenticcode.version=113
agenticcode.version=119
# MCP server (HTTP/SSE transport) — tools exposed at http://<host>:8787/mcp/sse
quarkus.mcp.server.server-info.name=agenticcode

View File

@@ -189,6 +189,34 @@ class DynamicCallOverrideIT {
.body("variable", hasItem("#TGT"));
}
@Test
void callTreeHonoursTheOverrideLikeCallees() {
// Audit defect C: the call-tree BFS did not filter manualHidden, so it walked the marker edge the
// override only hides and reported the variable #TGT as a MODULE in the closure — while callees,
// which does filter, correctly did not. The two views must agree at every step.
resetAll();
given().pathParam("name", "CALLERDYN")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/call-tree?depth=3")
.then().statusCode(200)
.body("items.name", hasItem("#TGT"));
Object[] site = unresolvedSite();
setOverride((String) site[0], (Integer) site[1], List.of("TARGETMOD"));
given().pathParam("name", "CALLERDYN")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/call-tree?depth=3")
.then().statusCode(200)
.body("items.name", hasItem("TARGETMOD"))
.body("items.name", not(hasItem("#TGT")));
resetAll();
given().pathParam("name", "CALLERDYN")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/call-tree?depth=3")
.then().statusCode(200)
.body("items.name", hasItem("#TGT"))
.body("items.name", not(hasItem("TARGETMOD")));
}
@Test
void overrideSurvivesDeepRefresh() {
resetAll();

View File

@@ -0,0 +1,140 @@
package com.agenticcode.codeserver.api;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
/**
* Audit defects A and B, end to end over {@code db-accesses}.
*
* <p>{@code ACCESSMN0} is shaped like the {@code Y****MN0} access layer of {@code upms}: two view
* variables over one table (the generator's boilerplate {@code NEXT-VIEW} plus a
* {@code VDB2-}-prefixed one), a {@code STORE}, a labelled {@code FIND} loop, and the
* {@code UPDATE(<label>.)} / {@code DELETE(<label>.)} pair inside it.
*
* <p>Before the fix this module reported three "tables" — {@code NEXT-VIEW},
* {@code VDB2-VERSVW_THING} and the real {@code VERSVW_THING} — and no write at all for the update
* and delete, so the CRUD layer looked read-only apart from a single insert. {@code SECONDMN0}
* pins the cross-module half of the defect: it declares the same boilerplate {@code NEXT-VIEW}
* over a <em>different</em> table, and since {@code DB_TABLE} nodes merge on the name, the alias
* conflated the two modules onto one node.
*/
@QuarkusTest
class NaturalViewAliasDbAccessIT {
private static final String PROJECT = "nat-view-alias";
private static final String ACCESSMN0 = """
DEFINE DATA
LOCAL
01 NEXT-VIEW VIEW OF VERSVW_THING
02 THING_ID (N10)
01 VDB2-VERSVW_THING VIEW OF VERSVW_THING
02 THING_ID (N10)
01 #ID (N10)
END-DEFINE
*
DEFINE SUBROUTINE ADD-OBJECT
STORE VDB2-VERSVW_THING
END-SUBROUTINE
*
DEFINE SUBROUTINE CHECK-EXISTENCE
EXISTENCE-CHECK.
FIND NUMBER NEXT-VIEW
WITH THING_ID = #ID
END-SUBROUTINE
*
DEFINE SUBROUTINE HOLD-OBJECT
HOLD-PRIME.
FIND VDB2-VERSVW_THING WITH
THING_ID = #ID
UPDATE(HOLD-PRIME.)
DELETE(HOLD-PRIME.)
END-FIND
END-SUBROUTINE
*
END
""";
/**
* Same boilerplate alias name, different table — must not collapse onto one node.
*/
private static final String SECONDMN0 = """
DEFINE DATA
LOCAL
01 NEXT-VIEW VIEW OF VERSVW_OTHER
02 OTHER_ID (N10)
01 #ID (N10)
END-DEFINE
*
DEFINE SUBROUTINE CHECK-EXISTENCE
FIND NUMBER NEXT-VIEW
WITH OTHER_ID = #ID
END-SUBROUTINE
*
END
""";
@TempDir
static Path root;
@BeforeAll
static void ingest() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
write("ACCESSMN0.nat", ACCESSMN0);
write("SECONDMN0.nat", SECONDMN0);
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);
}
}
@Test
void viewAliasesNeverSurfaceAsTables() {
given().pathParam("name", "ACCESSMN0")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/db-accesses")
.then().statusCode(200)
.body("name", everyItem(equalTo("VERSVW_THING")))
.body("name", not(hasItem("NEXT-VIEW")))
.body("name", not(hasItem("VDB2-VERSVW_THING")));
}
@Test
void byReferenceUpdateAndDeleteAreRecordedAsWrites() {
// STORE (insert) plus the UPDATE/DELETE pair: three writes, not one.
given().pathParam("name", "ACCESSMN0")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/db-accesses")
.then().statusCode(200)
.body("findAll { it.mode == 'WRITES' }.lineNos.flatten()", hasSize(3))
.body("findAll { it.mode == 'READS' }.name", hasItem("VERSVW_THING"));
}
@Test
void theSameBoilerplateAliasInTwoModulesResolvesToTwoTables() {
given().pathParam("name", "SECONDMN0")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/db-accesses")
.then().statusCode(200)
.body("name", everyItem(equalTo("VERSVW_OTHER")));
}
}

View File

@@ -840,7 +840,8 @@ public final class CypherQueries {
public static final String MODULE_HOP_OUT = """
UNWIND $names AS n
MATCH (m:AstNode {type: 'MODULE', name: n, project: $project})
MATCH (m)-[:CONTAINS*0..1]->(src:AstNode)-[:CALLS]->(callee:AstNode {type: 'MODULE'})
MATCH (m)-[:CONTAINS*0..1]->(src:AstNode)-[r:CALLS]->(callee:AstNode {type: 'MODULE'})
WHERE coalesce(r.manualHidden, false) = false
RETURN DISTINCT callee.name AS name
""";
@@ -851,11 +852,20 @@ public final class CypherQueries {
* asked for: with {@code followWiring=true} but a {@code CALLS}-only module set, a class reached only
* by injection is not in the set, the per-hop predicate prunes it, and {@code call-tree} returns an
* empty list.
*
* <p>Both hop queries filter {@code manualHidden} exactly as {@link #callees}/{@link #callers} do.
* A manual dynamic-call override does not delete the marker edge to the variable-named placeholder
* (see {@link #DELETE_DYNAMIC_CALLNAT_PLACEHOLDER_EDGES}) — it hides it. Without the filter the BFS
* walked the hidden marker and pulled the placeholder into the closure as a {@code MODULE}, so
* {@code call-tree} listed a variable (e.g. {@code #GETSHORT-MODUL}) as a module although
* {@code callees} correctly did not, and everything driven by the BFS — {@code graph},
* {@code db-accesses?depth=N}, {@code sql-statements?depth=N} — inherited it.
*/
public static final String MODULE_HOP_OUT_WIRING = """
UNWIND $names AS n
MATCH (m:AstNode {type: 'MODULE', name: n, project: $project})
MATCH (m)-[:CONTAINS*0..1]->(src:AstNode)-[:CALLS|INJECTS|REFERENCES]->(callee:AstNode {type: 'MODULE'})
MATCH (m)-[:CONTAINS*0..1]->(src:AstNode)-[r:CALLS|INJECTS|REFERENCES]->(callee:AstNode {type: 'MODULE'})
WHERE coalesce(r.manualHidden, false) = false
RETURN DISTINCT callee.name AS name
""";

View File

@@ -63,7 +63,9 @@ public final class NaturalCoarseScanner implements CoarseScanner {
private static final Pattern MACRO_ARG = Pattern.compile("'[^']*'|[#A-Za-z][#A-Za-z0-9.\\-]*");
private static AstNode dbTable(Map<String, AstNode> tables, List<AstNode> nodes, String name, int lineNo) {
return tables.computeIfAbsent(name, n -> {
// Upper-cased for the same reason as NaturalParser.dbTable: Natural is case-insensitive and
// DB_TABLE nodes merge on the name.
return tables.computeIfAbsent(name.toUpperCase(Locale.ROOT), n -> {
AstNode table = placeholder(NodeType.DB_TABLE, n, lineNo);
nodes.add(table);
return table;
@@ -255,6 +257,11 @@ public final class NaturalCoarseScanner implements CoarseScanner {
}
}
// Audit defects A/B: shared with NaturalParser rather than mirrored, so the coarse and deep
// passes cannot report different table names for the same statement — they merge on the node
// name, so a divergence would leave both a real and an alias-named DB_TABLE in the graph.
Map<String, String> viewAliases = NaturalParser.viewAliases(lines);
Map<String, String> loopTablesByLabel = NaturalParser.loopTablesByLabel(lines, viewAliases);
Map<String, AstNode> tables = new HashMap<>();
Map<String, AstNode> workfiles = new HashMap<>();
// The enclosing subroutine of the current line (null at main-program level), so calls/DB access
@@ -394,14 +401,16 @@ public final class NaturalCoarseScanner implements CoarseScanner {
Matcher dbRead = DB_READ.matcher(line);
if (dbRead.find()) {
AstNode table = dbTable(tables, nodes, dbRead.group(2), lineNo);
AstNode table = dbTable(tables, nodes,
NaturalParser.resolveViewAlias(viewAliases, dbRead.group(2)), lineNo);
edges.add(edge(EdgeType.READS, caller, table.id(), lineNo));
continue;
}
Matcher dbWrite = DB_WRITE.matcher(line);
if (dbWrite.find()) {
AstNode table = dbTable(tables, nodes, dbWrite.group(2), lineNo);
AstNode table = dbTable(tables, nodes,
NaturalParser.resolveViewAlias(viewAliases, dbWrite.group(2)), lineNo);
edges.add(edge(EdgeType.WRITES, caller, table.id(), lineNo));
continue;
}
@@ -410,6 +419,18 @@ public final class NaturalCoarseScanner implements CoarseScanner {
if (dbDeleteFrom.find()) {
AstNode table = dbTable(tables, nodes, dbDeleteFrom.group(1), lineNo);
edges.add(edge(EdgeType.WRITES, caller, table.id(), lineNo));
continue;
}
// Matched after DB_DELETE_FROM so the SQL form keeps precedence; an unresolvable reference
// records nothing, exactly as in NaturalParser.
Matcher dbWriteByRef = NaturalParser.DB_WRITE_BY_REF.matcher(line);
if (dbWriteByRef.find()) {
String loopTable = loopTablesByLabel.get(dbWriteByRef.group(2).toUpperCase(Locale.ROOT));
if (loopTable != null) {
AstNode table = dbTable(tables, nodes, loopTable, lineNo);
edges.add(edge(EdgeType.WRITES, caller, table.id(), lineNo));
}
}
}
// Remap the copycode-expanded statement nodes/edges back to real file positions, then prepend

View File

@@ -69,6 +69,21 @@ public final class NaturalParser implements LanguageParser {
private static final Pattern END_FIND = Pattern.compile("(?i)^\\s*END-FIND\\b");
private static final Pattern END_READ = Pattern.compile("(?i)^\\s*END-READ\\b");
private static final Pattern VIEW_OF = Pattern.compile("(?i)\\bVIEW\\s+OF\\s+(\\S+)");
// Natural DML by reference: `UPDATE (r)` / `DELETE (r)` act on the current record of the loop
// identified by r — a statement label (`HOLD-PRIME.`) or, in a form this corpus does not use, a
// source-line number. The operand is never a view, so the table comes from the referenced loop
// (audit defect B). Whitespace before `(` is optional: the corpus writes `UPDATE(HOLD-PRIME.)`.
static final Pattern DB_WRITE_BY_REF =
Pattern.compile("(?i)^\\s*(UPDATE|DELETE)\\s*\\(\\s*([A-Z0-9#@$&\\-_.]+?)\\.?\\s*\\)");
// A `DEFINE DATA` view declaration `<level> <alias> VIEW OF <table>`: the alias is a *variable*, and
// it — not the table — is what every Natural DML statement names. Resolving it is what keeps
// `db-accesses` reporting real tables (audit defect A); without it a module that only ever touches
// VERSVW_LITERALES reports three "tables", one of them the generator's boilerplate name NEXT-VIEW,
// which then collides across every access layer that uses the same boilerplate.
private static final Pattern VIEW_DECL =
Pattern.compile("(?i)^\\s*\\d+\\s+([A-Z0-9#@$&\\-_]+)\\s+VIEW\\s+OF\\s+(\\S+)");
// A statement label introducing the FIND/READ on the following line (`HOLD-PRIME.` on its own line).
private static final Pattern STATEMENT_LABEL = Pattern.compile("^\\s*([A-Z0-9#@$&\\-_]+)\\.\\s*(?:/\\*.*)?$");
// STORE takes a real ADABAS view operand. UPDATE has two forms: `UPDATE <view>` (real) and Natural
// DML `UPDATE (label)` (updates the current record of the enclosing loop via a reference label — no
// view); DELETE only ever has the latter shape (`DELETE [(label)]`, or the EXAMINE clause
@@ -176,13 +191,100 @@ public final class NaturalParser implements LanguageParser {
Pattern.compile("(?i)^\\s*DEFINE\\s+SUBROUTINE\\s+(GET-XML-LINE|GET-XML-ACT)\\b");
private static AstNode dbTable(Map<String, AstNode> dbTables, List<AstNode> nodes, String name, int lineNo) {
return dbTables.computeIfAbsent(name, n -> {
// Natural is case-insensitive, and DB_TABLE nodes merge on (type, name, sourceFile="") — so a
// lower-case statement would otherwise mint a second node for a table already known upper-case.
return dbTables.computeIfAbsent(name.toUpperCase(Locale.ROOT), n -> {
AstNode table = node(NodeType.DB_TABLE, n, "", lineNo, lineNo, null, null);
nodes.add(table);
return table;
});
}
/**
* Audit defect A: maps each {@code DEFINE DATA} view alias to the table it is declared over
* ({@code 1 NEXT-VIEW VIEW OF VERSVW_LITERALES} &rarr; {@code NEXT-VIEW} &rarr;
* {@code VERSVW_LITERALES}), so a Natural DML operand can be resolved to a real table.
*
* <p>Runs over the copycode-expanded lines, so an alias declared in an included {@code .cpy} data
* block is seen too.
*/
static Map<String, String> viewAliases(String[] lines) {
Map<String, String> aliases = new HashMap<>();
for (String line : lines) {
Matcher m = VIEW_DECL.matcher(line);
if (m.find()) {
aliases.putIfAbsent(m.group(1).toUpperCase(Locale.ROOT), m.group(2).toUpperCase(Locale.ROOT));
}
}
return aliases;
}
/**
* The table a Natural DML operand denotes: the view alias resolved, or the operand itself.
*/
static String resolveViewAlias(Map<String, String> aliases, String operand) {
return aliases.getOrDefault(operand.toUpperCase(Locale.ROOT), operand);
}
/**
* Audit defect B: maps a {@code FIND}/{@code READ} statement label to the (alias-resolved) table its
* loop reads, so {@code UPDATE(<label>.)} / {@code DELETE(<label>.)} can be attributed to that table
* instead of being dropped.
*
* <p>Deliberately label-only. Natural also allows a bare {@code UPDATE}/{@code DELETE} and a
* source-line reference, both of which would need the enclosing-loop extent to resolve; neither
* occurs in this corpus, and guessing an enclosing loop is how phantom tables got in before. An
* unresolvable reference stays unrecorded.
*/
static Map<String, String> loopTablesByLabel(String[] lines, Map<String, String> aliases) {
Map<String, String> byLabel = new HashMap<>();
for (int i = 0; i < lines.length; i++) {
Matcher read = DB_READ.matcher(lines[i]);
if (read.find()) {
String label = precedingStatementLabel(lines, i);
if (label != null) {
byLabel.putIfAbsent(label, resolveViewAlias(aliases, read.group(2)));
}
continue;
}
// A labelled SQL `SELECT` is a loop too, and the generated access layer holds its record that
// way as often as with a FIND (YELEMMN0/YMULTMN0 do). Its table is the FROM operand — already
// a real table, so no alias resolution applies.
if (SELECT_FROM.matcher(lines[i]).find()) {
String label = precedingStatementLabel(lines, i);
if (label == null) {
continue;
}
for (int j = i; j < lines.length; j++) {
Matcher from = FROM_VIEW.matcher(lines[j]);
if (from.find()) {
byLabel.putIfAbsent(label, from.group(1).toUpperCase(Locale.ROOT));
break;
}
if (END_SELECT.matcher(lines[j]).find()) {
break;
}
}
}
}
return byLabel;
}
/**
* The statement label on the line before {@code idx}, skipping blank and comment lines.
*/
private static @Nullable String precedingStatementLabel(String[] lines, int idx) {
for (int j = idx - 1; j >= 0; j--) {
String candidate = lines[j].trim();
if (candidate.isEmpty() || candidate.startsWith("*")) {
continue;
}
Matcher label = STATEMENT_LABEL.matcher(lines[j]);
return label.matches() ? label.group(1).toUpperCase(Locale.ROOT) : null;
}
return null;
}
private static AstNode workfile(Map<String, AstNode> workfiles, List<AstNode> nodes, String number,
@Nullable String physicalName, int lineNo) {
return workfiles.computeIfAbsent(number, n -> {
@@ -924,6 +1026,12 @@ public final class NaturalParser implements LanguageParser {
workfilePhysicalNames.putIfAbsent(wd.group(1), wd.group(2).trim());
}
}
// Pre-scan the view declarations and the labelled FIND/READ loops: both resolve a DML operand to a
// real table, and both must be known before the first statement is seen (a label may be declared
// after the write that references it only in generated code, but the cost of scanning up front is
// one pass and it removes the ordering question entirely).
Map<String, String> viewAliases = viewAliases(lines);
Map<String, String> loopTablesByLabel = loopTablesByLabel(lines, viewAliases);
Map<String, AstNode> dataStructures = new HashMap<>();
Map<String, AstNode> variables = new HashMap<>();
Map<String, AstNode> placeholderFields = new HashMap<>();
@@ -1267,7 +1375,7 @@ public final class NaturalParser implements LanguageParser {
Matcher dbWriteMatcher = DB_WRITE.matcher(line);
if (dbWriteMatcher.find()) {
AstNode table = dbTable(dbTables, nodes, dbWriteMatcher.group(2), lineNo);
AstNode table = dbTable(dbTables, nodes, resolveViewAlias(viewAliases, dbWriteMatcher.group(2)), lineNo);
edges.add(edge(EdgeType.WRITES, caller, table.id(), lineNo));
AstNode access = node(NodeType.DB_ACCESS, table.name(), sourceFile, lineNo, lineNo, "WRITE", line.trim());
nodes.add(access);
@@ -1287,10 +1395,29 @@ public final class NaturalParser implements LanguageParser {
continue;
}
// Matched after DB_DELETE_FROM so the SQL form `DELETE FROM <table>` keeps precedence.
Matcher dbWriteByRefMatcher = DB_WRITE_BY_REF.matcher(line);
if (dbWriteByRefMatcher.find()) {
String loopTable = loopTablesByLabel.get(dbWriteByRefMatcher.group(2).toUpperCase(Locale.ROOT));
if (loopTable != null) {
String verb = dbWriteByRefMatcher.group(1).toUpperCase(Locale.ROOT);
AstNode table = dbTable(dbTables, nodes, loopTable, lineNo);
edges.add(edge(EdgeType.WRITES, caller, table.id(), lineNo));
AstNode access = node(NodeType.DB_ACCESS, table.name(), sourceFile, lineNo, lineNo,
"DELETE".equals(verb) ? "DELETE" : "WRITE", line.trim());
nodes.add(access);
edges.add(edge(EdgeType.CONTAINS, caller, access.id(), lineNo));
edges.add(edge(EdgeType.USES_TYPE, access.id(), table.id(), lineNo));
}
// An unresolvable reference (unknown label, or the numeric source-line form) records
// nothing: no enclosing loop is guessed, so no phantom table can be minted.
continue;
}
Matcher dbReadMatcher = DB_READ.matcher(line);
if (dbReadMatcher.find()) {
String verb = dbReadMatcher.group(1).toUpperCase(Locale.ROOT);
AstNode table = dbTable(dbTables, nodes, dbReadMatcher.group(2), lineNo);
AstNode table = dbTable(dbTables, nodes, resolveViewAlias(viewAliases, dbReadMatcher.group(2)), lineNo);
edges.add(edge(EdgeType.READS, caller, table.id(), lineNo));
// P1-j: collect multi-line statement body up to END-FIND / END-READ
Pattern endPattern = "FIND".equals(verb) ? END_FIND : END_READ;

View File

@@ -276,4 +276,34 @@ class NaturalCoarseScannerTest {
assertFalse(hasNode(r, NodeType.MODULE, "USIA008N"),
"a real module named in a literal is not a call");
}
@Test
void viewAliasesAndByReferenceWritesResolveExactlyAsInTheDeepPass() {
// Audit defects A/B: the coarse pass shares NaturalParser's resolution. If it drifted, a shallow
// module would report the alias and a FULL one the table — and since DB_TABLE nodes merge on the
// name, both would end up in the graph for the same table.
String src = """
DEFINE DATA LOCAL
1 VDB2-T_REAL VIEW OF T_REAL
2 REC-ID (N10)
END-DEFINE
HOLD-PRIME.
FIND VDB2-T_REAL WITH
REC-ID = 1
UPDATE(HOLD-PRIME.)
DELETE(HOLD-PRIME.)
END-FIND
END
""";
ParseResult r = scanner.scan("PGM.nat", src);
assertTrue(hasNode(r, NodeType.DB_TABLE, "T_REAL"), "the underlying table is indexed");
assertFalse(hasNode(r, NodeType.DB_TABLE, "VDB2-T_REAL"), "the view alias is not a table");
assertFalse(hasNode(r, NodeType.DB_TABLE, "HOLD-PRIME"), "the statement label is not a table");
assertEquals(2, r.edges().stream()
.filter(e -> e.type() == EdgeType.WRITES)
.filter(e -> r.nodes().stream().anyMatch(n -> n.id().equals(e.targetId())
&& n.type() == NodeType.DB_TABLE && n.name().equals("T_REAL")))
.count(),
"UPDATE(label.) and DELETE(label.) each write the loop's table");
}
}

View File

@@ -32,6 +32,28 @@ class NaturalParserTest {
return result.nodes().stream().anyMatch(n -> n.type() == type && n.name().equals(name));
}
/**
* The ascending line numbers of {@code type} edges from {@code source} to the DB_TABLE {@code table}.
*/
private static List<Integer> edgeLines(LanguageParser.ParseResult result, EdgeType type, AstNode source, String table) {
return result.edges().stream()
.filter(e -> e.type() == type && e.sourceId().equals(source.id()))
.filter(e -> result.nodes().stream().anyMatch(
n -> n.id().equals(e.targetId()) && n.type() == NodeType.DB_TABLE && n.name().equals(table)))
.map(AstEdge::lineNo)
.distinct()
.sorted()
.toList();
}
private static List<Integer> readLines(LanguageParser.ParseResult result, AstNode source, String table) {
return edgeLines(result, EdgeType.READS, source, table);
}
private static List<Integer> writeLines(LanguageParser.ParseResult result, AstNode source, String table) {
return edgeLines(result, EdgeType.WRITES, source, table);
}
private static boolean hasEdge(LanguageParser.ParseResult result, EdgeType type, AstNode source, String targetName, NodeType targetType) {
return result.edges().stream().anyMatch(e -> e.type() == type
&& e.sourceId().equals(source.id())
@@ -910,6 +932,129 @@ class NaturalParserTest {
"UPDATE <view> still records the real view");
}
@Test
void viewAliasResolvesToTheUnderlyingTable() {
// Audit defect A: a Natural DML operand is a view *variable* (`1 <alias> VIEW OF <table>`), not the
// table. Reporting the alias mints a phantom DB_TABLE and splits one table across several names —
// in upms a single node `NEXT-VIEW` stood for 11 different tables.
String content = """
DEFINE DATA LOCAL
1 NEXT-VIEW VIEW OF T_REAL
2 REC-ID (N10)
1 VDB2-T_REAL VIEW OF T_REAL
2 REC-ID (N10)
END-DEFINE
DEFINE SUBROUTINE ACCESS-IT
FIND NUMBER NEXT-VIEW
WITH REC-ID = 1
FIND VDB2-T_REAL WITH
REC-ID = 2
END-FIND
STORE VDB2-T_REAL
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("VIEW_ALIAS_SAMPLE.nat", content);
AstNode access = findNode(result, NodeType.FUNCTION, "ACCESS-IT");
assertFalse(hasNode(result, NodeType.DB_TABLE, "NEXT-VIEW"),
"The view variable NEXT-VIEW must not become a DB_TABLE");
assertFalse(hasNode(result, NodeType.DB_TABLE, "VDB2-T_REAL"),
"The view variable VDB2-T_REAL must not become a DB_TABLE");
assertTrue(hasEdge(result, EdgeType.READS, access, "T_REAL", NodeType.DB_TABLE),
"FIND through a view alias must READ the underlying table");
assertTrue(hasEdge(result, EdgeType.WRITES, access, "T_REAL", NodeType.DB_TABLE),
"STORE through a view alias must WRITE the underlying table");
assertEquals(List.of(8, 10), readLines(result, access, "T_REAL"),
"Both FIND variants (incl. FIND NUMBER) must resolve to T_REAL");
}
@Test
void updateAndDeleteByReferenceResolveToTheEnclosingLoopTable() {
// Audit defect B: `UPDATE(label.)` / `DELETE(label.)` write the current record of the labelled
// FIND/READ loop. Dropping them (to avoid a phantom `(label.)` table) made the whole Y****MN0 CRUD
// layer look read-only. The loop operand is itself a view alias, so A and B compose.
String content = """
DEFINE DATA LOCAL
1 VDB2-T_REAL VIEW OF T_REAL
2 REC-ID (N10)
END-DEFINE
DEFINE SUBROUTINE HOLD-OBJECT
HOLD-PRIME.
FIND VDB2-T_REAL WITH
REC-ID = 1
UPDATE(HOLD-PRIME.)
DELETE(HOLD-PRIME.)
END-FIND
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("BY_REF_SAMPLE.nat", content);
AstNode hold = findNode(result, NodeType.FUNCTION, "HOLD-OBJECT");
assertFalse(hasNode(result, NodeType.DB_TABLE, "HOLD-PRIME"),
"The statement label must not become a DB_TABLE");
assertEquals(List.of(9, 10), writeLines(result, hold, "T_REAL"),
"UPDATE(label.) and DELETE(label.) must WRITE the loop's table");
}
@Test
void byReferenceWriteResolvesThroughALabelledSelectLoop() {
// Audit defect B, second shape: the generated access layer holds its record with a labelled SQL
// SELECT as often as with a FIND (YELEMMN0, YMULTMN0). The table is the FROM operand.
String content = """
DEFINE DATA LOCAL
1 VDB2-T_REAL VIEW OF T_REAL
2 REC-ID (N10)
END-DEFINE
DEFINE SUBROUTINE HOLD-OBJECT
HOLD-PRIME.
SELECT *
INTO VIEW VDB2-T_REAL
FROM T_REAL
WHERE REC-ID = 1
UPDATE(HOLD-PRIME.)
DELETE(HOLD-PRIME.)
END-SELECT
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("SELECT_LABEL_SAMPLE.nat", content);
AstNode hold = findNode(result, NodeType.FUNCTION, "HOLD-OBJECT");
assertEquals(List.of(11, 12), writeLines(result, hold, "T_REAL"),
"A by-reference write must resolve through a labelled SELECT loop too");
}
@Test
void byReferenceWriteWithoutAResolvableLoopNamesNoTable() {
// The no-phantom guarantee must survive the defect-B fix: an unmatched label, and the numeric
// source-line form `UPDATE (r)` that Natural also allows, resolve to nothing rather than to a
// guessed table.
String content = """
DEFINE DATA LOCAL
1 VDB2-T_REAL VIEW OF T_REAL
2 REC-ID (N10)
END-DEFINE
DEFINE SUBROUTINE SAVE
UPDATE(NO-SUCH-LABEL.)
DELETE(0100)
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("BY_REF_UNRESOLVED_SAMPLE.nat", content);
AstNode save = findNode(result, NodeType.FUNCTION, "SAVE");
assertFalse(hasNode(result, NodeType.DB_TABLE, "NO-SUCH-LABEL"), "Unmatched label names no table");
assertFalse(hasNode(result, NodeType.DB_TABLE, "0100"), "A source-line reference names no table");
assertTrue(writeLines(result, save, "T_REAL").isEmpty(),
"An unresolvable by-reference write must not be attributed to any table");
}
@Test
void multiLineFindStatementTextIsCapturedFully() {
// P1-j: FIND spanning multiple lines must collect all lines until END-FIND

View File

@@ -1,12 +1,17 @@
#!/usr/bin/env bash
#
# rebuild-and-refresh.sh — stop the server, rebuild + redeploy it, then run a deep refresh
# of both projects (upms, pur) and announce when both refreshes have finished.
# of the given project(s) and announce when all refreshes have finished.
#
# Usage:
# ./rebuild-and-refresh.sh
# ./rebuild-and-refresh.sh <project> [<project> ...]
#
# Example:
# ./rebuild-and-refresh.sh upms
# ./rebuild-and-refresh.sh upms pur
#
# Notes:
# * At least one project is required (no default) — the script exits with usage if none is given.
# * A deep refresh is long and mutates the graph — do not interrupt it once running.
# * `manage-ac.sh deploy` already bumps the version, rebuilds ac-code-server + ac-ui and
# brings the stack up; we stop first (explicit) so the sequence is unambiguous.
@@ -20,10 +25,17 @@ AC="${AC:-ac}" # ac-cli launcher (on PATH: ~/.local
SERVER_URL="${AC_SERVER_URL:-http://localhost:8787}"
READY_PROBE="$SERVER_URL/api/projects"
READY_TIMEOUT="${READY_TIMEOUT:-300}" # seconds to wait for the server to come up
PROJECTS=(upms pur)
log() { printf '\n\033[1;34m[%(%H:%M:%S)T] %s\033[0m\n' -1 "$*"; }
fail() { printf '\n\033[1;31m[%(%H:%M:%S)T] %s\033[0m\n' -1 "$*" >&2; exit 1; }
log() { printf '\n\033[1;34m[%(%H:%M:%S)T] %s\033[0m\n' -1 "$*"; }
fail() { printf '\n\033[1;31m[%(%H:%M:%S)T] %s\033[0m\n' -1 "$*" >&2; exit 1; }
usage() { echo "Usage: ./rebuild-and-refresh.sh <project> [<project> ...]" >&2; exit 2; }
# Mandatory: one or more projects to deep-refresh, given as arguments.
if (( $# == 0 )); then
echo "Error: no project given — at least one is required." >&2
usage
fi
PROJECTS=("$@")
# 1. Stop the server -----------------------------------------------------------------------
log "Stopping ac-code-server ..."

View File

@@ -283,6 +283,34 @@ module. Before item 93 the transitive query carried only the `READS`/`WRITES` br
*same* module with `depth` dropped its declared table and a Java caller's transitive `db-accesses` came
back empty although the entity it persists through maps to a real table.
**Natural view aliases are resolved to the underlying table (item 95).** A Natural DML statement names a
*view variable* (`1 VDB2-VERSIS_LITERALES VIEW OF VERSVW_LITERALES`), not the DDM. `db-accesses` reports
the **table** — `FIND VDB2-VERSIS_LITERALES`, `FIND NUMBER NEXT-VIEW` and `STORE VDB2-VERSIS_LITERALES`
in `YLITEMN0` all come back as `VERSVW_LITERALES`, matching the SQL `SELECT … FROM` rows in the same
module. Before item 95 the alias itself was the reported name, which (a) split one table across several
names, (b) made the generator's boilerplate alias `NEXT-VIEW` a single node shared by 11 modules meaning
11 different tables, and (c) hid every `VERSVW_LOGFILE` write behind 11 `VDB2-*-VLOG` aliases. Table
names are upper-cased (Natural is case-insensitive).
**Natural `UPDATE(<label>.)` / `DELETE(<label>.)` count as writes (item 96).** These act on the current
record of the labelled `FIND`/`READ` loop, and are reported as `WRITES` on that loop's table. This is
what makes the `Y****MN0` access layer's update/delete path visible: `YLITEMN0` reports `WRITES
VERSVW_LITERALES` at the `STORE` **and** at `UPDATE(HOLD-PRIME.)` / `DELETE(HOLD-PRIME.)`, where before
item 96 it reported only the `STORE` — reading, wrongly, as an insert-only layer. A reference that
resolves to no labelled loop (an unknown label, or the numeric source-line form) records nothing rather
than guessing a table.
**`call-tree`/`graph` agree with `callees` about overridden dynamic calls (item 97).** A manual
dynamic-call override hides the placeholder marker rather than deleting it. All read paths now filter it,
so a pinned `CALLNAT <var>` shows the real target and never the variable name. Everything driven by the
call-tree BFS — `graph`, `db-accesses?depth=N`, `sql-statements?depth=N` — inherits this.
**`callers` on a dynamically-called module is an over-approximation, and says so.** A Natural web-service
module is reached by `CALLNAT #WIF`, resolved by naming pattern: `W-LST-N0.nat:362` alone resolves to 29
`W****B*S`/`W****X*S` targets, so `WGEAGB0S` lists `W-LST-N0` and `W-MNT-N0` as callers. The rows are
tagged `edgeKind: "CALLNAT_DYNAMIC"` — treat those as *may-call*, not *does-call*, and check
`dynamic-calls/overrides` / `dynamic-calls/unresolved` when the distinction matters.
## XML payload / interface schema (item 45)
Natural XML wrapper subprograms build a wire payload by mapping data-area fields to XML tags via the

View File

@@ -1,170 +0,0 @@
# Funktionsvergleich: JX0034N0 (Natural, `upms`) ↔ MultiTableImportJob (Java, `pur`)
**Erstellt:** 2026-07-19 · **Server:** agenticcode v103 · **Analyse-Basis:** REST-API (MCP-Session-Fehler → Fallback
laut CLAUDE.md)
Beide Programme laden eine **multiple Schlüsseltabelle** (VERSIS-„MELE"-Einträge, Tabellen `VGUET`/`VGUETSPA`) aus
einem Workfile/CSV in die DB. Der Java-Job ist die Migration des Natural-Subprogramms; die Herkunft ist im Code als
Kommentar `// JX0034N0.nat` und Konstante `PROGRAM_IDENTIFIER = "JX0034N0"` festgehalten.
---
## 1. Strukturelle Zuordnung (bestätigt via API)
| Natural (Subroutine) | Java (Klasse / Methode) | Status |
|--------------------------------|-------------------------------------------------------------|-----------------------------|
| `INIT-PROCESSING` | `MultiTableImportInitStep.doProcess()` | ✅ |
| `CHECK-PARMS` | `MultiTableImportInitStep.checkParamsImpl()` | ✅ |
| `MAIN-PROCESSING` (Orchestr.) | `MultiTableImportJob.jobSteps()` (init → processing → end) | ✅ |
| `MAIN-PART` (READ WORK-Loop) | `MultiTableImportProcessingStep` (Reader + `doProcessItem`) | ✅ |
| `HEADER` | `MultiTableImportProcessingStep.processHeader()` | ✅ |
| `DEL-MELEM` | `MultiTableImportProcessingStep.performDelete()` | ⚠️ (Fehlerbehandlung fehlt) |
| `LOAD-MELEM` | `MultiTableImportProcessingStep.performLoad()` | ⚠️ (kein UPDATE) |
| `FILL-VGUET` | — (in Natural auskommentiert; Java hat kein Äquivalent) | ✅ Parität |
| `FILL-RESULTS`/`WRITE-RESULTS` | `fillResults()` / `writeResults()` | ✅ |
| `ET-PROCESSING` | `doBeforeEndTransaction()` + Framework-Chunk-Commit | ⚠️ (andere Commit-Kadenz) |
| `END-PROCESSING` | `MultiTableImportEndStep.doProcess()` | ✅ |
| `WRITE-MELE-DATA` | `writeMeleData()` | ✅ |
Client-Validierung `CALLNAT USIX004N` → `ClientValidationLogic.validate()`,
Branch-Prüfung `CALLNAT ISI173N0` → `FieldRecoveryLogic.findAlternativeKey()`,
Batch-Monitoring `CALLNAT VBATCHN0 (BEGIN/END)` → `PurBatchLogic.begin()` — jeweils **funktional äquivalent**.
**Fazit Struktur:** Der Kontrollfluss ist 1:1 abgebildet. Die Steuerparameter (PAR3 `#JP-CHECK-ONLY`,
PAR4 `#JP-DEL` als Y/N-Schalter), die VGUET/VGUETSPA-Filterung, die Anteils-Summenprüfung (=100 je Gruppe) und die
zehn Statistik-Zähler sind vollständig übernommen.
---
## 2. Funktionale Unterschiede
### 🔴 U1 — Kein In-Place-UPDATE (Upsert verloren) — **verhaltensrelevant**
- **Natural** `LOAD-MELEM` (Zeile 590–619): bei `#JP-DEL='N'` erst `C-MOD-GET`; wenn Satz existiert → **`C-MOD-UPD`**
(`#C-UPD-MELEM++`), sonst → `C-MOD-ADD` (`#C-INSRT-MELEM++`). Echter **Upsert** über den MELE-Schlüssel.
- **Java** `doWriteItems` (Zeile 74–83): ruft **immer** `saveWithAppendEntry(entity)` und zählt **immer**
`INSRT_MELEM`. Der UPDATE-Pfad ist bewusst weggelassen — Kommentar im Code:
*„Legacy code has logic for update when #JP-DEL = 'N', but it doesn't function properly due to
MultiTableEntryEntity not having a unique key outside vid."*
- **Konsequenz:** Ein erneuter Lauf **ohne** vorheriges Löschen erzeugt in Java **Duplikate** statt Änderungen.
Der Zähler `#C-UPD-MELEM` ist in Java strukturell immer `0`. Fachliche Absicherung: In der Praxis läuft der Job mit
Default `PAR4=Y` (Delete), wodurch vorher alles gelöscht wird und Insert-only korrekt ist — aber die
`#JP-DEL='N'`-Semantik ist **nicht** erhalten.
### 🟠 U2 — Delete-Fehlerbehandlung nicht implementiert
- **Natural** `DEL-MELEM` (Zeile 471–477): bei Löschfehler → `#L-BACKOUT := TRUE`, `#C-DELETE-MELEM-ERR++`,
Abbruch der laufenden Verarbeitung.
- **Java** `performDelete` (Zeile 257–267): `// TODO CSA Handle individual errors when deleting L.471`.
`DELETE_MELEM_ERR` wird nie hochgezählt, kein Backout bei fehlgeschlagenem Löschen.
- **Konsequenz:** Ein Teil-Löschfehler bleibt in Java unbemerkt; der Job läuft weiter und committet ggf. inkonsistent.
### 🟠 U3 — `numAdditionalAttri` 1 → 2 (bewusste Abweichung)
- **Natural** (Zeile 568): `C#ADDITIONAL-ATTRIBUTE-VALUES := 1` (mit Alt-Kommentar `#02 ??? := 3`).
- **Java** (Zeile 227): `setNumAdditionalAttri(2)` — Kommentar: *„Legacy code sets this to 1, but DB has it with 2"*.
- Absichtliche Daten-Korrektur, aber **eine bewusste inhaltliche Abweichung** vom Original.
### 🟡 U4 — Commit-/Transaktions-Kadenz
- **Natural** `ET-PROCESSING`: `END TRANSACTION` alle `#P-ET-MAX` Sätze bzw. am Ende; `BACKOUT` bei Check-Only/Backout.
- **Java**: Commit-Granularität kommt aus dem jBeret-Chunk/Batchlet-Framework; `doBeforeEndTransaction` macht nur den
Rollback bei Check-Only/Backout. Die feinkörnige `#P-ET-MAX`-Batchung existiert nicht mehr.
- **Konsequenz:** Endzustand (voller Commit bzw. voller Rollback) ist äquivalent, aber Zwischen-Commit-Punkte und damit
Restart-/Recovery-Verhalten unterscheiden sich. `isRestartable()=false` entschärft das.
### 🟡 U5 — Serialisierung der Zusatzattribute
- **Natural**: setzt gezielte Occurrences (`VAL-NUMERIC-ADD-ATTR(1)=Branche`, `(2)=Anteil`,
`VAL-ALFANUMERIC-ADD-ATTR(1)=Langtext`).
- **Java** `fillNewVGUETSPAEntry` (Zeile 234–248): baut einen gepackten String über 10 Slot-Paare
(18-stellig numerisch + 50-stellig alpha). Repräsentativ vermutlich deckungsgleich mit dem gepackten DB-Format, aber
**nicht trivial gleich** — sollte gegen echte DB-Werte verifiziert werden.
### 🟡 U6 — Header-Erkennung positional vs. inhaltlich
- **Natural**: Zeile 1 ist **immer** Header (`#L-HEADER-LINE`-Flag).
- **Java**: `rowMapper` erkennt Header **inhaltlich** (erstes Feld == `VGUET`/`VGUETSPA`) *und* `doProcessItem` nutzt
zusätzlich das positionale `headerLineExists`-Flag. Konvergiert in der Praxis, ist aber ein doppelter Mechanismus mit
theoretischem Abweichungspotenzial.
**Äquivalent bestätigt (keine Abweichung):** Parameter-Check (Y/N-Validierung + Fehlerslot), Client-/Branch-Validierung,
VGUET-vs-VGUETSPA (beide bauen **nur** SPA — Natural-`FILL-VGUET` ist auskommentiert), Check-Only-Backout,
Gruppenwechsel-Summenprüfung, Statistik-Ausgabe, End-/Error-Reporting.
---
## 3. DB-Wirkung (via API ermittelt)
| Seite | Ziel-Tabelle (API) | Zugriffsweg |
|---------|---------------------------------------------------------------------------|--------------------------------------------------------------------------------------|
| Natural | `VERSVW_ELEMENTOS` (READ), `VDB2-VERSIS_ELEMENTOS` (WRITE) | nur transitiv über Access-Layer `YELEMMN0` (`db-accesses?depth=3`, `via:"YELEMMN0"`) |
| Java | `multi_table_entry` (`MultiTableEntryEntity` → `@Table`, Mode `DECLARES`) | über `MultiTableEntryLogic` |
Beide adressieren dieselbe fachliche Entität (VERSIS-Multelem). Ein **direkter** Tabellen-für-Tabelle-Abgleich ist
aktuell nur manuell möglich (siehe Verbesserung V3).
---
## 4. Wie die agentic API bei dieser Analyse geholfen hat
1. **Struktur ohne Framework-Lesen rekonstruiert:** `callees` auf `MultiTableImportJob` lieferte sofort
`EXTENDS AbstractPurBatchJob`, `REFERENCES` auf die drei Step-Klassen und die `INJECTS`-Wiring — der Job→Step-Graph
war ohne Durchsuchen der Batch-Basisklassen sichtbar.
2. **Vollständige Aufruf-Hülle des Natural-Programms:** `call-tree` enumerierte die **46-Modul-Closure** inkl.
Access-Layer (`YELEMMN0`, `YMTABMN0`), Validierungs-Subprogramme (`USIX004N`, `ISI173N0`) und Batch-Monitoring
(`VBATCHN0/N1`) — die Grundlage, um jedes `CALLNAT` einer Java-Logic-Klasse zuzuordnen.
3. **DB-Zugriff durch Indirektion aufgelöst:** `db-accesses?depth=3` fand `VERSVW_ELEMENTOS`/`VDB2-VERSIS_ELEMENTOS`,
obwohl der Job **direkt** kein SQL enthält (`depth=0` = leer). Die Provenienz (`via:"YELEMMN0"`, `viaCopycode`,
`includedAt`) zeigte exakt, über welche Copycode-/Access-Layer-Kette der Zugriff läuft — das findet ein grep nicht.
4. **SQL-Text im Klartext:** `sql-statements?depth=3` lieferte die konkreten `SELECT … FROM VERSVW_ELEMENTOS …`.
5. **Subroutinen-Inventar für 1:1-Mapping:** `module_context.functions` gab die 13 Natural-Subroutinen, gegen die die
8 Java-Methoden gemappt wurden.
6. **Cross-Projekt-Suche:** `search/value?value=JX0034N0` bestätigte den Herkunfts-Anker im Java-Code.
---
## 5. Verbesserungsmöglichkeiten der statischen Analyse
### V1 — Cross-Projekt-„migrated-from"-Verknüpfung (größter Hebel)
Der Java-Code trägt den Anker (`PROGRAM_IDENTIFIER="JX0034N0"`, Kommentar `// JX0034N0.nat`), aber die API kennt **keine
projektübergreifende Kante** Java↔Natural. `search/value` in `upms` nach `MultiTableImportJob` = leer. Ein Enricher, der
solche Identifier/Kommentar-Marker indexiert und eine `MIGRATED_FROM`-Beziehung (pur-Modul → upms-Modul) exponiert,
würde einen Agenten direkt vom Java-Job zur Natural-Quelle springen lassen — genau der Sprung, den ich hier manuell
gebaut habe.
### V2 — `followWiring`-Call-Tree gegen CHA-Explosion absichern
`call-tree?followWiring=true&depth=6` auf `MultiTableImportJob` **lief in Timeout** (CHA-Over-Approximation der
INJECTS/REFERENCES-Fan-outs). Nötig: Default-Tiefenbegrenzung, ein „collapse over-approximation"-Flag oder gestreamte
Ausgabe, damit der wichtigste Java-Traversal-Modus nutzbar bleibt.
### V3 — DB-Wirkung über die Java-Logic-/Repository-Schicht propagieren
`db-accesses?depth=4` auf `MultiTableImportProcessingStep` = **leer**, obwohl der Step über
`MultiTableEntryLogic.saveWithAppendEntry` schreibt. Die Entity→Tabelle-Kante existiert (`multi_table_entry`,
`DECLARES`), aber es gibt keine READS/WRITES-Propagation Step→Logic→Repository→`@Table`. Würde der Java-Analyzer
Repository-/`save`/`delete`-Aufrufe zu READS/WRITES auf die Entity-Tabelle auflösen, wäre der DB-Effekt beider Seiten
**direkt** vergleichbar (Natural `VERSVW_ELEMENTOS` ↔ Java `multi_table_entry`).
### V4 — Semantische Diff-Unterstützung
Ein „side-effect summary" pro Modul (welche Kontext-/Zähler-Felder wo geschrieben werden — via `field_flow`) würde den
manuellen Abgleich der zehn Statistik-Zähler und Flags (`#L-BACKOUT`, `#SUMME-ANT`) automatisieren.
---
## 6. Gesamtbewertung
**MultiTableImportJob bildet JX0034N0 funktional weitgehend korrekt ab** — Kontrollfluss, Parameter-Semantik,
Validierungen, Filterung, Summenprüfung und Statistik stimmen überein. Es gibt jedoch **echte, teils bewusste
Abweichungen**, die dokumentiert gehören:
- **U1 (kein UPDATE / immer Insert)** und **U2 (fehlende Delete-Fehlerbehandlung)** sind die einzigen mit potenziell
fachlicher Auswirkung. Beide sind im Java-Code als bewusste Einschränkung bzw. TODO markiert und durch den
Default-Betrieb (`PAR4=Y` Delete-then-Insert) praktisch entschärft — aber die `#JP-DEL='N'`-Semantik ist **nicht**
vollständig migriert.
- **U3 (numAdditionalAttri=2)** ist eine absichtliche Daten-Korrektur gegenüber dem Original.
- **U4/U5/U6** sind Framework-/Repräsentations-Unterschiede ohne erwarteten Endzustands-Effekt, sollten aber gegen
Echtdaten (U5) verifiziert werden.

View File

@@ -184,6 +184,65 @@ wrong answer, found by the 2026-07-17 `VMULTMN4` audit.)*
external subroutines are a language feature this parser does not resolve — worth its own item if the
corpus ever needs it.)*
- [ ] **98. View aliases declared in a `LOCAL USING` data area are still reported as tables** (found
2026-07-27 while re-verifying item 95). Item 95's alias pre-scan is per-module, over the
copycode-expanded lines — but a `LOCAL USING` data area is a *separate* module, so a view declared
there is invisible to it. Live example: `YGEAGBNH.nat:2617` does `FIND (1) VDB2-VERSIS_GENAGREE`, and
the alias is declared in `src/manual/local_data_area/new/YGEAGVL1.lda`, not in `YGEAGBNH` — so
`db-accesses` still reports `VDB2-VERSIS_GENAGREE` instead of `VERSVW_GENAGREE`. It is the last such
row in the `WGEAGB0S` closure (58 → 1), but 348 view aliases are declared corpus-wide, so the class is
probably wider than this one instance. Not a parser fix: `parseDataArea` already emits
`DATA_STRUCTURE --USES_TYPE--> DB_TABLE` for a `.lda`/`.pda` view, so the graph holds the link. Likely
shape: a post-ingest enricher redirecting a `DB_TABLE` whose name matches a view-alias
`DATA_STRUCTURE` onto the table that structure `USES_TYPE`, then reaping the orphaned alias node.
Sizing it properly wants a `GET /db-tables` listing endpoint, which does not exist yet.
- [x] **97. `call-tree` leaked the dynamic-call placeholder a manual override only hides** (2026-07-27,
third WGEAGB0S deep API audit). `call-tree` for `WGEAGB0S` listed `#GETSHORT-MODUL` — a *variable*
(`YGEAGGNH.nat:443`, `CALLNAT #GETSHORT-MODUL`) — as a `MODULE` in the closure, while `callees` for the
same module correctly reported only the resolved target `YGEAGGN0`. Cause: a manual override does not
delete the marker edge to the variable-named placeholder, it sets `manualHidden = true` and relies on
the read queries to suppress it (`DELETE_DYNAMIC_CALLNAT_PLACEHOLDER_EDGES`). `callees`/`callers` filter
it; the BFS behind `call-tree` did not — `MODULE_HOP_OUT`/`MODULE_HOP_OUT_WIRING` did not even bind the
relationship. Everything driven by that BFS inherited the pollution (`graph`, `db-accesses?depth=N`,
`sql-statements?depth=N`). **Done:** both hop queries bind `r` and apply
`coalesce(r.manualHidden, false) = false`, matching `callees`/`callers`. Characterization IT
`DynamicCallOverrideIT#callTreeHonoursTheOverrideLikeCallees` (placeholder present → override → absent →
reset → present again); verified red against the pre-fix query.
- [x] **96. Natural `UPDATE(ref.)` / `DELETE(ref.)` were dropped, hiding every access layer's write path**
(2026-07-27, third WGEAGB0S deep API audit). 34 statement sites across 13 of the 65 modules in the
`WGEAGB0S` closure — every `Y****MN0` CRUD module — produced no `WRITES` edge, so `db-accesses` showed
them as read-only plus a single `STORE`. Cause: `DB_WRITE`'s `(?!\()` guard (added by item 90 to stop a
phantom `(OLD.)` table) suppressed the phantom but never recovered the real table, and `DELETE` was only
handled in its SQL `DELETE FROM` form. **Done:** new `DB_WRITE_BY_REF` plus a pre-scan mapping each
`FIND`/`READ` statement label to its (alias-resolved) table; an unresolvable reference still records
nothing, so item 90's no-phantom guarantee holds — its two tests stay green unchanged and now serve as
the negative cases. Shared with `NaturalCoarseScanner` so tier-1 and deep agree. Tests:
`NaturalParserTest#updateAndDeleteByReferenceResolveToTheEnclosingLoopTable`,
`#byReferenceWriteWithoutAResolvableLoopNamesNoTable`, `NaturalCoarseScannerTest`, IT
`NaturalViewAliasDbAccessIT`. A label may also introduce a SQL `SELECT` loop rather than a `FIND`
(`YELEMMN0`, `YMULTMN0` hold their record that way) — those resolve through the `FROM` clause;
`#byReferenceWriteResolvesThroughALabelledSelectLoop`. **Verified on live `upms`:** all 34 by-reference
sites in the `WGEAGB0S` closure now recorded, 0 missing.
- [x] **95. Natural view aliases were reported as DB tables** (2026-07-27, third WGEAGB0S deep API audit).
`db-accesses` named the Natural *view variable* of a DML statement, not the DDM it is declared over:
58 rows across 13 of the 65 modules in the `WGEAGB0S` closure, 32 alias names standing in for 20 real
tables. Worst effects — the generator's boilerplate alias `NEXT-VIEW` became **one** `DB_TABLE` node
shared by 11 modules meaning 11 different tables (and reporting no columns), and 11
`VDB2-*-VLOG` aliases hid every write to `VERSVW_LOGFILE`, so "who writes the audit log?" answered
nothing. Cause: `VIEW OF` was only recognised in `parseDataArea` (`.pda` files); `parseModule` — which
parses every `.nat` — never built an alias map, and the DML branches passed the operand verbatim to
`dbTable(...)`. **Done:** `VIEW_DECL` pre-scan over the copycode-expanded lines feeds
`resolveViewAlias` into the `DB_WRITE`/`DB_READ` branches; `dbTable()` now upper-cases (Natural is
case-insensitive and `DB_TABLE` merges on the name). Shared with `NaturalCoarseScanner` so a shallow
and a FULL module cannot report different names for the same statement. Tests:
`NaturalParserTest#viewAliasResolvesToTheUnderlyingTable`, `NaturalCoarseScannerTest`, IT
`NaturalViewAliasDbAccessIT` (incl. the same alias in two modules resolving to two tables).
**Verified on live `upms`:** alias rows in the `WGEAGB0S` closure 58 → 1, the phantom `NEXT-VIEW` node
gone, `VERSVW_LOGFILE` reachable for the first time. The remaining row is the cross-file case, item 98.
- [x] **94. `call-tree` no longer enumerates paths; `followWiring` usable again** (2026-07-20, JX0034N0 ↔
MultiTableImportJob functional comparison). `call-tree?followWiring=true` timed out on `pur` at
`depth ≥ 2` (>120s; depth 1 already took 5.3s), which made the Java wiring closure unobtainable.