From 4ac31b0fb28e3183b56735c7399ef36e894567b5 Mon Sep 17 00:00:00 2001 From: squid-protocol Date: Wed, 7 Oct 2026 21:17:12 -0400 Subject: [PATCH 1/9] det runtime: truncate arithmetic intermediates as cobc's ARITHMETIC-OSVS does (#4287) The det translator replays cobc -std=ibm's compile-time ARITHMETIC-OSVS decision (det/osvs.py, from GnuCOBOL 3.1.2 typeck.c / tree.c): dmax from receivers and cb_walk_cond, the expr_decp stack with its pushes, pending aligns flushed on the next load, constant folding, conditions built right to left, the state an EVALUATE leaves to its sentence. It emits Cobol.align (libcob's cob_decimal_align, its downward shift included) where cobc emits cob_decimal_align, and a literal on the right of an operation as a Cobol.Dc (libcob's decimal constant, whose scale its uses change). Cobol.divide keeps cob_decimal_div's places and Cobol.power cob_decimal_pow's trimming. Register C2 rewritten: the det runtime matches the oracle; the oracle's departures from IBM's rule are listed. Co-Authored-By: Claude Opus 5.5 --- docs/language_status/det_port_design.md | 9 +- docs/language_status/oracle_assumptions.md | 62 ++- .../cobol_to_java/det/cobolrt/Cobol.java | 117 ++++- gitgalaxy/tools/cobol_to_java/det/expr.py | 6 +- gitgalaxy/tools/cobol_to_java/det/gen.py | 353 ++++++++++++++-- gitgalaxy/tools/cobol_to_java/det/osvs.py | 400 ++++++++++++++++++ gitgalaxy/tools/cobol_to_java/det/program.py | 3 + tests/cobol_mainframe/test_det_osvs.py | 207 +++++++++ 8 files changed, 1089 insertions(+), 68 deletions(-) create mode 100644 gitgalaxy/tools/cobol_to_java/det/osvs.py create mode 100644 tests/cobol_mainframe/test_det_osvs.py diff --git a/docs/language_status/det_port_design.md b/docs/language_status/det_port_design.md index f8ffce40f0..103a05ea58 100644 --- a/docs/language_status/det_port_design.md +++ b/docs/language_status/det_port_design.md @@ -160,10 +160,11 @@ public final class Cobol { } ``` -Arithmetic intermediates: exact `BigDecimal`. `cobc -std=ibm` instead truncates them to IBM's fixed-point decimal places -(ARITHMETIC-OSVS), which the runtime does not model yet (#4287, register C2); no proven scenario reaches the -difference. `store` truncates high-order digits beyond the PICTURE and low-order digits beyond the scale (or rounds half -away from zero with ROUNDED). +Arithmetic intermediates: `BigDecimal`, truncated where `cobc -std=ibm` truncates them (ARITHMETIC-OSVS, #4287, +register C2): the translator replays cobc's compile-time decision (`det/osvs.py`) and wraps each such intermediate in +`Cobol.align(value, places)` (libcob's cob_decimal_align, its shift the wrong way included); a literal on the right +of an operation is a `Cobol.Dc`, libcob's decimal constant whose scale its uses change. `store` truncates high-order +digits beyond the PICTURE and low-order digits beyond the scale (or rounds half away from zero with ROUNDED). What GnuCOBOL (`-std=ibm`) does, which the runtime follows (each is a case in `tests/cobol_mainframe/test_cobolrt.py`): diff --git a/docs/language_status/oracle_assumptions.md b/docs/language_status/oracle_assumptions.md index eefb96b737..a723ac5e86 100644 --- a/docs/language_status/oracle_assumptions.md +++ b/docs/language_status/oracle_assumptions.md @@ -59,7 +59,7 @@ verdict, but it limits what the proof says about inputs outside the scenarios. | id | area | entry | status | reached by a proof? | |---|---|---|---|---| | C1 | compiler | Binary truncation: `TRUNC(STD)` (IBM's default) on both sides (#4102, fixed) | MATCHED | reachable (GenApp LGICDB01) | -| C2 | compiler | Arithmetic intermediates: the oracle truncates them (ARITHMETIC-OSVS, IBM's decimal places), the det runtime does not (#4287) | DIFFERS (det runtime) / ASSUMED (oracle) | yes (INTCALC, POSTTRAN …); the difference: not by a proof | +| C2 | compiler | Arithmetic intermediates: the oracle truncates them (ARITHMETIC-OSVS), the det runtime the same way (#4287); where GnuCOBOL departs from IBM's decimal places | MATCHED (det runtime = oracle) / DIFFERS (oracle, GnuCOBOL's departures) | yes (INTCALC, POSTTRAN …); a departure: not known to be | | C3 | compiler | An integer literal truncated to zero keeps no sign | DIFFERS | no | | C4 | compiler | An unsigned binary taken below zero by ADD/SUBTRACT wraps | DIFFERS | no | | C5 | compiler | `NUMPROC(MIG)` as Enterprise COBOL 5+ compiles it (NOPFD), `NUMPROC(PFD)` with preferred signs (#4271); `INTDATE(LILIAN)`, `ARITH(EXTEND)`, `TRUNC(OPT)` | MATCHED (NUMPROC) / REFUSED (the rest) | NUMPROC: no | @@ -136,21 +136,49 @@ verdict, but it limits what the proof says about inputs outside the scenarios. - **Reach.** GenApp's LGICDB01 moves the 10-digit CA-CUSTOMER-NUM into an `S9(9) COMP`: a customer number of 10 digits would now behave as on z/OS. -### C2. Arithmetic intermediates — the oracle ASSUMED, the det runtime DIFFERS (#4287) -- **The oracle.** `cobc -std=ibm` turns on GnuCOBOL's `arithmetic-osvs` (`ibm-strict.conf`), so cobc truncates each - intermediate result to a number of decimal places (`cob_decimal_align`): IBM's fixed-point rules (Enterprise COBOL - 6.4 Programming Guide, SC27-8714-03, Appendix A, "Fixed-point data and intermediate results": `+ -` the larger of - d1, d2; `*` d1 + d2; `/` the larger of the operands' difference and dmax, the most decimal places of any operand or - receiver). DIVIDE's quotient follows `cob_decimal_div` (the dividend shifted 38 digits, truncated). IBM's other - limit, at most 30 digits for an intermediate under `ARITH(COMPAT)`, GnuCOBOL does not apply: ASSUMED that no - proven intermediate is that long. -- **The det runtime** computes in exact `BigDecimal` and does not truncate intermediates: `COMPUTE R = A / B * C` with - A = 1, B = 3, C = 300 and R `PIC 999V99` gives 099.00 on the oracle (and by IBM's rule) and 099.99 on the det port - (measured 2026-10-03, #4287). No proven scenario reaches such an expression; IBM DBB EPSMPMT's would. -- **Reached.** Every COMPUTE with a division or a multiplication of large items, among them INTCALC's interest - computation; the det runtime's difference: not by a proof. -- **To settle.** #4287 (model the aligns in the det translator), then a table of division and multiply-then-divide - cases on z/OS against `tests/equivalence/rounding/RND.cbl`. +### C2. Arithmetic intermediates — the det runtime MATCHES the oracle (#4287); the oracle DIFFERS where GnuCOBOL departs from IBM +- **IBM's rule.** Enterprise COBOL keeps each intermediate result to a number of decimal places (6.4 Programming + Guide, SC27-8714-03, Appendix A, "Fixed-point data and intermediate results"): `+ -` the larger of d1, d2; `*` + d1 + d2; `/` the larger of the operands' difference and dmax, the most decimal places of any operand or receiver; + at most 30 digits under `ARITH(COMPAT)`. +- **The oracle.** `cobc -std=ibm` turns on GnuCOBOL's `arithmetic-osvs` (`ibm-strict.conf`), so cobc truncates + intermediates (`cob_decimal_align`) with decimal places it works out at compile time from those rules. The + reproducer of #4287 is IBM's own: `COMPUTE R = A / B * C` with A = 1, B = 3, C = 300 and R `PIC 999V99` gives + 099.00 (A / B kept to 2 places). The 30-digit limit GnuCOBOL does not apply: ASSUMED that no proven intermediate + is that long. +- **The det runtime (since #4287)** makes the same truncations: the translator replays cobc's decision + (`det/osvs.py`, from GnuCOBOL 3.1.2's `cobc/typeck.c` and `cobc/tree.c`) and emits `Cobol.align(value, places)` + where cobc emits `cob_decimal_align`; a literal on the right of an operation is libcob's decimal constant + (`Cobol.Dc`); `Cobol.divide` keeps cob_decimal_div's 38 + max(d1 - d2, 0) places and `Cobol.power` + cob_decimal_pow's trimming. COMPUTE, ADD / SUBTRACT / MULTIPLY / DIVIDE with an expression, and the relations of + IF, PERFORM UNTIL, SEARCH WHEN and EVALUATE are planned; floating-point statements keep their HFP model (C6). + Proven by `tests/cobol_mainframe/test_det_osvs.py` and by a randomized differential run (2026-10-07: about 120 + programs, 26,000 COMPUTE, ADD, IF and EVALUATE statements over zoned, packed and binary items, every output equal + to the oracle's; the same programs on the earlier runtime: about one line in six differed). +- **Where GnuCOBOL departs from IBM's rule** (each measured on the oracle; the det port does what the oracle does, + so a proof cannot see them, and z/OS may not do them): + - the stack of decimal places pairs an operation with its own operands only when every operand pushes its + places; a binary item of scale 0, a short integer literal, ZERO and a literal on the right push nothing, and the + operation then takes dmax or a neighbour's places: `COMPUTE R = XB + YB + Z` with two `PIC 9(4) COMP` items and + R `PIC 999V99` aligns XB + YB to 2 places; + - `cob_decimal_align` with fewer places than the target shifts the wrong way: the value loses as many low-order + digits (579 aligned to 2 places is 500, so that COMPUTE gives 501.00 for 123 + 456 + 1; `- A + B` aligns 0 - A + and loses A); + - a literal's decimal constant takes the places of every intermediate it is added to or subtracted from, for the + rest of the run, and an exponent literal loses its trailing zeros; + - an EVALUATE leaves its dmax and stack to the next statements of the same sentence (a period, a COMPUTE or an IF + resets them), and an IF's folded literal pair keeps it from walking its condition; + - a relation of two literals with decimals compares wrongly (`100 > 1.25` is false), a separate GnuCOBOL matter: + no case compares two literals. +- **Not replayed by the det translator** (refused by name, or exact as before): a negative literal exponent (libcob + overwrites the constant: refused); an arithmetic expression in a PERFORM VARYING FROM / BY, a SEARCH ALL key or a + subscript (cobc computes those otherwise); an EVALUATE whose last WHEN ends with a statement that leaves the state + dirty (refused); statements inside ON SIZE ERROR phrases, which cobc parses before their statement's expression. +- **Reached.** Every COMPUTE with more than one operation, among them INTCALC's interest computation. Across the + det sweep's ports (2026-10-07) the only truncation emitted is POSTTRAN's `ACCT-CURR-CYC-CREDIT - + ACCT-CURR-CYC-DEBIT` to 2 places, a value of 2 places (no change); none of GnuCOBOL's departures is reached. +- **To settle.** A table of division, multiply-then-divide and binary-sum cases on z/OS against + `tests/equivalence/rounding/RND.cbl` (and the departures above). ### C3. An integer literal truncated to zero — DIFFERS - **What.** GnuCOBOL folds `MOVE -1000 TO PIC S9(3)` at compile time to +0 (`00{`). Every other truncating MOVE keeps @@ -198,7 +226,7 @@ verdict, but it limits what the proof says about inputs outside the scenarios. CBSA's build JCL passes `TRUNC(OPT)`, but its programs' PROCESS cards override it with `TRUNC(STD)` (C1). - `ARITH(EXTEND)` changes only intermediates past 30 digits (31 instead) and float-mode precision (extended instead of long). GnuCOBOL caps neither (C2), so the oracle would compute COMPAT and EXTEND the same; a model needs a - guard on both sides that stops an intermediate past 30 digits, after #4287. + guard on both sides that stops an intermediate past 30 digits. - `INTDATE(LILIAN)` is pinned by IBM (day 1 is 15 October 1582 instead of 1 January 1601), and could be modelled by the documented offset of the integer-date functions. Dates before 1601 cannot run on GnuCOBOL and would be refused. No case needs it yet. diff --git a/gitgalaxy/tools/cobol_to_java/det/cobolrt/Cobol.java b/gitgalaxy/tools/cobol_to_java/det/cobolrt/Cobol.java index 0362511cca..70c59813b8 100644 --- a/gitgalaxy/tools/cobol_to_java/det/cobolrt/Cobol.java +++ b/gitgalaxy/tools/cobol_to_java/det/cobolrt/Cobol.java @@ -75,24 +75,121 @@ public static boolean swapNumprocPfd(boolean pfd) { } // ------------------------------------------------------------------------------------------ ARITHMETIC - /** An intermediate quotient as GnuCOBOL forms it (cob_decimal_div): the dividend shifted 38 digits, then - * divided and truncated -- the receiver's own truncation or ROUNDED then applies in store. */ + /** An intermediate quotient as GnuCOBOL forms it (cob_decimal_div): the scale a.scale - b.scale, the dividend + * shifted 38 digits more (and as many again as that scale is below zero), then divided and truncated -- so the + * quotient keeps 38 + max(a.scale - b.scale, 0) decimal places; a zero dividend is 0 of scale 0. The + * receiver's own truncation or ROUNDED then applies in store. */ public static BigDecimal divide(BigDecimal a, BigDecimal b) { if (b.signum() == 0) { throw new ArithmeticException("division by zero"); } - int shift = 38 + Math.max(0, -a.scale()); - int scale = Math.max(0, a.scale() + shift - b.scale()); - return a.divide(b, scale, java.math.RoundingMode.DOWN); + if (a.signum() == 0) { + return BigDecimal.ZERO; + } + return a.divide(b, 38 + Math.max(a.scale() - b.scale(), 0), java.math.RoundingMode.DOWN); + } + + /** ARITHMETIC-OSVS (#4287, oracle_assumptions C2): an intermediate result truncated to `scale` decimal places, as + * libcob's cob_decimal_align does it where cobc -std=ibm emits it (the translator decides where, det/osvs.py). + * With more places than `scale` the low-order ones are dropped (toward zero); with fewer, libcob shifts the + * other way: the value loses as many low-order digits as it lacks places (579 aligned to 2 places is 500). */ + public static BigDecimal align(BigDecimal d, int scale) { + if (d.scale() > scale) { + return d.setScale(scale, java.math.RoundingMode.DOWN); + } + if (d.scale() < scale) { + int k = scale - d.scale(); + return new BigDecimal(d.unscaledValue().divide(java.math.BigInteger.TEN.pow(k)), d.scale() - k); + } + return d; + } + + /** A numeric literal on the right of an operation in a decimal expression: libcob's decimal constant (cobc's + * dc_N, one per distinct literal of the program, set once when the program starts). libcob changes its scale + * in place: an ADD or SUBTRACT from an intermediate with more decimal places raises it to theirs + * (align_decimal), an exponent loses its trailing zeros (cob_decimal_pow) -- and every later use of the same + * literal sees the changed scale (oracle_assumptions C2, #4287). */ + public static final class Dc { + private final BigDecimal initial; + BigDecimal v; + + public Dc(String literal) { + initial = new BigDecimal(literal); + v = initial; + } + + /** The program starts again (its initial state): the literal as written. */ + public void reset() { + v = initial; + } + } + + /** cob_decimal_add with a decimal constant (Dc). */ + public static BigDecimal add(BigDecimal a, Dc c) { + if (a.scale() > c.v.scale()) { + c.v = c.v.setScale(a.scale()); + } + return a.add(c.v); + } + + /** cob_decimal_sub with a decimal constant (Dc). */ + public static BigDecimal subtract(BigDecimal a, Dc c) { + if (a.scale() > c.v.scale()) { + c.v = c.v.setScale(a.scale()); + } + return a.subtract(c.v); } - /** A ** b: exact for a whole exponent, else through double. */ + /** cob_decimal_mul with a decimal constant (Dc): the constant as it is now. */ + public static BigDecimal multiply(BigDecimal a, Dc c) { + return a.multiply(c.v); + } + + /** cob_decimal_div with a decimal constant (Dc): the constant as it is now. */ + public static BigDecimal divide(BigDecimal a, Dc c) { + return divide(a, c.v); + } + + /** cob_decimal_pow with a decimal constant exponent (Dc, never negative: the translator refuses one): trimmed in + * place once the base is not zero. */ + public static BigDecimal power(BigDecimal a, Dc c) { + if (c.v.signum() != 0 && a.signum() != 0) { + c.v = trim(c.v); + } + return power(a, c.v); + } + + /** cob_trim_decimal: trailing zeros dropped while the scale is above zero; zero is 0 of scale 0. */ + private static BigDecimal trim(BigDecimal d) { + if (d.signum() == 0) { + return BigDecimal.ZERO; + } + while (d.scale() > 0) { + java.math.BigInteger[] qr = d.unscaledValue().divideAndRemainder(java.math.BigInteger.TEN); + if (qr[1].signum() != 0) { + break; + } + d = new BigDecimal(qr[0], d.scale() - 1); + } + return d; + } + + /** A ** b as cob_decimal_pow forms it: a whole exponent exactly (base and result trimmed of trailing zeros, a + * negative one through cob_decimal_div), else through double. */ public static BigDecimal power(BigDecimal a, BigDecimal b) { - if (b.signum() >= 0 && b.stripTrailingZeros().scale() <= 0 && b.compareTo(BigDecimal.valueOf(999)) <= 0) { - return a.pow(b.intValueExact()); + boolean whole = b.stripTrailingZeros().scale() <= 0; + if (b.signum() == 0) { + return BigDecimal.ONE; + } + if (a.signum() == 0) { + return BigDecimal.ZERO; + } + if (whole && b.signum() > 0 && b.compareTo(BigDecimal.valueOf(999)) <= 0) { + int n = b.intValueExact(); + return n == 1 ? trim(a) : trim(trim(a).pow(n)); } - if (b.signum() < 0 && b.stripTrailingZeros().scale() <= 0) { - return divide(BigDecimal.ONE, a.pow(-b.intValueExact())); + if (whole && b.signum() < 0) { + return trim(divide(BigDecimal.ONE, trim(trim(a).pow(-b.intValueExact())))); } return new BigDecimal(Math.pow(a.doubleValue(), b.doubleValue())); } diff --git a/gitgalaxy/tools/cobol_to_java/det/expr.py b/gitgalaxy/tools/cobol_to_java/det/expr.py index 856f943f2e..3c788ae10d 100644 --- a/gitgalaxy/tools/cobol_to_java/det/expr.py +++ b/gitgalaxy/tools/cobol_to_java/det/expr.py @@ -32,6 +32,8 @@ class Lit: """A nonnumeric literal (text) or a numeric literal (Decimal).""" value: str | Decimal | bytes + # a numeric literal as written (its leading zeros: cobc's literal identity, det/osvs.py); not part of its value + text: str | None = field(default=None, compare=False, repr=False) @dataclass @@ -180,10 +182,10 @@ def operand(self) -> object: raise ExprError("NULL: pointers are not modelled") if _is_number(tok): self.i += 1 - return Lit(Decimal(tok.replace(",", ".") if tok.count(",") == 1 and "." not in tok else tok)) + return Lit(Decimal(tok.replace(",", ".") if tok.count(",") == 1 and "." not in tok else tok), tok) if u in ("+", "-") and (nxt1 := self.peek(1)) is not None and _is_number(nxt1): self.i += 2 - return Lit(Decimal(u + self.t[self.i - 1])) + return Lit(Decimal(u + self.t[self.i - 1]), u + self.t[self.i - 1]) if u == "FUNCTION": self.i += 1 name = self.take().upper() diff --git a/gitgalaxy/tools/cobol_to_java/det/gen.py b/gitgalaxy/tools/cobol_to_java/det/gen.py index a8c8502451..a32f4ed7d5 100644 --- a/gitgalaxy/tools/cobol_to_java/det/gen.py +++ b/gitgalaxy/tools/cobol_to_java/det/gen.py @@ -16,6 +16,7 @@ from gitgalaxy.tools.cobol_to_java.det import expr as E from gitgalaxy.tools.cobol_to_java.det import layout as L +from gitgalaxy.tools.cobol_to_java.det import osvs as O from gitgalaxy.tools.cobol_to_java.det import stmt as S from gitgalaxy.tools.cobol_to_java.det.source import WIDE @@ -115,6 +116,13 @@ def __init__(self, prog: Program, structured: bool = False): self.lines: list[str] = [] self.stats: dict[str, Any] = {"statements": 0, "translated": 0, "holes": []} self.sentence: str | None = None # the label of the NEXT SENTENCE block being generated + # ARITHMETIC-OSVS (#4287, det/osvs.py): the plan of the expression being generated (id(node) -> ALIGN / FOLD) + # and, inside a condition, the plans of its relations still to generate, in order + self.osvs: dict = {} + self.rel_plans: list | None = None + # what the last decimal build of the sentence left: (dmax, expr_decp stack), None when nothing (an EVALUATE's + # condition leaves its state to the next statements of the sentence; a period, a COMPUTE or an IF resets it) + self.leak: tuple | None = None self.items: dict[str, list[L.Item]] = {} self.ids: dict[int, str] = {} self.storage_of: dict[int, str] = {} @@ -153,6 +161,9 @@ def __init__(self, prog: Program, structured: bool = False): self.conds.setdefault(c.name, []).append(c) self.para_index = {p.name: i for i, p in enumerate(prog.proc.paragraphs)} self.consts: dict[str, str] = {} + self.dcs: dict[ + tuple, tuple + ] = {} # ARITHMETIC-OSVS: libcob's decimal constants (Cobol.Dc): key -> (name, value) self.tmp = 0 self.cur = 0 self.cics: Cics | None = None @@ -320,6 +331,157 @@ def walk(e) -> None: else "LONG" ) + # ---- ARITHMETIC-OSVS (#4287) ------------------------------------------------------------------------------ + def osvs_leaf(self, e) -> O.Leaf: + """What cobc's decimal_expand does with a data item or a FUNCTION (det/osvs.py).""" + if isinstance(e, E.Func): + return O.Leaf("FUNC", 0, True) + it = self.resolve(e) + if it.level == 88: + return O.Leaf("COND", 0, False) + scale = it.scale if it.category in ("NUMERIC", "NUMERIC-EDITED") else 0 + # a binary item of scale 0 is loaded as an integer (cob_decimal_set_llint): no places pushed + binary = it.usage in ("BINARY", "COMP-5", "INDEX") and scale == 0 and e.refmod is None + return O.Leaf("ITEM", scale, not binary) + + def osvs_length(self, e: E.LengthOf) -> int: + it = self.resolve(e.ref) + return it.size * it.occurs + + @contextlib.contextmanager + def osvs_plan(self, plan: dict): + outer, self.osvs = self.osvs, plan + try: + yield + finally: + self.osvs = outer + + def plan_arith(self, expr, receivers: list, operands_first: bool = False) -> dict: + """The plan of an arithmetic statement's expression, its receivers' places counted (build_decimal_assign).""" + scales = [] + for t in receivers: + it = self.resolve(t) if isinstance(t, E.Ref) else None + if it is not None and it.category in ("NUMERIC", "NUMERIC-EDITED"): + scales.append(it.scale) + elif it is not None: + scales.append(0) + dmax_in = self.leak[0] if self.leak is not None else -1 + self.leak = None + try: + return O.plan_compute(expr, scales, self.osvs_leaf, self.osvs_length, operands_first, dmax_in) + except O.OsvsError as e: + raise Untranslatable(f"ARITHMETIC-OSVS: {e} (#4287)") from e + + def cond_relations(self, c, out: list) -> None: + """The relations of a condition in the order cond() generates them, each with its operands in cobc's order.""" + if isinstance(c, (E.And, E.Or)): + self.cond_relations(c.left, out) + self.cond_relations(c.right, out) + elif isinstance(c, E.Not): + self.cond_relations(c.cond, out) + elif isinstance(c, E.CondName): + if c.abbrev is not None and self.resolve_cond(c.ref) is None: + out.append(self._relation(c.abbrev[1], c.ref, c.abbrev[0] == "=")) + elif isinstance(c, E.ClassCond): + if c.kind in ("POSITIVE", "NEGATIVE", "ZERO"): + out.append(self._relation(c.operand, E.Fig("ZEROS"))) # cobc: x > ZERO, a constant + elif isinstance(c, E.Rel): + out.append(self._relation(c.left, c.right, c.op == "=")) + + @staticmethod + def _relation(x, y, equality: bool = False) -> O.Relation: + return O.Relation(x, y, equality) + + def field_constant(self, ref: E.Ref, lit, equality: bool) -> bool: + """Whether cobc decides a relation of a USAGE DISPLAY item with a literal at compile time (cobc/tree.c + compare_field_literal): a literal longer than an alphanumeric item, or with more decimals or more integer + digits than a numeric one, makes = / NOT = constant -- and, for more integer digits, < > <= >= on a numeric + item too. Such a relation is neither walked nor built (#4287).""" + try: + it = self.resolve(ref) + except Untranslatable: + return False + if it.usage != "DISPLAY" or it.level == 88: + return False + if isinstance(lit, O.Num): + data, lscale = lit.digits, lit.scale + elif isinstance(lit, E.Fig): # ZERO: cb_zero_lit + data, lscale = "0", 0 + elif isinstance(lit, E.Lit) and isinstance(lit.value, str): + data, lscale = lit.value, 0 + else: + return False + refmod_length = 0 + if ref.refmod is not None: + start, length = ref.refmod + if length is not None and isinstance(length, E.Lit) and isinstance(length.value, Decimal): + refmod_length = int(length.value) + elif isinstance(start, E.Lit) and isinstance(start.value, Decimal) and length is None: + refmod_length = it.size - int(start.value) + 1 + else: + return False + lit_length = len(data.rstrip(" ")) + numeric = it.category in ("NUMERIC", "NUMERIC-EDITED") + if not numeric or refmod_length: + return equality and lit_length > (refmod_length or it.size) + fscale = it.scale + if fscale < 0 or not data.isdigit(): + return False + if set(data) == {"0"}: + i, scale = 0, 0 + else: + lit_start = len(data) - len(data.lstrip("0")) + lit_length -= lit_start + scale, i, j = lscale, lit_length, len(data) + while scale > 0 and j > 0 and data[j - 1] == "0": + scale, i, j = scale - 1, i - 1, j - 1 + if scale > 0 and fscale < scale: + return equality + if i - scale > 0 and it.size - fscale >= 0 and i - scale > it.size - fscale: + return equality or it.category == "NUMERIC" + return False + + def plan_relations(self, groups: list[list[O.Relation]], evaluate: bool = False) -> list[dict]: + state_in, self.leak = (None if evaluate else self.leak), None + try: + out = O.plan_condition(groups, self.osvs_leaf, self.osvs_length, evaluate, state_in, self.field_constant) + except O.OsvsError as e: + raise Untranslatable(f"ARITHMETIC-OSVS: {e} (#4287)") from e + self.leak = out if evaluate else None + return [r.plan for g in groups for r in g] + + @contextlib.contextmanager + def relations_planned(self, plans: list[dict]): + outer, self.rel_plans = self.rel_plans, list(plans) + try: + yield + if self.rel_plans: + raise Untranslatable("ARITHMETIC-OSVS: a condition's relations out of step with their plan (#4287)") + finally: + self.rel_plans = outer + + @contextlib.contextmanager + def relation(self): + """One relation being generated: its plan, the next of the condition's (none outside a planned one).""" + if self.rel_plans is None: + plan: dict = {} + elif not self.rel_plans: + raise Untranslatable("ARITHMETIC-OSVS: a condition's relations out of step with their plan (#4287)") + else: + plan = self.rel_plans.pop(0) + with self.osvs_plan(plan): + yield + + def cond_top(self, c) -> str: + """A condition as cobc builds one (cb_build_cond then cb_end_cond: IF, PERFORM UNTIL, SEARCH WHEN).""" + if isinstance(c, tuple): + self.leak = None + return self.cond(c) + rels: list = [] + self.cond_relations(c, rels) + with self.relations_planned(self.plan_relations([rels])): + return self.cond(c) + @contextlib.contextmanager def floating(self, mode: str | None): outer, self.fmode = self.fmode, mode @@ -711,20 +873,13 @@ def num(self, e) -> str: return f"Cobol.num({self.field_expr(e)}, CS)" if isinstance(e, E.Fig) and e.kind == "ZEROS": return "BigDecimal.ZERO" - if isinstance(e, E.Neg): - return f"{self.num(e.operand)}.negate()" - if isinstance(e, E.Bin): - a, b = self.num(e.left), self.num(e.right) - if e.op == "+": - return f"{a}.add({b})" - if e.op == "-": - return f"{a}.subtract({b})" - if e.op == "*": - return f"{a}.multiply({b})" - if e.op == "/": - return f"Cobol.divide({a}, {b})" - if e.op == "**": - return f"Cobol.power({a}, {b})" + if isinstance(e, (E.Bin, E.Neg)): + p = self.osvs.get(id(e)) + if p is not None and p[0] == "FOLD": # cobc folded the two literals at compile time (det/osvs.py) + return self.const(p[1].value) + v = self.num_op(e) + # ARITHMETIC-OSVS: cobc truncates this intermediate (cob_decimal_align, det/osvs.py) + return f"Cobol.align({v}, {p[1]})" if p is not None else v if isinstance(e, E.LengthOf): it = self.resolve(e.ref) return f"BigDecimal.valueOf({it.size * it.occurs})" @@ -732,6 +887,59 @@ def num(self, e) -> str: return self.func(e) raise Untranslatable(f"expression {type(e).__name__}") + def dc(self, n: O.Num) -> str: + """libcob's decimal constant of a literal (Cobol.Dc): one per literal as cobc keys it (its digits as written, + scale and sign), its scale changed by use.""" + if n.key not in self.dcs: + name = "DC_" + re.sub(r"[^A-Za-z0-9]", "_", str(n.value).replace("-", "M")) + while name in (x for x, _ in self.dcs.values()): + name += "_" + self.dcs[n.key] = (name, str(n.value)) + return self.dcs[n.key][0] + + def dc_literal(self, e) -> O.Num | None: + """The literal on the right of an operation that cobc makes a decimal constant (planned expressions only).""" + if O.PLANNED not in self.osvs: + return None + p = self.osvs.get(id(e)) + if p is not None and p[0] == "FOLD": + return p[1] + if isinstance(e, E.Lit) and isinstance(e.value, Decimal): + return O.Num.written(e.text) if e.text else O.Num.of(e.value) + if isinstance(e, E.LengthOf): + return O.Num.of(Decimal(self.osvs_length(e))) + return None + + def num_op(self, e) -> str: + if isinstance(e, E.Neg): + if O.PLANNED in self.osvs: # cobc: 0 - x (its scale: x's, at least 0; a literal x a decimal constant) + lit = self.dc_literal(e.operand) + if lit is not None: + return f"Cobol.subtract(BigDecimal.ZERO, {self.dc(lit)})" + return f"BigDecimal.ZERO.subtract({self.num(e.operand)})" + return f"{self.num(e.operand)}.negate()" + lit = self.dc_literal(e.right) + if lit is not None: + a = self.num(e.left) + if e.op == "**" and lit.sign < 0: + raise Untranslatable( + "ARITHMETIC-OSVS: a negative literal exponent (libcob overwrites the constant, #4287)" + ) + fn = {"+": "add", "-": "subtract", "*": "multiply", "/": "divide", "**": "power"}[e.op] + return f"Cobol.{fn}({a}, {self.dc(lit)})" + a, b = self.num(e.left), self.num(e.right) + if e.op == "+": + return f"{a}.add({b})" + if e.op == "-": + return f"{a}.subtract({b})" + if e.op == "*": + return f"{a}.multiply({b})" + if e.op == "/": + return f"Cobol.divide({a}, {b})" + if e.op == "**": + return f"Cobol.power({a}, {b})" + raise Untranslatable(f"operator {e.op}") + def func(self, f: E.Func) -> str: refmod = next((a[1] for a in f.args if isinstance(a, tuple) and a[0] == "REFMOD"), None) if refmod is None: @@ -815,7 +1023,8 @@ def cond(self, c) -> str: cn = self.resolve_cond(c.ref) if cn is None and c.abbrev is not None: op, subject, negated = c.abbrev # an abbreviated relation's object - t = self.rel(op, subject, c.ref) + with self.relation(): + t = self.rel(op, subject, c.ref) return f"!({t})" if negated else t if cn is None: raise Untranslatable(f"condition-name {c.ref.name}") @@ -823,7 +1032,8 @@ def cond(self, c) -> str: if isinstance(c, E.ClassCond): if c.kind in ("POSITIVE", "NEGATIVE", "ZERO"): sig = {"POSITIVE": "> 0", "NEGATIVE": "< 0", "ZERO": "== 0"}[c.kind] - t = f"{self.num(c.operand)}.signum() {sig}" + with self.relation(): + t = f"{self.num(c.operand)}.signum() {sig}" else: if not isinstance(c.operand, E.Ref): raise Untranslatable("class condition on an expression") @@ -832,7 +1042,8 @@ def cond(self, c) -> str: t = f"Cobol.{fn}({self.field_expr(c.operand)}, CS)" return f"!({t})" if c.negated else t if isinstance(c, E.Rel): - return self.rel(c.op, c.left, c.right) + with self.relation(): + return self.rel(c.op, c.left, c.right) raise Untranslatable(f"condition {type(c).__name__}") def rel(self, op: str, a, b) -> str: @@ -1200,17 +1411,25 @@ def factory(self, it: L.Item, storage: str, offset: str) -> str: def paragraph(self, p: S.Paragraph, ind: str) -> list[str]: """A paragraph's statements; with NEXT SENTENCE in it, each sentence is a labelled block NEXT SENTENCE breaks out of (out of an inline PERFORM too, as COBOL's does).""" + ends = [e for e in p.sentence_ends if e > 0] + [len(p.body)] if not any(s.kind == "NEXT-SENTENCE" for s in S.walk(p.body)): - return self.block(p.body, ind) + out, start = [], 0 + for end in ends: # a period resets cobc's arithmetic state (cb_end_statement, #4287) + self.leak = None + out += self.block(p.body[start:end], ind) + start = max(start, end) + self.leak = None + return out out, start = [], 0 - ends = [e for e in p.sentence_ends if e > 0] + [len(p.body)] for k, end in enumerate(ends): if end <= start: continue self.sentence = f"sentence{k}" + self.leak = None out += [f"{ind}{self.sentence}: {{", *self.block(p.body[start:end], ind + " "), f"{ind}}}"] start = end self.sentence = None + self.leak = None return out def block(self, stmts: list, ind: str) -> list[str]: @@ -1251,7 +1470,7 @@ def _statement(self, s: S.Stmt, ind: str) -> list[str]: if k == "MOVE": return [c] + [ind + self.move(s.data["from"], t) for t in s.data["to"]] if k == "IF": - out = [c, f"{ind}if ({self.cond(s.data['cond'])}) {{", *self.block(s.body, ind + " ")] + out = [c, f"{ind}if ({self.cond_top(s.data['cond'])}) {{", *self.block(s.body, ind + " ")] if s.orelse: out += [f"{ind}}} else {{", *self.block(s.orelse, ind + " ")] return [*out, f"{ind}}}"] @@ -1312,7 +1531,9 @@ def _statement(self, s: S.Stmt, ind: str) -> list[str]: mode = self.float_mode([s.data["expr"]], [t for t, _ in s.data["targets"]]) if mode is not None: self.float_statement(s, s.data["targets"]) - with self.floating(mode): + plan = self.plan_arith(s.data["expr"], [t for t, _ in s.data["targets"]]) + plan = {} if mode is not None else plan + with self.floating(mode), self.osvs_plan(plan): value = self.num(s.data["expr"]) return [c, *self.store_all(s, s.data["targets"], value, ind)] if k == "ARITH": @@ -1441,7 +1662,7 @@ def search(self, s: S.Stmt, ind: str) -> list[str]: f"{ind} if ({self.int_expr(idx)} > {size}) {{", *at_end, f"{ind} break {loop};", f"{ind} }}"] # fmt: skip for i, (cond, body) in enumerate(s.whens): - out += [f"{ind} {'} else ' if i else ''}if ({self.cond(cond)}) {{", *self.block(body, ind + " "), + out += [f"{ind} {'} else ' if i else ''}if ({self.cond_top(cond)}) {{", *self.block(body, ind + " "), f"{ind} break {loop};"] # fmt: skip out.append(f"{ind} }}") out.append(f"{ind} Cobol.store({fi}, Cobol.num({fi}, CS).add(BigDecimal.ONE), false, CS);") @@ -1543,6 +1764,8 @@ def float_statement(self, s: S.Stmt, targets: list) -> None: def arith_float(self, s: S.Stmt, ind: str) -> list[str]: """ADD / SUBTRACT / MULTIPLY / DIVIDE in floating point (self.fmode): as arith, each operation HFP's.""" d = s.data + if d.get("giving") is not None or len(d["operands"]) > 1: + self.leak = None # cobc's build_decimal_assign (#4287) op, ops = d["op"], d["operands"] lng = _b(self.fmode == "LONG") if d.get("giving") is not None: @@ -1605,21 +1828,28 @@ def arith(self, s: S.Stmt, ind: str) -> list[str]: op = d["op"] ops = d["operands"] if d.get("giving") is not None: - if op == "+": - val = " ".join([self.num(ops[0])] + [f".add({self.num(o)})" for o in ops[1:]]).replace(" .", ".") - elif op == "-": - val = self.num(ops[0]) + "".join(f".subtract({self.num(o)})" for o in ops[1:]) - elif op == "*": - val = f"{self.num(ops[0])}.multiply({self.num(ops[1])})" - else: - val = f"Cobol.divide({self.num(ops[0])}, {self.num(ops[1])})" + # cobc: one expression, as COMPUTE (cb_build_binary_list): ((A + B) + C), ((C - A) - B), A * B, A / B + expr = ops[0] + for o in ops[1:] if op in ("+", "-") else ops[1:2]: + expr = E.Bin(op, expr, o) + plan = {} if d.get("remainder") is not None else self.plan_arith(expr, [t for t, _ in d["giving"]]) + with self.osvs_plan(plan): + val = self.num(expr) out = self.store_all(s, d["giving"], val, ind) if d.get("remainder") is not None: q = self.field_expr(d["giving"][0][0]) out.append(f"{ind}Cobol.store({self.field_expr(d['remainder'])}, {self.num(ops[0])}.subtract(" f"Cobol.num({q}, CS).multiply({self.num(ops[1])})), false, CS);") # fmt: skip return out - total = self.num(ops[0]) + "".join(f".add({self.num(o)})" for o in ops[1:]) if ops else "BigDecimal.ZERO" + if len(ops) > 1: + # cobc: the operands' sum is one expression, aligned when the first receiver is loaded (#4287) + expr = ops[0] + for o in ops[1:]: + expr = E.Bin("+", expr, o) + with self.osvs_plan(self.plan_arith(expr, [t for t, _ in d["targets"]], operands_first=True)): + total = self.num(expr) + else: + total = self.num(ops[0]) if ops else "BigDecimal.ZERO" out = [] tsum = self.tmpname("t") out.append(f"{ind}BigDecimal {tsum} = {total};") @@ -1653,6 +1883,40 @@ def arith(self, s: S.Stmt, ind: str) -> list[str]: return out def evaluate(self, s: S.Stmt, ind: str) -> list[str]: + # ARITHMETIC-OSVS (#4287): cobc builds each WHEN's condition in turn at END-EVALUATE, the state carried over + groups = [] + for conds, _ in s.whens: + g: list = [] + for objs in conds or []: + for subj, obj in zip(s.data["subjects"], objs, strict=False): + self.when_relations(subj, obj, g) + groups.append(g) + plans = self.plan_relations(groups, evaluate=True) + leak_out = self.leak + with self.relations_planned(plans): + out = self.evaluate_planned(s, ind) + if self.leak is not None and any(plans): + # cobc builds the WHEN conditions after the last WHEN's statements, from the state they leave + raise Untranslatable("ARITHMETIC-OSVS: an EVALUATE whose last WHEN leaves cobc's arithmetic state to its " + "conditions (#4287)") # fmt: skip + self.leak = leak_out + return out + + def when_relations(self, subject, obj, out: list) -> None: + """The relations of one WHEN test in the order when_test generates them (cobc's evaluate_test).""" + kind = obj[0] + if kind in ("ANY", "UNPARSED") or (isinstance(subject, tuple) and subject[0] == "UNPARSED"): + return + if subject in ("TRUE", "FALSE"): + if kind == "COND": + self.cond_relations(obj[1], out) + return + if kind == "VALUE": + out.append(self._relation(subject, obj[1], True)) + elif kind == "RANGE": # cobc: low <= subject AND subject <= high + out += [self._relation(obj[1], subject), self._relation(subject, obj[2])] + + def evaluate_planned(self, s: S.Stmt, ind: str) -> list[str]: subjects = s.data["subjects"] out = [] first = True @@ -1667,6 +1931,8 @@ def evaluate(self, s: S.Stmt, ind: str) -> list[str]: alts.append(" && ".join(self.when_test(subj, obj) for subj, obj in zip(subjects, objs))) test = " || ".join(f"({a})" for a in alts) kw = "if" if first else "} else if" + if conds is not None: # cb_end_cond after the WHEN's objects: its statements start clean + self.leak = None out += [f"{ind}{kw} ({test}) {{", *self.block(body, ind + " ")] first = False if not first: @@ -1696,10 +1962,15 @@ def when_test(self, subject, obj) -> str: t = f"!({t})" if (obj[-1] is True and kind in ("COND", "VALUE")) else t return t if subject == "TRUE" else f"!({t})" if kind == "VALUE": - t = self.rel("=", subject, obj[1]) + with self.relation(): + t = self.rel("=", subject, obj[1]) return f"!({t})" if obj[3] else t if kind == "RANGE": - t = f"({self.rel('>=', subject, obj[1])} && {self.rel('<=', subject, obj[2])})" + with self.relation(): + lo = self.rel(">=", subject, obj[1]) + with self.relation(): + hi = self.rel("<=", subject, obj[2]) + t = f"({lo} && {hi})" return f"!{t}" if obj[3] else t if kind == "COND": raise Untranslatable("a condition as WHEN object of a value subject") @@ -1707,6 +1978,11 @@ def when_test(self, subject, obj) -> str: def perform(self, s: S.Stmt, ind: str) -> list[str]: d = s.data + # cobc builds an UNTIL condition before the inline statements (#4287): it sees the state before them, and + # they start from the clean one it leaves + leak_before = self.leak + if d["until"] is not None or d["varying"] is not None: + self.leak = None if d["inline"] or d["target"] is None: body = self.block(s.body, ind + " ") else: @@ -1729,8 +2005,11 @@ def perform(self, s: S.Stmt, ind: str) -> list[str]: *body, f"{ind}}}", ] + leak_after = self.leak if d["until"] is not None: - cond = self.cond(d["until"]) + self.leak = leak_before + cond = self.cond_top(d["until"]) + self.leak = leak_after if d["test_after"]: return [f"{ind}do {{", *body, f"{ind}}} while (!({cond}));"] return [f"{ind}while (!({cond})) {{", *body, f"{ind}}}"] @@ -1743,7 +2022,11 @@ def perform(self, s: S.Stmt, ind: str) -> list[str]: else f"Cobol.store({f}, {self.num(frm)}, false, CS);" ) step = f"Cobol.store({f}, Cobol.num({f}, CS).add({self.num(by)}), false, CS);" - return [f"{ind}{init}", f"{ind}while (!({self.cond(until)})) {{", *body, f"{ind} {step}", f"{ind}}}"] + self.leak = leak_before + until_code = self.cond_top(until) + self.leak = leak_after + return [f"{ind}{init}", f"{ind}while (!({until_code})) {{", *body, f"{ind} {step}", f"{ind}}}"] + self.leak = leak_after if d["inline"]: return [f"{ind}{{", *body, f"{ind}}}"] return [x[4:] if x.startswith(ind + " ") else x for x in body] diff --git a/gitgalaxy/tools/cobol_to_java/det/osvs.py b/gitgalaxy/tools/cobol_to_java/det/osvs.py new file mode 100644 index 0000000000..02f798ceab --- /dev/null +++ b/gitgalaxy/tools/cobol_to_java/det/osvs.py @@ -0,0 +1,400 @@ +"""ARITHMETIC-OSVS: where the oracle truncates an arithmetic intermediate, and to how many decimal places (#4287). + +`cobc -std=ibm` turns on GnuCOBOL's `arithmetic-osvs` (config/ibm-strict.conf). cobc then decides at compile time, +for each operation of a COMPUTE, an ADD / SUBTRACT / MULTIPLY / DIVIDE with more than one operand, or a relation with +an arithmetic side, a number of decimal places, and emits `cob_decimal_align` on the operation's result before the +next operand is loaded. This module replays that decision exactly as GnuCOBOL 3.1.2 makes it (cobc/typeck.c: +build_decimal_assign, cb_build_cond, cb_walk_cond, decimal_expand, decimal_compute, decimal_align; cobc/tree.c: +cb_build_binary_op's constant folding), so the det translator emits `Cobol.align(value, places)` at the same points. + +The decision, as cobc makes it: + +- dmax, "the most decimal places of any operand or receiver": the receivers' scales and cb_walk_cond over the + expression (every numeric literal and data item, except the divisor side of a division). For a condition the walk + covers the whole condition and is made only when nothing set dmax before -- in an IF, a folded pair of literals + sets it while the condition is parsed; in an EVALUATE only the first WHEN is walked and the others share its dmax. +- A stack of decimal places (expr_decp): an item pushes its scale, a literal of 19 digits or more or with decimals + pushes its scale, a FUNCTION pushes 0. A binary item of scale 0, a short integer literal, ZERO and the right operand + of an operation when it is a numeric literal push nothing -- so the stack does not always pair an operation with its + own operands; cobc's choice stands as it is. +- Each operation pops one entry into the one below (+ - the larger, * the sum, / the difference or dmax, whichever is + greater, ** the difference when the exponent's entry is positive); with fewer than two entries its places are dmax. +- The places are applied (cob_decimal_align) only when the next operand is loaded or another decimal is operated + on: an operation followed by a literal on its right, and the last operation of an expression, are not aligned + (the receiver's own store truncates or rounds that one). +- Conditions: `A AND B` builds B before A (gcc evaluates cb_build_binary_op's arguments right to left -- measured on + the pinned oracle), so the relations of one condition are built in reverse order, and the stack and dmax carry + from one relation to the next (and, in an EVALUATE, from one WHEN to the next). + +The runtime's `Cobol.align` is libcob's cob_decimal_align, its shift included: an intermediate with fewer decimal +places than the target loses as many low-order digits (an integer 579 aligned to 2 places is 500). +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from decimal import Decimal + +from gitgalaxy.tools.cobol_to_java.det import expr as E + +MAX_NESTED_EXPR = 42 +PLANNED = "planned" # a plan's marker: the expression is generated as cobc's decimal expansion (Cobol.Dc constants) # cobc/typeck.c: pushes past it are dropped with a warning + +# a plan: id(node) -> ("ALIGN", places) for an operation whose result cobc aligns, ("FOLD", value) for a pair of +# literals cobc folds at compile time (the folded literal keeps cobc's scale) +Plan = dict + + +class OsvsError(Exception): + """An expression whose cobc decision this model does not replay.""" + + +@dataclass +class Leaf: + """What decimal_expand does with an operand: `kind` LITERAL | ITEM | FUNC | CONST, `scale` its decimal places, + `pushes` whether it pushes them (expr_decp).""" + + kind: str + scale: int = 0 + pushes: bool = True + + +@dataclass +class Relation: + """One relation of a condition, its operands in cobc's order (x expanded first); `decimal` when either side is + an operation (cobc compares in decimal then, cob_decimal_cmp).""" + + x: object + y: object + equality: bool = False # = or NOT = (cobc's '=' / '~'): what compare_field_literal can decide at compile time + decimal: bool = False + plan: Plan = field(default_factory=dict) + constant: bool = False # decided at compile time (cb_true / cb_false): neither walked nor built + + +@dataclass(frozen=True) +class Num: + """A numeric literal as cobc holds it (struct cb_literal): its digits as written (leading zeros kept), its scale + and its sign (-1, 0, +1). Two literals are one decimal constant when all three agree (cb_lookup_literal).""" + + sign: int + digits: str + scale: int + + @property + def value(self) -> Decimal: + v = Decimal(int(self.digits or "0")).scaleb(-self.scale) + return -v if self.sign < 0 else v + + @property + def unscaled(self) -> int: + return -int(self.digits or "0") if self.sign < 0 else int(self.digits or "0") + + @property + def key(self) -> tuple: + return (self.sign, self.digits, self.scale) + + @staticmethod + def written(text: str) -> Num: + sign = -1 if text[:1] == "-" else 1 if text[:1] == "+" else 0 + body = text.lstrip("+-") + sep = "." if "." in body else "," if "," in body else None + scale = len(body.split(sep, 1)[1]) if sep else 0 + return Num(sign, body.replace(".", "").replace(",", ""), scale) + + @staticmethod + def of(v: Decimal) -> Num: + """A literal known only by its value (a folded result, LENGTH OF, a DFHRESP value): no leading zeros.""" + t = v.as_tuple() + scale = -t.exponent if isinstance(t.exponent, int) and t.exponent < 0 else 0 + digits = "".join(map(str, t.digits)) + if isinstance(t.exponent, int) and t.exponent > 0: + digits += "0" * t.exponent + return Num(-1 if t.sign else 0, digits, scale) + + @staticmethod + def folded(rslt: int, rscale: int) -> Num: + """cb_build_numeric_literal (0, sprintf ("%lld", rslt), rscale): a minus sign becomes the literal's sign.""" + return Num(-1 if rslt < 0 else 0, str(abs(rslt)), rscale) + + +def is_op(e) -> bool: + return isinstance(e, (E.Bin, E.Neg)) + + +class Folder: + """cb_build_binary_op's constant folding of two numeric literals (cobc/tree.c), bottom up: the folded nodes and + the scales cb_set_dmax saw.""" + + def __init__(self, length: Callable | None = None): + self.folded: dict[int, Num] = {} + self.length = length + self.dmax = -1 + self.attempted = False + + def literal(self, e) -> Num | None: + """The literal an operand is to cobc: a numeric literal, LENGTH OF an item, a folded pair; else None.""" + if isinstance(e, E.Lit) and isinstance(e.value, Decimal): + return Num.written(e.text) if e.text else Num.of(e.value) + if isinstance(e, E.LengthOf) and self.length is not None: + return Num.of(Decimal(self.length(e))) + if isinstance(e, (E.Bin, E.Neg)) and id(e) in self.folded: + return self.folded[id(e)] + return None + + def fold(self, e) -> None: + if isinstance(e, E.Neg): # 0 - x: cb_zero is no literal, nothing to fold + self.fold(e.operand) + return + if not isinstance(e, E.Bin): + return + self.fold(e.left) + self.fold(e.right) + x, y = self.literal(e.left), self.literal(e.right) + if x is None or y is None or len(x.digits) < x.scale or len(y.digits) < y.scale: + return + if len(x.digits) > 18 or len(y.digits) > 18: + raise OsvsError("a folded literal of more than 18 digits") + xs, ys = x.scale, y.scale + self.attempted = True + self.dmax = max(self.dmax, xs, ys) + xv, yv = x.unscaled, y.unscaled + op = e.op + rscale, rslt = 0, 0 + if op in ("+", "-"): + while xs < ys: + xv, xs = xv * 10, xs + 1 + while xs > ys: + yv, ys = yv * 10, ys + 1 + rscale = xs + rslt = xv + yv if op == "+" else xv - yv + elif op == "*": + rscale, rslt = xs + ys, xv * yv + elif op == "/" and yv != 0: + while ys > 0: + xv, ys = xv * 10, ys - 1 + rscale = xs + if xv % yv == 0: + rslt = _cdiv(xv, yv) + while rscale > 0 and rslt != 0 and rslt % 10 == 0: + rslt, rscale = _cdiv(rslt, 10), rscale - 1 + if op in ("+", "-", "*"): + self.folded[id(e)] = Num.folded(rslt, rscale) + elif op == "/": + if yv == 0: + return + if rslt != 0: + self.folded[id(e)] = Num.folded(rslt, rscale) + elif x.scale == 0 and y.scale == 0 and xv % yv == 0: + self.folded[id(e)] = Num.folded(_cdiv(xv, yv), rscale) + elif op == "**": + if x.scale != 0 or y.scale != 0 or yv < 0: + return + self.folded[id(e)] = Num.folded(1 if yv == 0 or xv == 1 else xv**yv, 0) + + +def _cdiv(a: int, b: int) -> int: + """C's integer division (toward zero).""" + q = abs(a) // abs(b) + return q if (a < 0) == (b < 0) else -q + + +class Sim: + """cobc's compile-time state for one statement or condition: dmax, the expr_decp stack, the pending align.""" + + def __init__(self, leaf: Callable, length: Callable, folder: Folder): + self.leaf = leaf # (operand) -> Leaf, for E.Ref / E.Func + self.length = length # (E.LengthOf) -> int + self.folder = folder + self.dmax = -1 + self.stack: list[int] = [] + self.pending: tuple | None = None # (decimal, places, node, generation) + self.plan: Plan = {} + self.gen = 0 # the relation being built: an align left pending by another one hits a dead decimal + + # ---- dmax: cb_walk_cond ------------------------------------------------------------------------------------ + def walk(self, e) -> None: + v = self.folder.literal(e) + if v is not None: + self.dmax = max(self.dmax, v.scale) + return + if isinstance(e, E.Ref): + lf = self.leaf(e) + if lf.kind == "ITEM": + self.dmax = max(self.dmax, lf.scale) + elif isinstance(e, E.Bin): + self.walk(e.left) + if e.op != "/": + self.walk(e.right) + elif isinstance(e, E.Neg): + self.walk(e.operand) + + def receiver(self, scale: int) -> None: + self.dmax = max(self.dmax, scale) + + # ---- decimal_expand / decimal_compute / decimal_align --------------------------------------------------------- + def flush(self) -> None: + if self.pending is not None: + _, places, node, gen = self.pending + if gen == self.gen: + self.plan[id(node)] = ("ALIGN", places) + self.pending = None + + def push(self, places: int) -> None: + if len(self.stack) >= MAX_NESTED_EXPR: + raise OsvsError(f"more than {MAX_NESTED_EXPR} nested expressions") + self.stack.append(places) + + def _leaf_of(self, e) -> Leaf: + v = self.folder.literal(e) + if v is not None: # cob_decimal_set_llint for a short integer, else cob_decimal_set_field + return Leaf("LITERAL", v.scale, len(v.digits) >= 19 or v.scale != 0) + if isinstance(e, E.Fig) and e.kind == "ZEROS": + return Leaf("CONST", 0, False) + if isinstance(e, (E.Ref, E.Func)): + return self.leaf(e) + raise OsvsError(f"an operand {type(e).__name__} in arithmetic") + + def _right_is_literal(self, e) -> bool: + return self.folder.literal(e) is not None + + def expand(self, d: object, e) -> None: + if (isinstance(e, E.Bin) and id(e) not in self.folder.folded) or isinstance(e, E.Neg): + if isinstance(e, E.Neg): # cobc: 0 - x (cb_zero, a constant: no align, no push) + op, right = "-", e.operand + else: + op, right = e.op, e.right + self.expand(d, e.left) + if self._right_is_literal(right): + self.compute(op, d, e) + else: + t = object() + self.expand(t, right) + self.compute(op, d, e) + return + lf = self._leaf_of(e) + if lf.kind == "CONST": + return + self.flush() + if lf.pushes: + self.push(lf.scale) + + def compute(self, op: str, d: object, node) -> None: + if self.pending is not None and self.pending[0] is not d: + self.flush() + places = self.dmax + if len(self.stack) > 1: + top = self.stack.pop() + below = self.stack[-1] + if op in ("+", "-"): + below = max(below, top) + elif op == "*": + below += top + elif op == "/": + below = max(below - top, self.dmax) + elif op == "**" and below - top < below: + below -= top + self.stack[-1] = below + places = below + self.pending = (d, places, node, self.gen) if places >= 0 else None + + +def plan_compute( + expr, receiver_scales: list[int], leaf: Callable, length: Callable, operands_first: bool = False, dmax_in: int = -1 +) -> Plan: + """The plan of a COMPUTE (or an ADD / SUBTRACT / MULTIPLY / DIVIDE ... GIVING, op 0): build_decimal_assign. + `operands_first` for ADD / SUBTRACT ... TO / FROM (an op other than 0): each receiver is loaded after the + operands' sum, which aligns it -- then the plan also holds the sum's places under id(expr). `dmax_in`: the dmax an + earlier statement of the sentence left (an EVALUATE's; build_decimal_assign takes the larger).""" + folder = Folder(length) + folder.fold(expr) + sim = Sim(leaf, length, folder) + sim.dmax = max(folder.dmax, dmax_in) + for s in receiver_scales: + sim.receiver(s) + sim.walk(expr) + sim.expand(object(), expr) + if operands_first: + sim.flush() # decimal_expand (t, receiver) -> decimal_align + plan = dict(sim.plan) + for k, v in folder.folded.items(): + plan[k] = ("FOLD", v) + plan[PLANNED] = True + return plan + + +def plan_condition( + groups: list[list[Relation]], + leaf: Callable, + length: Callable, + evaluate: bool = False, + state_in: tuple | None = None, + field_constant: Callable | None = None, +) -> tuple | None: + """Fill each Relation's plan, the relations of a condition (one group) or of an EVALUATE (one group per WHEN), + each group in emission order (left to right). cobc builds a group's relations right to left; the state carries + over the groups in order. An IF's dmax comes from its folded literals when it has any, else from the walk; an + EVALUATE's folds were reset at parse time (cb_end_cond), and its first WHEN's walk serves every WHEN. `state_in` + (dmax, stack): what an earlier statement of the sentence left (None: nothing). Returns the state the build leaves + (an EVALUATE's stays for the rest of the sentence; an IF's, PERFORM's or SEARCH's is reset, cb_end_cond). + `field_constant(item operand, literal, equality)`: whether cobc's compare_field_literal decides a relation of a + USAGE DISPLAY item with a literal at compile time.""" + folder = Folder(length) + for g in groups: + for r in g: + folder.fold(r.x) + folder.fold(r.y) + sim = Sim(leaf, length, folder) + if state_in is not None: + sim.dmax, sim.stack = state_in[0], list(state_in[1]) + if not evaluate and folder.attempted: + sim.dmax = max(sim.dmax, folder.dmax) + for g in groups: + for r in g: + r.decimal = any(is_op(n) and id(n) not in folder.folded for n in (r.x, r.y)) + x, y = folder.literal(r.x), folder.literal(r.y) + if x is not None and y is not None: + # two unsigned integer literals compare at compile time (cb_build_binary_op): cb_true / cb_false + r.constant = x.sign == y.sign == 0 and x.scale == y.scale == 0 + elif field_constant is not None and not r.decimal: + if (x is not None or _literal_node(r.x)) and isinstance(r.y, E.Ref): + r.constant = field_constant(r.y, x if x is not None else r.x, r.equality) + elif (y is not None or _literal_node(r.y)) and isinstance(r.x, E.Ref): + r.constant = field_constant(r.x, y if y is not None else r.y, r.equality) + for g in groups: + if sim.dmax == -1: + for r in g: + if not r.constant: + sim.walk(r.x) + sim.walk(r.y) + for r in reversed(g): + if not r.decimal: + continue + sim.plan = {} + sim.gen += 1 + sim.expand(object(), r.x) + sim.expand(object(), r.y) + r.plan = dict(sim.plan) + r.plan[PLANNED] = True + for n in (r.x, r.y): + _folds_of(n, folder, r.plan) + # a pending align left on the last relation's right side is never applied to a live value + return None if sim.dmax == -1 and not sim.stack else (sim.dmax, list(sim.stack)) + + +def _literal_node(e) -> bool: + """ZERO (cobc's cb_zero_lit there) or a nonnumeric literal.""" + return (isinstance(e, E.Fig) and e.kind == "ZEROS") or (isinstance(e, E.Lit) and not isinstance(e.value, Decimal)) + + +def _folds_of(e, folder: Folder, plan: Plan) -> None: + if isinstance(e, (E.Bin, E.Neg)): + if id(e) in folder.folded: + plan[id(e)] = ("FOLD", folder.folded[id(e)]) + return + if isinstance(e, E.Bin): + _folds_of(e.left, folder, plan) + _folds_of(e.right, folder, plan) + else: + _folds_of(e.operand, folder, plan) diff --git a/gitgalaxy/tools/cobol_to_java/det/program.py b/gitgalaxy/tools/cobol_to_java/det/program.py index f4a02038c5..ca47f75297 100644 --- a/gitgalaxy/tools/cobol_to_java/det/program.py +++ b/gitgalaxy/tools/cobol_to_java/det/program.py @@ -861,6 +861,8 @@ def callee_types(cls: str) -> list[str]: ctor_repos += [(c, f) for c, f in gen.sql.repos.items()] extra_imports.append(f"{package}.cobolrt.sql.DetSql") consts = [f' private static final BigDecimal {n} = new BigDecimal("{v}");' for v, n in gen.consts.items()] + # ARITHMETIC-OSVS (#4287): libcob's decimal constants, their scale changed by use, set again at each entry + consts += [f' private final Cobol.Dc {n} = new Cobol.Dc("{v}");' for n, v in gen.dcs.values()] if gen.pcs_used: # #4539: the PROGRAM COLLATING SEQUENCE its nonnumeric relation conditions compare under consts.append(f" private static final Sort.Collating COLLATING = {gen.pcs_used};") n_para = len(proc.paragraphs) @@ -1047,6 +1049,7 @@ def callee_types(cls: str) -> list[str]: *inits, # an entry point starts a run unit: a first FUNCTION RANDOM with no seed seeds zero (IBM; C12) *([" funcRandom.reset();"] if gen.uses_random else []), + *[f" {n}.reset();" for n, _ in gen.dcs.values()], " }", "", ] diff --git a/tests/cobol_mainframe/test_det_osvs.py b/tests/cobol_mainframe/test_det_osvs.py new file mode 100644 index 0000000000..2acaf6e4ef --- /dev/null +++ b/tests/cobol_mainframe/test_det_osvs.py @@ -0,0 +1,207 @@ +"""ARITHMETIC-OSVS in the det port (#4287, oracle_assumptions C2): `cobc -std=ibm` truncates arithmetic intermediates +(cob_decimal_align) where its compile-time state says so; the det translator replays that state (det/osvs.py) and the +runtime truncates the same way (Cobol.align, Cobol.Dc). + +The planning tests need no Docker. The end-to-end test runs one program through GnuCOBOL (the harness's oracle) and +through the det port; it needs EQUIVALENCE_E2E=1, Docker and a JDK 17.""" + +from __future__ import annotations + +import os +import shutil +import sys +from decimal import Decimal +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from gitgalaxy.tools.cobol_to_java.det import expr as E # noqa: E402 +from gitgalaxy.tools.cobol_to_java.det import osvs as O # noqa: E402 + +# items by name: (scale, binary integer -- loaded without its places pushed) +ITEMS = {"A": (0, False), "B": (0, False), "C": (0, False), "XB": (0, True), "YB": (0, True), "Z": (0, False), + "A2": (2, False), "ZB": (0, True), "Q3": (3, False), "I5": (5, False)} # fmt: skip + + +def leaf(e): + if isinstance(e, E.Func): + return O.Leaf("FUNC", 0, True) + scale, binary = ITEMS[e.name] + return O.Leaf("ITEM", scale, not binary) + + +def length(_e): + return 4 + + +def aligns(plan: dict) -> list[int]: + return [v[1] for k, v in plan.items() if k != O.PLANNED and v[0] == "ALIGN"] + + +def test_the_issues_quotient_is_aligned_to_the_receivers_places(): + # COMPUTE R = A / B * C, R PIC 999V99: A / B kept to dmax = 2 places, then times 300 -- 099.00 (#4287) + q = E.parse_arith("A / B") + expr = E.Bin("*", q, E.Ref("C")) + plan = O.plan_compute(expr, [2], leaf, length) + assert plan[id(q)] == ("ALIGN", 2) + assert id(expr) not in plan # the last operation: the receiver's store truncates it + + +def test_a_literal_on_the_right_does_not_load_so_nothing_is_aligned_before_it(): + # A / B * 3: the literal is a decimal constant, never loaded -- the quotient goes on exact (measured: 000.99) + q = E.parse_arith("A / B") + plan = O.plan_compute(E.Bin("*", q, E.Lit(Decimal(3), "3")), [2], leaf, length) + assert aligns(plan) == [] + + +def test_binary_integers_push_nothing_so_their_sum_takes_dmax(): + # COMPUTE R = XB + YB + Z, R PIC 999V99: XB + YB aligned to 2 places from scale 0 -- libcob drops two digits + s = E.parse_arith("XB + YB") + plan = O.plan_compute(E.Bin("+", s, E.Ref("Z")), [2], leaf, length) + assert plan[id(s)] == ("ALIGN", 2) + + +def test_unary_minus_is_zero_minus_and_aligned_like_any_operation(): + # - A + A7 is (0 - A) + A7: the difference aligned to dmax (measured: 007.00 for A = 1, A7 = 7) + e = E.parse_arith("- A + B") + assert isinstance(e.left, E.Neg) + plan = O.plan_compute(e, [2], leaf, length) + assert plan[id(e.left)] == ("ALIGN", 2) + + +def test_a_product_with_a_literal_takes_dmax_not_its_scale(): + # A2 * 1.25 * ZB: the product (4 places) aligned to dmax 2 when ZB is loaded (measured: 156.00, not 156.25) + p = E.parse_arith("A2 * 1.25") + plan = O.plan_compute(E.Bin("*", p, E.Ref("ZB")), [2], leaf, length) + assert plan[id(p)] == ("ALIGN", 2) + + +def test_constant_folding_keeps_cobcs_scale_and_identity(): + e = E.parse_arith("100 * 0.5 + Q3") + plan = O.plan_compute(e, [5], leaf, length) + folded = plan[id(e.left)] + assert folded[0] == "FOLD" and folded[1] == O.Num(0, "50", 0) # trailing zeros dropped: 50, scale 0 + # a written 0.03 and a folded 0.03 are two decimal constants (cb_lookup_literal compares the digits as written) + assert O.Num.written("0.03").key != O.Num.folded(3, 2).key + assert O.Num.written("1.25").key == O.Num.folded(125, 2).key + + +def test_an_earlier_evaluate_leaves_its_dmax_to_the_next_compute_of_the_sentence(): + e = E.parse_arith("- B + Q3") + clean = O.plan_compute(e, [0], leaf, length) + after = O.plan_compute(e, [0], leaf, length, dmax_in=5) + assert clean[id(e.left)] == ("ALIGN", 3) and after[id(e.left)] == ("ALIGN", 5) + + +def test_conditions_are_built_right_to_left_and_share_the_stack(): + # IF A2 * A2 > 0 AND XB * Z + YB > 1000 (measured on the oracle's C: the second relation's product aligned to + # dmax, the first one's to the stack's 4 -- the second relation was built first) + c = E.parse_condition("A2 * A2 > 0 AND XB * Z + YB > 1000") + r1 = O.Relation(c.left.left, c.left.right) + r2 = O.Relation(c.right.left, c.right.right) + O.plan_condition([[r1, r2]], leaf, length) + assert aligns(r1.plan) == [4] + prod = c.right.left.left + assert r2.plan[id(prod)] == ("ALIGN", 2) and r2.plan[id(c.right.left)] == ("ALIGN", 2) + + +def test_two_integer_literals_compare_at_compile_time_and_are_not_walked(): + # EVALUATE TRUE WHEN 3 > 75000 ... : cb_false, so the second WHEN's walk sets dmax (3), not the first's (0) + w1 = O.Relation(E.Lit(Decimal(3), "3"), E.Lit(Decimal(75000), "75000")) + e = E.parse_arith("Q3 ** 1") + w2 = O.Relation(e, E.Ref("A")) + out = O.plan_condition([[w1], [w2]], leaf, length, evaluate=True) + assert w1.constant and not w1.decimal + assert out is not None and out[0] == 3 + + +PROGRAM = """ IDENTIFICATION DIVISION. + PROGRAM-ID. OSVS. + DATA DIVISION. + WORKING-STORAGE SECTION. + 01 A PIC 9 VALUE 1. + 01 B PIC 9 VALUE 3. + 01 C PIC 999 VALUE 300. + 01 A7 PIC 9 VALUE 7. + 01 Z PIC 9 VALUE 1. + 01 XB PIC 9(4) COMP VALUE 123. + 01 YB PIC 9(4) COMP VALUE 456. + 01 ZB PIC 9(4) COMP VALUE 100. + 01 A2 PIC 9V99 VALUE 1.25. + 01 Q3 PIC 9V999 VALUE 3.125. + 01 P0 PIC S9(7) COMP-3 VALUE 98765. + 01 R PIC 999V99. + 01 R0 PIC 9(11). + 01 R5 PIC 9(9)V9(5). + PROCEDURE DIVISION. + COMPUTE R = A / B * C + DISPLAY 'ISSUE ' R + COMPUTE R = A / B * 3 + DISPLAY 'LITERAL ' R + COMPUTE R = XB + YB + Z + DISPLAY 'BINARY ' R + COMPUTE R = - A + A7 + DISPLAY 'UNARY ' R + COMPUTE R = A2 * 1.25 * ZB + DISPLAY 'PRODUCT ' R + COMPUTE R = (A / B) + (A / B) + (A / B) + DISPLAY 'SUM ' R + ADD XB YB TO R + DISPLAY 'ADD ' R + IF A / B * C > 99.5 + DISPLAY 'IF Y' + ELSE + DISPLAY 'IF N' + END-IF + COMPUTE R5 = Q3 - 1.25 + DISPLAY 'CONST1 ' R5 + COMPUTE R5 = (100 * 0.5) + (Q3 - 1.25) + DISPLAY 'CONST2 ' R5. + EVALUATE TRUE + WHEN A2 > Q3 * 2 + R5 + DISPLAY 'W1' + WHEN R5 < 0.00001 + DISPLAY 'W2' + END-EVALUATE + COMPUTE R0 = ((- P0) + Q3) + Q3 * 1.25 + DISPLAY 'LEAK ' R0. + COMPUTE R0 = ((- P0) + Q3) + Q3 * 1.25 + DISPLAY 'CLEAN ' R0 + GOBACK. +""" + +# what the oracle prints (measured 2026-10-07, GnuCOBOL 3.1.2 -std=ibm): exact decimal would give 09999, 00099, +# 58000, 00600, 15625, 00099, 58000+, ...; ARITHMETIC-OSVS truncates as det/osvs.py says +WANT = {"ISSUE": "09900", "LITERAL": "00099", "BINARY": "50100", "UNARY": "00700", "PRODUCT": "15600", + "SUM": "00099", "IF": "N"} # fmt: skip + + +def _java_ok() -> bool: + from test_det_programs import _java + + return _java() is not None + + +@pytest.mark.skipif(os.environ.get("EQUIVALENCE_E2E") != "1" or not shutil.which("docker"), + reason="needs Docker and a JDK 17 (JAVA_HOME / JDK_17)") # fmt: skip +@pytest.mark.parametrize("mode", ["bytes", "typed"]) +def test_intermediates_are_truncated_as_the_oracle_truncates_them(mode, tmp_path): + pytest.importorskip("tree_sitter_language_pack") + sys.path.insert(0, str(Path(__file__).resolve().parent)) + import test_det_programs as T + + if not _java_ok(): + pytest.skip("needs a JDK 17 (JAVA_HOME / JDK_17)") + cob = tmp_path / "cobol" + cob.mkdir() + want = T._cobol(PROGRAM, cob) + got = T._java_run("osvs", PROGRAM, tmp_path, mode != "bytes") + assert got == want, f"java {got!r} != cobol {want!r}" + shown = dict(line.split(" ", 1) for line in want.splitlines() if " " in line) + for k, v in WANT.items(): + assert shown[k] == v, (k, shown[k]) + # the EVALUATE leaves its dmax (5) to the next COMPUTE of the sentence: (0 - P0) aligned to 5 places loses it all + assert shown["LEAK"] != shown["CLEAN"] From cedbcb0f9d2bf7ebbee79ccecb81f3cb0e50f0d7 Mon Sep 17 00:00:00 2001 From: squid-protocol Date: Wed, 7 Oct 2026 21:34:45 -0400 Subject: [PATCH 2/9] oracle_assumptions C2: the differential run's size as measured (#4287) Co-Authored-By: Claude Opus 5.5 --- docs/language_status/oracle_assumptions.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/language_status/oracle_assumptions.md b/docs/language_status/oracle_assumptions.md index a723ac5e86..e3a9280dc2 100644 --- a/docs/language_status/oracle_assumptions.md +++ b/docs/language_status/oracle_assumptions.md @@ -152,9 +152,9 @@ verdict, but it limits what the proof says about inputs outside the scenarios. (`Cobol.Dc`); `Cobol.divide` keeps cob_decimal_div's 38 + max(d1 - d2, 0) places and `Cobol.power` cob_decimal_pow's trimming. COMPUTE, ADD / SUBTRACT / MULTIPLY / DIVIDE with an expression, and the relations of IF, PERFORM UNTIL, SEARCH WHEN and EVALUATE are planned; floating-point statements keep their HFP model (C6). - Proven by `tests/cobol_mainframe/test_det_osvs.py` and by a randomized differential run (2026-10-07: about 120 - programs, 26,000 COMPUTE, ADD, IF and EVALUATE statements over zoned, packed and binary items, every output equal - to the oracle's; the same programs on the earlier runtime: about one line in six differed). + Proven by `tests/cobol_mainframe/test_det_osvs.py` and by a randomized differential run (2026-10-07: over 100 + random programs, about 25,000 COMPUTE, ADD, SUBTRACT, IF and EVALUATE statements over zoned, packed and binary + items, every output equal to the oracle's; on the earlier runtime about one line in six differed). - **Where GnuCOBOL departs from IBM's rule** (each measured on the oracle; the det port does what the oracle does, so a proof cannot see them, and z/OS may not do them): - the stack of decimal places pairs an operation with its own operands only when every operand pushes its From dc994d61a3fc2d7c09fc2417376fe99a4d1999c5 Mon Sep 17 00:00:00 2001 From: squid-protocol Date: Wed, 7 Oct 2026 23:40:24 -0400 Subject: [PATCH 3/9] det runtime: a zero divisor is libcob's NaN, receivers unchanged (#4655) The oracle (GnuCOBOL 3.1.2 -std=ibm) leaves every receiver unchanged on a zero divisor, with or without ON SIZE ERROR; the det port threw ArithmeticException. Cobol.divide now returns libcob's NaN (scale -32768) and raises the statement's size error; add/subtract/multiply/negate/power carry it where the translator sees a division or exponent below; store/storeChecked leave the receiver (lifted receivers guarded with isNan); Cobol.align truncates a NaN to 0 as libcob does. A division in a function argument is 0 (cob_intr_binop); FUNCTION MOD/REM by zero are 0. 0 ** 0 raises the size error; a non-finite exponent is NaN. Statements with ON SIZE ERROR clear and read the size-error state. DIVIDE REMAINDER now uses the quotient truncated to the receiver's places (cob_div_quotient), not the stored quotient. Register entry oracle_assumptions C14 (IBM: undefined / S0CB on z/OS). Co-Authored-By: Claude Opus 5.5 --- docs/language_status/det_port_design.md | 2 + docs/language_status/oracle_assumptions.md | 49 +++- .../cobol_to_java/det/cobolrt/Cobol.java | 127 ++++++++- .../cobol_to_java/det/cobolrt/Funcs.java | 10 +- .../tools/cobol_to_java/det/cobolrt/Hfp.java | 3 +- gitgalaxy/tools/cobol_to_java/det/gen.py | 92 +++++-- tests/cobol_mainframe/test_det_size_error.py | 240 ++++++++++++++++++ 7 files changed, 497 insertions(+), 26 deletions(-) create mode 100644 tests/cobol_mainframe/test_det_size_error.py diff --git a/docs/language_status/det_port_design.md b/docs/language_status/det_port_design.md index 103a05ea58..fb1fcaa87b 100644 --- a/docs/language_status/det_port_design.md +++ b/docs/language_status/det_port_design.md @@ -131,6 +131,7 @@ public final class Cobol { // arithmetic results (ADD / SUBTRACT / MULTIPLY / DIVIDE / COMPUTE): `value` exact, then stored public static void store(Field to, BigDecimal value, boolean rounded, Charset cs); // no ON SIZE ERROR: truncated public static boolean storeChecked(Field to, BigDecimal value, boolean rounded, Charset cs); // ON SIZE ERROR: true and `to` unchanged when the value does not fit + // a zero divisor: libcob's NaN (scale -32768) -- store leaves `to`, storeChecked returns true (#4655, oracle_assumptions C14) // comparisons: <0, 0, >0 by COBOL's rules (numeric: by value; nonnumeric: shorter padded with spaces, by the // charset's collating sequence -- the harness's ASCII; group: nonnumeric) public static int compare(Field a, Field b, Charset cs); @@ -299,6 +300,7 @@ Only generator output, never a test case: - A numeric DISPLAY item MOVEd to an alphanumeric one is its digit bytes as they are -- invalid data included, the sign de-punched (GnuCOBOL, checked): the runtime had decoded `ABC` as `123`. - DIVIDE's intermediate follows GnuCOBOL's cob_decimal_div (the dividend shifted 38 digits, truncated). + A zero divisor gives libcob's NaN: the receivers unchanged, the size error raised (oracle_assumptions C14). - #4501 (found proving #4413 through cics-crucible): a COMP-5 VALUE was written truncated to its PICTURE and big-endian while the runtime reads COMP-5 little-endian, so CAXA's `S9(4) COMP-5 VALUE 32767` reached XCTL LENGTH as -12534. The image now holds the whole value in the runtime's order (register C7). diff --git a/docs/language_status/oracle_assumptions.md b/docs/language_status/oracle_assumptions.md index e3a9280dc2..312fe495ab 100644 --- a/docs/language_status/oracle_assumptions.md +++ b/docs/language_status/oracle_assumptions.md @@ -70,6 +70,7 @@ verdict, but it limits what the proof says about inputs outside the scenarios. | C10 | compiler | INITIALIZE / VALUE ZERO zoned items: unsigned F zone (GnuCOBOL) vs preferred C sign (z/OS) | DIFFERS (tolerated where a case declares it) | yes (CardDemo READACCT ARRYFILE) | | C11 | compiler | MOVE of an alphanumeric item holding a non-digit to a numeric DISPLAY item (#4049) | DIFFERS (inputs kept out of the cases) | yes (COMEN01C option `1!`) | | C12 | compiler | FUNCTION RANDOM: the oracle's generator (glibc via GnuCOBOL), not IBM's unpublished one; a seed IBM does not allow refused | DIFFERS (the numbers) / ASSUMED (the interface) | translated, no proof yet (CBSA CRDTAGY1-5, INQCUST; GenApp LGICVS01) | +| C14 | compiler | Size errors without ON SIZE ERROR: a zero divisor leaves the receivers unchanged in the oracle (libcob's NaN), the det runtime the same (#4655); z/OS's result is undefined (a decimal-divide exception); 0 ** a negative is 0 in the oracle, a size error on z/OS | MATCHED (det runtime = oracle) / DIFFERS (oracle vs z/OS) | not known to be: no proven scenario divides by zero | | D1 | data | Text order is ASCII (Latin-1), not EBCDIC | DIFFERS | keys: no; comparisons: not audited | | D2 | data | Hex literals that name EBCDIC characters (`X'40'`) | DIFFERS | no | | D3 | data | Zoned signs in ASCII data (`{`, `}`, A–R overpunch) | MATCHED | yes | @@ -173,7 +174,8 @@ verdict, but it limits what the proof says about inputs outside the scenarios. - **Not replayed by the det translator** (refused by name, or exact as before): a negative literal exponent (libcob overwrites the constant: refused); an arithmetic expression in a PERFORM VARYING FROM / BY, a SEARCH ALL key or a subscript (cobc computes those otherwise); an EVALUATE whose last WHEN ends with a statement that leaves the state - dirty (refused); statements inside ON SIZE ERROR phrases, which cobc parses before their statement's expression. + dirty (refused); statements inside ON SIZE ERROR phrases, which cobc parses before their statement's expression (their + size-error semantics are C14's). - **Reached.** Every COMPUTE with more than one operation, among them INTCALC's interest computation. Across the det sweep's ports (2026-10-07) the only truncation emitted is POSTTRAN's `ACCT-CURR-CYC-CREDIT - ACCT-CURR-CYC-DEBIT` to 2 places, a value of 2 places (no change); none of GnuCOBOL's departures is reached. @@ -381,6 +383,51 @@ verdict, but it limits what the proof says about inputs outside the scenarios. - **To settle.** Only a z/OS run can give IBM's numbers; even then they would be data for a declared difference, not a model, since the generator is unpublished. +### C14. Size errors (a zero divisor, an exponent, overflow) — the det runtime MATCHES the oracle (#4655); the oracle DIFFERS from z/OS where IBM leaves the result undefined +- **IBM** (Enterprise COBOL for z/OS Language Reference, "SIZE ERROR phrases"; the wording below is paraphrased + from the 6.x manual and not re-fetched for this entry -- confirm it against + https://www.ibm.com/docs/en/cobol-zos/6.4.0?topic=statements-size-error-phrases before quoting it): a size error + condition is a result whose absolute value, after decimal-point alignment, exceeds the receiver; a division by + zero; and, in an exponentiation, zero raised to the zero power, zero raised to a negative power, or a negative + number raised to a fractional power. It applies to final results only. With ON SIZE ERROR the receivers keep + their values and the imperative statement runs. Without it, an overflow is truncated (the receiver's high-order + digits are lost) and the result of the other cases is undefined; on z/OS a packed-decimal divide by zero is a + decimal-divide exception (S0CB abend) unless the compiler checks first. +- **The oracle** (GnuCOBOL 3.1.2 `-std=ibm`, measured 2026-10-07): a zero divisor makes libcob's NaN intermediate + (`cob_decimal_div` sets scale COB_DECIMAL_NAN and EC-SIZE-ZERO-DIVIDE); an operation with a NaN operand gives NaN; + storing a NaN (`cob_decimal_get_field`) leaves the receiver unchanged. So, with or without ON SIZE ERROR: + `COMPUTE R R2 = A / Z` changes neither receiver; `COMPUTE R = A / Z + 1` and `(A / Z) * 0 + 7` change nothing; + `DIVIDE Z INTO R R2` leaves each receiver; `DIVIDE A BY Z GIVING Q REMAINDER RM` leaves Q and RM; `0 / 0` is the + same. Where cobc truncates an intermediate (ARITHMETIC-OSVS, C2), `cob_decimal_align` turns the NaN into 0: + `COMPUTE R = A / Z * C` stores 0, and `(A / Z) + (A / B)` stores A / B; in a condition an aligned quotient is 0 + (`A / Z = 0` is true) while an unaligned one compares as its dividend scaled by 10^32768 (`0 = A / Z` is false). + An intrinsic function's argument divided by zero is 0 (`cob_intr_binop`), and FUNCTION MOD / REM by zero are 0 + with no size error. `0 ** 0` is 1 with the size error raised; `0 ** -1` is 0 with no size error; a negative base + with a fractional exponent, or an exponent past a double's range, leaves the receiver unchanged with the size + error raised. An overflow without ON SIZE ERROR is truncated (IBM's rule). With ON SIZE ERROR the phrase runs + whenever the statement raised a size error, even if a receiver changed (the aligned 0, 0 ** 0's 1); the + exception code is cleared when the statement starts. +- **The det runtime (since #4655)** does the same: `Cobol.divide` returns libcob's NaN (the dividend's digits at + scale -32768) for a zero divisor, `Cobol.add / subtract / multiply / negate / power` carry it where the translator + sees that an operand can be one (a division or an exponent below it), `Cobol.store` and `storeChecked` leave the + receiver (a lifted receiver is guarded with `Cobol.isNan`), `Cobol.align` truncates it to 0 as libcob does, and a + comparison sees its scaled value as cob_decimal_cmp does. A division inside a function's argument is + `Cobol.divideIntr` (0). A statement with ON SIZE ERROR and a division or exponent clears and reads the size-error + state (`Cobol.sizeClear` / `sizeRaised`, per thread). `Cobol.remainder` computes DIVIDE's REMAINDER from the + quotient truncated to the quotient receiver's places, as cob_div_quotient does (before, the stored quotient was + used: a quotient too big for its receiver, or ROUNDED up, gave a wrong remainder). Pinned by + `tests/cobol_mainframe/test_det_size_error.py` (every shape above, both port modes) and a randomized differential + run (2026-10-07: 56 random programs with zero divisors, DIVIDE and ON SIZE ERROR / NOT ON SIZE ERROR phrases, + every output equal to the oracle's). +- **Not modelled.** A zero divisor in floating point (COMP-1 / COMP-2, `Hfp.divide`) still stops the run with an + ArithmeticException, a named stop rather than a guess. +- **So a proof says:** for a scenario that divides by zero without ON SIZE ERROR, the port does what the oracle + does (receivers unchanged), which z/OS does not promise: IBM leaves the result undefined and a z/OS run may + abend. With ON SIZE ERROR both sides agree with IBM (receivers unchanged, the phrase runs), except `0 ** -n`, + which IBM calls a size error and the oracle (and so the port) computes as 0. +- **Reached.** Not known to be: no proven scenario divides by zero or raises a size error from an exponent. +- **To settle.** A z/OS run of the shapes in `test_det_size_error.py` without ON SIZE ERROR. + ### C8. DISPLAY text — MATCHED - **What.** GnuCOBOL writes a signed zoned item as `012-` and a binary item as `-00007`. IBM writes their external decimal form with the sign overpunched (`01K`, `000P`). `ggdisplay.c` (LD_PRELOAD) rewrites each such operand as diff --git a/gitgalaxy/tools/cobol_to_java/det/cobolrt/Cobol.java b/gitgalaxy/tools/cobol_to_java/det/cobolrt/Cobol.java index 70c59813b8..1ec4c9c6d0 100644 --- a/gitgalaxy/tools/cobol_to_java/det/cobolrt/Cobol.java +++ b/gitgalaxy/tools/cobol_to_java/det/cobolrt/Cobol.java @@ -75,13 +75,74 @@ public static boolean swapNumprocPfd(boolean pfd) { } // ------------------------------------------------------------------------------------------ ARITHMETIC + // ------------------------------------------------------------------------------- size error (#4655) + /** libcob's invalid intermediate (COB_DECIMAL_NAN, oracle_assumptions C14): a decimal whose scale is -32768. A + * zero divisor (cob_decimal_div) makes one of the dividend; an operation with one makes one of its left + * operand's value; storing one (cob_decimal_get_field) leaves the receiver unchanged with a size error. It is + * an ordinary number to everything else: cob_decimal_align truncates it to 0 (Cobol.align does the same with + * its scale) and cob_decimal_cmp compares its value scaled by 10^32768. */ + public static final int NAN_SCALE = -32768; + + public static boolean isNan(BigDecimal d) { + return d.scale() == NAN_SCALE; + } + + private static BigDecimal nan(BigDecimal d) { + return new BigDecimal(d.unscaledValue(), NAN_SCALE); + } + + /** The statement's size-error state (libcob's EC-SIZE exception code): set by a zero divisor, 0 ** 0 or an + * exponent without a finite result even when no invalid value reaches a receiver (an aligned NaN is 0, a + * function's argument divided by zero is 0); cleared where a statement with ON SIZE ERROR starts. */ + private static final ThreadLocal SIZE_EC = ThreadLocal.withInitial(() -> new boolean[1]); + + public static void sizeClear() { + SIZE_EC.get()[0] = false; + } + + public static boolean sizeRaised() { + return SIZE_EC.get()[0]; + } + + private static void raiseSize() { + SIZE_EC.get()[0] = true; + } + + /** cob_decimal_add of two intermediates where either may be libcob's NaN (#4655): NaN in, NaN out. */ + public static BigDecimal add(BigDecimal a, BigDecimal b) { + return isNan(a) ? a : isNan(b) ? nan(a) : a.add(b); + } + + /** cob_decimal_sub, as {@link #add(BigDecimal, BigDecimal)}. */ + public static BigDecimal subtract(BigDecimal a, BigDecimal b) { + return isNan(a) ? a : isNan(b) ? nan(a) : a.subtract(b); + } + + /** cob_decimal_mul, as {@link #add(BigDecimal, BigDecimal)}. */ + public static BigDecimal multiply(BigDecimal a, BigDecimal b) { + return isNan(a) ? a : isNan(b) ? nan(a) : a.multiply(b); + } + + /** Unary minus where the operand may be NaN: cobc's 0 - x, so a NaN's value is 0. */ + public static BigDecimal negate(BigDecimal a) { + return isNan(a) ? nan(BigDecimal.ZERO) : a.negate(); + } + /** An intermediate quotient as GnuCOBOL forms it (cob_decimal_div): the scale a.scale - b.scale, the dividend * shifted 38 digits more (and as many again as that scale is below zero), then divided and truncated -- so the * quotient keeps 38 + max(a.scale - b.scale, 0) decimal places; a zero dividend is 0 of scale 0. The - * receiver's own truncation or ROUNDED then applies in store. */ + * receiver's own truncation or ROUNDED then applies in store. A zero divisor makes the dividend NaN and raises + * the size error (#4655); a NaN operand gives NaN. */ public static BigDecimal divide(BigDecimal a, BigDecimal b) { + if (isNan(a)) { + return a; + } + if (isNan(b)) { + return nan(a); + } if (b.signum() == 0) { - throw new ArithmeticException("division by zero"); + raiseSize(); + return nan(a); } if (a.signum() == 0) { return BigDecimal.ZERO; @@ -89,6 +150,27 @@ public static BigDecimal divide(BigDecimal a, BigDecimal b) { return a.divide(b, 38 + Math.max(a.scale() - b.scale(), 0), java.math.RoundingMode.DOWN); } + /** DIVIDE ... REMAINDER (cob_div_quotient, cob_div_remainder): the dividend less the divisor times the quotient + * `q` truncated to the first GIVING receiver's `scale` places -- not to its PICTURE's high-order digits, so a + * quotient too big for its receiver still gives the true remainder. A NaN quotient (a zero divisor) gives NaN: + * the remainder receiver unchanged (#4655). */ + public static BigDecimal remainder(BigDecimal a, BigDecimal q, BigDecimal b, int scale) { + if (isNan(q)) { + return q; + } + return a.subtract(q.setScale(scale, RoundingMode.DOWN).multiply(b)); + } + + /** A division in an intrinsic function's argument (cob_intr_binop): a zero divisor gives 0, with the size + * error raised -- never NaN (#4655). */ + public static BigDecimal divideIntr(BigDecimal a, BigDecimal b) { + if (b.signum() == 0) { + raiseSize(); + return BigDecimal.ZERO; + } + return divide(a, b); + } + /** ARITHMETIC-OSVS (#4287, oracle_assumptions C2): an intermediate result truncated to `scale` decimal places, as * libcob's cob_decimal_align does it where cobc -std=ibm emits it (the translator decides where, det/osvs.py). * With more places than `scale` the low-order ones are dropped (toward zero); with fewer, libcob shifts the @@ -126,6 +208,9 @@ public void reset() { /** cob_decimal_add with a decimal constant (Dc). */ public static BigDecimal add(BigDecimal a, Dc c) { + if (isNan(a)) { + return a; + } if (a.scale() > c.v.scale()) { c.v = c.v.setScale(a.scale()); } @@ -134,6 +219,9 @@ public static BigDecimal add(BigDecimal a, Dc c) { /** cob_decimal_sub with a decimal constant (Dc). */ public static BigDecimal subtract(BigDecimal a, Dc c) { + if (isNan(a)) { + return a; + } if (a.scale() > c.v.scale()) { c.v = c.v.setScale(a.scale()); } @@ -142,7 +230,7 @@ public static BigDecimal subtract(BigDecimal a, Dc c) { /** cob_decimal_mul with a decimal constant (Dc): the constant as it is now. */ public static BigDecimal multiply(BigDecimal a, Dc c) { - return a.multiply(c.v); + return isNan(a) ? a : a.multiply(c.v); } /** cob_decimal_div with a decimal constant (Dc): the constant as it is now. */ @@ -153,6 +241,9 @@ public static BigDecimal divide(BigDecimal a, Dc c) { /** cob_decimal_pow with a decimal constant exponent (Dc, never negative: the translator refuses one): trimmed in * place once the base is not zero. */ public static BigDecimal power(BigDecimal a, Dc c) { + if (isNan(a)) { + return a; + } if (c.v.signum() != 0 && a.signum() != 0) { c.v = trim(c.v); } @@ -175,10 +266,21 @@ private static BigDecimal trim(BigDecimal d) { } /** A ** b as cob_decimal_pow forms it: a whole exponent exactly (base and result trimmed of trailing zeros, a - * negative one through cob_decimal_div), else through double. */ + * negative one through cob_decimal_div), else through double. 0 ** 0 is 1 with the size error raised; an + * exponent through double without a finite result (a negative base, an overflow) is NaN with the size error + * raised, as libcob's (#4655); a NaN operand gives NaN. */ public static BigDecimal power(BigDecimal a, BigDecimal b) { + if (isNan(a)) { + return a; + } + if (isNan(b)) { + return nan(a); + } boolean whole = b.stripTrailingZeros().scale() <= 0; if (b.signum() == 0) { + if (a.signum() == 0) { + raiseSize(); + } return BigDecimal.ONE; } if (a.signum() == 0) { @@ -191,7 +293,12 @@ public static BigDecimal power(BigDecimal a, BigDecimal b) { if (whole && b.signum() < 0) { return trim(divide(BigDecimal.ONE, trim(trim(a).pow(-b.intValueExact())))); } - return new BigDecimal(Math.pow(a.doubleValue(), b.doubleValue())); + double r = Math.pow(a.doubleValue(), b.doubleValue()); + if (Double.isNaN(r) || Double.isInfinite(r)) { + raiseSize(); + return nan(a); + } + return new BigDecimal(r); } // ------------------------------------------------------------------------------------------------ MOVE @@ -519,8 +626,11 @@ public static void putText(Field f, String s, Charset cs) { // -------------------------------------------------------------------------------------- arithmetic store /** No ON SIZE ERROR: low-order digits beyond the scale dropped (or rounded half away from zero), high-order - * digits beyond the PICTURE lost. */ + * digits beyond the PICTURE lost; libcob's NaN (a zero divisor) leaves `to` unchanged (#4655). */ public static void store(Field to, BigDecimal value, boolean rounded, Charset cs) { + if (isNan(value)) { + return; + } if (to.kind == Field.Kind.NUMERIC_FLOAT) { // a float receiver: the value converted to its precision (Hfp) Hfp.store(to, value); return; @@ -533,8 +643,11 @@ public static void store(Field to, BigDecimal value, boolean rounded, Charset cs } } - /** ON SIZE ERROR: true, `to` unchanged, when the value does not fit. */ + /** ON SIZE ERROR: true, `to` unchanged, when the value does not fit or is libcob's NaN (#4655). */ public static boolean storeChecked(Field to, BigDecimal value, boolean rounded, Charset cs) { + if (isNan(value)) { + return true; + } if (to.kind == Field.Kind.NUMERIC_FLOAT) { // an exponent overflow is refused by name in Hfp, never a size error store(to, value, rounded, cs); return false; diff --git a/gitgalaxy/tools/cobol_to_java/det/cobolrt/Funcs.java b/gitgalaxy/tools/cobol_to_java/det/cobolrt/Funcs.java index cb6dcac36c..49451cf719 100644 --- a/gitgalaxy/tools/cobol_to_java/det/cobolrt/Funcs.java +++ b/gitgalaxy/tools/cobol_to_java/det/cobolrt/Funcs.java @@ -45,14 +45,20 @@ public static String trimTrailing(String s) { return s.substring(0, j); } - /** FUNCTION MOD: a - b * FLOOR(a / b). */ + /** FUNCTION MOD: a - b * FLOOR(a / b); a zero b gives 0, no size error (the oracle, #4655). */ public static BigDecimal mod(BigDecimal a, BigDecimal b) { + if (b.signum() == 0) { + return BigDecimal.ZERO; + } BigDecimal q = a.divide(b, 0, RoundingMode.FLOOR); return a.subtract(b.multiply(q)); } - /** FUNCTION REM: a - b * INTEGER-PART(a / b). */ + /** FUNCTION REM: a - b * INTEGER-PART(a / b); a zero b gives 0, no size error (the oracle, #4655). */ public static BigDecimal rem(BigDecimal a, BigDecimal b) { + if (b.signum() == 0) { + return BigDecimal.ZERO; + } BigDecimal q = a.divide(b, 0, RoundingMode.DOWN); return a.subtract(b.multiply(q)); } diff --git a/gitgalaxy/tools/cobol_to_java/det/cobolrt/Hfp.java b/gitgalaxy/tools/cobol_to_java/det/cobolrt/Hfp.java index 1c44cc9ddb..e0f2fc971b 100644 --- a/gitgalaxy/tools/cobol_to_java/det/cobolrt/Hfp.java +++ b/gitgalaxy/tools/cobol_to_java/det/cobolrt/Hfp.java @@ -216,7 +216,8 @@ public static BigDecimal multiply(BigDecimal a, BigDecimal b, boolean longP) { return h == null ? BigDecimal.ZERO : value(h, d); } - /** DIVIDE: the exact quotient truncated. A zero divisor: as Cobol.divide, an ArithmeticException. */ + /** DIVIDE: the exact quotient truncated. A zero divisor: an ArithmeticException, not modelled in floating point + * (oracle_assumptions C14; Cobol.divide gives libcob's NaN). */ public static BigDecimal divide(BigDecimal a, BigDecimal b, boolean longP) { int d = digits(longP); H x = exact(a, d); diff --git a/gitgalaxy/tools/cobol_to_java/det/gen.py b/gitgalaxy/tools/cobol_to_java/det/gen.py index a32f4ed7d5..8dae3de068 100644 --- a/gitgalaxy/tools/cobol_to_java/det/gen.py +++ b/gitgalaxy/tools/cobol_to_java/det/gen.py @@ -120,6 +120,8 @@ def __init__(self, prog: Program, structured: bool = False): # and, inside a condition, the plans of its relations still to generate, in order self.osvs: dict = {} self.rel_plans: list | None = None + # inside an intrinsic function's arguments (cob_intr_binop: a zero divisor gives 0, never NaN, #4655) + self.intr = 0 # what the last decimal build of the sentence left: (dmax, expr_decp stack), None when nothing (an EVALUATE's # condition leaves its state to the next statements of the sentence; a period, a COMPUTE or an IF resets it) self.leak: tuple | None = None @@ -911,13 +913,21 @@ def dc_literal(self, e) -> O.Num | None: return None def num_op(self, e) -> str: + # an operand that may be libcob's NaN (a zero divisor, #4655): the operation through Cobol, NaN in, NaN out + nan = not self.intr and self.fmode is None if isinstance(e, E.Neg): + nan = nan and _may_nan(e.operand) if O.PLANNED in self.osvs: # cobc: 0 - x (its scale: x's, at least 0; a literal x a decimal constant) lit = self.dc_literal(e.operand) if lit is not None: return f"Cobol.subtract(BigDecimal.ZERO, {self.dc(lit)})" + if nan: + return f"Cobol.subtract(BigDecimal.ZERO, {self.num(e.operand)})" return f"BigDecimal.ZERO.subtract({self.num(e.operand)})" + if nan: + return f"Cobol.negate({self.num(e.operand)})" return f"{self.num(e.operand)}.negate()" + nan = nan and (_may_nan(e.left) or _may_nan(e.right)) lit = self.dc_literal(e.right) if lit is not None: a = self.num(e.left) @@ -928,6 +938,8 @@ def num_op(self, e) -> str: fn = {"+": "add", "-": "subtract", "*": "multiply", "/": "divide", "**": "power"}[e.op] return f"Cobol.{fn}({a}, {self.dc(lit)})" a, b = self.num(e.left), self.num(e.right) + if nan and e.op in ("+", "-", "*"): + return f"Cobol.{ {'+': 'add', '-': 'subtract', '*': 'multiply'}[e.op] }({a}, {b})" if e.op == "+": return f"{a}.add({b})" if e.op == "-": @@ -935,7 +947,7 @@ def num_op(self, e) -> str: if e.op == "*": return f"{a}.multiply({b})" if e.op == "/": - return f"Cobol.divide({a}, {b})" + return f"Cobol.divideIntr({a}, {b})" if self.intr else f"Cobol.divide({a}, {b})" if e.op == "**": return f"Cobol.power({a}, {b})" raise Untranslatable(f"operator {e.op}") @@ -943,14 +955,21 @@ def num_op(self, e) -> str: def func(self, f: E.Func) -> str: refmod = next((a[1] for a in f.args if isinstance(a, tuple) and a[0] == "REFMOD"), None) if refmod is None: - return self._func(f) + return self.intr_func(f) # #4462: FUNCTION CURRENT-DATE (1:4): the characters of the function's text (an alphanumeric function only) if f.name not in ("UPPER-CASE", "LOWER-CASE", "TRIM", "REVERSE", "CURRENT-DATE"): raise Untranslatable(f"FUNCTION {f.name} with a reference modification") start, length = refmod at = f"{self.int_expr(start)} - 1" end = f", {at} + {self.int_expr(length)}" if length is not None else "" - return f"{self._func(f)}.substring({at}{end})" + return f"{self.intr_func(f)}.substring({at}{end})" + + def intr_func(self, f: E.Func) -> str: + self.intr += 1 + try: + return self._func(f) + finally: + self.intr -= 1 def _func(self, f: E.Func) -> str: name = f.name @@ -1535,7 +1554,7 @@ def _statement(self, s: S.Stmt, ind: str) -> list[str]: plan = {} if mode is not None else plan with self.floating(mode), self.osvs_plan(plan): value = self.num(s.data["expr"]) - return [c, *self.store_all(s, s.data["targets"], value, ind)] + return [c, *self.store_all(s, s.data["targets"], value, ind, s.data["expr"] if mode is None else None)] if k == "ARITH": d = s.data receivers = [t for t, _ in d.get("targets") or []] + [t for t, _ in d.get("giving") or []] @@ -1791,20 +1810,31 @@ def arith_float(self, s: S.Stmt, ind: str) -> list[str]: out.append(ind + self.store_into(tgt, val, rounded)) return out - def store_all(self, s: S.Stmt, targets: list, value: str, ind: str) -> list[str]: + def store_all(self, s: S.Stmt, targets: list, value: str, ind: str, expr=None, after=None) -> list[str]: + """The value of a decimal expression `expr` (None: a floating-point value) stored in each target, then + `after(v)`'s lines, then the SIZE ERROR phrases. libcob's NaN (a zero divisor, #4655) changes no target: a + lifted one is guarded; one with a division or an exponent also raises the statement's size error, which a + statement with ON SIZE ERROR clears first and reads after (the receivers may still change: an aligned NaN + is 0, 0 ** 0 is 1).""" out = [] checked = "SIZE-ERROR" in s.phrases or "NOT-SIZE-ERROR" in s.phrases if not checked: v = self.tmpname("v") out.append(f"{ind}BigDecimal {v} = {value};") + nan = expr is not None and _may_nan(expr) for t, rounded in targets: - out.append(ind + self.store_into(t, v, rounded)) - return out + guard = f"if (!Cobol.isNan({v})) " if nan and self.lift(t) else "" + out.append(ind + guard + self.store_into(t, v, rounded)) + return out + (after(v) if after else []) v, err = self.tmpname("v"), self.tmpname("sizeError") + sized = expr is not None and _raises_size(expr) + if sized: + out.append(f"{ind}Cobol.sizeClear();") out.append(f"{ind}BigDecimal {v} = {value};") - out.append(f"{ind}boolean {err} = false;") + out.append(f"{ind}boolean {err} = {'Cobol.sizeRaised()' if sized else 'false'};") for t, rounded in targets: out.append(f"{ind}{err} |= Cobol.storeChecked({self.field_expr(t)}, {v}, {_b(rounded)}, CS);") + out += after(v) if after else [] if "SIZE-ERROR" in s.phrases: out += [f"{ind}if ({err}) {{", *self.block(s.phrases["SIZE-ERROR"], ind + " "), f"{ind}}}"] if "NOT-SIZE-ERROR" in s.phrases: @@ -1835,12 +1865,17 @@ def arith(self, s: S.Stmt, ind: str) -> list[str]: plan = {} if d.get("remainder") is not None else self.plan_arith(expr, [t for t, _ in d["giving"]]) with self.osvs_plan(plan): val = self.num(expr) - out = self.store_all(s, d["giving"], val, ind) + after = None if d.get("remainder") is not None: - q = self.field_expr(d["giving"][0][0]) - out.append(f"{ind}Cobol.store({self.field_expr(d['remainder'])}, {self.num(ops[0])}.subtract(" - f"Cobol.num({q}, CS).multiply({self.num(ops[1])})), false, CS);") # fmt: skip - return out + # cob_div_remainder: from the quotient truncated to the first receiver's places (not its PICTURE's + # high-order digits); a zero divisor leaves the remainder unchanged (#4655) + q = self.resolve(d["giving"][0][0]) + + def after(v, q=q): + rem = f"Cobol.remainder({self.num(ops[0])}, {v}, {self.num(ops[1])}, {q.scale})" + return [f"{ind}Cobol.store({self.field_expr(d['remainder'])}, {rem}, false, CS);"] + + return self.store_all(s, d["giving"], val, ind, expr, after) if len(ops) > 1: # cobc: the operands' sum is one expression, aligned when the first receiver is loaded (#4287) expr = ops[0] @@ -1863,8 +1898,13 @@ def arith(self, s: S.Stmt, ind: str) -> list[str]: lt = self.lift(tgt) if lt and lt[0] in ("BIN", "NUM") and not checked: # a lifted target: its value, the store as above cur = f"BigDecimal.valueOf({lt[1]})" if lt[0] == "BIN" else lt[1] - val = {"+=": f"{cur}.add({tsum})", "-=": f"{cur}.subtract({tsum})", "*=": f"{tsum}.multiply({cur})", - "/=": f"Cobol.divide({cur}, {tsum})"}[op] # fmt: skip + if op == "/=": # a zero divisor: libcob's NaN, the target unchanged (#4655) + q = self.tmpname("q") + out.append(f"{ind}BigDecimal {q} = Cobol.divide({cur}, {tsum});") + out.append(f"{ind}if (!Cobol.isNan({q})) {self.store_into(tgt, q, rounded)}") + continue + val = {"+=": f"{cur}.add({tsum})", "-=": f"{cur}.subtract({tsum})", + "*=": f"{tsum}.multiply({cur})"}[op] # fmt: skip out.append(ind + self.store_into(tgt, val, rounded)) continue f = self.field_expr(tgt) @@ -2555,6 +2595,28 @@ def _unsigned_zoned(it: L.Item) -> bool: return not it.children and it.category == "NUMERIC" and it.usage == "DISPLAY" and "S" not in it.picture() +def _may_nan(e) -> bool: + """An expression whose value can be libcob's NaN at run time (#4655): a division (a zero divisor), an exponent + (no finite result), or an operation on one. A function's value never is (cob_intr_binop gives 0).""" + if isinstance(e, E.Bin): + return e.op in ("/", "**") or _may_nan(e.left) or _may_nan(e.right) + if isinstance(e, E.Neg): + return _may_nan(e.operand) + return False + + +def _raises_size(e) -> bool: + """An expression that can raise libcob's size error (#4655): a division or an exponent anywhere, a function's + arguments included (FUNCTION MOD / REM by zero give 0 and raise nothing).""" + if isinstance(e, E.Bin): + return e.op in ("/", "**") or _raises_size(e.left) or _raises_size(e.right) + if isinstance(e, E.Neg): + return _raises_size(e.operand) + if isinstance(e, E.Func): + return any(_raises_size(a) for a in e.args if not isinstance(a, tuple)) + return False + + def _b(v: bool) -> str: return "true" if v else "false" diff --git a/tests/cobol_mainframe/test_det_size_error.py b/tests/cobol_mainframe/test_det_size_error.py new file mode 100644 index 0000000000..f4ea165d26 --- /dev/null +++ b/tests/cobol_mainframe/test_det_size_error.py @@ -0,0 +1,240 @@ +"""Size errors in the det port (#4655, oracle_assumptions C14): a zero divisor, 0 ** 0, an exponent without a finite +result and a receiver too small, with and without ON SIZE ERROR, as the oracle (GnuCOBOL 3.1.2 `-std=ibm`) does them. + +A zero divisor makes libcob's NaN intermediate: the receivers keep their values (every receiver of the statement, +each receiver of DIVIDE ... INTO), the REMAINDER too; cob_decimal_align turns a NaN into 0, a function's argument +divided by zero is 0, and the size error is raised either way. The codegen tests need no Docker; the end-to-end test +runs one program through GnuCOBOL and the det port (EQUIVALENCE_E2E=1, Docker and a JDK 17).""" + +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from gitgalaxy.tools.cobol_to_java.det import expr as E # noqa: E402 +from gitgalaxy.tools.cobol_to_java.det import gen as G # noqa: E402 + + +def test_only_a_division_or_an_exponent_can_be_nan(): + assert G._may_nan(E.parse_arith("A / B + 1")) + assert G._may_nan(E.parse_arith("- (A ** B)")) + assert not G._may_nan(E.parse_arith("A * B - C")) + # a function's value never is: cob_intr_binop gives 0 for a zero divisor + assert not G._may_nan(E.parse_arith("FUNCTION ABS(A / B) + 1")) + + +def test_a_function_argument_still_raises_the_size_error(): + assert G._raises_size(E.parse_arith("FUNCTION ABS(A / B) + 1")) + assert G._raises_size(E.parse_arith("A ** 2")) + assert not G._raises_size(E.parse_arith("FUNCTION MOD(A, B)")) # measured: MOD / REM by zero raise nothing + assert not G._raises_size(E.parse_arith("A + B * C")) + + +PROGRAM = """ IDENTIFICATION DIVISION. + PROGRAM-ID. SIZEERR. + DATA DIVISION. + WORKING-STORAGE SECTION. + 01 A PIC 999V99 VALUE 50. + 01 Z0 PIC 9 VALUE 0. + 01 ZN PIC S9 VALUE 0. + 01 M1 PIC S9 VALUE -1. + 01 M2 PIC S9 VALUE -2. + 01 A59 PIC 99 VALUE 59. + 01 Q99 PIC 99 VALUE 0. + 01 R PIC 999V99 VALUE 12.34. + 01 R2 PIC 999V99 VALUE 56.78. + 01 RM PIC 999V99 VALUE 9.99. + 01 RB PIC 9(4) COMP VALUE 77. + 01 RP PIC S9(5)V99 COMP-3 VALUE 11. + 01 RE PIC ZZ9.99 VALUE ' 5.00'. + 01 W PIC 9(5)V99. + 01 S PIC 9(3) VALUE 123. + 01 T PIC 9(3) VALUE 456. + 01 AN PIC S999V99 VALUE -50. + 01 B PIC 9 VALUE 3. + 01 C PIC 999 VALUE 300. + 01 ZZ PIC 9 VALUE 0. + 01 K PIC 9 VALUE 0. + 01 Q PIC 9 VALUE 0. + PROCEDURE DIVISION. + COMPUTE R = A / Z0 + DISPLAY 'C1 ' R + COMPUTE R R2 = A / Z0 + DISPLAY 'C2 ' R ' ' R2 + COMPUTE R = A / Z0 + 1 + DISPLAY 'C3 ' R + COMPUTE R = (A / Z0) * 0 + 7 + DISPLAY 'C4 ' R + COMPUTE RB = A / Z0 + MOVE RB TO W + DISPLAY 'C5 ' W + COMPUTE RP = A / Z0 + MOVE RP TO W + DISPLAY 'C6 ' W + COMPUTE RE = A / Z0 + DISPLAY 'C7 ' RE + DIVIDE Z0 INTO A GIVING R R2 + DISPLAY 'D1 ' R ' ' R2 + DIVIDE Z0 INTO R R2 + DISPLAY 'D2 ' R ' ' R2 + DIVIDE A BY Z0 GIVING R REMAINDER RM + DISPLAY 'D3 ' R ' ' RM + DIVIDE Z0 INTO R ROUNDED + DISPLAY 'D4 ' R + COMPUTE R = Z0 ** M1 + DISPLAY 'P1 ' R + COMPUTE R = Z0 ** M2 + 3 + DISPLAY 'P2 ' R + COMPUTE R = ZN ** 0 + DISPLAY 'P3 ' R + COMPUTE R = 0 ** 0 + DISPLAY 'P4 ' R + COMPUTE R = 123456 + DISPLAY 'O1 ' R + COMPUTE R R2 = A * 100 + DISPLAY 'O2 ' R ' ' R2 + ADD 99999 TO R + DISPLAY 'O3 ' R + MULTIPLY 1000 BY R + DISPLAY 'O4 ' R + ADD S T GIVING R + DISPLAY 'O5 ' R + COMPUTE R = A / Z0 + ON SIZE ERROR DISPLAY 'SE1 Y ' R + NOT ON SIZE ERROR DISPLAY 'SE1 N ' R + END-COMPUTE + DIVIDE Z0 INTO R R2 + ON SIZE ERROR DISPLAY 'SE2 Y ' R ' ' R2 + END-DIVIDE + DIVIDE A BY Z0 GIVING R REMAINDER RM + ON SIZE ERROR DISPLAY 'SE3 Y ' R ' ' RM + END-DIVIDE + COMPUTE R = Z0 ** M1 + ON SIZE ERROR DISPLAY 'SE4 Y ' R + NOT ON SIZE ERROR DISPLAY 'SE4 N ' R + END-COMPUTE + MOVE 5 TO R + COMPUTE R = 2 / 4 + ON SIZE ERROR DISPLAY 'SE5 Y ' R + NOT ON SIZE ERROR DISPLAY 'SE5 N ' R + END-COMPUTE + IF A / Z0 = 0 DISPLAY 'I1 EQ0' ELSE DISPLAY 'I1 NE0' + END-IF + IF A / Z0 = A DISPLAY 'I2 EQA' ELSE DISPLAY 'I2 NEA' + END-IF + IF A / Z0 > 1000 DISPLAY 'I3 GT' ELSE DISPLAY 'I3 LE' + END-IF + DIVIDE 6 INTO A59 GIVING Q99 ROUNDED REMAINDER RM + DISPLAY 'RR ' Q99 ' ' RM + MOVE 12.34 TO R + COMPUTE R = A / Z0 * C + DISPLAY 'A1 ' R + MOVE 12.34 TO R + COMPUTE R = A / Z0 * C + ON SIZE ERROR DISPLAY 'A2 Y ' R + NOT ON SIZE ERROR DISPLAY 'A2 N ' R + END-COMPUTE + COMPUTE R = ZZ ** 0 + ON SIZE ERROR DISPLAY 'E1 Y ' R + NOT ON SIZE ERROR DISPLAY 'E1 N ' R + END-COMPUTE + PERFORM VARYING K FROM 0 BY 1 UNTIL K > 2 + IF K = 0 MOVE 7 TO ZZ ELSE MOVE 0 TO ZZ END-IF + COMPUTE R = FUNCTION ABS(A / ZZ) + DISPLAY 'F' K ' ' R + END-PERFORM + COMPUTE R = FUNCTION ABS(A / Z0) + ON SIZE ERROR DISPLAY 'F3 Y ' R + NOT ON SIZE ERROR DISPLAY 'F3 N ' R + END-COMPUTE + COMPUTE R = (A / Z0) + (A / B) + DISPLAY 'N1 ' R + IF 0 = A / Z0 DISPLAY 'N2 EQ' ELSE DISPLAY 'N2 NE'. + IF A / Z0 + 5 = 5 DISPLAY 'N3 EQ' ELSE DISPLAY 'N3 NE'. + IF (A / Z0) - (A / Z0) = 0 DISPLAY 'N4 EQ' + ELSE DISPLAY 'N4 NE'. + MOVE 12.34 TO R + COMPUTE R = Z0 / Z0 + DISPLAY 'Z1 ' R + COMPUTE R = AN ** 0.5 + ON SIZE ERROR DISPLAY 'W1 Y ' R + NOT ON SIZE ERROR DISPLAY 'W1 N ' R + END-COMPUTE + COMPUTE R = A ** 300.5 + ON SIZE ERROR DISPLAY 'W2 Y ' R + NOT ON SIZE ERROR DISPLAY 'W2 N ' R + END-COMPUTE + COMPUTE R = FUNCTION MOD(A, Z0) + ON SIZE ERROR DISPLAY 'M1 Y ' R + NOT ON SIZE ERROR DISPLAY 'M1 N ' R + END-COMPUTE + MOVE 12.34 TO R + COMPUTE R = FUNCTION REM(A, Z0) + ON SIZE ERROR DISPLAY 'M2 Y ' R + NOT ON SIZE ERROR DISPLAY 'M2 N ' R + END-COMPUTE + MOVE 9.99 TO RM + DIVIDE 0.01 INTO A GIVING Q REMAINDER RM + DISPLAY 'Q1 ' Q ' ' RM + MOVE 9.99 TO RM + DIVIDE 0.01 INTO A GIVING Q REMAINDER RM + ON SIZE ERROR DISPLAY 'Q2 Y ' Q ' ' RM + END-DIVIDE + MOVE 12.34 TO R + COMPUTE R = A / Z0 + ON SIZE ERROR + COMPUTE R2 = A / B * C + END-COMPUTE + NOT ON SIZE ERROR + COMPUTE R2 = A / B * 7 + END-COMPUTE + END-COMPUTE + DISPLAY 'B1 ' R ' ' R2 + GOBACK. +""" + +# what the oracle prints (measured 2026-10-07, GnuCOBOL 3.1.2 -std=ibm; R starts 12.34, R2 56.78, RM 9.99) +WANT = { + "C1": "01234", "C2": "01234 05678", "C3": "01234", "C4": "01234", "C5": "0007700", "C6": "0001100", + "C7": " 5.00", "D1": "01234 05678", "D2": "01234 05678", "D3": "01234 00999", "D4": "01234", + "P1": "00000", "P2": "00300", "P3": "00100", "P4": "00100", + "O1": "45600", "O2": "00000 00000", "O3": "99900", "O4": "00000", "O5": "57900", + "SE1": "Y 57900", "SE2": "Y 57900 00000", "SE3": "Y 57900 00999", "SE4": "N 00000", "SE5": "N 00050", + "I1": "EQ0", "I2": "NEA", "I3": "LE", "RR": "10 00500", + "A1": "00000", "A2": "Y 00000", "E1": "Y 00100", "F0": "00714", "F1": "00000", "F2": "00000", "F3": "Y 00000", + "N1": "01666", "N2": "NE", "N3": "NE", "N4": "EQ", "Z1": "01234", "W1": "Y 01234", "W2": "Y 01234", + "M1": "N 00000", "M2": "N 00000", "Q1": "0 00000", "Q2": "Y 0 00000", "B1": "01234 99800", +} # fmt: skip + + +def _java_ok() -> bool: + from test_det_programs import _java + + return _java() is not None + + +@pytest.mark.skipif(os.environ.get("EQUIVALENCE_E2E") != "1" or not shutil.which("docker"), + reason="needs Docker and a JDK 17 (JAVA_HOME / JDK_17)") # fmt: skip +@pytest.mark.parametrize("mode", ["bytes", "typed"]) +def test_size_errors_are_the_oracles(mode, tmp_path): + pytest.importorskip("tree_sitter_language_pack") + sys.path.insert(0, str(Path(__file__).resolve().parent)) + import test_det_programs as T + + if not _java_ok(): + pytest.skip("needs a JDK 17 (JAVA_HOME / JDK_17)") + cob = tmp_path / "cobol" + cob.mkdir() + want = T._cobol(PROGRAM, cob) + got = T._java_run("sizeerr", PROGRAM, tmp_path, mode != "bytes") + shown = dict(line.split(" ", 1) for line in want.splitlines() if " " in line and not line.startswith("prog.cbl")) + for k, v in WANT.items(): + assert shown[k] == v, (k, shown[k]) + assert got.splitlines() == [x for x in want.splitlines() if not x.startswith("prog.cbl")] From 23719f017236673b866ccb3142d0357d3dcb8551 Mon Sep 17 00:00:00 2001 From: squid-protocol Date: Wed, 7 Oct 2026 23:45:56 -0400 Subject: [PATCH 4/9] det gen: a distinct name for the lifted DIVIDE INTO quotient (mypy) (#4655) Co-Authored-By: Claude Opus 5.5 --- gitgalaxy/tools/cobol_to_java/det/gen.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gitgalaxy/tools/cobol_to_java/det/gen.py b/gitgalaxy/tools/cobol_to_java/det/gen.py index 8dae3de068..b76691f9ce 100644 --- a/gitgalaxy/tools/cobol_to_java/det/gen.py +++ b/gitgalaxy/tools/cobol_to_java/det/gen.py @@ -1899,9 +1899,9 @@ def after(v, q=q): if lt and lt[0] in ("BIN", "NUM") and not checked: # a lifted target: its value, the store as above cur = f"BigDecimal.valueOf({lt[1]})" if lt[0] == "BIN" else lt[1] if op == "/=": # a zero divisor: libcob's NaN, the target unchanged (#4655) - q = self.tmpname("q") - out.append(f"{ind}BigDecimal {q} = Cobol.divide({cur}, {tsum});") - out.append(f"{ind}if (!Cobol.isNan({q})) {self.store_into(tgt, q, rounded)}") + qv = self.tmpname("q") + out.append(f"{ind}BigDecimal {qv} = Cobol.divide({cur}, {tsum});") + out.append(f"{ind}if (!Cobol.isNan({qv})) {self.store_into(tgt, qv, rounded)}") continue val = {"+=": f"{cur}.add({tsum})", "-=": f"{cur}.subtract({tsum})", "*=": f"{tsum}.multiply({cur})"}[op] # fmt: skip From bf0e159abc9b24c8e0d8606ac6c444b601635d26 Mon Sep 17 00:00:00 2001 From: squid-protocol Date: Thu, 8 Oct 2026 01:49:57 -0400 Subject: [PATCH 5/9] det: a float zero divisor is libcob's NaN; a phrase binds to the innermost open statement (#4675, #4676) Co-Authored-By: Claude Opus 5.5 --- docs/language_status/oracle_assumptions.md | 18 +- .../cobol_to_java/det/cobolrt/Cobol.java | 4 +- .../tools/cobol_to_java/det/cobolrt/Hfp.java | 27 ++- gitgalaxy/tools/cobol_to_java/det/gen.py | 60 +++++-- gitgalaxy/tools/cobol_to_java/det/stmt.py | 60 ++++++- tests/cobol_mainframe/test_det_hfp.py | 1 - tests/cobol_mainframe/test_det_size_error.py | 167 ++++++++++++++++++ 7 files changed, 312 insertions(+), 25 deletions(-) diff --git a/docs/language_status/oracle_assumptions.md b/docs/language_status/oracle_assumptions.md index 312fe495ab..1d535f74de 100644 --- a/docs/language_status/oracle_assumptions.md +++ b/docs/language_status/oracle_assumptions.md @@ -419,8 +419,22 @@ verdict, but it limits what the proof says about inputs outside the scenarios. `tests/cobol_mainframe/test_det_size_error.py` (every shape above, both port modes) and a randomized differential run (2026-10-07: 56 random programs with zero divisors, DIVIDE and ON SIZE ERROR / NOT ON SIZE ERROR phrases, every output equal to the oracle's). -- **Not modelled.** A zero divisor in floating point (COMP-1 / COMP-2, `Hfp.divide`) still stops the run with an - ArithmeticException, a named stop rather than a guess. +- **Floating point (#4675).** The oracle converts a COMP-1 / COMP-2 operand to a decimal, so a zero divisor behaves + as above (measured 2026-10-08): `COMPUTE G = F / FZ`, `DIVIDE FZ INTO G`, `DIVIDE F BY FZ GIVING G` (or a decimal + receiver) leave every receiver unchanged and raise the size error, with or without ON SIZE ERROR; ON SIZE ERROR + also fires for a decimal receiver too small for a float result (`R = F * 3000`). The det runtime does the same for + a division that is the statement's whole value (`Hfp.divide` returns the NaN and raises the size error; the float + statements now take ON SIZE ERROR phrases, an HFP exponent overflow is still refused by name). A zero divisor + inside a larger float expression is NOT modelled: the oracle's `cob_decimal_align` meets the NaN there + (`(F / FZ) * 3` is unchanged but `(F / FZ) * C`, `FM * (F / FZ)` store 0, `2 - (F / FZ)` stores 2) and the HFP model + does not replay it, so `Hfp.divideNested` stops the run with an ArithmeticException, a named stop rather than a guess. + z/OS leaves the result of a floating-point zero divide undefined (a floating-point exception or a program-defined + result), so the oracle's "unchanged" is the same kind of choice as for decimal items. +- **Phrase scope (#4676).** A conditional phrase belongs to the innermost unterminated statement that can take it, + and an END-verb closes the innermost open statement of its verb (measured: `COMPUTE ... ON SIZE ERROR COMPUTE ... + NOT ON SIZE ERROR ...` gives the NOT phrase to the inner COMPUTE; with an inner ON/NOT pair of its own the next NOT + goes to the outer). `det/stmt.py` binds them that way (`_phrase_owner`, `_end_owner`). A NOT phrase after a + statement that already has one, or after a finished one, goes outward; cobc rejects a stray one with a syntax error. - **So a proof says:** for a scenario that divides by zero without ON SIZE ERROR, the port does what the oracle does (receivers unchanged), which z/OS does not promise: IBM leaves the result undefined and a z/OS run may abend. With ON SIZE ERROR both sides agree with IBM (receivers unchanged, the phrase runs), except `0 ** -n`, diff --git a/gitgalaxy/tools/cobol_to_java/det/cobolrt/Cobol.java b/gitgalaxy/tools/cobol_to_java/det/cobolrt/Cobol.java index 1ec4c9c6d0..548a480abc 100644 --- a/gitgalaxy/tools/cobol_to_java/det/cobolrt/Cobol.java +++ b/gitgalaxy/tools/cobol_to_java/det/cobolrt/Cobol.java @@ -87,7 +87,7 @@ public static boolean isNan(BigDecimal d) { return d.scale() == NAN_SCALE; } - private static BigDecimal nan(BigDecimal d) { + static BigDecimal nan(BigDecimal d) { return new BigDecimal(d.unscaledValue(), NAN_SCALE); } @@ -104,7 +104,7 @@ public static boolean sizeRaised() { return SIZE_EC.get()[0]; } - private static void raiseSize() { + static void raiseSize() { SIZE_EC.get()[0] = true; } diff --git a/gitgalaxy/tools/cobol_to_java/det/cobolrt/Hfp.java b/gitgalaxy/tools/cobol_to_java/det/cobolrt/Hfp.java index e0f2fc971b..102c6af8f1 100644 --- a/gitgalaxy/tools/cobol_to_java/det/cobolrt/Hfp.java +++ b/gitgalaxy/tools/cobol_to_java/det/cobolrt/Hfp.java @@ -216,13 +216,22 @@ public static BigDecimal multiply(BigDecimal a, BigDecimal b, boolean longP) { return h == null ? BigDecimal.ZERO : value(h, d); } - /** DIVIDE: the exact quotient truncated. A zero divisor: an ArithmeticException, not modelled in floating point - * (oracle_assumptions C14; Cobol.divide gives libcob's NaN). */ + /** DIVIDE: the exact quotient truncated. A zero divisor is libcob's NaN with the size error raised, as in + * Cobol.divide (#4675, oracle_assumptions C14): the oracle converts a float to a decimal and divides it, so a + * receiver is left unchanged; z/OS leaves the result undefined. Only a division that is a statement's whole + * value (COMPUTE x = a / b, DIVIDE): inside a larger expression divideNested refuses. */ public static BigDecimal divide(BigDecimal a, BigDecimal b, boolean longP) { + if (b.signum() == 0) { + Cobol.raiseSize(); + return Cobol.nan(a); + } int d = digits(longP); H x = exact(a, d); H y = exact(b, d); - if (y == null) throw new ArithmeticException("division by zero"); + if (y == null) { // a divisor that is HFP zero + Cobol.raiseSize(); + return Cobol.nan(a); + } if (x == null) return BigDecimal.ZERO; // (fx 16^(ex-d)) / (fy 16^(ey-d)) = (fx / fy) 16^(ex-ey) BigInteger num = x.f; @@ -234,6 +243,18 @@ public static BigDecimal divide(BigDecimal a, BigDecimal b, boolean longP) { return value(h, d); } + /** DIVIDE inside a larger floating-point expression: a zero divisor is refused by name. The oracle's NaN meets + * cob_decimal_align there (NaN * x is 0, 2 - NaN is 2, NaN + 1 unchanged: measured, #4675) and the HFP model + * does not replay that (oracle_assumptions C14). */ + public static BigDecimal divideNested(BigDecimal a, BigDecimal b, boolean longP) { + if (b.signum() == 0) { + throw new ArithmeticException("division by zero inside a floating-point expression (oracle_assumptions C14)"); + } + BigDecimal q = divide(a, b, longP); + if (Cobol.isNan(q)) throw new ArithmeticException("division by zero inside a floating-point expression (oracle_assumptions C14)"); + return q; + } + // ------------------------------------------------------------------------------------------- storage /** The item's bytes as an exact value. An unnormalized fraction reads as its value too. */ diff --git a/gitgalaxy/tools/cobol_to_java/det/gen.py b/gitgalaxy/tools/cobol_to_java/det/gen.py index b76691f9ce..94c9a965f1 100644 --- a/gitgalaxy/tools/cobol_to_java/det/gen.py +++ b/gitgalaxy/tools/cobol_to_java/det/gen.py @@ -151,6 +151,7 @@ def __init__(self, prog: Program, structured: bool = False): # it (a group holding it, a REDEFINES) -- is refused by name: HFP on z/OS, IEEE on the oracle (register C6) self._value_use = 0 # > 0 while value_field asks self._init_bytes = 0 # > 0 inside INITIALIZE: a group is walked to its elementary items, never copied + self.float_top: int = 0 # id of the COMPUTE expression whose own division may be libcob's NaN (#4675) self.fmode: str | None = None # "LONG" / "SHORT" while num() generates a floating-point expression self.float_extents: dict[int, list] = {} # storage root id -> [(item, first extent, full extent, tables)] self.root_of: dict[int, int] = {} # id(record) -> id(its storage root) @@ -514,6 +515,9 @@ def fnum(self, e) -> str: "bit; oracle_assumptions.md C6)" ) fn = {"+": "add", "-": "subtract", "*": "multiply", "/": "divide"}[e.op] + literal = isinstance(e.right, E.Lit) and isinstance(e.right.value, Decimal) and e.right.value != 0 + if fn == "divide" and id(e) != self.float_top and not literal: + fn = "divideNested" # a zero divisor inside a larger expression: refused at run time (#4675) return f"Hfp.{fn}({self.fnum(e.left)}, {self.fnum(e.right)}, {lng})" if isinstance(e, E.LengthOf): with self.floating(None): @@ -1552,9 +1556,11 @@ def _statement(self, s: S.Stmt, ind: str) -> list[str]: self.float_statement(s, s.data["targets"]) plan = self.plan_arith(s.data["expr"], [t for t, _ in s.data["targets"]]) plan = {} if mode is not None else plan + self.float_top = id(s.data["expr"]) if mode is not None else 0 with self.floating(mode), self.osvs_plan(plan): value = self.num(s.data["expr"]) - return [c, *self.store_all(s, s.data["targets"], value, ind, s.data["expr"] if mode is None else None)] + self.float_top = 0 + return [c, *self.store_all(s, s.data["targets"], value, ind, s.data["expr"])] if k == "ARITH": d = s.data receivers = [t for t, _ in d.get("targets") or []] + [t for t, _ in d.get("giving") or []] @@ -1772,10 +1778,8 @@ def display_operand(self, o) -> str: raise Untranslatable(f"DISPLAY of {type(o).__name__}") def float_statement(self, s: S.Stmt, targets: list) -> None: - """What a floating-point statement does not model: ON SIZE ERROR (HFP exponent overflow and underflow are - refused by name in the runtime) and ROUNDED into a float (its precision is the rounding).""" - if "SIZE-ERROR" in s.phrases or "NOT-SIZE-ERROR" in s.phrases: - raise Untranslatable(f"ON SIZE ERROR in a floating-point statement ({FLOAT_BYTES})") + """What a floating-point statement does not model: ROUNDED into a float (its precision is the rounding). + HFP exponent overflow and underflow are refused by name in the runtime; a zero divisor is libcob's NaN (#4675).""" for t, rounded in targets: if rounded and self.float_item(t) is not None: raise Untranslatable(f"ROUNDED into {t.name}, a floating-point item ({FLOAT_BYTES})") @@ -1795,19 +1799,42 @@ def arith_float(self, s: S.Stmt, ind: str) -> list[str]: else: fn = "multiply" if op == "*" else "divide" val = f"Hfp.{fn}({self.fnum(ops[0])}, {self.fnum(ops[1])}, {lng})" - return self.store_all(s, d["giving"], val, ind) + nanx = E.Bin("/", ops[0], ops[1]) if op == "/" else None # a zero divisor: libcob's NaN (#4675) + return self.store_all(s, d["giving"], val, ind, nanx) total = self.fnum(ops[0]) if ops else "BigDecimal.ZERO" for o in ops[1:]: total = f"Hfp.add({total}, {self.fnum(o)}, {lng})" tsum = self.tmpname("t") out = [f"{ind}BigDecimal {tsum} = {total};"] + checked = "SIZE-ERROR" in s.phrases or "NOT-SIZE-ERROR" in s.phrases + err = self.tmpname("sizeError") + if checked: + out.append(f"{ind}boolean {err} = false;") for tgt, rounded in d["targets"]: if not isinstance(tgt, E.Ref): raise Untranslatable("arithmetic target is not a data item") cur = self.fnum(tgt) val = {"+=": f"Hfp.add({cur}, {tsum}, {lng})", "-=": f"Hfp.subtract({cur}, {tsum}, {lng})", "*=": f"Hfp.multiply({tsum}, {cur}, {lng})", "/=": f"Hfp.divide({cur}, {tsum}, {lng})"}[op] # fmt: skip - out.append(ind + self.store_into(tgt, val, rounded)) + if checked: + out.append(f"{ind}{err} |= Cobol.storeChecked({self.value_field(tgt)}, {val}, {_b(rounded)}, CS);") + elif op == "/=": # a zero divisor: libcob's NaN, the target unchanged (#4675) + qv = self.tmpname("q") + out += [ + f"{ind}BigDecimal {qv} = {val};", + f"{ind}if (!Cobol.isNan({qv})) {self.store_into(tgt, qv, rounded)}", + ] + else: + out.append(ind + self.store_into(tgt, val, rounded)) + if checked: + if "SIZE-ERROR" in s.phrases: + out += [f"{ind}if ({err}) {{", *self.phrase_block(s.phrases["SIZE-ERROR"], ind + " "), f"{ind}}}"] + if "NOT-SIZE-ERROR" in s.phrases: + out += [ + f"{ind}if (!{err}) {{", + *self.phrase_block(s.phrases["NOT-SIZE-ERROR"], ind + " "), + f"{ind}}}", + ] return out def store_all(self, s: S.Stmt, targets: list, value: str, ind: str, expr=None, after=None) -> list[str]: @@ -1833,14 +1860,19 @@ def store_all(self, s: S.Stmt, targets: list, value: str, ind: str, expr=None, a out.append(f"{ind}BigDecimal {v} = {value};") out.append(f"{ind}boolean {err} = {'Cobol.sizeRaised()' if sized else 'false'};") for t, rounded in targets: - out.append(f"{ind}{err} |= Cobol.storeChecked({self.field_expr(t)}, {v}, {_b(rounded)}, CS);") + out.append(f"{ind}{err} |= Cobol.storeChecked({self.value_field(t)}, {v}, {_b(rounded)}, CS);") out += after(v) if after else [] if "SIZE-ERROR" in s.phrases: - out += [f"{ind}if ({err}) {{", *self.block(s.phrases["SIZE-ERROR"], ind + " "), f"{ind}}}"] + out += [f"{ind}if ({err}) {{", *self.phrase_block(s.phrases["SIZE-ERROR"], ind + " "), f"{ind}}}"] if "NOT-SIZE-ERROR" in s.phrases: - out += [f"{ind}if (!{err}) {{", *self.block(s.phrases["NOT-SIZE-ERROR"], ind + " "), f"{ind}}}"] + out += [f"{ind}if (!{err}) {{", *self.phrase_block(s.phrases["NOT-SIZE-ERROR"], ind + " "), f"{ind}}}"] return out + def phrase_block(self, body: list, ind: str) -> list[str]: + """A SIZE ERROR phrase's statements: their own arithmetic mode, never the statement's (#4675).""" + with self.floating(None): + return self.block(body, ind) + def store_into(self, t: E.Ref, value: str, rounded: bool) -> str: """An arithmetic result (no ON SIZE ERROR) stored in a target: a lifted binary item through Cobol.binary.""" lt = self.lift(t) @@ -1917,9 +1949,13 @@ def after(v, q=q): out.append(f"{ind}Cobol.store({f}, {val}, {_b(rounded)}, CS);") if checked: if "SIZE-ERROR" in s.phrases: - out += [f"{ind}if ({err}) {{", *self.block(s.phrases["SIZE-ERROR"], ind + " "), f"{ind}}}"] + out += [f"{ind}if ({err}) {{", *self.phrase_block(s.phrases["SIZE-ERROR"], ind + " "), f"{ind}}}"] if "NOT-SIZE-ERROR" in s.phrases: - out += [f"{ind}if (!{err}) {{", *self.block(s.phrases["NOT-SIZE-ERROR"], ind + " "), f"{ind}}}"] + out += [ + f"{ind}if (!{err}) {{", + *self.phrase_block(s.phrases["NOT-SIZE-ERROR"], ind + " "), + f"{ind}}}", + ] return out def evaluate(self, s: S.Stmt, ind: str) -> list[str]: diff --git a/gitgalaxy/tools/cobol_to_java/det/stmt.py b/gitgalaxy/tools/cobol_to_java/det/stmt.py index 3de8517d6d..8230263fd7 100644 --- a/gitgalaxy/tools/cobol_to_java/det/stmt.py +++ b/gitgalaxy/tools/cobol_to_java/det/stmt.py @@ -151,6 +151,7 @@ def origin(node) -> int: paragraphs: list[Paragraph] = [Paragraph("(MAIN)", None, 0)] section: str | None = None stack: list[_Frame] = [_Frame("PARA", None, paragraphs[0].body)] + closed: set[int] = set() # id of the statements an END-x has terminated (#4676) def close_all(): del stack[1:] @@ -283,17 +284,22 @@ def node_text(n) -> str: continue if t in PHRASES: name = PHRASES[t] - while stack[-1].kind == "PHRASE": - stack.pop() if t == "at_end" and stack[-1].kind == "SEARCH" and not _node(stack[-1]).whens: # #4462: SEARCH ... AT END (before its WHENs): the SEARCH's own phrase search = _node(stack[-1]) stack.append(_Frame("PHRASE", search, search.phrases.setdefault(name, []))) continue - owner = next((x for x in reversed(stack[-1].target) if x.kind not in ("HOLE",)), None) + # #4676: the innermost unterminated statement that can take the phrase and has not got it yet + hit = _phrase_owner(stack, name, closed) + if hit is None: # no statement can: the old rule, the last statement of the innermost scope + while stack[-1].kind == "PHRASE": + stack.pop() + hit = (len(stack) - 1, next((x for x in reversed(stack[-1].target) if x.kind != "HOLE"), None)) + k, owner = hit if owner is None: stack[-1].target.append(Stmt("HOLE", origin(n), node_text(n), {"why": "phrase with no statement"})) continue + del stack[k + 1 :] body = owner.phrases.setdefault(name, []) stack.append(_Frame("PHRASE", owner, body)) continue @@ -311,8 +317,14 @@ def node_text(n) -> str: stack.pop() continue if t in STATEMENT_ENDS: - while stack[-1].kind == "PHRASE": - stack.pop() + # #4676: END-x closes the innermost unterminated statement of its verb (and the phrases it holds open) + k = _end_owner(stack, t[4:], closed) + if k is None: # none open: the old rule, close the phrases on top + while stack[-1].kind == "PHRASE": + stack.pop() + else: + del stack[k[0] + 1 :] + closed.add(id(k[1])) continue if t.endswith("_statement") or t.startswith("perform_statement"): s = _statement(node_text(n), origin(n)) @@ -366,6 +378,44 @@ def _parser_cache(): return _PARSER +# the statements each phrase can follow (#4676); EXCEPTION is also DISPLAY's and the JSON / XML statements' +_PHRASE_TAKERS = {"AT-END": {"READ", "RETURN"}, "NOT-AT-END": {"READ", "RETURN"}, + "INVALID-KEY": {"READ", "WRITE", "REWRITE", "START"}, "NOT-INVALID-KEY": {"READ", "WRITE", "REWRITE", "START"}, + "SIZE-ERROR": {"COMPUTE", "ARITH"}, "NOT-SIZE-ERROR": {"COMPUTE", "ARITH"}, + "OVERFLOW": {"CALL", "STRING", "UNSTRING"}, "NOT-OVERFLOW": {"CALL", "STRING", "UNSTRING"}, + "EXCEPTION": None, "NOT-EXCEPTION": None, "AT-EOP": {"WRITE"}, "NOT-AT-EOP": {"WRITE"}} # fmt: skip + + +def _open_last(f: _Frame, closed: set[int]) -> Stmt | None: + """The last statement of a scope that is not a hole, and no END-x has terminated.""" + s = next((x for x in reversed(f.target) if x.kind != "HOLE"), None) + return None if s is None or id(s) in closed else s + + +def _phrase_owner(stack: list[_Frame], name: str, closed: set[int]) -> tuple[int, Stmt] | None: + """#4676: (stack index, statement) of the innermost unterminated statement that can take the phrase `name` and has + not got it -- the last statement of the innermost scope, else of the scope around it, and so on. cobc binds a NOT + ON SIZE ERROR after a nested, unterminated COMPUTE to the nested COMPUTE.""" + takers = _PHRASE_TAKERS[name] + for k in range(len(stack) - 1, -1, -1): + s = _open_last(stack[k], closed) + if s is not None and name not in s.phrases and (takers is None or s.kind in takers): + return k, s + return None + + +def _end_owner(stack: list[_Frame], verb: str, closed: set[int]) -> tuple[int, Stmt] | None: + """#4676: the innermost unterminated statement of `verb` an END-verb closes, looking through the phrases around + it only: an enclosing IF / PERFORM scope is not left.""" + for k in range(len(stack) - 1, -1, -1): + s = _open_last(stack[k], closed) + if s is not None and s.text.split()[0].upper() == verb: + return k, s + if stack[k].kind != "PHRASE": + return None + return None + + def _node(f: _Frame) -> Stmt: if f.node is None: raise E.ExprError(f"{f.kind} frame has no statement") diff --git a/tests/cobol_mainframe/test_det_hfp.py b/tests/cobol_mainframe/test_det_hfp.py index 711801c373..2ccf2ef483 100644 --- a/tests/cobol_mainframe/test_det_hfp.py +++ b/tests/cobol_mainframe/test_det_hfp.py @@ -265,7 +265,6 @@ def test_the_runtime_is_the_model_bit_for_bit(tmp_path): ("MOVE S1(1:2) TO X4", "the bytes of a COMP-1 item"), ("COMPUTE L1 = S1 ** 2", "exponentiation in a floating-point expression"), ("COMPUTE L1 = FUNCTION NUMVAL(X4)", "FUNCTION NUMVAL in a floating-point expression"), - ("COMPUTE L1 = N / 3 ON SIZE ERROR DISPLAY 'E' END-COMPUTE", "ON SIZE ERROR in a floating-point statement"), ("COMPUTE S1 ROUNDED = N / 3", "ROUNDED into S1"), ("IF S1 = 'AB' DISPLAY 'Y' END-IF", "compared with a nonnumeric operand"), ("IF S1 = SPACES DISPLAY 'Y' END-IF", "compared with a nonnumeric operand"), diff --git a/tests/cobol_mainframe/test_det_size_error.py b/tests/cobol_mainframe/test_det_size_error.py index f4ea165d26..d50f1f192c 100644 --- a/tests/cobol_mainframe/test_det_size_error.py +++ b/tests/cobol_mainframe/test_det_size_error.py @@ -37,6 +37,52 @@ def test_a_function_argument_still_raises_the_size_error(): assert not G._raises_size(E.parse_arith("A + B * C")) +def _tree(*body: str): + from gitgalaxy.tools.cobol_to_java.det import source as SRC + from gitgalaxy.tools.cobol_to_java.det import stmt as ST + + rows = [ + "IDENTIFICATION DIVISION.", + "PROGRAM-ID. PROG.", + "DATA DIVISION.", + "WORKING-STORAGE SECTION.", + "01 A PIC 9.", + "PROCEDURE DIVISION.", + "P1.", + *body, + " GOBACK.", + ] + return ST.parse(SRC.logical_lines([" " + r for r in rows], "/x/PROG.cbl")).paragraphs[0].body + + +def test_a_phrase_binds_to_the_innermost_unterminated_statement(): + """#4676 (cobc): NOT ON SIZE ERROR after a nested, unterminated COMPUTE is the nested one's; an END-COMPUTE closes + the innermost COMPUTE, so what follows it stays in the nested statement's phrase.""" + pytest.importorskip("tree_sitter_language_pack") + outer = _tree( + "COMPUTE A = 1", + " ON SIZE ERROR COMPUTE A = A * A", + " NOT ON SIZE ERROR COMPUTE A = A + A", + "END-COMPUTE", + "DISPLAY A.", + )[0] + assert set(outer.phrases) == {"SIZE-ERROR"} + inner = outer.phrases["SIZE-ERROR"][0] + assert set(inner.phrases) == {"NOT-SIZE-ERROR"} + # the END-COMPUTE closed the COMPUTE inside the inner NOT phrase; the DISPLAY after it is still in that phrase + assert [x.kind for x in inner.phrases["NOT-SIZE-ERROR"]] == ["COMPUTE", "DISPLAY"] + assert [x.kind for x in outer.phrases["SIZE-ERROR"]] == ["COMPUTE"] + # terminated: the NOT phrase is the outer statement's again + outer = _tree( + "COMPUTE A = 1", + " ON SIZE ERROR COMPUTE A = A * A END-COMPUTE", + " NOT ON SIZE ERROR DISPLAY A", + "END-COMPUTE.", + )[0] + assert set(outer.phrases) == {"SIZE-ERROR", "NOT-SIZE-ERROR"} + assert not outer.phrases["SIZE-ERROR"][0].phrases + + PROGRAM = """ IDENTIFICATION DIVISION. PROGRAM-ID. SIZEERR. DATA DIVISION. @@ -63,6 +109,15 @@ def test_a_function_argument_still_raises_the_size_error(): 01 ZZ PIC 9 VALUE 0. 01 K PIC 9 VALUE 0. 01 Q PIC 9 VALUE 0. + 01 R3 PIC 999V99 VALUE 2. + 01 F1 COMP-1 VALUE 5. + 01 F2 COMP-2 VALUE 7. + 01 FZ1 COMP-1 VALUE 0. + 01 FZ2 COMP-2 VALUE 0. + 01 G1 COMP-1 VALUE 1.5. + 01 G2 COMP-2 VALUE 2.5. + 01 E1 PIC -(5)9.99. + 01 E2 PIC -(5)9.99. PROCEDURE DIVISION. COMPUTE R = A / Z0 DISPLAY 'C1 ' R @@ -197,6 +252,114 @@ def test_a_function_argument_still_raises_the_size_error(): END-COMPUTE END-COMPUTE DISPLAY 'B1 ' R ' ' R2 + COMPUTE G1 = F1 / FZ1 + MOVE G1 TO E1 + DISPLAY 'H1 ' E1 + COMPUTE G2 = F2 / Z0 + MOVE G2 TO E2 + DISPLAY 'H2 ' E2 + COMPUTE R = F2 / FZ2 + DISPLAY 'H3 ' R + COMPUTE G1 = F1 / FZ1 + ON SIZE ERROR DISPLAY 'H4 Y' + NOT ON SIZE ERROR DISPLAY 'H4 N' + END-COMPUTE + MOVE G1 TO E1 + DISPLAY 'H4v ' E1 + COMPUTE G2 = F2 / FZ2 + ON SIZE ERROR DISPLAY 'H5 Y' + NOT ON SIZE ERROR DISPLAY 'H5 N' + END-COMPUTE + MOVE G2 TO E2 + DISPLAY 'H5v ' E2 + DIVIDE FZ2 INTO G2 + MOVE G2 TO E2 + DISPLAY 'H6 ' E2 + DIVIDE FZ1 INTO G1 + ON SIZE ERROR DISPLAY 'H7 Y' + NOT ON SIZE ERROR DISPLAY 'H7 N' + END-DIVIDE + MOVE G1 TO E1 + DISPLAY 'H7v ' E1 + DIVIDE F2 BY FZ2 GIVING G2 + ON SIZE ERROR DISPLAY 'H8 Y' + END-DIVIDE + MOVE G2 TO E2 + DISPLAY 'H8v ' E2 + DIVIDE F2 BY FZ2 GIVING R + ON SIZE ERROR DISPLAY 'H9 Y' + END-DIVIDE + DISPLAY 'H9v ' R + COMPUTE G2 = F2 * 3 + ON SIZE ERROR DISPLAY 'HA Y' + NOT ON SIZE ERROR DISPLAY 'HA N' + END-COMPUTE + MOVE 12.34 TO R + COMPUTE R = F2 * 3000 + ON SIZE ERROR DISPLAY 'HB Y' + NOT ON SIZE ERROR DISPLAY 'HB N' + END-COMPUTE + DISPLAY 'HBv ' R + MOVE 12.34 TO R + MOVE 1 TO R2 + COMPUTE R = A / Z0 + ON SIZE ERROR COMPUTE R2 = A / B * C + NOT ON SIZE ERROR COMPUTE R2 = A / B * 7 + END-COMPUTE + DISPLAY 'U1 ' R ' ' R2 + DISPLAY 'U1b'. + MOVE 1 TO R2 + COMPUTE R = A / Z0 + ON SIZE ERROR COMPUTE R2 = A / B * C + ON SIZE ERROR DISPLAY 'U2 in Y' + NOT ON SIZE ERROR DISPLAY 'U2 in N' + NOT ON SIZE ERROR COMPUTE R2 = A / B * 7 + END-COMPUTE + DISPLAY 'U2 ' R ' ' R2 + DISPLAY 'U2b'. + MOVE 1 TO R2 + COMPUTE R = A / Z0 + ON SIZE ERROR COMPUTE R2 = A / B * C + ON SIZE ERROR DISPLAY 'U3 in Y' + NOT ON SIZE ERROR DISPLAY 'U3 in N' + END-COMPUTE + NOT ON SIZE ERROR COMPUTE R2 = A / B * 7 + END-COMPUTE + DISPLAY 'U3 ' R ' ' R2 + DISPLAY 'U3b'. + MOVE 1 TO R2 + COMPUTE R = A / Z0 + ON SIZE ERROR + IF B > 1 + COMPUTE R2 = A / B * C + NOT ON SIZE ERROR DISPLAY 'U4 if N' + END-IF + DISPLAY 'U4 x' + NOT ON SIZE ERROR DISPLAY 'U4 outer N' + END-COMPUTE + DISPLAY 'U4 ' R ' ' R2 + DISPLAY 'U4b'. + MOVE 1 TO R2 + COMPUTE R = A / B + ON SIZE ERROR DISPLAY 'U5 o Y' + NOT ON SIZE ERROR + COMPUTE R2 = A / Z0 + ON SIZE ERROR DISPLAY 'U5 i Y' + ADD 1 TO R3 + NOT ON SIZE ERROR DISPLAY 'U5 a N' + END-COMPUTE + DISPLAY 'U5 ' R ' ' R2 ' ' R3 + DISPLAY 'U5b'. + ADD 1 TO R + ON SIZE ERROR DISPLAY 'U6 o Y' + NOT ON SIZE ERROR + MULTIPLY 2 BY R3 + SUBTRACT 1 FROM R3 ON SIZE ERROR DISPLAY 'U6 i Y' + END-SUBTRACT + ADD A TO R2 NOT ON SIZE ERROR DISPLAY 'U6 in N' + END-ADD + DISPLAY 'U6 ' R ' ' R2 ' ' R3 + DISPLAY 'U6b'. GOBACK. """ @@ -211,6 +374,10 @@ def test_a_function_argument_still_raises_the_size_error(): "A1": "00000", "A2": "Y 00000", "E1": "Y 00100", "F0": "00714", "F1": "00000", "F2": "00000", "F3": "Y 00000", "N1": "01666", "N2": "NE", "N3": "NE", "N4": "EQ", "Z1": "01234", "W1": "Y 01234", "W2": "Y 01234", "M1": "N 00000", "M2": "N 00000", "Q1": "0 00000", "Q2": "Y 0 00000", "B1": "01234 99800", + "H1": " 1.50", "H2": " 2.50", "H3": "01234", "H4": "Y", "H4v": " 1.50", "H5": "Y", "H5v": " 2.50", + "H6": " 2.50", "H7": "Y", "H7v": " 1.50", "H8": "Y", "H8v": " 2.50", "H9": "Y", "H9v": "01234", "HA": "N", + "HB": "Y", "HBv": "01234", + "U2": "in Y", "U3": "in Y", "U4": "01234 00100", "U5": "01666 00100 00300", "U6": "01766 05100 00500", } # fmt: skip From 71f65cd02d73682ac24ba12a026a7d2c5edfdbad Mon Sep 17 00:00:00 2001 From: squid-protocol Date: Thu, 8 Oct 2026 01:52:56 -0400 Subject: [PATCH 6/9] det: lint and mypy for the scope binding (#4676) Co-Authored-By: Claude Opus 5.5 --- gitgalaxy/tools/cobol_to_java/det/gen.py | 6 +++--- gitgalaxy/tools/cobol_to_java/det/stmt.py | 17 ++++++++++------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/gitgalaxy/tools/cobol_to_java/det/gen.py b/gitgalaxy/tools/cobol_to_java/det/gen.py index 94c9a965f1..78079c2dbf 100644 --- a/gitgalaxy/tools/cobol_to_java/det/gen.py +++ b/gitgalaxy/tools/cobol_to_java/det/gen.py @@ -1553,7 +1553,7 @@ def _statement(self, s: S.Stmt, ind: str) -> list[str]: if k == "COMPUTE": mode = self.float_mode([s.data["expr"]], [t for t, _ in s.data["targets"]]) if mode is not None: - self.float_statement(s, s.data["targets"]) + self.float_statement(s.data["targets"]) plan = self.plan_arith(s.data["expr"], [t for t, _ in s.data["targets"]]) plan = {} if mode is not None else plan self.float_top = id(s.data["expr"]) if mode is not None else 0 @@ -1567,7 +1567,7 @@ def _statement(self, s: S.Stmt, ind: str) -> list[str]: receivers += [d["remainder"]] if d.get("remainder") is not None else [] mode = self.float_mode(d["operands"], receivers, d["op"] in ("*", "*=")) if mode is not None: - self.float_statement(s, list(d.get("targets") or []) + list(d.get("giving") or [])) + self.float_statement(list(d.get("targets") or []) + list(d.get("giving") or [])) if d.get("remainder") is not None: raise Untranslatable(f"DIVIDE REMAINDER in floating point ({FLOAT_BYTES})") with self.floating(mode): @@ -1777,7 +1777,7 @@ def display_operand(self, o) -> str: return self.text(o) raise Untranslatable(f"DISPLAY of {type(o).__name__}") - def float_statement(self, s: S.Stmt, targets: list) -> None: + def float_statement(self, targets: list) -> None: """What a floating-point statement does not model: ROUNDED into a float (its precision is the rounding). HFP exponent overflow and underflow are refused by name in the runtime; a zero divisor is libcob's NaN (#4675).""" for t, rounded in targets: diff --git a/gitgalaxy/tools/cobol_to_java/det/stmt.py b/gitgalaxy/tools/cobol_to_java/det/stmt.py index 8230263fd7..92cf045276 100644 --- a/gitgalaxy/tools/cobol_to_java/det/stmt.py +++ b/gitgalaxy/tools/cobol_to_java/det/stmt.py @@ -291,11 +291,14 @@ def node_text(n) -> str: continue # #4676: the innermost unterminated statement that can take the phrase and has not got it yet hit = _phrase_owner(stack, name, closed) - if hit is None: # no statement can: the old rule, the last statement of the innermost scope + owner: Stmt | None + if hit is not None: + k, owner = hit + else: # no statement can: the old rule, the last statement of the innermost scope while stack[-1].kind == "PHRASE": stack.pop() - hit = (len(stack) - 1, next((x for x in reversed(stack[-1].target) if x.kind != "HOLE"), None)) - k, owner = hit + k = len(stack) - 1 + owner = next((x for x in reversed(stack[-1].target) if x.kind != "HOLE"), None) if owner is None: stack[-1].target.append(Stmt("HOLE", origin(n), node_text(n), {"why": "phrase with no statement"})) continue @@ -318,13 +321,13 @@ def node_text(n) -> str: continue if t in STATEMENT_ENDS: # #4676: END-x closes the innermost unterminated statement of its verb (and the phrases it holds open) - k = _end_owner(stack, t[4:], closed) - if k is None: # none open: the old rule, close the phrases on top + end = _end_owner(stack, t[4:], closed) + if end is None: # none open: the old rule, close the phrases on top while stack[-1].kind == "PHRASE": stack.pop() else: - del stack[k[0] + 1 :] - closed.add(id(k[1])) + del stack[end[0] + 1 :] + closed.add(id(end[1])) continue if t.endswith("_statement") or t.startswith("perform_statement"): s = _statement(node_text(n), origin(n)) From 4bcc510ec83038c174cf174f27b5b13913e59fbb Mon Sep 17 00:00:00 2001 From: squid-protocol Date: Thu, 8 Oct 2026 10:46:38 -0400 Subject: [PATCH 7/9] chore: break det osvs/expr import cycle (CodeQL) expr imported det.cics (for DFHRESP) and det.cvda lazily, and det.cics imported det.gen, which imports det.osvs, which imports expr. expr now imports DFHRESP from gitgalaxy.standards.cics.resp (its home) and CVDA from det.cvda (no imports) at module level, so expr no longer reaches gen. Also one import form for test_det_programs in test_det_osvs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MQNoe4wPJgr7dbUvs3DG6D --- gitgalaxy/tools/cobol_to_java/det/expr.py | 7 +++---- tests/cobol_mainframe/test_det_osvs.py | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/gitgalaxy/tools/cobol_to_java/det/expr.py b/gitgalaxy/tools/cobol_to_java/det/expr.py index 3c788ae10d..7a63fc80ec 100644 --- a/gitgalaxy/tools/cobol_to_java/det/expr.py +++ b/gitgalaxy/tools/cobol_to_java/det/expr.py @@ -11,6 +11,9 @@ from dataclasses import dataclass, field from decimal import Decimal +from gitgalaxy.standards.cics.resp import DFHRESP +from gitgalaxy.tools.cobol_to_java.det.cvda import CVDA + class ExprError(Exception): pass @@ -213,16 +216,12 @@ def operand(self) -> object: r = self.ref() if r.name == "DFHRESP" and len(r.subscripts) == 1 and isinstance(r.subscripts[0], Ref): # DFHRESP(condition): the condition's RESP value (IBM CICS TS) - from gitgalaxy.tools.cobol_to_java.det.cics import DFHRESP - cond = r.subscripts[0].name if cond not in DFHRESP: raise ExprError(f"DFHRESP({cond}) is not a documented condition") return Lit(Decimal(DFHRESP[cond])) if r.name == "DFHVALUE" and len(r.subscripts) == 1 and isinstance(r.subscripts[0], Ref): # DFHVALUE(name): the CVDA's numeric value (IBM CICS TS, CVDAs and numeric values) - from gitgalaxy.tools.cobol_to_java.det.cvda import CVDA - name = r.subscripts[0].name if name not in CVDA: raise ExprError(f"DFHVALUE({name}) is not a documented CVDA") diff --git a/tests/cobol_mainframe/test_det_osvs.py b/tests/cobol_mainframe/test_det_osvs.py index 2acaf6e4ef..dd583ddab9 100644 --- a/tests/cobol_mainframe/test_det_osvs.py +++ b/tests/cobol_mainframe/test_det_osvs.py @@ -180,9 +180,9 @@ def test_two_integer_literals_compare_at_compile_time_and_are_not_walked(): def _java_ok() -> bool: - from test_det_programs import _java + import test_det_programs as T - return _java() is not None + return T._java() is not None @pytest.mark.skipif(os.environ.get("EQUIVALENCE_E2E") != "1" or not shutil.which("docker"), From 07ea5a4128295376fd5553fc0f9cedfbe0b881f3 Mon Sep 17 00:00:00 2001 From: squid-protocol Date: Thu, 8 Oct 2026 11:04:18 -0400 Subject: [PATCH 8/9] chore: one import form for test_det_programs in test_det_size_error (CodeQL) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MQNoe4wPJgr7dbUvs3DG6D --- tests/cobol_mainframe/test_det_size_error.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/cobol_mainframe/test_det_size_error.py b/tests/cobol_mainframe/test_det_size_error.py index f4ea165d26..b807c15eb9 100644 --- a/tests/cobol_mainframe/test_det_size_error.py +++ b/tests/cobol_mainframe/test_det_size_error.py @@ -215,9 +215,9 @@ def test_a_function_argument_still_raises_the_size_error(): def _java_ok() -> bool: - from test_det_programs import _java + import test_det_programs as T - return _java() is not None + return T._java() is not None @pytest.mark.skipif(os.environ.get("EQUIVALENCE_E2E") != "1" or not shutil.which("docker"), From a022cf2ef8dd733674214f87a7567616705b6a53 Mon Sep 17 00:00:00 2001 From: squid-protocol Date: Thu, 8 Oct 2026 11:13:07 -0400 Subject: [PATCH 9/9] chore: clear CodeQL py/import-and-import-from (tests/cobol_mainframe/test_det_size_error.py) _java_ok imports test_det_programs as a module, as the e2e test below it does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MQNoe4wPJgr7dbUvs3DG6D --- tests/cobol_mainframe/test_det_size_error.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/cobol_mainframe/test_det_size_error.py b/tests/cobol_mainframe/test_det_size_error.py index d50f1f192c..48c4fc1700 100644 --- a/tests/cobol_mainframe/test_det_size_error.py +++ b/tests/cobol_mainframe/test_det_size_error.py @@ -382,9 +382,9 @@ def test_a_phrase_binds_to_the_innermost_unterminated_statement(): def _java_ok() -> bool: - from test_det_programs import _java + import test_det_programs as T - return _java() is not None + return T._java() is not None @pytest.mark.skipif(os.environ.get("EQUIVALENCE_E2E") != "1" or not shutil.which("docker"),