Files
agenticCode/ac-parser-natural/src/test/java/com/agenticcode/parsernatural/NaturalParserTest.java
Ingo Schnabel d4b14a3b6d Bug fixes
2026-07-27 16:20:42 +02:00

1699 lines
78 KiB
Java

package com.agenticcode.parsernatural;
import com.agenticcode.parsercore.ast.model.AstEdge;
import com.agenticcode.parsercore.ast.model.AstNode;
import com.agenticcode.parsercore.ast.model.EdgeType;
import com.agenticcode.parsercore.ast.model.NodeType;
import com.agenticcode.parsercore.ast.spi.LanguageParser;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class NaturalParserTest {
private final NaturalParser parser = new NaturalParser();
private static AstNode findNode(LanguageParser.ParseResult result, NodeType type, String name) {
return result.nodes().stream()
.filter(n -> n.type() == type && n.name().equals(name))
.findFirst()
.orElseThrow(() -> new AssertionError("No node " + type + " " + name));
}
private static boolean hasNode(LanguageParser.ParseResult result, NodeType type, String name) {
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())
&& result.nodes().stream().anyMatch(n -> n.id().equals(e.targetId()) && n.name().equals(targetName) && n.type() == targetType));
}
private static String readFixture(String name) throws IOException {
Path path = Path.of("src/test/resources/fixtures/natural", name);
return Files.readString(path, StandardCharsets.UTF_8);
}
@Test
void language() {
assertEquals("natural", parser.language());
}
@Test
void capturesModuleDescriptionFromBanner() {
String content = """
**SAG GENERATOR: VERSIS-XML-IF
**SAG TITLE: XML Interface for BGEAGFN
* Title : ignored once SAG TITLE is found
DEFINE DATA LOCAL
01 #X (A1)
END-DEFINE
END
""";
LanguageParser.ParseResult result = parser.parse("WGEAGB0S.nat", content);
AstNode module = result.nodes().stream()
.filter(n -> n.type() == NodeType.MODULE)
.findFirst().orElseThrow();
assertNotNull(module.properties());
assertEquals("XML Interface for BGEAGFN", module.properties().get("description"));
}
@Test
void fallsBackToTitleHeaderWhenNoSagBanner() {
String content = """
* Title : XML/web maintenance interface
* Function : does stuff
DEFINE DATA LOCAL
01 #X (A1)
END-DEFINE
END
""";
LanguageParser.ParseResult result = parser.parse("W-MNT-N0.nat", content);
AstNode module = result.nodes().stream()
.filter(n -> n.type() == NodeType.MODULE)
.findFirst().orElseThrow();
assertNotNull(module.properties());
assertEquals("XML/web maintenance interface", module.properties().get("description"));
}
@Test
void parsesSampleModule() throws IOException {
String content = readFixture("YADDRBN0_SAMPLE.nat");
LanguageParser.ParseResult result = parser.parse("YADDRBN0_SAMPLE.nat", content);
AstNode module = result.nodes().stream()
.filter(n -> n.type() == NodeType.MODULE)
.findFirst().orElseThrow();
assertEquals("YADDRBN0_SAMPLE", module.name());
// includes (PARAMETER USING / LOCAL USING)
assertTrue(hasNode(result, NodeType.DATA_STRUCTURE, "YADDRKEY"));
assertTrue(hasNode(result, NodeType.DATA_STRUCTURE, "CDRANGEA"));
assertTrue(hasEdge(result, EdgeType.INCLUDES, module, "YADDRKEY", NodeType.DATA_STRUCTURE));
// constants
AstNode alphaTrue = findNode(result, NodeType.CONSTANT, "#ALPHA-TRUE");
assertEquals("'1'", alphaTrue.value());
AstNode maxAttempts = findNode(result, NodeType.CONSTANT, "#MAX-ATTEMPTS");
assertEquals("3", maxAttempts.value());
// REDEFINE group with nested fields
AstNode redefineGroup = findNode(result, NodeType.DATA_STRUCTURE, "#S_ADDRESS-SP");
AstNode grp = findNode(result, NodeType.DATA_STRUCTURE, "#S_ADDRESS-SP-GRP");
assertTrue(hasEdge(result, EdgeType.CONTAINS, redefineGroup, "#S_ADDRESS-SP-GRP", NodeType.DATA_STRUCTURE));
assertTrue(hasEdge(result, EdgeType.CONTAINS, grp, "COD-EMISOR", NodeType.VARIABLE));
// subroutines
AstNode initializations = findNode(result, NodeType.FUNCTION, "INITIALIZATIONS");
AstNode rAddressSp = findNode(result, NodeType.FUNCTION, "R-ADDRESS_SP");
assertTrue(hasEdge(result, EdgeType.CONTAINS, module, "INITIALIZATIONS", NodeType.FUNCTION));
// PERFORM (internal call) and CALLNAT (external call)
assertTrue(hasEdge(result, EdgeType.CALLS, module, "INITIALIZATIONS", NodeType.FUNCTION));
assertTrue(hasEdge(result, EdgeType.CALLS, module, "CDRANGE", NodeType.MODULE));
assertTrue(hasEdge(result, EdgeType.CALLS, initializations, "R-ADDRESS_SP", NodeType.FUNCTION));
// SQL SELECT ... FROM <view> and UPDATE <view>
assertTrue(hasEdge(result, EdgeType.READS, rAddressSp, "VERSVW_ADDRESS", NodeType.DB_TABLE));
assertTrue(hasEdge(result, EdgeType.WRITES, rAddressSp, "VERSVW_ADDRESS", NodeType.DB_TABLE));
// DB_ACCESS nodes for SELECT and UPDATE, with raw statement text and CONTAINS/USES_TYPE edges
AstNode selectAccess = result.nodes().stream()
.filter(n -> n.type() == NodeType.DB_ACCESS && "SELECT".equals(n.dataType()))
.findFirst().orElseThrow(() -> new AssertionError("No SELECT DB_ACCESS node"));
assertTrue(selectAccess.value() != null && selectAccess.value().contains("SELECT *"));
assertTrue(selectAccess.value() != null && selectAccess.value().contains("END-SELECT"));
assertTrue(hasEdge(result, EdgeType.CONTAINS, rAddressSp, selectAccess.name(), NodeType.DB_ACCESS));
assertTrue(hasEdge(result, EdgeType.USES_TYPE, selectAccess, "VERSVW_ADDRESS", NodeType.DB_TABLE));
assertTrue(hasEdge(result, EdgeType.USES_TYPE, selectAccess, "VDB2-VERSIS_ADDRESS", NodeType.DATA_STRUCTURE));
AstNode updateAccess = result.nodes().stream()
.filter(n -> n.type() == NodeType.DB_ACCESS && "WRITE".equals(n.dataType()))
.findFirst().orElseThrow(() -> new AssertionError("No WRITE DB_ACCESS node"));
assertEquals("UPDATE VERSVW_ADDRESS", updateAccess.value());
assertTrue(hasEdge(result, EdgeType.CONTAINS, rAddressSp, updateAccess.name(), NodeType.DB_ACCESS));
assertTrue(hasEdge(result, EdgeType.USES_TYPE, updateAccess, "VERSVW_ADDRESS", NodeType.DB_TABLE));
// ASSIGN #COUNTER = #COUNTER + 1 -> reads and writes #COUNTER
AstNode counter = findNode(result, NodeType.VARIABLE, "#COUNTER");
assertTrue(hasEdge(result, EdgeType.READS, rAddressSp, counter.name(), NodeType.VARIABLE));
assertTrue(hasEdge(result, EdgeType.WRITES, rAddressSp, counter.name(), NodeType.VARIABLE));
// MOVE #MAX-ATTEMPTS TO #COUNTER -> reads #MAX-ATTEMPTS, writes #COUNTER
assertTrue(hasEdge(result, EdgeType.READS, rAddressSp, maxAttempts.name(), NodeType.CONSTANT));
// Nested IF/FOR/IF control flow inside INITIALIZATIONS
AstNode outerIf = result.nodes().stream()
.filter(n -> n.type() == NodeType.CONTROL_FLOW && "IF".equals(n.dataType())
&& "#COUNTER < #MAX-ATTEMPTS".equals(n.value()))
.findFirst().orElseThrow(() -> new AssertionError("No outer IF block"));
AstNode forBlock = result.nodes().stream()
.filter(n -> n.type() == NodeType.CONTROL_FLOW && "FOR".equals(n.dataType()))
.findFirst().orElseThrow(() -> new AssertionError("No FOR block"));
AstNode innerIf = result.nodes().stream()
.filter(n -> n.type() == NodeType.CONTROL_FLOW && "IF".equals(n.dataType())
&& "#COUNTER = #MAX-ATTEMPTS".equals(n.value()))
.findFirst().orElseThrow(() -> new AssertionError("No inner IF block"));
assertTrue(result.edges().stream().anyMatch(e -> e.type() == EdgeType.CONTAINS
&& e.sourceId().equals(initializations.id()) && e.targetId().equals(outerIf.id())));
assertTrue(result.edges().stream().anyMatch(e -> e.type() == EdgeType.CONTAINS
&& e.sourceId().equals(outerIf.id()) && e.targetId().equals(forBlock.id())));
assertTrue(result.edges().stream().anyMatch(e -> e.type() == EdgeType.CONTAINS
&& e.sourceId().equals(forBlock.id()) && e.targetId().equals(innerIf.id())));
assertEquals("#COUNTER = 1 TO #MAX-ATTEMPTS", forBlock.value());
assertTrue(outerIf.startLine() < forBlock.startLine());
assertTrue(forBlock.endLine() < outerIf.endLine());
}
@Test
void parsesLdaWithMultipleTopLevelStructures() throws IOException {
String content = readFixture("YFRAML01_SAMPLE.lda");
LanguageParser.ParseResult result = parser.parse("YFRAML01_SAMPLE.lda", content);
AstNode yframl01 = findNode(result, NodeType.DATA_STRUCTURE, "YFRAML01");
AstNode debug = findNode(result, NodeType.DATA_STRUCTURE, "YFRAML01-DEBUG");
// top-level structures have no CONTAINS parent
assertTrue(result.edges().stream().noneMatch(e -> e.type() == EdgeType.CONTAINS && e.targetId().equals(yframl01.id())));
assertTrue(result.edges().stream().noneMatch(e -> e.type() == EdgeType.CONTAINS && e.targetId().equals(debug.id())));
// CONST<...> -> CONSTANT with type/length prefix as dataType
AstNode retSuccess = findNode(result, NodeType.CONSTANT, "C-RET-SUCCESS");
assertEquals("N4", retSuccess.dataType());
assertEquals("0", retSuccess.value());
AstNode wildcard = findNode(result, NodeType.CONSTANT, "C-WILDCARD");
assertEquals("'*'", wildcard.value());
assertTrue(hasEdge(result, EdgeType.CONTAINS, yframl01, "C-RET-SUCCESS", NodeType.CONSTANT));
// INIT<...> -> VARIABLE (no CONST value), type-only prefix ("L") as dataType
AstNode debugErr = findNode(result, NodeType.VARIABLE, "L-DEBUG-ERR");
assertEquals("L", debugErr.dataType());
assertNull(debugErr.value());
assertTrue(hasEdge(result, EdgeType.CONTAINS, debug, "L-DEBUG-ERR", NodeType.VARIABLE));
AstNode debugProg = findNode(result, NodeType.VARIABLE, "L-DEBUG-PROG");
assertEquals("A8", debugProg.dataType());
}
@Test
void fieldFormatAttachedWithoutSpaceIsSplitFromName() {
// A DEFINE DATA field may glue its format to the name with no space, e.g. `01 KEY(A1/1:3,1:V)`
// or `01 #DELIMITER(A1)`. The name must be KEY / #DELIMITER (not the whole token), with the
// format captured as dataType — otherwise the field is unsearchable and mistyped as a group.
String content = """
DEFINE DATA LOCAL
01 KEY(A1/1:3,1:V)
01 #DELIMITER(A1)
01 #SPACED (I4)
END-DEFINE
END
""";
LanguageParser.ParseResult result = parser.parse("GLUED.nat", content);
AstNode key = findNode(result, NodeType.VARIABLE, "KEY");
assertEquals("A1/1:3,1:V", key.dataType());
AstNode delim = findNode(result, NodeType.VARIABLE, "#DELIMITER");
assertEquals("A1", delim.dataType());
// The spaced form still works (regression guard).
AstNode spaced = findNode(result, NodeType.VARIABLE, "#SPACED");
assertEquals("I4", spaced.dataType());
// The glued token must NOT survive as a node name.
assertTrue(result.nodes().stream().noneMatch(n -> "KEY(A1/1:3,1:V)".equals(n.name())),
"glued name+format must not be stored as a single identifier");
}
@Test
void decideValueAssignmentsCarryDispatchGuard() {
// A DECIDE ON VALUE OF <subject> that assigns a program name per branch is a dispatch table:
// each WRITES edge must carry the controlling guard (whenField/whenValue) so the
// object-type → program mapping is recoverable (P1-z).
String content = """
DEFINE DATA LOCAL
01 #P-OBJECT-TYPE (A32)
01 #P-CALLED-PROG (A8)
END-DEFINE
DEFINE SUBROUTINE GET-PROG-NAME
DECIDE ON FIRST VALUE OF #P-OBJECT-TYPE
VALUE 'genagree'
IF #L-LIST
#P-CALLED-PROG := 'WGEAGB0S'
ELSE
#P-CALLED-PROG := 'WGEAGX0S'
END-IF
VALUE 'Address'
#P-CALLED-PROG := 'WADDRB0S'
NONE VALUE
IGNORE
END-DECIDE
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("KDWWIFN0_SAMPLE.nat", content);
AstNode prog = findNode(result, NodeType.VARIABLE, "#P-CALLED-PROG");
// Each guarded assignment as "whenField|whenValue->program" (genagree maps to BOTH the list
// and detail programs; Address to WADDRB0S; the NONE/IGNORE branch contributes nothing).
List<String> routes = new ArrayList<>();
for (var e : result.edges()) {
if (e.type() != EdgeType.WRITES || !e.targetId().equals(prog.id())) {
continue;
}
Map<String, String> props = e.properties();
if (props == null || props.get("whenValue") == null) {
continue;
}
String value = e.value() == null ? "" : e.value().replace("'", "").trim();
routes.add(props.get("whenField") + "|" + props.get("whenValue") + "->" + value);
}
assertEquals(3, routes.size(), "all three branch assignments must be guarded");
assertTrue(routes.contains("#P-OBJECT-TYPE|genagree->WGEAGB0S"));
assertTrue(routes.contains("#P-OBJECT-TYPE|genagree->WGEAGX0S"));
assertTrue(routes.contains("#P-OBJECT-TYPE|Address->WADDRB0S"));
}
@Test
void multiValueBranchDropsBlankAlternativeFromGuard() {
// A `VALUE 'X', ' '` branch (real literal + a Natural "also match blank" catch) must yield a
// clean guard value 'X', not the artifact 'X, ' with the blank swallowed (roadmap #55). A branch
// with two real literals stays comma-joined.
String content = """
DEFINE DATA LOCAL
01 #P-DESC-NAME (A32)
01 #P-KEY (A8)
END-DEFINE
DEFINE SUBROUTINE ROUTE
DECIDE ON FIRST VALUE OF #P-DESC-NAME
VALUE 'GENAGREE-WOUT-SP', ' '
#P-KEY := 'A'
VALUE 'PHON-A', 'PHON-B'
#P-KEY := 'B'
NONE VALUE
IGNORE
END-DECIDE
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("ROUTE_SAMPLE.nat", content);
AstNode key = findNode(result, NodeType.VARIABLE, "#P-KEY");
List<String> guards = new ArrayList<>();
for (var e : result.edges()) {
if (e.type() != EdgeType.WRITES || !e.targetId().equals(key.id())) {
continue;
}
Map<String, String> props = e.properties();
if (props != null && props.get("whenValue") != null) {
guards.add(props.get("whenValue"));
}
}
// The blank alternative is dropped (clean guard), the two-real-literal branch stays joined.
assertTrue(guards.contains("GENAGREE-WOUT-SP"), "blank alternative must be dropped: " + guards);
assertFalse(guards.contains("GENAGREE-WOUT-SP, "), "no trailing-blank artifact: " + guards);
assertTrue(guards.contains("PHON-A, PHON-B"), "two real literals stay comma-joined: " + guards);
}
@Test
void doubleHashFieldNamesAreNotTruncated() {
// ##-prefixed names (message/global fields, e.g. in CDPDA-M) must be captured whole, not
// truncated to "#" by the data-area field regex (P1-y).
String content = """
* synthetic copybook with double-hash fields
1MSG-INFO
A 79 2##MSG
N 4 2##MSG-NR
""";
LanguageParser.ParseResult result = parser.parse("MSGDH.pda", content);
assertTrue(hasNode(result, NodeType.VARIABLE, "##MSG"), "##MSG must be parsed whole");
assertTrue(hasNode(result, NodeType.VARIABLE, "##MSG-NR"), "##MSG-NR must be parsed whole");
assertFalse(hasNode(result, NodeType.VARIABLE, "#"), "no field should be truncated to '#'");
}
@Test
void parsesPdaWithRedefineGroup() throws IOException {
String content = readFixture("WGEAGL01_SAMPLE.pda");
LanguageParser.ParseResult result = parser.parse("WGEAGL01_SAMPLE.pda", content);
// The group name (WGEAGL01) differs from the file name (WGEAGL01_SAMPLE), so the area is
// wrapped in a file-named root DATA_STRUCTURE for USING resolution (P1-s); the group nests
// under it, and the wrapper is the actual top-level node.
AstNode fileRoot = findNode(result, NodeType.DATA_STRUCTURE, "WGEAGL01_SAMPLE");
AstNode root = findNode(result, NodeType.DATA_STRUCTURE, "WGEAGL01");
assertTrue(result.edges().stream().noneMatch(e -> e.type() == EdgeType.CONTAINS && e.targetId().equals(fileRoot.id())));
assertTrue(hasEdge(result, EdgeType.CONTAINS, fileRoot, "WGEAGL01", NodeType.DATA_STRUCTURE));
// plain field and its REDEFINE group are siblings under the WGEAGL01 group, same name, different types
AstNode addParmField = findNode(result, NodeType.VARIABLE, "#P-ADD-PARM");
assertEquals("A250", addParmField.dataType());
AstNode redefineGroup = findNode(result, NodeType.DATA_STRUCTURE, "#P-ADD-PARM");
assertTrue(hasEdge(result, EdgeType.CONTAINS, root, addParmField.name(), NodeType.VARIABLE));
assertTrue(hasEdge(result, EdgeType.CONTAINS, root, redefineGroup.name(), NodeType.DATA_STRUCTURE));
// level-3 fields nest under the REDEFINE group
AstNode codEmisor = findNode(result, NodeType.VARIABLE, "COD-EMISOR");
assertEquals("A8", codEmisor.dataType());
assertTrue(hasEdge(result, EdgeType.CONTAINS, redefineGroup, "COD-EMISOR", NodeType.VARIABLE));
AstNode datStart = findNode(result, NodeType.VARIABLE, "DAT-START");
assertEquals("N8", datStart.dataType());
}
@Test
void bareAssignWithoutKeywordIsRecognized() {
String content = """
DEFINE DATA LOCAL
01 #SOURCE (A10)
01 #TARGET (A10)
END-DEFINE
DEFINE SUBROUTINE SET-TARGET
#TARGET := #SOURCE
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("BARE_ASSIGN_SAMPLE.nat", content);
AstNode setTarget = findNode(result, NodeType.FUNCTION, "SET-TARGET");
AstNode source = findNode(result, NodeType.VARIABLE, "#SOURCE");
AstNode target = findNode(result, NodeType.VARIABLE, "#TARGET");
assertTrue(hasEdge(result, EdgeType.READS, setTarget, source.name(), NodeType.VARIABLE));
assertTrue(hasEdge(result, EdgeType.WRITES, setTarget, target.name(), NodeType.VARIABLE));
}
@Test
void qualifiedAssignTargetMatchesUnqualifiedDeclaredField() {
String content = """
DEFINE DATA LOCAL
01 #SOURCE (A10)
01 SORT-KEY (A32)
END-DEFINE
DEFINE SUBROUTINE SET-SORT-KEY
STRUCT.SORT-KEY := #SOURCE
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("QUALIFIED_ASSIGN_SAMPLE.nat", content);
AstNode setSortKey = findNode(result, NodeType.FUNCTION, "SET-SORT-KEY");
AstNode sortKey = findNode(result, NodeType.VARIABLE, "SORT-KEY");
assertTrue(hasEdge(result, EdgeType.WRITES, setSortKey, sortKey.name(), NodeType.VARIABLE));
}
@Test
void addStatementReadsAndWritesAccumulator() {
String content = """
DEFINE DATA LOCAL
01 #COUNT (I4)
END-DEFINE
DEFINE SUBROUTINE INCREMENT
ADD 1 TO #COUNT
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("ADD_SAMPLE.nat", content);
AstNode increment = findNode(result, NodeType.FUNCTION, "INCREMENT");
AstNode count = findNode(result, NodeType.VARIABLE, "#COUNT");
assertTrue(hasEdge(result, EdgeType.READS, increment, count.name(), NodeType.VARIABLE));
assertTrue(hasEdge(result, EdgeType.WRITES, increment, count.name(), NodeType.VARIABLE));
}
@Test
void addStatementWithGivingWritesOnlyTheTarget() {
String content = """
DEFINE DATA LOCAL
01 #A (I4)
01 #B (I4)
01 #SUM (I4)
END-DEFINE
DEFINE SUBROUTINE SUM-VALUES
ADD #A #B GIVING #SUM
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("ADD_GIVING_SAMPLE.nat", content);
AstNode sumValues = findNode(result, NodeType.FUNCTION, "SUM-VALUES");
AstNode a = findNode(result, NodeType.VARIABLE, "#A");
AstNode b = findNode(result, NodeType.VARIABLE, "#B");
AstNode sum = findNode(result, NodeType.VARIABLE, "#SUM");
assertTrue(hasEdge(result, EdgeType.READS, sumValues, a.name(), NodeType.VARIABLE));
assertTrue(hasEdge(result, EdgeType.READS, sumValues, b.name(), NodeType.VARIABLE));
assertTrue(hasEdge(result, EdgeType.WRITES, sumValues, sum.name(), NodeType.VARIABLE));
assertFalse(hasEdge(result, EdgeType.READS, sumValues, sum.name(), NodeType.VARIABLE));
}
@Test
void subtractStatementWithGivingReadsMinuendAndSubtrahendWritesGivingTarget() {
String content = """
DEFINE DATA LOCAL
01 #A (I4)
01 #B (I4)
01 #DIFF (I4)
END-DEFINE
DEFINE SUBROUTINE COMPUTE-DIFF
SUBTRACT #B FROM #A GIVING #DIFF
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("SUBTRACT_GIVING_SAMPLE.nat", content);
AstNode computeDiff = findNode(result, NodeType.FUNCTION, "COMPUTE-DIFF");
AstNode a = findNode(result, NodeType.VARIABLE, "#A");
AstNode b = findNode(result, NodeType.VARIABLE, "#B");
AstNode diff = findNode(result, NodeType.VARIABLE, "#DIFF");
assertTrue(hasEdge(result, EdgeType.READS, computeDiff, a.name(), NodeType.VARIABLE));
assertTrue(hasEdge(result, EdgeType.READS, computeDiff, b.name(), NodeType.VARIABLE));
assertTrue(hasEdge(result, EdgeType.WRITES, computeDiff, diff.name(), NodeType.VARIABLE));
assertFalse(hasEdge(result, EdgeType.WRITES, computeDiff, a.name(), NodeType.VARIABLE));
}
@Test
void subtractStatementWithoutGivingReadsAndWritesTheMinuend() {
String content = """
DEFINE DATA LOCAL
01 #A (I4)
01 #B (I4)
END-DEFINE
DEFINE SUBROUTINE DECREMENT
SUBTRACT #B FROM #A
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("SUBTRACT_SAMPLE.nat", content);
AstNode decrement = findNode(result, NodeType.FUNCTION, "DECREMENT");
AstNode a = findNode(result, NodeType.VARIABLE, "#A");
AstNode b = findNode(result, NodeType.VARIABLE, "#B");
assertTrue(hasEdge(result, EdgeType.READS, decrement, b.name(), NodeType.VARIABLE));
assertTrue(hasEdge(result, EdgeType.READS, decrement, a.name(), NodeType.VARIABLE));
assertTrue(hasEdge(result, EdgeType.WRITES, decrement, a.name(), NodeType.VARIABLE));
}
@Test
void multiplyStatementWithoutGivingReadsAndWritesOperand1() {
String content = """
DEFINE DATA LOCAL
01 #A (I4)
01 #FACTOR (I4)
END-DEFINE
DEFINE SUBROUTINE SCALE
MULTIPLY #A BY #FACTOR
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("MULTIPLY_SAMPLE.nat", content);
AstNode scale = findNode(result, NodeType.FUNCTION, "SCALE");
AstNode a = findNode(result, NodeType.VARIABLE, "#A");
AstNode factor = findNode(result, NodeType.VARIABLE, "#FACTOR");
assertTrue(hasEdge(result, EdgeType.READS, scale, a.name(), NodeType.VARIABLE));
assertTrue(hasEdge(result, EdgeType.WRITES, scale, a.name(), NodeType.VARIABLE));
assertTrue(hasEdge(result, EdgeType.READS, scale, factor.name(), NodeType.VARIABLE));
}
@Test
void multiplyStatementWithGivingWritesOnlyTheTarget() {
String content = """
DEFINE DATA LOCAL
01 #A (I4)
01 #FACTOR (I4)
01 #RESULT (I4)
END-DEFINE
DEFINE SUBROUTINE SCALE-INTO-RESULT
MULTIPLY #A BY #FACTOR GIVING #RESULT
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("MULTIPLY_GIVING_SAMPLE.nat", content);
AstNode scale = findNode(result, NodeType.FUNCTION, "SCALE-INTO-RESULT");
AstNode a = findNode(result, NodeType.VARIABLE, "#A");
AstNode factor = findNode(result, NodeType.VARIABLE, "#FACTOR");
AstNode resultVar = findNode(result, NodeType.VARIABLE, "#RESULT");
assertTrue(hasEdge(result, EdgeType.READS, scale, a.name(), NodeType.VARIABLE));
assertTrue(hasEdge(result, EdgeType.READS, scale, factor.name(), NodeType.VARIABLE));
assertTrue(hasEdge(result, EdgeType.WRITES, scale, resultVar.name(), NodeType.VARIABLE));
assertFalse(hasEdge(result, EdgeType.WRITES, scale, a.name(), NodeType.VARIABLE));
}
@Test
void divideStatementWithGivingAndRemainderWritesBothTargets() {
String content = """
DEFINE DATA LOCAL
01 #DIVISOR (I4)
01 #DIVIDEND (I4)
01 #QUOTIENT (I4)
01 #REMAINDER (I4)
END-DEFINE
DEFINE SUBROUTINE SPLIT
DIVIDE #DIVISOR INTO #DIVIDEND GIVING #QUOTIENT REMAINDER #REMAINDER
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("DIVIDE_SAMPLE.nat", content);
AstNode split = findNode(result, NodeType.FUNCTION, "SPLIT");
AstNode divisor = findNode(result, NodeType.VARIABLE, "#DIVISOR");
AstNode dividend = findNode(result, NodeType.VARIABLE, "#DIVIDEND");
AstNode quotient = findNode(result, NodeType.VARIABLE, "#QUOTIENT");
AstNode remainder = findNode(result, NodeType.VARIABLE, "#REMAINDER");
assertTrue(hasEdge(result, EdgeType.READS, split, divisor.name(), NodeType.VARIABLE));
assertTrue(hasEdge(result, EdgeType.READS, split, dividend.name(), NodeType.VARIABLE));
assertTrue(hasEdge(result, EdgeType.WRITES, split, quotient.name(), NodeType.VARIABLE));
assertTrue(hasEdge(result, EdgeType.WRITES, split, remainder.name(), NodeType.VARIABLE));
assertFalse(hasEdge(result, EdgeType.WRITES, split, dividend.name(), NodeType.VARIABLE));
}
@Test
void divideStatementWithoutGivingReadsAndWritesTheDividend() {
String content = """
DEFINE DATA LOCAL
01 #DIVISOR (I4)
01 #DIVIDEND (I4)
END-DEFINE
DEFINE SUBROUTINE HALVE
DIVIDE #DIVISOR INTO #DIVIDEND
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("DIVIDE_NO_GIVING_SAMPLE.nat", content);
AstNode halve = findNode(result, NodeType.FUNCTION, "HALVE");
AstNode divisor = findNode(result, NodeType.VARIABLE, "#DIVISOR");
AstNode dividend = findNode(result, NodeType.VARIABLE, "#DIVIDEND");
assertTrue(hasEdge(result, EdgeType.READS, halve, divisor.name(), NodeType.VARIABLE));
assertTrue(hasEdge(result, EdgeType.READS, halve, dividend.name(), NodeType.VARIABLE));
assertTrue(hasEdge(result, EdgeType.WRITES, halve, dividend.name(), NodeType.VARIABLE));
}
@Test
void decideForIsRecognizedAsControlFlowBlock() {
String content = """
DEFINE DATA LOCAL
01 #COUNTER (I4)
01 #STATUS (A1)
END-DEFINE
DEFINE SUBROUTINE CLASSIFY
DECIDE FOR FIRST CONDITION
WHEN #COUNTER = 0
#STATUS := 'Z'
WHEN NONE
#STATUS := 'N'
END-DECIDE
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("DECIDE_SAMPLE.nat", content);
AstNode classify = findNode(result, NodeType.FUNCTION, "CLASSIFY");
AstNode status = findNode(result, NodeType.VARIABLE, "#STATUS");
AstNode decideBlock = result.nodes().stream()
.filter(n -> n.type() == NodeType.CONTROL_FLOW && "DECIDE".equals(n.dataType()))
.findFirst().orElseThrow(() -> new AssertionError("No DECIDE block"));
assertTrue(decideBlock.value() != null && decideBlock.value().startsWith("DECIDE FOR FIRST CONDITION"));
assertTrue(hasEdge(result, EdgeType.CONTAINS, classify, decideBlock.name(), NodeType.CONTROL_FLOW));
// Statements inside the DECIDE block still produce normal WRITES edges
assertTrue(hasEdge(result, EdgeType.WRITES, classify, status.name(), NodeType.VARIABLE));
}
@Test
void qualifiedFieldOfIncludedDataAreaResolvesToPlaceholderUnderThatStructure() {
String content = """
DEFINE DATA
PARAMETER USING CDBRPDA
LOCAL
01 #COMPARE-DATA
02 COMPARE
03 SORT-KEY (A32)
END-DEFINE
MOVE 'X' TO CDBRPDA.SORT-KEY
END
""";
LanguageParser.ParseResult result = parser.parse("SAMPLE.nat", content);
AstNode module = findNode(result, NodeType.MODULE, "SAMPLE");
AstNode cdbrpda = findNode(result, NodeType.DATA_STRUCTURE, "CDBRPDA");
assertEquals("", cdbrpda.sourceFile());
AstNode placeholderField = result.nodes().stream()
.filter(n -> n.type() == NodeType.VARIABLE && n.name().equals("SORT-KEY") && n.sourceFile().isEmpty())
.findFirst().orElseThrow(() -> new AssertionError("No placeholder SORT-KEY field"));
assertTrue(hasEdge(result, EdgeType.CONTAINS, cdbrpda, placeholderField.name(), NodeType.VARIABLE));
assertTrue(hasEdge(result, EdgeType.WRITES, module, placeholderField.name(), NodeType.VARIABLE));
// The local #COMPARE-DATA.COMPARE.SORT-KEY field is a distinct node and must not
// receive the WRITES edge meant for CDBRPDA.SORT-KEY.
AstNode localSortKey = result.nodes().stream()
.filter(n -> n.type() == NodeType.VARIABLE && n.name().equals("SORT-KEY") && !n.sourceFile().isEmpty())
.findFirst().orElseThrow(() -> new AssertionError("No local SORT-KEY field"));
assertNotEquals(localSortKey.id(), placeholderField.id());
assertFalse(result.edges().stream().anyMatch(e -> e.type() == EdgeType.WRITES
&& e.sourceId().equals(module.id()) && e.targetId().equals(localSortKey.id())));
}
@Test
void findWithRecordLimitIsNotMisparsedAsTableName() {
// P1-k: FIND (1) <view> must use <view> as the table name, not "(1)"
String content = """
DEFINE DATA LOCAL
END-DEFINE
DEFINE SUBROUTINE LOOKUP
FIND (1) MY_VIEW WITH COD-EMISOR = '123'
END-FIND
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("FIND_LIMIT_SAMPLE.nat", content);
AstNode lookup = findNode(result, NodeType.FUNCTION, "LOOKUP");
assertTrue(hasEdge(result, EdgeType.READS, lookup, "MY_VIEW", NodeType.DB_TABLE),
"Expected READS edge to MY_VIEW");
assertFalse(hasNode(result, NodeType.DB_TABLE, "(1)"),
"DB_TABLE named '(1)' must not exist");
}
@Test
void readWorkFileIsNotMisparsedAsDbTableWork() {
// Finding 2: `READ WORK FILE n <buffer>` is sequential work-file I/O, NOT an ADABAS READ of a
// view called "WORK". It must produce a WORKFILE (not a DB_TABLE) access.
String content = """
DEFINE DATA LOCAL
END-DEFINE
DEFINE SUBROUTINE LOAD
READ WORK FILE 8 ONCE RECORD USIX053A
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("WORKFILE_READ_SAMPLE.nat", content);
assertFalse(hasNode(result, NodeType.DB_TABLE, "WORK"),
"READ WORK FILE must not create a DB_TABLE named 'WORK'");
AstNode load = findNode(result, NodeType.FUNCTION, "LOAD");
assertTrue(hasNode(result, NodeType.WORKFILE, "8"), "Expected WORKFILE node '8'");
assertTrue(hasEdge(result, EdgeType.READS, load, "8", NodeType.WORKFILE),
"Expected READS edge from LOAD to WORKFILE '8'");
AstNode access = result.nodes().stream()
.filter(n -> n.type() == NodeType.WORKFILE_ACCESS && "8".equals(n.name()))
.findFirst().orElseThrow(() -> new AssertionError("No WORKFILE_ACCESS for file 8"));
assertEquals("READ", access.dataType());
assertEquals("USIX053A", access.properties() == null ? null : access.properties().get("recordBuffer"));
}
@Test
void writeWorkFileCreatesWorkfileAccessWithPhysicalName() {
// Finding 2 (WRITE side + DEFINE WORK FILE): WRITE WORK FILE creates a WORKFILE WRITES access,
// and DEFINE WORK FILE n '<name>' attaches the physical name to the WORKFILE node.
String content = """
DEFINE DATA LOCAL
END-DEFINE
DEFINE WORK FILE 11 'CPRO-CHANGES.CSV'
DEFINE SUBROUTINE DUMP
WRITE WORK FILE 11 VARIABLE #WORK-RECORD11
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("WORKFILE_WRITE_SAMPLE.nat", content);
AstNode dump = findNode(result, NodeType.FUNCTION, "DUMP");
AstNode wf = findNode(result, NodeType.WORKFILE, "11");
assertEquals("CPRO-CHANGES.CSV", wf.properties() == null ? null : wf.properties().get("physicalName"));
assertTrue(hasEdge(result, EdgeType.WRITES, dump, "11", NodeType.WORKFILE),
"Expected WRITES edge from DUMP to WORKFILE '11'");
AstNode access = result.nodes().stream()
.filter(n -> n.type() == NodeType.WORKFILE_ACCESS && "11".equals(n.name()))
.findFirst().orElseThrow(() -> new AssertionError("No WORKFILE_ACCESS for file 11"));
assertEquals("WRITE", access.dataType());
assertEquals("#WORK-RECORD11", access.properties() == null ? null : access.properties().get("recordBuffer"));
}
@Test
void findNumberResolvesToViewNotTheNumberKeyword() {
// Finding 3: `FIND NUMBER <view>` is a count-only FIND (natural-grammar.md §7.1); NUMBER is a
// statement keyword, not the accessed view. The DB table must be <view>, never "NUMBER".
String content = """
DEFINE DATA LOCAL
END-DEFINE
DEFINE SUBROUTINE COUNTER
FIND NUMBER MY_VIEW WITH COD = '1'
FIND NUMBER IN FILE OTHER_VIEW WITH COD = '2'
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("FIND_NUMBER_SAMPLE.nat", content);
AstNode counter = findNode(result, NodeType.FUNCTION, "COUNTER");
assertFalse(hasNode(result, NodeType.DB_TABLE, "NUMBER"),
"FIND NUMBER must not create a DB_TABLE named 'NUMBER'");
assertTrue(hasEdge(result, EdgeType.READS, counter, "MY_VIEW", NodeType.DB_TABLE),
"Expected READS edge to MY_VIEW");
assertTrue(hasEdge(result, EdgeType.READS, counter, "OTHER_VIEW", NodeType.DB_TABLE),
"IN FILE noise words must be skipped so OTHER_VIEW is the view");
}
@Test
void readWorkWithoutFileKeywordIsWorkfileNotDbTable() {
// Finding 4: `READ WORK n` (the FILE keyword omitted, as in `READ WORK 1 ONCE RECORD …`) is still
// work-file I/O, not an ADABAS READ of a view "WORK".
String content = """
DEFINE DATA LOCAL
1 MIG-DATEN (A80)
END-DEFINE
DEFINE SUBROUTINE LOAD
READ WORK 1 ONCE RECORD MIG-DATEN
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("READ_WORK_NOFILE.nat", content);
assertFalse(hasNode(result, NodeType.DB_TABLE, "WORK"), "READ WORK n must not create a DB_TABLE 'WORK'");
AstNode load = findNode(result, NodeType.FUNCTION, "LOAD");
assertTrue(hasEdge(result, EdgeType.READS, load, "1", NodeType.WORKFILE), "Expected WORKFILE '1' READS");
}
@Test
void sqlDeleteFromResolvesToTheTableNotTheFromKeyword() {
// Finding 5: `DELETE FROM <table>` (SQL) — the table is the FROM operand, not the token right
// after DELETE. Must produce the real table, never a phantom `FROM`.
String content = """
DEFINE DATA LOCAL
END-DEFINE
DEFINE SUBROUTINE PURGE
DELETE FROM VDB2_MY_TABLE WHERE COD = '1'
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("DELETE_FROM_SAMPLE.nat", content);
AstNode purge = findNode(result, NodeType.FUNCTION, "PURGE");
assertFalse(hasNode(result, NodeType.DB_TABLE, "FROM"), "DELETE FROM must not create a DB_TABLE 'FROM'");
assertTrue(hasEdge(result, EdgeType.WRITES, purge, "VDB2_MY_TABLE", NodeType.DB_TABLE),
"Expected WRITES edge to VDB2_MY_TABLE");
}
@Test
void sqlSelectColumnEndingInFromDoesNotShadowTheRealFromClause() {
// Finding (audit tail): a Natural SQL SELECT column list may contain a hyphenated field whose
// last segment is literally FROM (e.g. `DAT-CALC-FROM`). A `\bFROM` match treats the hyphen as
// a word boundary and captures the following operand `(*)` as a phantom table, and — because the
// FROM view is only bound on the first match — the real `FROM <view>` clause below is swallowed.
// The real view must be resolved; no phantom `(*)` table may appear.
String content = """
DEFINE DATA LOCAL
1 YCOMIROW VIEW OF VERSVW_COMISION
2 DAT-CALC-FROM (A8)
END-DEFINE
DEFINE SUBROUTINE LOAD
SELECT COD_EMISOR
, YCOMIROW.DAT-CALC-FROM (*)
INTO VIEW YCOMIROW
FROM VERSVW_COMISION
WHERE COD = '1'
END-SELECT
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("SELECT_FROM_COLUMN.nat", content);
AstNode load = findNode(result, NodeType.FUNCTION, "LOAD");
assertFalse(hasNode(result, NodeType.DB_TABLE, "(*)"),
"SELECT column `-FROM (*)` must not create a phantom DB_TABLE '(*)'");
assertTrue(hasEdge(result, EdgeType.READS, load, "VERSVW_COMISION", NodeType.DB_TABLE),
"Expected READS edge to the real FROM view VERSVW_COMISION");
}
@Test
void naturalDeleteWithoutFromNamesNoTable() {
// Finding 5: Natural DML `DELETE [(label)]` deletes the current loop record and names no view;
// and the EXAMINE clause `… DELETE FIRST` is not a DELETE statement at all. Neither may create a
// DB_TABLE ('OLD', a label, or 'FIRST').
String content = """
DEFINE DATA LOCAL
1 #W-VIEW (A32)
1 #K (A4)
END-DEFINE
DEFINE SUBROUTINE CLEAN
DELETE (OLD.)
EXAMINE #W-VIEW FOR #K
DELETE FIRST
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("DELETE_NOFROM_SAMPLE.nat", content);
assertFalse(hasNode(result, NodeType.DB_TABLE, "FIRST"), "EXAMINE … DELETE FIRST is not a DB DELETE");
assertFalse(hasNode(result, NodeType.DB_TABLE, "(OLD.)"), "DELETE (label) names no table");
assertFalse(hasNode(result, NodeType.DB_TABLE, "OLD"), "DELETE (label) names no table");
}
@Test
void updateWithLabelReferenceNamesNoTable() {
// Finding 5 (tail): `UPDATE (label)` updates the current record of the enclosing loop via a
// reference label — no view. A view never starts with '(', so it must not become a table.
String content = """
DEFINE DATA LOCAL
END-DEFINE
DEFINE SUBROUTINE SAVE
UPDATE (OLD.)
UPDATE VERSVW_REAL
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("UPDATE_LABEL_SAMPLE.nat", content);
AstNode save = findNode(result, NodeType.FUNCTION, "SAVE");
assertFalse(hasNode(result, NodeType.DB_TABLE, "(OLD.)"), "UPDATE (label) names no table");
assertTrue(hasEdge(result, EdgeType.WRITES, save, "VERSVW_REAL", NodeType.DB_TABLE),
"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
String content = """
DEFINE DATA LOCAL
END-DEFINE
DEFINE SUBROUTINE FETCH
FIND MY_TABLE WITH
COD-EMISOR = '123'
AND NIF-PERSONA = '456'
END-FIND
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("MULTILINE_FIND_SAMPLE.nat", content);
AstNode findAccess = result.nodes().stream()
.filter(n -> n.type() == NodeType.DB_ACCESS && "READ".equals(n.dataType()))
.findFirst().orElseThrow(() -> new AssertionError("No READ DB_ACCESS node"));
assertNotNull(findAccess.value(), "DB_ACCESS value must not be null");
assertTrue(findAccess.value().contains("COD-EMISOR"),
"Statement text must include continuation line COD-EMISOR");
assertTrue(findAccess.value().contains("END-FIND"),
"Statement text must include END-FIND terminator");
}
@Test
void pdaViewOfCreatesUsesTypeEdgeToDbTable() {
// P1-i: VIEW OF <table> in a PDA creates USES_TYPE edge from DATA_STRUCTURE to DB_TABLE.
// Content uses the PDA export format: [format] [length] [level][name]
String content = """
*-----------------------------------------------------------------
* Test PDA: view of CUST_TABLE
*-----------------------------------------------------------------
1ADDR-VIEW VIEW OF CUST_TABLE
A 10 2CUST-ID
A 40 2CUST-NAME
""";
LanguageParser.ParseResult result = parser.parse("ADDR_VIEW.pda", content);
AstNode viewStruct = findNode(result, NodeType.DATA_STRUCTURE, "ADDR-VIEW");
assertTrue(hasNode(result, NodeType.DB_TABLE, "CUST_TABLE"),
"DB_TABLE CUST_TABLE must be created");
assertTrue(hasEdge(result, EdgeType.USES_TYPE, viewStruct, "CUST_TABLE", NodeType.DB_TABLE),
"USES_TYPE edge from ADDR-VIEW to CUST_TABLE must exist");
assertTrue(hasNode(result, NodeType.VARIABLE, "CUST-ID"),
"Column CUST-ID must be parsed as VARIABLE");
}
@Test
void callnatArgumentsAreCapturedOnTheCallsEdge() throws IOException {
LanguageParser.ParseResult result = parser.parse("DF_CALLER.nat", readFixture("DF_CALLER.nat"));
AstNode callee = findNode(result, NodeType.MODULE, "DF_CALLEE");
com.agenticcode.parsercore.ast.model.AstEdge call = result.edges().stream()
.filter(e -> e.type() == EdgeType.CALLS && e.targetId().equals(callee.id()))
.findFirst().orElseThrow();
assertNotNull(call.properties());
assertEquals("#CLIENT", call.properties().get("args"));
}
@Test
void dynamicCallnatRecordsPlaceholderAndMarkerWithoutShadowingLiteralCallnat() {
String content = """
DEFINE DATA
LOCAL
1 #WIF (A8)
END-DEFINE
#WIF := 'WTARGET'
CALLNAT #WIF #ARG1 #ARG2
CALLNAT 'WLITERAL' #ARG1
END
""";
LanguageParser.ParseResult result = parser.parse("DYN_CALLER.nat", content);
// The dynamic call yields a placeholder MODULE named after the dispatch variable, plus a
// CALLS edge tagged CALLNAT_DYNAMIC carrying the variable name and the trailing args.
AstNode dynTarget = findNode(result, NodeType.MODULE, "#WIF");
com.agenticcode.parsercore.ast.model.AstEdge dynCall = result.edges().stream()
.filter(e -> e.type() == EdgeType.CALLS && e.targetId().equals(dynTarget.id()))
.findFirst().orElseThrow();
assertNotNull(dynCall.properties());
assertEquals("CALLNAT_DYNAMIC", dynCall.properties().get("callKind"));
assertEquals("#WIF", dynCall.properties().get("dynamicVar"));
assertEquals("#ARG1,#ARG2", dynCall.properties().get("args"));
// The literal CALLNAT on the next line is unaffected (no shadowing): still a MODULE target
// tagged plain CALLNAT.
AstNode litTarget = findNode(result, NodeType.MODULE, "WLITERAL");
com.agenticcode.parsercore.ast.model.AstEdge litCall = result.edges().stream()
.filter(e -> e.type() == EdgeType.CALLS && e.targetId().equals(litTarget.id()))
.findFirst().orElseThrow();
assertNotNull(litCall.properties());
assertEquals("CALLNAT", litCall.properties().get("callKind"));
assertEquals("#ARG1", litCall.properties().get("args"));
}
@Test
void multiLineCallnatArgumentsAreCapturedInOrderUntilNextStatement() {
String content = """
DEFINE DATA
LOCAL
1 #A (A8)
1 #B (A8)
1 #C (A8)
END-DEFINE
CALLNAT 'TARGETMOD' #A
#B
#C
*
MOVE #A TO #B
END
""";
LanguageParser.ParseResult result = parser.parse("MLCALL.nat", content);
AstNode callee = findNode(result, NodeType.MODULE, "TARGETMOD");
com.agenticcode.parsercore.ast.model.AstEdge call = result.edges().stream()
.filter(e -> e.type() == EdgeType.CALLS && e.targetId().equals(callee.id()))
.findFirst().orElseThrow();
assertNotNull(call.properties());
// Args span three physical lines; the comment line terminates the list before MOVE.
assertEquals("#A,#B,#C", call.properties().get("args"));
}
@Test
void defineDataFieldsCarryTheirDeclaredScope() {
String content = """
DEFINE DATA
PARAMETER
1 #P-IN (A8)
LOCAL
1 #L-VAR (A4)
END-DEFINE
*
END
""";
LanguageParser.ParseResult result = parser.parse("SCOPED.nat", content);
AstNode pin = findNode(result, NodeType.VARIABLE, "#P-IN");
AstNode lvar = findNode(result, NodeType.VARIABLE, "#L-VAR");
assertNotNull(pin.properties());
assertNotNull(lvar.properties());
assertEquals("PARAMETER", pin.properties().get("scope"));
assertEquals("LOCAL", lvar.properties().get("scope"));
}
@Test
void parameterUsingAreasAdvanceInlineParameterPositions() {
String content = """
DEFINE DATA
PARAMETER USING AREA1
PARAMETER USING AREA2
PARAMETER
1 #P-FIRST (A8)
1 #P-SECOND (A1)
END-DEFINE
*
END
""";
LanguageParser.ParseResult result = parser.parse("PCALLEE.nat", content);
// The two PARAMETER USING areas occupy positions 0 and 1, so the inline params follow at 2/3
// — matching the caller's positional CALLNAT arguments.
AstNode first = findNode(result, NodeType.VARIABLE, "#P-FIRST");
AstNode second = findNode(result, NodeType.VARIABLE, "#P-SECOND");
assertNotNull(first.properties());
assertNotNull(second.properties());
assertEquals("2", first.properties().get("paramPosition"));
assertEquals("3", second.properties().get("paramPosition"));
}
@Test
void bareReferenceToIncludedFieldCreatesModuleLevelPlaceholder() {
String content = """
DEFINE DATA
LOCAL USING SOMEAREA
LOCAL
1 #X (A8)
END-DEFINE
MOVE #X TO SORT-KEY
END
""";
LanguageParser.ParseResult result = parser.parse("BARE_CALLER.nat", content);
AstNode module = findNode(result, NodeType.MODULE, "BARE_CALLER");
// SORT-KEY is unqualified and not local, but the module has a USING include ->
// a module-level placeholder field is created (to be resolved against SOMEAREA in Neo4j).
AstNode sortKey = result.nodes().stream()
.filter(n -> n.type() == NodeType.VARIABLE && n.name().equals("SORT-KEY"))
.findFirst().orElseThrow();
assertEquals("", sortKey.sourceFile());
assertTrue(hasEdge(result, EdgeType.CONTAINS, module, "SORT-KEY", NodeType.VARIABLE));
assertTrue(hasEdge(result, EdgeType.WRITES, module, "SORT-KEY", NodeType.VARIABLE));
}
@Test
void bareUnknownIsNotPromotedWithoutIncludes() {
String content = """
DEFINE DATA
LOCAL
1 #X (A8)
END-DEFINE
MOVE #X TO SORT-KEY
END
""";
LanguageParser.ParseResult result = parser.parse("NOINC.nat", content);
// No USING include -> bare SORT-KEY must NOT become a placeholder.
assertFalse(hasNode(result, NodeType.VARIABLE, "SORT-KEY"));
}
@Test
void parameterFieldsAreTaggedWithPosition() throws IOException {
LanguageParser.ParseResult result = parser.parse("DF_CALLEE.nat", readFixture("DF_CALLEE.nat"));
AstNode pClient = findNode(result, NodeType.VARIABLE, "#P-CLIENT");
assertNotNull(pClient.properties());
assertEquals("0", pClient.properties().get("paramPosition"));
AstNode pStatus = findNode(result, NodeType.VARIABLE, "#P-STATUS");
assertNotNull(pStatus.properties());
assertEquals("1", pStatus.properties().get("paramPosition"));
}
@Test
void capturesSubscriptedArrayAssignmentAndCopyForDynamicResolution() {
// A dynamic-dispatch routing-table pattern: a literal is assigned to an array element
// (note the space before the subscript), the dispatch variable is copied from that array,
// then called indirectly. The parser must capture the subscripted literal write and read
// the array on the copy line — the two facts the indirect dynamic-CALLNAT resolver joins on.
String content = """
DEFINE DATA LOCAL
1 #TBL (A8/1:10)
1 #W-ACT-PROG (A8)
1 #I (I4)
END-DEFINE
ASSIGN #TBL (1) = 'WPARTD2S'
#W-ACT-PROG := #TBL (#I)
CALLNAT #W-ACT-PROG
END
""";
LanguageParser.ParseResult result = parser.parse("DISP.nat", content);
AstNode tbl = findNode(result, NodeType.VARIABLE, "#TBL");
AstNode dispatchVar = findNode(result, NodeType.VARIABLE, "#W-ACT-PROG");
// 1) the subscripted ASSIGN records a WRITES to the array carrying the literal value
assertTrue(result.edges().stream().anyMatch(e -> e.type() == EdgeType.WRITES
&& e.targetId().equals(tbl.id()) && "'WPARTD2S'".equals(e.value())),
"subscripted ASSIGN should record a WRITES to #TBL carrying the literal 'WPARTD2S'");
// 2) the copy WRITES the dispatch variable and READS the array on the same line
int copyLine = result.edges().stream()
.filter(e -> e.type() == EdgeType.WRITES && e.targetId().equals(dispatchVar.id()))
.mapToInt(e -> e.lineNo()).min().orElse(-1);
assertTrue(copyLine > 0, "the dispatch-var copy should record a WRITES to #W-ACT-PROG");
assertTrue(result.edges().stream().anyMatch(e -> e.type() == EdgeType.READS
&& e.targetId().equals(tbl.id()) && e.lineNo() == copyLine),
"the dispatch-var copy should READ #TBL on the same line it WRITES the dispatch var");
}
// --- item 44: framework include-macro ------------------------------------------------------
@Test
void frameworkIncludeMacroEmitsCallToDequotedAccessor() {
String content = """
DEFINE DATA
LOCAL USING FRELEMK
LOCAL USING FRELEMV
END-DEFINE
INCLUDE YFRAMGC0 'FRELEMV.C-MOD-GET' '"CO-ELEMENTO-ALT"'
'"FRELEMG0"' 'FRELEMK' 'FRELEMV'
END
""";
LanguageParser.ParseResult result = parser.parse("FRAMGET.nat", content);
AstNode module = findNode(result, NodeType.MODULE, "FRAMGET");
assertTrue(hasEdge(result, EdgeType.CALLS, module, "FRELEMG0", NodeType.MODULE),
"INCLUDE YFRAMGC0 must emit a CALLS edge to the de-quoted accessor FRELEMG0 (arg 2)");
// The macro name itself is not a call target, and the other quoted args are not accessors.
assertFalse(hasNode(result, NodeType.MODULE, "YFRAMGC0"));
assertFalse(hasNode(result, NodeType.MODULE, "FRELEMK"));
}
@Test
void unknownStatementLevelIncludeProducesNoCall() {
String content = """
DEFINE DATA LOCAL
01 #X (A1)
END-DEFINE
INCLUDE SOMECOPY 'ARG1' 'ARG2'
END
""";
LanguageParser.ParseResult result = parser.parse("CLIENT.nat", content);
assertFalse(result.edges().stream().anyMatch(e -> e.type() == EdgeType.CALLS),
"an INCLUDE of an unknown (non-framework) copycode must not be turned into a call");
}
// --- item 45: XML payload contract ---------------------------------------------------------
@Test
void extractsXmlPayloadTriplesFromAddXmlLineIdiom() {
String content = """
DEFINE DATA
LOCAL USING WXMLIN
LOCAL
01 #W-TAG (A32)
01 #W-VALUE (A50)
01 #W-LINE (A100)
END-DEFINE
PERFORM BUILD-PAYLOAD
DEFINE SUBROUTINE BUILD-PAYLOAD
#W-TAG := 'cod_usuario'
#W-VALUE := WXMLIN.P-COD-USUARIO
PERFORM ADD-XML-LINE
#W-TAG := 'nif_persona'
#W-VALUE := WXMLIN.P-NIF-PERSONA
PERFORM ADD-XML-LINE
END-SUBROUTINE
DEFINE SUBROUTINE ADD-XML-LINE
COMPRESS '<' #W-TAG '>' #W-VALUE '</' #W-TAG '>' INTO #W-LINE LEAVING NO
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("WNXMLD0S.nat", content);
List<AstNode> payloads = result.nodes().stream()
.filter(n -> n.type() == NodeType.PAYLOAD_FIELD).toList();
assertEquals(2, payloads.size(), "one PAYLOAD_FIELD per emitted tag");
AstNode cod = payloads.stream().filter(n -> "cod_usuario".equals(n.name())).findFirst().orElseThrow();
Map<String, String> props = java.util.Objects.requireNonNull(cod.properties());
assertEquals("cod_usuario", props.get("tag"));
assertEquals("P-COD-USUARIO", props.get("field"), "qualifier stripped to the field name");
assertEquals("REQUEST", props.get("direction"), "COMPRESS-emit is an outbound (REQUEST) field");
// Each triple is contained by the module.
AstNode module = findNode(result, NodeType.MODULE, "WNXMLD0S");
assertTrue(hasEdge(result, EdgeType.CONTAINS, module, "nif_persona", NodeType.PAYLOAD_FIELD));
}
// --- item 46a: copycode (.cpy) expansion ---------------------------------------------------
@Test
void copycodeExpansionAttributesCallToHostWithCopycodeLineAndViaMarker() {
String host = """
DEFINE DATA LOCAL
01 #A (A1)
END-DEFINE
PERFORM SETUP
INCLUDE CALLC2 '5'
IF #A = ' '
IGNORE
END-IF
DEFINE SUBROUTINE SETUP
IGNORE
END-SUBROUTINE
END
""";
String copycode = """
* a call-mediating copycode
CALLNAT 'TARGETX' #A &1&
""";
CopycodeResolver resolver = name -> "CALLC2".equalsIgnoreCase(name)
? new CopycodeResolver.Copycode("copycode/CALLC2.cpy", copycode)
: null;
LanguageParser.ParseResult result = parser.parse("JE0005N0.nat", host, resolver);
AstNode module = findNode(result, NodeType.MODULE, "JE0005N0");
// The CALLNAT hidden in the copycode is now a callee of the host module.
assertTrue(hasEdge(result, EdgeType.CALLS, module, "TARGETX", NodeType.MODULE));
AstEdge callEdge = result.edges().stream()
.filter(e -> e.type() == EdgeType.CALLS)
.filter(e -> result.nodes().stream().anyMatch(n -> n.id().equals(e.targetId())
&& "TARGETX".equals(n.name())))
.findFirst().orElseThrow();
// Line points INTO the copycode file (line 2 of CALLC2.cpy), tagged with its origin.
assertEquals(2, callEdge.lineNo(), "CALLNAT line must resolve into the copycode, not the host INCLUDE line");
assertNotNull(callEdge.properties());
assertEquals("CALLC2", callEdge.properties().get("viaCopycode"));
assertEquals("5", callEdge.properties().get("includedAt"), "the host INCLUDE is on line 5");
// Item 66: the edge must also name the file its lineNo belongs to. Nodes carry that in
// sourceFile; an edge has no such field, so without originFile the API can only pair the
// copycode's line with the host's file — an explicitly wrong location.
assertEquals("copycode/CALLC2.cpy", callEdge.properties().get("originFile"),
"lineNo 2 belongs to the copycode, so the edge must carry that file's real path");
// A host statement AFTER the INCLUDE keeps its real host line (6), i.e. no line-shift leakage.
AstNode ifBlock = findNode(result, NodeType.CONTROL_FLOW, "IF");
assertEquals(6, ifBlock.startLine(), "host IF must stay on its original line despite the splice");
// PERFORM SETUP before the INCLUDE is unaffected (host line 4).
AstEdge perform = result.edges().stream()
.filter(e -> e.type() == EdgeType.CALLS)
.filter(e -> result.nodes().stream().anyMatch(n -> n.id().equals(e.targetId())
&& "SETUP".equals(n.name())))
.findFirst().orElseThrow();
assertEquals(4, perform.lineNo());
}
@Test
void unknownCopycodeIsLeftAsIsWithoutExpansion() {
String host = """
DEFINE DATA LOCAL
01 #A (A1)
END-DEFINE
INCLUDE NOSUCHCOPY '5'
END
""";
// NONE resolver: nothing is expanded; the unknown INCLUDE yields no call (not a framework macro).
LanguageParser.ParseResult result = parser.parse("PROG.nat", host, CopycodeResolver.NONE);
assertFalse(result.edges().stream().anyMatch(e -> e.type() == EdgeType.CALLS));
// The 2-arg SPI delegates to NONE, producing the same node/edge shape (identity remap).
LanguageParser.ParseResult viaSpi = parser.parse("PROG.nat", host);
assertEquals(result.nodes().size(), viaSpi.nodes().size());
assertEquals(result.edges().size(), viaSpi.edges().size());
}
// --- item 46c: Global Data Areas (.gda) ----------------------------------------------------
@Test
void globalUsingResolvesToIncludedDataArea() {
String content = """
DEFINE DATA
GLOBAL USING MYGDA
LOCAL
01 #X (A1)
END-DEFINE
END
""";
LanguageParser.ParseResult result = parser.parse("PROG.nat", content);
AstNode module = findNode(result, NodeType.MODULE, "PROG");
assertTrue(hasEdge(result, EdgeType.INCLUDES, module, "MYGDA", NodeType.DATA_STRUCTURE),
"GLOBAL USING must create an INCLUDES edge to the GDA, like PARAMETER/LOCAL USING");
}
@Test
void gdaFileParsesAsDataAreaNotModule() {
String content = """
* a global data area
1MYGDA
A 10 2#COMPANY
N 4 2#YEAR
""";
LanguageParser.ParseResult result = parser.parse("MYGDA.gda", content);
assertTrue(hasNode(result, NodeType.VARIABLE, "#COMPANY"));
assertTrue(hasNode(result, NodeType.VARIABLE, "#YEAR"));
assertFalse(result.nodes().stream().anyMatch(n -> n.type() == NodeType.MODULE),
"a .gda is a data area, not a module");
}
@Test
void staticIdiomSupportsDerivedTagsAndResponseDirection() {
// REQUEST with a derived tag (#COD -> _COD via EXAMINE) and a RESPONSE via GET-XML-LINE.
String content = """
DEFINE DATA
LOCAL USING WXMLIN
LOCAL
01 #W-TAG (A32)
01 #W-VALUE (A50)
01 #W-LINE (A100)
END-DEFINE
*
PERFORM BUILD-PAYLOAD
DEFINE SUBROUTINE BUILD-PAYLOAD
#W-TAG := '#COD-USUARIO'
#W-VALUE := WXMLIN.P-COD-USUARIO
EXAMINE #W-TAG FOR '#' REPLACE '_'
PERFORM ADD-XML-LINE
#W-TAG := 'nif_persona'
WXMLIN.P-NIF-PERSONA := #W-VALUE
PERFORM GET-XML-LINE
END-SUBROUTINE
DEFINE SUBROUTINE ADD-XML-LINE
COMPRESS '<' #W-TAG '>' #W-VALUE '</' #W-TAG '>' INTO #W-LINE LEAVING NO
END-SUBROUTINE
DEFINE SUBROUTINE GET-XML-LINE
IGNORE
END-SUBROUTINE
END
""";
LanguageParser.ParseResult result = parser.parse("WBIDIR0S.nat", content);
List<AstNode> payloads = result.nodes().stream()
.filter(n -> n.type() == NodeType.PAYLOAD_FIELD).toList();
assertEquals(2, payloads.size());
AstNode outbound = payloads.stream().filter(n -> "_COD-USUARIO".equals(n.name())).findFirst().orElseThrow();
Map<String, String> op = java.util.Objects.requireNonNull(outbound.properties());
assertEquals("_COD-USUARIO", op.get("tag"), "derived tag: # normalised to _");
assertEquals("P-COD-USUARIO", op.get("field"));
assertEquals("REQUEST", op.get("direction"));
AstNode inbound = payloads.stream().filter(n -> "nif_persona".equals(n.name())).findFirst().orElseThrow();
Map<String, String> ip = java.util.Objects.requireNonNull(inbound.properties());
assertEquals("P-NIF-PERSONA", ip.get("field"), "reverse binding field := valueVar");
assertEquals("RESPONSE", ip.get("direction"), "GET-XML-LINE is a parse (inbound) sub");
}
@Test
void moduleWithoutTheXmlIdiomHasNoPayloadFields() {
String content = """
DEFINE DATA LOCAL
01 #X (A1)
END-DEFINE
COMPRESS 'a' 'b' INTO #X
END
""";
LanguageParser.ParseResult result = parser.parse("PLAIN.nat", content);
assertFalse(result.nodes().stream().anyMatch(n -> n.type() == NodeType.PAYLOAD_FIELD));
}
@Test
void commentedAndInlineCommentCallnatsAreNotParsedAsCalls() {
// Regression: the unanchored CALLNAT / CALLNAT_DYNAMIC patterns used to match inside comments,
// producing phantom MODULE callees (WGEAGB0S: commented ISINDATE/ADLML02 call-sites; tree-wide
// "unresolved" WAS/RESULTED/DOES from prose like "* What the callnat does:").
String content = """
DEFINE DATA LOCAL
1 #X (A4)
END-DEFINE
* CALLNAT 'GHOSTA'
**SAG CALLNAT 'GHOSTB'
MOVE 1 TO #X /* CALLNAT 'GHOSTC' inline comment after real code
* What the callnat does: nothing
/* Subsequent callnat was issued, last callnat resulted in end-of-data
CALLNAT 'REALMOD' #X
END
""";
LanguageParser.ParseResult result = parser.parse("CMT.nat", content);
AstNode module = findNode(result, NodeType.MODULE, "CMT");
// The one real call is recorded...
assertTrue(hasEdge(result, EdgeType.CALLS, module, "REALMOD", NodeType.MODULE));
// ...and no commented / prose "callnat" produces a MODULE placeholder.
for (String ghost : new String[]{"GHOSTA", "GHOSTB", "GHOSTC", "DOES", "WAS", "RESULTED"}) {
assertFalse(hasNode(result, NodeType.MODULE, ghost),
"comment token '" + ghost + "' must not be parsed as a CALLNAT target");
}
}
/**
* The WRITES-edge guard properties recorded for the assignment to {@code targetField}.
*/
private static Map<String, String> guardOf(LanguageParser.ParseResult result, String targetField) {
for (AstEdge edge : result.edges()) {
if (edge.type() != EdgeType.WRITES || edge.properties() == null
|| !edge.properties().containsKey("whenValue")) {
continue;
}
for (AstNode node : result.nodes()) {
if (node.id().equals(edge.targetId()) && node.name().equals(targetField)) {
return edge.properties();
}
}
}
throw new AssertionError("no guarded WRITES edge to " + targetField);
}
private static List<String> guardValues(Map<String, String> guard) {
String encoded = guard.get("whenValues");
assertNotNull(encoded, "whenValues must be recorded");
return List.of(encoded.split(NaturalParser.GUARD_VALUE_SEPARATOR));
}
@Test
void multiValueDecideBranchKeepsEveryAlternativeIncludingBlank() {
// Item 64: `VALUE 'A', ' '` means the branch also catches a blank guard field. The legacy
// joined whenValue cannot express that (it drops blanks to avoid a trailing ", "), so the
// faithful alternatives travel in whenValues. Real shape from WGEAGB0S:530-545.
String content = """
DEFINE DATA LOCAL
1 #P-DESC-NAME (A20)
1 #W-KEY (A8)
1 #GENAGREE-WOUT-SP (A8)
1 #ROUTE (A8)
END-DEFINE
DECIDE ON FIRST VALUE OF #P-DESC-NAME
VALUE 'GENAGREE-WOUT-SP', ' '
#GENAGREE-WOUT-SP := #W-KEY
VALUE 'A1', 'A2'
#ROUTE := #W-KEY
NONE
IGNORE
END-DECIDE
END
""";
LanguageParser.ParseResult result = parser.parse("DISPATCH.nat", content);
Map<String, String> blankBranch = guardOf(result, "#GENAGREE-WOUT-SP");
assertEquals("#P-DESC-NAME", blankBranch.get("whenField"));
// The lossy field stays as-is for compatibility...
assertEquals("GENAGREE-WOUT-SP", blankBranch.get("whenValue"));
// ...while whenValues records that a blank #P-DESC-NAME also routes into this branch.
assertEquals(List.of("GENAGREE-WOUT-SP", " "), guardValues(blankBranch));
// A multi-alternative branch keeps each value separately, rather than only the synthetic
// joined "A1, A2" that the guarded field never actually equals.
Map<String, String> multiBranch = guardOf(result, "#ROUTE");
assertEquals("A1, A2", multiBranch.get("whenValue"));
assertEquals(List.of("A1", "A2"), guardValues(multiBranch));
}
@Test
void callnatInsideAStringLiteralIsNotParsedAsACall() {
// Regression (bug #63): the unanchored CALLNAT / CALLNAT_DYNAMIC patterns matched the keyword
// inside quoted string literals, so prose in a PRINT/WRITE/assignment fabricated a call. All
// three lines below are real upms sites. The USIA008N/ISINGEAG ones are the dangerous class:
// those names are real modules, so the phantom became a real -> real CALLS edge that the
// item-62 placeholder reaper cannot remove (it only reaps names with no real module).
String content = """
DEFINE DATA LOCAL
1 #X (A4)
1 #ERR-TYPE (A20)
END-DEFINE
PRINT '==> callnat before DREQUFN0'
WRITE(#MSG) 'NACH CALLNAT ISINGEAG:'
#ERR-TYPE := 'Callnat USIA008N'
MOVE 'Start of Callnat' TO #ERR-TYPE
CALLNAT 'REALMOD' #X
CALLNAT #DISP
END
""";
LanguageParser.ParseResult result = parser.parse("STRLIT.nat", content);
AstNode module = findNode(result, NodeType.MODULE, "STRLIT");
// A quoted literal never yields a call target...
for (String ghost : new String[]{"before", "ISINGEAG", "USIA008N"}) {
assertFalse(hasNode(result, NodeType.MODULE, ghost),
"'" + ghost + "' inside a string literal must not be parsed as a CALLNAT target");
}
// ...while a real static call, whose keyword sits outside the quotes, still resolves...
assertTrue(hasEdge(result, EdgeType.CALLS, module, "REALMOD", NodeType.MODULE));
// ...as does genuine dynamic dispatch.
assertTrue(hasEdge(result, EdgeType.CALLS, module, "#DISP", NodeType.MODULE));
}
}