Bug fixes
This commit is contained in:
66
README.md
66
README.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
""";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} → {@code NEXT-VIEW} →
|
||||
* {@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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ..."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user