From 5a58d022a33fb55540a727503c13fc8b553c0475 Mon Sep 17 00:00:00 2001 From: squid-protocol Date: Fri, 2 Oct 2026 19:56:07 -0400 Subject: [PATCH 1/5] WIP (deferred, not for merge): CBACT01C case, COBDATFT model, variable-length records, C10 sign tolerance, Devin CBACT01C ports Deferred by the coordinator mid-way (usage window). State: carddemo-readacct proves for our det port (13 runs, 16/16 paragraphs, 41/44 branches); register entries C10 / L3 / F3 not yet written; no sweep run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RZE6us7fUL6Hynk59rWLvZ --- .../det/cobolrt/batch/DetFiles.java | 95 ++++ .../det/cobolrt/le/Cobdatft.java | 42 ++ gitgalaxy/tools/cobol_to_java/det/gen.py | 13 +- gitgalaxy/tools/cobol_to_java/det/program.py | 38 ++ tests/equivalence/carddemo-readacct/LICENSE | 175 ++++++ tests/equivalence/carddemo-readacct/NOTICE | 1 + tests/equivalence/carddemo-readacct/case.json | 201 +++++++ tests/equivalence/le/cobdatft.c | 52 ++ .../adapter/devinadapter/EnvLauncher.java | 26 + tests/tools/devin_port.py | 503 ++++++++++++++++++ tests/tools/equivalence.py | 57 +- tests/tools/equivalence_common.py | 54 ++ 12 files changed, 1251 insertions(+), 6 deletions(-) create mode 100644 gitgalaxy/tools/cobol_to_java/det/cobolrt/le/Cobdatft.java create mode 100644 tests/equivalence/carddemo-readacct/LICENSE create mode 100644 tests/equivalence/carddemo-readacct/NOTICE create mode 100644 tests/equivalence/carddemo-readacct/case.json create mode 100644 tests/equivalence/le/cobdatft.c create mode 100644 tests/tools/devin/adapter/devinadapter/EnvLauncher.java create mode 100644 tests/tools/devin_port.py diff --git a/gitgalaxy/tools/cobol_to_java/det/cobolrt/batch/DetFiles.java b/gitgalaxy/tools/cobol_to_java/det/cobolrt/batch/DetFiles.java index c742207f41..406ea8557b 100644 --- a/gitgalaxy/tools/cobol_to_java/det/cobolrt/batch/DetFiles.java +++ b/gitgalaxy/tools/cobol_to_java/det/cobolrt/batch/DetFiles.java @@ -177,6 +177,101 @@ public String rewrite(int length) { } } + /** + * A sequential file of variable-length records (RECORD IS VARYING ... DEPENDING ON): each WRITE writes as many + * bytes of the record area as the DEPENDING ON item holds, framed as GnuCOBOL frames a variable-length record + * (COB_VARSEQ_FORMAT 0: the length as a big-endian halfword, two zero bytes, then the data). A length outside + * the FD's FROM / TO range is status 44 (a boundary violation), and nothing is written. Output only: the + * translator refuses a READ of such a file. + */ + public static final class VarSequential implements DetFile { + private final CobolFiles files; + private final String dd; + private final Supplier path; + private final Storage rec; + private final int offset; + private final int min; + private final int max; + private OutputStream out; + + public VarSequential(CobolFiles files, String dd, Supplier path, Storage rec, int offset, int min, + int max) { + this.files = files; + this.dd = dd; + this.path = path; + this.rec = rec; + this.offset = offset; + this.min = min; + this.max = max; + } + + @Override + public String open(String mode) { + if (mode.equals("INPUT") || mode.equals("I-O")) { + throw new UnsupportedOperationException("OPEN " + mode + " of a variable-length file"); + } + Path p = path.get(); + String st = files.open(dd, p, false); + if (!st.startsWith("0")) { + return st; + } + try { + if (p != null) { + if (p.getParent() != null) { + Files.createDirectories(p.getParent()); + } + out = mode.equals("EXTEND") + ? Files.newOutputStream(p, StandardOpenOption.CREATE, StandardOpenOption.APPEND) + : Files.newOutputStream(p); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return st; + } + + @Override + public String close() { + return files.close(dd, () -> { + if (out != null) { + out.close(); + out = null; + } + }); + } + + @Override + public String readNext() { + throw new UnsupportedOperationException("READ of a variable-length file"); + } + + @Override + public String readKey(int keyOffset, int keyLength) { + throw new UnsupportedOperationException("keyed READ of a sequential file"); + } + + @Override + public String write(int length) { + if (length < min || length > max) { + return "44"; + } + byte[] b = new byte[4 + length]; + b[0] = (byte) (length >> 8); + b[1] = (byte) length; + System.arraycopy(rec.bytes, offset, b, 4, length); + return files.write(dd, () -> { + if (out != null) { + out.write(b); + } + }); + } + + @Override + public String rewrite(int length) { + throw new UnsupportedOperationException("REWRITE of a sequential file"); + } + } + /** * An indexed (VSAM KSDS) file kept in a repository: records are entities, converted with the generated * fromRecord / toRecord. Records are found by comparing key bytes (any key, primary or alternate); the diff --git a/gitgalaxy/tools/cobol_to_java/det/cobolrt/le/Cobdatft.java b/gitgalaxy/tools/cobol_to_java/det/cobolrt/le/Cobdatft.java new file mode 100644 index 0000000000..38d8fea465 --- /dev/null +++ b/gitgalaxy/tools/cobol_to_java/det/cobolrt/le/Cobdatft.java @@ -0,0 +1,42 @@ +package __PACKAGE__.cobolrt.le; + +import __PACKAGE__.cobolrt.Field; + +/** + * COBDATFT -- CardDemo's assembler date routine (app/asm/COBDATFT.asm), as the equivalence harness models it for + * the COBOL side (tests/equivalence/le/cobdatft.c): the same bytes, and the same refusals -- what that model does + * not model, this does not either (oracle_assumptions.md A1). + * + * CALL 'COBDATFT' USING CODATECN-REC + * + * Input type '2' (YYYY-MM-DD) to output type '2' (YYYYMMDD) is modelled: COOUTDT(4) = COINPDT(4), + * COOUTDT+4(2) = COINPDT+5(2), COOUTDT+6(2) = COINPDT+8(2); every other byte of the record is left as it was. + * Input type '1' (its check is a 20-byte CLC against a 1-byte literal) and the error path (an MVC of 38 bytes from + * a 13-byte literal) depend on the load module's literal pool, not on the source: refused. + */ +public final class Cobdatft { + private static final int IN_TYPE = 0; + private static final int IN_DATE = 1; + private static final int OUT_TYPE = 21; + private static final int OUT_DATE = 22; + + private Cobdatft() { + } + + public static void call(Field rec) { + byte[] b = rec.storage().bytes; + int r = rec.offset(); + byte inType = b[r + IN_TYPE]; + if (inType == '1') { + throw new UnsupportedOperationException( + "COBDATFT: input type '1' (VALIDIN1: a 20-byte CLC against a 1-byte literal) is not modelled"); + } + if (inType != '2' || b[r + OUT_TYPE] == '1') { + throw new UnsupportedOperationException( + "COBDATFT: the error path (MVC of 38 bytes from a 13-byte literal) is not modelled"); + } + System.arraycopy(b, r + IN_DATE, b, r + OUT_DATE, 4); + System.arraycopy(b, r + IN_DATE + 5, b, r + OUT_DATE + 4, 2); + System.arraycopy(b, r + IN_DATE + 8, b, r + OUT_DATE + 6, 2); + } +} diff --git a/gitgalaxy/tools/cobol_to_java/det/gen.py b/gitgalaxy/tools/cobol_to_java/det/gen.py index 0d0ad4711e..9fe310a50e 100644 --- a/gitgalaxy/tools/cobol_to_java/det/gen.py +++ b/gitgalaxy/tools/cobol_to_java/det/gen.py @@ -47,7 +47,7 @@ def __init__(self, names): # Library routines the runtime models (each the twin of the harness's COBOL-side model): program -> (Java, args) -LIBRARY = {"CEEDAYS": ("__PACKAGE__.cobolrt.le.Ceedays.call", 4)} +LIBRARY = {"CEEDAYS": ("__PACKAGE__.cobolrt.le.Ceedays.call", 4), "COBDATFT": ("__PACKAGE__.cobolrt.le.Cobdatft.call", 1)} def jstr(s: str) -> str: @@ -85,6 +85,7 @@ class FileDef: repository: str | None = None # its repository field handle: str | None = None # the Java expression creating the DetFile; None: a hole why: str = "" + varying: dict | None = None # RECORD VARYING ... DEPENDING ON: {min, max, depending} (program.fd_entries) @dataclass @@ -1384,6 +1385,8 @@ def io(self, s: S.Stmt, ind: str) -> list[str]: raise Untranslatable(f"READ {s.data['file']}: no SELECT") if fd.handle is None: raise Untranslatable(f"{fd.select}: {fd.why}") + if fd.varying is not None: + raise Untranslatable(f"READ of the variable-length file {fd.select}") v = jname(fd.select) seq = s.data["next"] or fd.access == "SEQUENTIAL" or fd.organization == "SEQUENTIAL" st = self.tmpname("st") @@ -1413,7 +1416,13 @@ def io(self, s: S.Stmt, ind: str) -> list[str]: if s.data["from"] is not None: out.append(ind + self.move(s.data["from"], s.data["record"])) st = self.tmpname("st") - out.append(f"{ind}String {st} = {v}.{k.lower()}({rec_item.size});") + if fd.varying is not None: # the record's length is the DEPENDING ON item's value when it is written + if k == "REWRITE": + raise Untranslatable(f"REWRITE of the variable-length file {fd.select}") + length = self.int_expr(E.Ref(fd.varying["depending"])) + out.append(f"{ind}String {st} = {v}.write({length});") + else: + out.append(f"{ind}String {st} = {v}.{k.lower()}({rec_item.size});") out += self.status(fd, st, ind) out += self.io_phrases(s, st, ind, at_end=None, invalid=("21", "22", "23", "24")) return out diff --git a/gitgalaxy/tools/cobol_to_java/det/program.py b/gitgalaxy/tools/cobol_to_java/det/program.py index ad19a740a7..c19df7e5bb 100644 --- a/gitgalaxy/tools/cobol_to_java/det/program.py +++ b/gitgalaxy/tools/cobol_to_java/det/program.py @@ -84,6 +84,29 @@ def file_control(lines: list[Line]) -> list[dict]: return out +def fd_entries(lines: list[Line]) -> dict[str, dict[str, Any]]: + """FD name -> its variable-length clauses: `varying` (RECORD IS VARYING [IN SIZE] [FROM m] [TO n] + [DEPENDING ON item]) as {min, max, depending}, and `mode_v` (RECORDING MODE IS V).""" + text = " ".join(ln.text for ln in lines) + m = re.search(r"\bFILE\s+SECTION\s*\.(.*?)(?:\bWORKING-STORAGE\s+SECTION\b|\bLOCAL-STORAGE\s+SECTION\b|" + r"\bLINKAGE\s+SECTION\b|\bPROCEDURE\s+DIVISION\b|\Z)", text, re.I | re.S) # fmt: skip + out: dict[str, dict[str, Any]] = {} + if not m: + return out + for fd in re.finditer(r"\b[FS]D\s+([A-Z0-9-]+)(.*?)(?=\s0?1\s+[A-Z0-9-]+|\s[FS]D\s|\Z)", m.group(1), re.I | re.S): + e = fd.group(2) + d: dict[str, Any] = {"varying": None, "mode_v": bool(re.search(r"\bRECORDING\s+(?:MODE\s+)?(?:IS\s+)?V\b", e, re.I))} + v = re.search(r"\bRECORD\s+(?:IS\s+)?VARYING\b(.*?)(?:\.\s*$|$)", e, re.I | re.S) + if v: + lo = re.search(r"\bFROM\s+(\d+)", v.group(1), re.I) + hi = re.search(r"\bTO\s+(\d+)", v.group(1), re.I) + dep = re.search(r"\bDEPENDING\s+(?:ON\s+)?([A-Z0-9-]+)", v.group(1), re.I) + d["varying"] = {"min": int(lo.group(1)) if lo else None, "max": int(hi.group(1)) if hi else None, + "depending": dep.group(1).upper().rstrip(".") if dep else None} # fmt: skip + out[fd.group(1).upper()] = d + return out + + def stub_files(stub: str) -> dict[str, str]: """SELECT name -> the repository field the generated stub uses for it.""" out = {} @@ -411,6 +434,7 @@ def _translate(program: Path, copy_dirs: list[Path], stub: str, package: str, es providers.append((f"ObjectProvider<{cls}Service>", field)) inferred.append(f"CALL {prog_name} -> {cls}Service.handleCall: the estate's service for it") file_decls, file_inits, ctor_repos = [], [], [] + fds = fd_entries(lines) for fc in file_control(lines): fd = G.FileDef(fc["select"], fc["assign"], fc["organization"], fc["access"], fc["status"], " ".join(fc["record_key"]) if fc["record_key"] else None) # fmt: skip @@ -439,6 +463,20 @@ def _translate(program: Path, copy_dirs: list[Path], stub: str, package: str, es storage = _storage_name(roots[id(fd.record)]) v = G.jname(fd.select) reclen = sizes[id(roots[id(fd.record)])] + clauses = fds.get((fd.fd or fd.select).upper(), {}) + varying = clauses.get("varying") + if varying is not None or clauses.get("mode_v"): + # variable-length records: written at the DEPENDING ON item's length, framed as GnuCOBOL frames them + if fd.organization != "SEQUENTIAL" or varying is None or varying["depending"] is None: + fd.why = ("variable-length records without RECORD VARYING ... DEPENDING ON" if fd.organization == + "SEQUENTIAL" else f"variable-length records, ORGANIZATION {fd.organization}") # fmt: skip + continue + fd.varying = varying + file_decls.append(f" private DetFiles.DetFile {v};") + fd.handle = (f"new DetFiles.VarSequential(files, {G.jstr(fd.dd)}, () -> datasets.path(dd(dds, " + f"{G.jstr(fd.dd)})), {storage}, 0, {varying['min'] or 1}, {varying['max'] or reclen})") # fmt: skip + file_inits.append(f" {v} = {fd.handle};") + continue file_decls.append(f" private DetFiles.DetFile {v};") if fd.organization in ("SEQUENTIAL", "LINE"): fd.handle = (f"new DetFiles.Sequential(files, {G.jstr(fd.dd)}, () -> datasets.path(dd(dds, {G.jstr(fd.dd)})), " diff --git a/tests/equivalence/carddemo-readacct/LICENSE b/tests/equivalence/carddemo-readacct/LICENSE new file mode 100644 index 0000000000..67db858821 --- /dev/null +++ b/tests/equivalence/carddemo-readacct/LICENSE @@ -0,0 +1,175 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. diff --git a/tests/equivalence/carddemo-readacct/NOTICE b/tests/equivalence/carddemo-readacct/NOTICE new file mode 100644 index 0000000000..616fc58894 --- /dev/null +++ b/tests/equivalence/carddemo-readacct/NOTICE @@ -0,0 +1 @@ +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/tests/equivalence/carddemo-readacct/case.json b/tests/equivalence/carddemo-readacct/case.json new file mode 100644 index 0000000000..3b44ca97a3 --- /dev/null +++ b/tests/equivalence/carddemo-readacct/case.json @@ -0,0 +1,201 @@ +{ + "corpus": "aws-mainframe-modernization-carddemo", + "program": "CBACT01C", + "program_source": "app/cbl/CBACT01C.cbl", + "copy_dirs": [ + "app/cpy" + ], + "job": "READACCT", + "step": "STEP05", + "parm": null, + "clock": "2022/07/18 10:30:15.00", + "datasets": { + "ACCTFILE": { + "input": "app/data/ASCII/acctdata.txt", + "organization": "indexed", + "reclen": 300, + "keys": [ + { + "offset": 0, + "length": 11 + } + ], + "entity": "AccountRecord" + }, + "OUTFILE": { + "organization": "sequential", + "reclen": 107, + "compare": true, + "copybook": "app/cbl/CBACT01C.cbl", + "record": "OUT-ACCT-REC" + }, + "ARRYFILE": { + "organization": "sequential", + "reclen": 110, + "compare": true, + "copybook": "app/cbl/CBACT01C.cbl", + "record": "ARR-ARRAY-REC" + }, + "VBRCFILE": { + "organization": "sequential", + "record_format": "V", + "reclen": 80, + "compare": true, + "copybook": "app/cbl/CBACT01C.cbl", + "layouts": { + "12": "VBRC-REC1", + "39": "VBRC-REC2" + } + } + }, + "notes": "Job READACCT step STEP05: CBACT01C reads CardDemo's account master (the ACCTDATA KSDS, acctdata.txt) in key order, DISPLAYs each account, and writes three files per account: OUTFILE (RECFM=FB, LRECL=107; the reissue date reformatted by the assembler routine COBDATFT, CURR-CYC-DEBIT as COMP-3, 2525.00 when it is zero), ARRYFILE (LRECL=110: a 5-entry table, three entries set, the rest as INITIALIZE leaves them) and VBRCFILE (RECFM=VB: a 12-byte and a 39-byte record per account, RECORD VARYING DEPENDING ON WS-RECD-LEN). COBDATFT is the harness's model of app/asm/COBDATFT.asm (tests/equivalence/le/cobdatft.c; oracle_assumptions.md L3). OUT-ACCT-REISSUE-DATE carries two bytes COBDATFT never writes (oracle_assumptions.md L3: GnuCOBOL's spaces). ARRYFILE's untouched table entries are INITIALIZE's zeros, unsigned under GnuCOBOL, with the preferred C sign on z/OS: zoned_sign_equivalent (oracle_assumptions.md C10). The program never CLOSEs its three output files (GOBACK does). Every input is the corpus's data. Derived from CardDemo (Apache-2.0; Copyright Amazon.com, Inc. or its affiliates): LICENSE and NOTICE here are the source's own.", + "zoned_sign_equivalent": [ + "ARRYFILE" + ], + "faults": [ + { + "name": "open-acctfile", + "plan": [ + { + "dd": "ACCTFILE", + "op": "OPEN", + "nth": 1, + "status": "35" + } + ], + "why": "35: OPEN of a file that is not present (IBM Enterprise COBOL for z/OS Language Reference, File status key values): 'ERROR OPENING ACCTFILE' from 0000-ACCTFILE-OPEN, 9910-DISPLAY-IO-STATUS, then 9999-ABEND-PROGRAM -> ABEND U0999" + }, + { + "name": "open-acctfile-verified", + "plan": [ + { + "dd": "ACCTFILE", + "op": "OPEN", + "nth": 1, + "status": "97" + } + ], + "why": "97: OPEN successful, file integrity verified (IBM Enterprise COBOL for z/OS Language Reference, File status key values) -- a successful open, but 0000-ACCTFILE-OPEN accepts only '00', so the step abends through 9910-DISPLAY-IO-STATUS's 9 arm, then 9999-ABEND-PROGRAM -> ABEND U0999" + }, + { + "name": "open-outfile", + "plan": [ + { + "dd": "OUTFILE", + "op": "OPEN", + "nth": 1, + "status": "37" + } + ], + "why": "37: OPEN on a file the mode does not allow (IBM Enterprise COBOL for z/OS Language Reference, File status key values): 'ERROR OPENING OUTFILE' with the status from 2000-OUTFILE-OPEN, 9910-DISPLAY-IO-STATUS, then 9999-ABEND-PROGRAM -> ABEND U0999" + }, + { + "name": "open-arryfile", + "plan": [ + { + "dd": "ARRYFILE", + "op": "OPEN", + "nth": 1, + "status": "37" + } + ], + "why": "37 (IBM Enterprise COBOL for z/OS Language Reference, File status key values): 'ERROR OPENING ARRAYFILE' from 3000-ARRFILE-OPEN, 9910-DISPLAY-IO-STATUS, then 9999-ABEND-PROGRAM -> ABEND U0999" + }, + { + "name": "open-vbrcfile", + "plan": [ + { + "dd": "VBRCFILE", + "op": "OPEN", + "nth": 1, + "status": "37" + } + ], + "why": "37 (IBM Enterprise COBOL for z/OS Language Reference, File status key values): 'ERROR OPENING VBRC FILE' from 4000-VBRFILE-OPEN, 9910-DISPLAY-IO-STATUS, then 9999-ABEND-PROGRAM -> ABEND U0999" + }, + { + "name": "acctfile-read-error", + "plan": [ + { + "dd": "ACCTFILE", + "op": "READ", + "nth": 3, + "status": "30" + } + ], + "why": "30: permanent I/O error (IBM Enterprise COBOL for z/OS Language Reference, File status key values) on the third account: 'ERROR READING ACCOUNT FILE' from 1000-ACCTFILE-GET-NEXT after two accounts are written, 9910-DISPLAY-IO-STATUS, then 9999-ABEND-PROGRAM -> ABEND U0999" + }, + { + "name": "acctfile-early-end", + "plan": [ + { + "dd": "ACCTFILE", + "op": "READ", + "nth": 3, + "status": "10" + } + ], + "why": "10: end of file (IBM Enterprise COBOL for z/OS Language Reference, File status key values) after two accounts: END-OF-FILE is set, the file is closed and the step ends normally with two accounts in each output" + }, + { + "name": "acctfile-status-9", + "plan": [ + { + "dd": "ACCTFILE", + "op": "READ", + "nth": 1, + "status": "9A" + } + ], + "why": "9x: implementor-defined condition (IBM Enterprise COBOL for z/OS Language Reference, File status key values): 1000-ACCTFILE-GET-NEXT -> 9910-DISPLAY-IO-STATUS's non-numeric / 9 arm, then 9999-ABEND-PROGRAM -> ABEND U0999" + }, + { + "name": "outfile-write-error", + "plan": [ + { + "dd": "OUTFILE", + "op": "WRITE", + "nth": 2, + "status": "30" + } + ], + "why": "30 (IBM Enterprise COBOL for z/OS Language Reference, File status key values) on the second OUTFILE record: 'ACCOUNT FILE WRITE STATUS IS:' from 1350-WRITE-ACCT-RECORD, 9910-DISPLAY-IO-STATUS, then 9999-ABEND-PROGRAM -> ABEND U0999" + }, + { + "name": "arryfile-write-error", + "plan": [ + { + "dd": "ARRYFILE", + "op": "WRITE", + "nth": 2, + "status": "30" + } + ], + "why": "30 (IBM Enterprise COBOL for z/OS Language Reference, File status key values) on the second ARRYFILE record: 1450-WRITE-ARRY-RECORD, 9910-DISPLAY-IO-STATUS, then 9999-ABEND-PROGRAM -> ABEND U0999" + }, + { + "name": "vbrcfile-write-error", + "plan": [ + { + "dd": "VBRCFILE", + "op": "WRITE", + "nth": 2, + "status": "30" + } + ], + "why": "30 (IBM Enterprise COBOL for z/OS Language Reference, File status key values) on the second VBRCFILE record -- the first account's 39-byte record: 1575-WRITE-VB2-RECORD, 9910-DISPLAY-IO-STATUS, then 9999-ABEND-PROGRAM -> ABEND U0999" + }, + { + "name": "close-acctfile", + "plan": [ + { + "dd": "ACCTFILE", + "op": "CLOSE", + "nth": 1, + "status": "30" + } + ], + "why": "30 (IBM Enterprise COBOL for z/OS Language Reference, File status key values) closing ACCTFILE after every account: 'ERROR CLOSING ACCOUNT FILE' from 9000-ACCTFILE-CLOSE, 9910-DISPLAY-IO-STATUS, then 9999-ABEND-PROGRAM -> ABEND U0999" + } + ] +} diff --git a/tests/equivalence/le/cobdatft.c b/tests/equivalence/le/cobdatft.c new file mode 100644 index 0000000000..64e3a0f00a --- /dev/null +++ b/tests/equivalence/le/cobdatft.c @@ -0,0 +1,52 @@ +/* + * cobdatft.c -- CardDemo's assembler date routine COBDATFT (app/asm/COBDATFT.asm, Apache-2.0), for the equivalence + * harness. GnuCOBOL cannot run S/370 assembler; a program that CALLs "COBDATFT" (CardDemo's CBACT01C) runs against + * this instead, and a port is proven against what this returns. It is the assembler, instruction for instruction: + * oracle_assumptions.md (A1) declares the translation. + * + * CALL 'COBDATFT' USING CODATECN-REC (app/cpy/CODATECN.cpy; the DSECT is app/maclib/COCDATFT.mac) + * + * offset 0 COINTYPE CL1 '1' input YYYYMMDD, '2' input YYYY-MM-DD + * offset 1 COINPDT CL20 the input date + * offset 21 COOUTYPE CL1 '1' output YYYY-MM-DD, '2' output YYYYMMDD + * offset 22 COOUTDT CL20 the output date: only the bytes the MVCs below name are written; the rest keep + * whatever the caller's storage held + * offset 42 COERMSG CL38 + * + * VALIDIN1 (type '1') is NOT modelled: its check `CLC COINPDT+4,=C'-'` has no explicit length, so it compares + * L'COINPDT = 20 bytes from COINPDT+4 against the 1-byte literal and the 19 bytes the literal pool holds after + * it -- what that comparison decides depends on the load module, not on the source. CBACT01C never takes it + * (it passes type '2'); a call that does is refused (exit 98). + * VALIDIN2 (type '2'): COOUTYPE must not be '1' (the separator check is commented out in the source); then + * COOUTDT(4) = COINPDT(4), COOUTDT+4(2) = COINPDT+5(2), COOUTDT+6(2) = COINPDT+8(2). + * Any other type, or the VALIDIN2 check failing, takes GOTOERR: MVC COERMSG,=C'INVALID INPUT' -- an MVC whose + * length is COERMSG's, 38 bytes, from a 13-byte literal, so it copies 25 bytes from past the literal pool's end: + * what the load module holds there. That is NOT modelled either. A call that would take an unmodelled path writes + * "COBDATFT: ... is not modelled" to stderr and ends the run (exit 98), so no proof rests on a guess. + * R15 is set to 0 (SR R15,R15): RETURN-CODE 0 on every path that returns. + */ +#include +#include +#include + +static void refuse(const char *what) { + fprintf(stderr, "COBDATFT: %s is not modelled\n", what); + exit(98); +} + +int COBDATFT(unsigned char *rec) { + unsigned char *intype = rec, *inp = rec + 1, *outtype = rec + 21, *out = rec + 22; + if (*intype == '1') { + refuse("input type '1' (VALIDIN1: a 20-byte CLC against a 1-byte literal)"); + } else if (*intype == '2') { + if (*outtype == '1') { + refuse("the error path (MVC of 38 bytes from a 13-byte literal)"); + } + memcpy(out, inp, 4); + memcpy(out + 4, inp + 5, 2); + memcpy(out + 6, inp + 8, 2); + } else { + refuse("the error path (MVC of 38 bytes from a 13-byte literal)"); + } + return 0; +} diff --git a/tests/tools/devin/adapter/devinadapter/EnvLauncher.java b/tests/tools/devin/adapter/devinadapter/EnvLauncher.java new file mode 100644 index 0000000000..486bf143f6 --- /dev/null +++ b/tests/tools/devin/adapter/devinadapter/EnvLauncher.java @@ -0,0 +1,26 @@ +package devinadapter; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Locale; +import java.util.TimeZone; + +/** + * Runs a third-party port's own entry point under one of the harness's JVM environments (#3821): the default + * locale and time zone are set first -- as the harness's generated test does for our ports -- and nothing else. + * + * java -cp adapter:theirs.jar devinadapter.EnvLauncher LOCALE TZ MAIN-CLASS [their arguments...] + * + * The port's main class is called unmodified, with the remaining arguments exactly as given. + */ +public final class EnvLauncher { + private EnvLauncher() { + } + + public static void main(String[] args) throws Exception { + Locale.setDefault(Locale.forLanguageTag(args[0])); + TimeZone.setDefault(TimeZone.getTimeZone(args[1])); + Method main = Class.forName(args[2]).getMethod("main", String[].class); + main.invoke(null, (Object) Arrays.copyOfRange(args, 3, args.length)); + } +} diff --git a/tests/tools/devin_port.py b/tests/tools/devin_port.py new file mode 100644 index 0000000000..71df892783 --- /dev/null +++ b/tests/tools/devin_port.py @@ -0,0 +1,503 @@ +#!/usr/bin/env python3 +"""Devin's CardDemo ports (Cognition's public workshop repositories), run through our equivalence harness. + + python tests/tools/devin_port.py --work DIR [--port NAME,...] [--case NAME,...] [--environments all|default,...] + [--jdk21 PATH] + +Phase 2 of the Devin benchmark (docs/research/devin-carddemo-survey.md lists every branch; the results are in +docs/research/devin-carddemo-harness.md). Each port is fetched at a pinned commit, built as it ships, and run on +the inputs one of our proven cases gives GnuCOBOL; its outputs go through the harness's own comparison +(equivalence.compare_run: RETURN-CODE or ABEND, every compared dataset field by field per its copybook, and +SYSOUT). Nothing of theirs is committed here or changed. + +The adapter -- this file and tests/tools/devin/adapter -- and nothing more: + - launching: each port's own command line, as its README or main method documents it (PORTS[...]["argv"]), + with the case's data sets, JCL PARM and frozen clock in the form that command line takes them; + - inputs: the case's input data sets either as the fixed-length records GnuCOBOL is given (an indexed one in + primary-key order, as a KSDS unloads), or -- for a port that reads text lines -- the case's input file as the + corpus ships it (the same records, one per line); + - the JVM default locale and time zone of each harness environment (#3821), set by EnvLauncher before the + port's unmodified main class runs -- what the harness's generated test does for our ports; + - how a port reports a user abend, read from its code (a stderr message, or a fixed exit status), recorded as + the harness's Unnnn (the code modulo 4096, as the COBOL side's abend stub records it); + - output decoding, declared per port and per data set (DECODERS): a port's own framing of variable-length + records (newline-separated, or a bare 2-byte length) re-framed as GnuCOBOL frames them; packed-decimal + fields a port writes as hex text turned back into their bytes. A port that writes variable-length records + with no framing at all is compared as written: the comparison reports the framing error. +Not applied: the case's fault runs. They inject FILE STATUS values at the program's I/O statements through an +I/O layer our ports call (CobolFiles); these ports have their own I/O code and no such point, and adding one would +mean changing their code. Each port is judged on the case's base run, under every environment. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +from datetime import datetime +from pathlib import Path +from typing import Any, Callable, Optional + +TOOLS = Path(__file__).resolve().parent +REPO_ROOT = TOOLS.parent.parent +sys.path[:0] = [str(TOOLS), str(REPO_ROOT)] +import equivalence as eq # noqa: E402 +import equivalence_java as ej # noqa: E402 +import mainframe_corpus as mc # noqa: E402 +from equivalence_common import _fixed, _input_path, data_encoding # noqa: E402 + +ADAPTER = TOOLS / "devin" / "adapter" +COGNITION = "https://github.com/Cognition-Partner-Workshops/uc-legacy-modernization-cobol-to-java" +CODEV = "https://github.com/codev-workshops/uc-legacy-modernization-cobol-to-java" +CBACT01C_FILES = {"ACCTFILE": "{dd:ACCTFILE}", "OUTFILE": "{dd:OUTFILE}", "ARRYFILE": "{dd:ARRYFILE}", + "VBRCFILE": "{dd:VBRCFILE}"} # fmt: skip +POSITIONAL = list(CBACT01C_FILES.values()) + + +def _dd_launcher(program: str) -> list[str]: + return [program, "{dd-options}", "{parm-option}", "--now", "{now}"] + + +# Each port: a pinned branch; how it builds ("jar": `mvn package`, the jar; "classes": `mvn compile` plus its +# dependency classpath; "javac": one source file); its main class and command line ({dd:NAME}: the data set's +# file, {dd-options}: `--dd NAME=file` for each, {outdir}: a directory the port writes its outputs into under +# their DD names, {parm-option}: `--parm PARM` when the step has one, {now}: the frozen clock in "now"'s form); +# the input form ("fixed" or "source"); the per-data-set output decoders (DECODERS); how it reports an abend. +PORTS: dict[str, dict[str, Any]] = { + "eval-arm-a": { + "repo": COGNITION, "branch": "eval/devin-arm-a-raw-cobol", "sha": "19dd9f267abd4eac914d50db9367fdc083110f7e", + "pr": None, "about": "eval arm A: the COBOL sources, copybooks, JCL and a golden-output harness only", + "build": ("jar", "java", "java/target/carddemo-batch.jar"), "jdk": 21, "main": "carddemo.batch.Main", + "now": "%Y-%m-%d %H:%M:%S", "abend": {"stderr": r"CEE3ABD: USER ABEND U(\d+)"}, "input": "fixed", + "programs": {"CBACT01C": ("carddemo-readacct", _dd_launcher("CBACT01C")), + "CBACT04C": ("carddemo-intcalc", _dd_launcher("CBACT04C")), + "CBTRN02C": ("carddemo-posttran", _dd_launcher("CBTRN02C"))}, + }, + "eval-arm-b": { + "repo": COGNITION, "branch": "eval/devin-arm-b-cobol-plus-atx-analysis", + "sha": "ef9aa4f08f6f2bd2e10e461ca302fd7d1ef350b3", "pr": None, + "about": "eval arm B: the same, plus AWS Transform's analysis of the code", + "build": ("jar", "java", "java/target/carddemo-batch.jar"), "jdk": 21, "main": "carddemo.Main", + "now": "%Y-%m-%d-%H.%M.%S.000000", "abend": {"exit": 999 & 0xFF, "code": 999}, "input": "fixed", + "programs": {"CBACT01C": ("carddemo-readacct", _dd_launcher("CBACT01C")), + "CBACT04C": ("carddemo-intcalc", _dd_launcher("CBACT04C")), + "CBTRN02C": ("carddemo-posttran", _dd_launcher("CBTRN02C"))}, + }, + "pr229": { + "repo": COGNITION, "branch": "devin/1785333294-cbact01c-java", "sha": "05c300cce2fc6ea9955e7913c06f598ebec74079", + "pr": 229, "about": "one source file; its own parity run against GnuCOBOL, byte for byte", + "build": ("javac", "java-migration/src/CBACT01C.java", None), "jdk": 17, "main": "CBACT01C", + "input": "fixed", "programs": {"CBACT01C": ("carddemo-readacct", ["{dd:ACCTFILE}", "{outdir}", "ascii"])}, + }, + "pr167": { + "repo": COGNITION, "branch": "devin/1776439113-cbact01c-java-migration", + "sha": "ac6c2443b42fa8725f4bb81a44fa235c67a0238e", "pr": 167, "about": "Maven project, positional arguments", + "build": ("classes", "java-migration", None), "jdk": 17, "main": "com.carddemo.batch.CBACT01C", + "input": "source", "decode": {"VBRCFILE": "vb-unframed"}, + "programs": {"CBACT01C": ("carddemo-readacct", POSITIONAL)}, + }, + "pr168": { + "repo": COGNITION, "branch": "devin/1776677664-cbact01c-java-migration", + "sha": "baf8cfd453e103429c86a80810f808d869ef4a52", "pr": 168, "about": "Maven project, positional arguments", + "build": ("classes", "java-app", None), "jdk": 17, "main": "com.carddemo.batch.Cbact01c", + "input": "source", "decode": {"VBRCFILE": "vb-lines"}, + "programs": {"CBACT01C": ("carddemo-readacct", POSITIONAL)}, + }, + "pr214": { + "repo": COGNITION, "branch": "devin/1781538606-cobol-to-java-cbact01c", + "sha": "1fa50de24c402efc98a25775bbd6fcfab894264f", "pr": 214, "about": "Maven project, positional arguments", + "build": ("classes", "java-migration", None), "jdk": 21, "main": "com.carddemo.batch.AccountFileProcessor", + "input": "source", + "decode": {"OUTFILE": ("lines-hex-packed", 107, [(90, 7)]), + "ARRYFILE": ("lines-hex-packed", 110, [(23 + 19 * k, 7) for k in range(5)]), + "VBRCFILE": "vb-len2"}, + "programs": {"CBACT01C": ("carddemo-readacct", POSITIONAL)}, + }, + "pr220": { + "repo": COGNITION, "branch": "devin/1782308896-cobol-to-java-cbact01c", + "sha": "ceea1ef9e5c0235b1bd1c8562bba325ab01b19df", "pr": 220, "about": "Maven project, positional arguments", + "build": ("classes", "java-modernized", None), "jdk": 17, "main": "com.carddemo.batch.AccountFileProcessor", + "input": "source", "programs": {"CBACT01C": ("carddemo-readacct", POSITIONAL)}, + }, + "codev13": { + "repo": CODEV, "branch": "devin/1788345561-cbact01c-batch-flow", "sha": "73e9452dda39d56d564f5456092705ed84864983", + "pr": 13, "about": "codev series: the batch flow", "build": ("classes", "java", None), "jdk": 17, + "main": "com.carddemo.batch.cbact01c.Cbact01c", "input": "fixed", + "programs": {"CBACT01C": ("carddemo-readacct", ["--acctfile", "{dd:ACCTFILE}", "--outfile", "{dd:OUTFILE}", + "--arryfile", "{dd:ARRYFILE}", "--vbrcfile", "{dd:VBRCFILE}", + "--charset", "ASCII", "--display"])}, + }, + "codev14": { + "repo": CODEV, "branch": "devin/1788346206-cbact01c-golden-master", + "sha": "2f5c0773b96ae98b4046783f8de2e4e96e79cf1c", "pr": 14, + "about": "codev series: the golden master (a Python oracle for the expected bytes)", + "build": ("classes", "java", None), "jdk": 17, "main": "com.carddemo.batch.cbact01c.Cbact01c", "input": "fixed", + "programs": {"CBACT01C": ("carddemo-readacct", ["--acctfile", "{dd:ACCTFILE}", "--outfile", "{dd:OUTFILE}", + "--arryfile", "{dd:ARRYFILE}", "--vbrcfile", "{dd:VBRCFILE}", + "--charset", "ASCII", "--display"])}, + }, +} # fmt: skip + + +# ---- output decoders: a port's own representation of a data set, as the harness's bytes --------------------- +def gnucobol_frame(records: list[bytes]) -> bytes: + """Variable-length records framed as GnuCOBOL writes them (equivalence_common.split_varseq).""" + return b"".join(len(r).to_bytes(2, "big") + b"\0\0" + r for r in records) + + +def vb_lines(data: bytes) -> bytes: + """Records separated by newlines (text with no newline inside a record), re-framed.""" + recs = data.split(b"\n") + return gnucobol_frame(recs[:-1] if recs and recs[-1] == b"" else recs) + + +def vb_len2(data: bytes) -> bytes: + """Records each led by a bare big-endian 2-byte length, re-framed.""" + recs, i = [], 0 + while i + 2 <= len(data): + n = int.from_bytes(data[i : i + 2], "big") + recs.append(data[i + 2 : i + 2 + n]) + i += 2 + n + if i != len(data): + return data # not that framing: compared as written (the comparison reports the framing error) + return gnucobol_frame(recs) + + +def lines_hex_packed(data: bytes, reclen: int, packed: list[tuple[int, int]]) -> bytes: + """Newline-terminated records whose packed-decimal fields (offset, bytes) are written as 2*bytes hex digits: + each record's bytes as the copybook lays them out.""" + out = [] + for line in data.split(b"\n"): + if not line: + continue + rec, pos = bytearray(), 0 + for off, n in sorted(packed): + rec += line[pos : pos + off - len(rec)] + pos += off - (len(rec) - n * 0) # advance to the hex digits + pos = pos if len(rec) == off else pos + try: + rec += bytes.fromhex(line[pos : pos + 2 * n].decode("ascii")) + except ValueError: + return data # not hex where a packed field sits: compared as written + pos += 2 * n + rec += line[pos:] + if len(rec) != reclen: + return data + out.append(bytes(rec)) + return b"".join(out) + + +def decode(spec: Any, data: bytes) -> bytes: + if spec is None: + return data + if spec == "vb-lines": + return vb_lines(data) + if spec == "vb-len2": + return vb_len2(data) + if spec == "vb-unframed": + return data # nothing marks where a record ends: compared as written + if isinstance(spec, tuple) and spec[0] == "lines-hex-packed": + return lines_hex_packed(data, spec[1], spec[2]) + raise ValueError(f"unknown decoder {spec!r}") + + +# ---- fetch, build, run ------------------------------------------------------------------------------------------ +def run(argv: list[str], cwd: Optional[Path] = None, env: Optional[dict[str, str]] = None, + timeout: int = 1800) -> subprocess.CompletedProcess: # fmt: skip + return subprocess.run(argv, cwd=cwd, env=env, capture_output=True, timeout=timeout, check=False) # noqa: S603 + + +def fetch(port: dict[str, Any], work: Path) -> Path: + """The port's repository at its pinned commit (one clone per repository, a detached worktree per port).""" + clone = work / "src" / port["repo"].rstrip("/").split("/")[-2] + if not (clone / ".git").is_dir(): + clone.parent.mkdir(parents=True, exist_ok=True) + p = run(["git", "clone", "-q", "--no-checkout", port["repo"], str(clone)]) + if p.returncode: + raise RuntimeError(f"git clone {port['repo']}: {p.stderr.decode()}") + tree = work / "ports" / port["sha"][:12] + if not tree.is_dir(): + if run(["git", "-C", str(clone), "cat-file", "-e", port["sha"]]).returncode: + run(["git", "-C", str(clone), "fetch", "-q", "origin", port["sha"]]) + p = run(["git", "-C", str(clone), "worktree", "add", "-q", "--detach", str(tree), port["sha"]]) + if p.returncode: + raise RuntimeError(f"checkout {port['sha']}: {p.stderr.decode()}") + return tree + + +def jdk_env(jdk: Optional[Path]) -> Optional[dict[str, str]]: + if not jdk: + return None + return {**os.environ, "JAVA_HOME": str(jdk), "PATH": f"{jdk / 'bin'}:{os.environ.get('PATH', '')}"} + + +def tool(jdk: Optional[Path], name: str) -> str: + return str(jdk / "bin" / name) if jdk else (shutil.which(name) or name) + + +def build(port: dict[str, Any], tree: Path, work: Path, jdk: Optional[Path]) -> str: + """The port built as it ships; returns its classpath. Its own tests are skipped (they are not our judge).""" + kind, where, jar = port["build"] + mvn = ["mvn", "-q", "-B", "-DskipTests", f"-Dmaven.repo.local={work / 'm2'}"] + if kind == "jar": + if not (tree / jar).is_file(): + p = run([*mvn, "package"], cwd=tree / where, env=jdk_env(jdk)) + if p.returncode or not (tree / jar).is_file(): + raise RuntimeError(f"build failed:\n{p.stdout.decode()[-2000:]}\n{p.stderr.decode()[-2000:]}") + return str(tree / jar) + if kind == "classes": + cpf = tree / where / "target" / "gg-classpath.txt" + if not cpf.is_file(): + p = run([*mvn, "compile", "dependency:build-classpath", f"-Dmdep.outputFile={cpf}"], cwd=tree / where, + env=jdk_env(jdk)) # fmt: skip + if p.returncode: + raise RuntimeError(f"build failed:\n{p.stdout.decode()[-2000:]}\n{p.stderr.decode()[-2000:]}") + deps = cpf.read_text(encoding="utf-8").strip() + return f"{tree / where / 'target' / 'classes'}" + (f":{deps}" if deps else "") + out = tree.parent / f"{tree.name}-classes" + if not out.is_dir(): + out.mkdir() + p = run([tool(jdk, "javac"), "-d", str(out), str(tree / where)]) + if p.returncode: + raise RuntimeError(f"javac failed:\n{p.stderr.decode()[-2000:]}") + return str(out) + + +def adapter_classes(work: Path, jdk: Optional[Path]) -> Path: + out = work / "adapter" + if not (out / "devinadapter" / "EnvLauncher.class").is_file(): + out.mkdir(parents=True, exist_ok=True) + p = run([tool(jdk, "javac"), "--release", "17", "-d", str(out), *map(str, ADAPTER.rglob("*.java"))]) + if p.returncode: + raise RuntimeError(p.stderr.decode()) + return out + + +def key_ordered(data: bytes, reclen: int, key: dict[str, int]) -> bytes: + """An indexed dataset's records in primary-key order, as a KSDS unloads (byte order of the key).""" + recs = [data[i : i + reclen] for i in range(0, len(data), reclen)] + return b"".join(sorted(recs, key=lambda r: r[key["offset"] : key["offset"] + key["length"]])) + + +def stage_inputs(case: dict[str, Any], corpus: Path, into: Path, form: str) -> dict[str, Path]: + """{DDNAME: file}: an input as the case gives GnuCOBOL (`fixed`) or as the corpus ships it (`source`); an + output, an empty file the port writes.""" + into.mkdir(parents=True, exist_ok=True) + enc = data_encoding(case) + dds = {} + for dd, spec in case["datasets"].items(): + f = into / f"{dd}.dat" + if "input" in spec and form == "source": + f.write_bytes(_input_path(case, corpus, spec["input"]).read_bytes()) + elif "input" in spec: + data = _fixed(_input_path(case, corpus, spec["input"]), spec["reclen"], enc) + if spec.get("organization") == "indexed": + data = key_ordered(data, spec["reclen"], spec["keys"][0]) + f.write_bytes(data) + else: + f.write_bytes(b"") + dds[dd] = f + return dds + + +def clock_arg(case: dict[str, Any], fmt: str) -> str: + """The case's frozen clock ('YYYY/MM/DD HH:MM:SS.hh') in the form the port's --now takes (`fmt`, strftime).""" + c = case["clock"] + if not re.fullmatch(r"\d{4}/\d\d/\d\d \d\d:\d\d:\d\d\.00", c): + raise ValueError(f"clock {c!r}: hundredths other than .00 cannot be passed to --now") + return datetime.strptime(c[:19], "%Y/%m/%d %H:%M:%S").strftime(fmt) + + +def command(port: dict[str, Any], template: list[str], case: dict[str, Any], dds: dict[str, Path], + outdir: Path) -> list[str]: # fmt: skip + argv: list[str] = [] + for t in template: + if t == "{dd-options}": + for dd, f in dds.items(): + argv += ["--dd", f"{dd}={f}"] + elif t == "{parm-option}": + argv += ["--parm", case["parm"]] if case.get("parm") else [] + elif t == "{now}": + argv.append(clock_arg(case, port["now"])) + elif t == "{outdir}": + argv.append(str(outdir)) + elif t.startswith("{dd:"): + argv.append(str(dds[t[4:-1]])) + else: + argv.append(t) + return argv + + +def abend_code(port: dict[str, Any], proc: subprocess.CompletedProcess) -> Optional[int]: + """The user abend code the port reports its own way: a message on stderr, or a fixed exit status.""" + how = port.get("abend") + if not how: + return None + if "stderr" in how: + m = re.search(how["stderr"].encode(), proc.stderr) + return int(m.group(1)) if m else None + return how["code"] if proc.returncode == how["exit"] else None + + +def java_outputs(case: dict[str, Any], port: dict[str, Any], dds: dict[str, Path], outdir: Path, + proc: subprocess.CompletedProcess) -> dict[str, bytes]: # fmt: skip + """Their run as the harness compares a run: every compared dataset (decoded per DECODERS), SYSOUT, and + RETURN-CODE or ABEND.""" + outs = {} + for dd, spec in case["datasets"].items(): + if not spec.get("compare"): + continue + f = outdir / dd if (outdir / dd).is_file() else dds[dd] + outs[dd] = decode((port.get("decode") or {}).get(dd), f.read_bytes() if f.is_file() else b"") + outs["SYSOUT"] = proc.stdout + code = abend_code(port, proc) + if code is not None: + outs["ABEND"] = f"U{code % 4096:04d}".encode() + else: + outs["RETURN-CODE"] = str(proc.returncode).encode() + return outs + + +# ---- what a difference is ---------------------------------------------------------------------------------------- +OVERPUNCH = {**{c: (i, 1) for i, c in enumerate("{ABCDEFGHI")}, **{c: (i, -1) for i, c in enumerate("}JKLMNOPQR")}} + + +def _ibm_display(t: str) -> Optional[int]: + """A signed zoned number as IBM's DISPLAY shows it: its digits, the last with the sign overpunched.""" + m = re.fullmatch(r"(\d*)([{}A-R])", t) + if not m: + return None + digit, sign = OVERPUNCH[m.group(2)] + return sign * int(m.group(1) + str(digit)) + + +def _gnucobol_display(t: str) -> Optional[int]: + """The same as GnuCOBOL's own DISPLAY shows it: every digit, then a separate + or -.""" + m = re.fullmatch(r"(\d+)([+-])", t) + return (1 if m.group(2) == "+" else -1) * int(m.group(1)) if m else None + + +def sysout_classes(case: dict[str, Any], cobol: bytes, java: bytes) -> dict[str, Any]: + """Every differing SYSOUT line, classed: `sign-display` when the only difference is a signed number shown as + GnuCOBOL's own DISPLAY shows it (digits, then + or -) where IBM's DISPLAY shows the zoned bytes (the last + digit overpunched; oracle_assumptions.md C8) -- the same value; `other` for anything else.""" + enc = data_encoding(case) + a, b = eq.sysout_lines(cobol, enc), eq.sysout_lines(java, enc) + out: dict[str, Any] = {"sign-display": 0, "other": 0, "examples": []} + for n in range(max(len(a), len(b))): + x, y = (a[n] if n < len(a) else None), (b[n] if n < len(b) else None) + if x == y: + continue + if x is not None and y is not None and ":" in x and x.rsplit(":", 1)[0] == y.rsplit(":", 1)[0]: + vx, vy = _ibm_display(x.rsplit(":", 1)[1].strip()), _gnucobol_display(y.rsplit(":", 1)[1].strip()) + if vx is not None and vx == vy: + out["sign-display"] += 1 + continue + out["other"] += 1 + if len(out["examples"]) < 3: + out["examples"].append({"line": n + 1, "cobol": x, "java": y}) + return out + + +def first_difference(result: dict[str, Any]) -> str: + """The run's first difference, in words: which output, which record, which field, both values.""" + if result.get("ok"): + return "" + for dd, d in (result.get("outputs") or {}).items(): + if d.get("diffs"): + return f"{dd} {json.dumps(d['diffs'][0], default=str)[:400]}" + s = result.get("sysout") or {} + if s.get("diffs"): + return f"SYSOUT {json.dumps(s['diffs'][0], default=str)[:400]}" + return result.get("summary", "") + + +def judge(name: str, program: str, work: Path, envs: list[str], jdk: Optional[Path]) -> dict[str, Any]: + port = PORTS[name] + case_name, template = port["programs"][program] + case = eq.load_case(case_name) + (entry,) = mc.select([case["corpus"]]) + corpus = mc.require_clone(entry) + out = work / "runs" / name / case_name + if out.exists(): + out = out.with_name(f"{case_name}-{datetime.now().strftime('%H%M%S%f')}") + out.mkdir(parents=True) + cobol = eq.run_cobol(case, corpus, work / "cobol" / case_name) + coverage = eq.cobol_coverage(case, corpus, [work / "cobol" / case_name / eq.cov.TRACE_NAME], out / "coverage.json") + result: dict[str, Any] = {"port": name, "branch": port["branch"], "sha": port["sha"], "pr": port.get("pr"), + "program": program, "case": case_name, "coverage": coverage, "runs": [], + "fault_runs": len(case.get("faults", [])), "fault_runs_applied": 0} # fmt: skip + try: + tree = fetch(port, work) + classpath = build(port, tree, work, jdk) + except RuntimeError as e: + result.update({"proven": False, "build_error": str(e)[:2000]}) + return result + adapter = adapter_classes(work, jdk) + for env_name in envs: + env = ej.environment(env_name) + dds = stage_inputs(case, corpus, out / env_name, port["input"]) + outdir = out / env_name / "out" + outdir.mkdir() + argv = [tool(jdk, "java"), "-cp", f"{adapter}:{classpath}", "devinadapter.EnvLauncher", env["locale"], + env["tz"], port["main"], *command(port, template, case, dds, outdir)] # fmt: skip + proc = run(argv, cwd=out / env_name, timeout=600) + (out / env_name / "stdout.txt").write_bytes(proc.stdout) + (out / env_name / "stderr.txt").write_bytes(proc.stderr) + java = java_outputs(case, port, dds, outdir, proc) + r = eq.compare_run(case, corpus, cobol, java) + result["runs"].append({"environment": env_name, "ok": r["ok"], "summary": r["summary"], + "first_difference": first_difference(r), "stderr": proc.stderr.decode(errors="replace")[:600], + "sysout_classes": sysout_classes(case, cobol.get("SYSOUT", b""), java["SYSOUT"]), + "result": r}) # fmt: skip + result["proven"] = all(x["ok"] for x in result["runs"]) + return result + + +def coverage_line(cov_report: Optional[dict[str, Any]]) -> str: + if not cov_report: + return "" + p, b = cov_report.get("paragraphs", {}), cov_report.get("branches", {}) + return f"{p.get('covered', '?')}/{p.get('live', '?')} paragraphs, {b.get('covered', '?')}/{b.get('total', '?')} branches" + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--work", type=Path, required=True) + ap.add_argument("--port", default=",".join(PORTS)) + ap.add_argument("--case", help="only these of our cases (default: every case a port's programs map to)") + ap.add_argument("--environments", default="all") + ap.add_argument("--jdk21", type=Path, help="a JDK 21 for the ports that target Java 21") + args = ap.parse_args() + envs = list(ej.ENVIRONMENTS) if args.environments == "all" else args.environments.split(",") + wanted = set(args.case.split(",")) if args.case else None + work = args.work.resolve() + results = [] + for name in args.port.split(","): + for program, (case_name, _) in PORTS[name]["programs"].items(): + if wanted and case_name not in wanted: + continue + jdk = args.jdk21 if PORTS[name]["jdk"] >= 21 else None + r = judge(name, program, work, envs, jdk) + results.append(r) + if "build_error" in r: + print(f"{name:11} {program} on {case_name}: BUILD FAILED\n {r['build_error'][:300]}") + continue + ok = sum(x["ok"] for x in r["runs"]) + print(f"{name:11} {program} on {case_name}: {'PROVEN' if r['proven'] else 'DIFFERS'} " + f"({ok}/{len(r['runs'])} environments; base run covers {coverage_line(r['coverage'])})") # fmt: skip + for x in r["runs"]: + if not x["ok"]: + c = x["sysout_classes"] + print(f" {x['environment']}: {x['summary']}\n first: {x['first_difference']}\n" + f" SYSOUT lines: {c['sign-display']} sign-display, {c['other']} other") # fmt: skip + (work / "devin-results.json").write_text(json.dumps(results, indent=1, default=str) + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/tools/equivalence.py b/tests/tools/equivalence.py index 71220c3559..229663c8bd 100644 --- a/tests/tools/equivalence.py +++ b/tests/tools/equivalence.py @@ -94,6 +94,7 @@ data_encoding, decode_field, diff_records, + diff_varseq, java_failure_report, layout_fields, read_program, @@ -177,6 +178,9 @@ def cobol_driver(program: str, parm: Optional[str]) -> str: FAULTS_DIR = CASES / "faults" # the fault injector and the abend stub (ggfault.c, ggabend.c) +LE_MODELS = CASES / "le" # models of the routines a program CALLs that GnuCOBOL has no code for (CEEDAYS, COBDATFT) +# A model refuses what it does not model ("NAME: ... is not modelled" on stderr, exit 98): never the oracle. +MODEL_REFUSAL = re.compile(rb"^([A-Z0-9]{1,8}): [^\n]* is not modelled$", re.M) FAULT_OPS = ("OPEN", "CLOSE", "READ", "WRITE", "REWRITE", "DELETE", "START") @@ -241,9 +245,13 @@ def run_cobol( (src / "EQDRIVER.cbl").write_text(cobol_driver(case["program"], case.get("parm")), encoding="ascii") for stub in ("ggabend.c", "ggfault.c", "ggdisplay.c"): shutil.copy(FAULTS_DIR / stub, src / stub) + models = sorted(LE_MODELS.glob("*.c")) + for m in models: + shutil.copy(m, src / m.name) # #4023: traced; CEE3ABD is the abend stub, which records the abend instead of failing the CALL sql = f" src/ggsql.c {equivalence_db2.COBOL_LINK}" if db2 else "" - script.append(f"cobc -x {flags} {cov.TRACE_FLAG} -o program src/EQDRIVER.cbl src/PROGRAM.cbl src/ggabend.c{sql}") + le = "".join(f" src/{m.name}" for m in models) + script.append(f"cobc -x {flags} {cov.TRACE_FLAG} -o program src/EQDRIVER.cbl src/PROGRAM.cbl src/ggabend.c{le}{sql}") # #4056: DISPLAY as IBM writes it (faults/ggdisplay.c), so SYSOUT is compared against IBM's text script.append("gcc -shared -fPIC -O2 -o /work/ggdisplay.so src/ggdisplay.c -ldl") inject = "LD_PRELOAD=/work/ggdisplay.so " @@ -258,7 +266,7 @@ def run_cobol( tz = f"TZ='{case['zone']}' " if case.get("zone") else "" # the step's RETURN-CODE is an output like any other (CBTRN02C sets 4 when it rejects): recorded, not fatal sqlenv = equivalence_db2.cobol_env("/work/stmts.txt") if db2 else "" - script.append(f"set +e; {cov.trace_env('/work/' + cov.TRACE_NAME)}GG_ABEND=/work/ABEND {inject}{tz}{clock}{env} " + script.append(f"set +e; {cov.trace_env('/work/' + cov.TRACE_NAME)}GG_ABEND=/work/ABEND COB_VARSEQ_FORMAT=0 {inject}{tz}{clock}{env} " f"{sqlenv}./program > /work/stdout.txt 2>&1; echo $? > /work/RETURN-CODE; set -e") # fmt: skip # after an abend (or an OPEN a fault refused) an output may not exist: unloaded if it does for dd, spec in case["datasets"].items(): @@ -288,6 +296,10 @@ def run_cobol( # A CALL to a routine nothing here provides (CBACT01C's assembler COBDATFT) ends the run in libcob's own # "module not found": that is GnuCOBOL's failure, never the program's behaviour -- refused, not recorded as the # oracle (SYSOUT leaves libcob's lines out, so a port imitating the crash would otherwise prove). + refused = MODEL_REFUSAL.search(outs["SYSOUT"]) + if refused: # a model of a CALLed routine reached what it does not model: no oracle for this run + raise RuntimeError(f"the run reaches what the {refused.group(1).decode()} model does not model " + f"({refused.group(0).decode()}) -- not runnable faithfully") # fmt: skip missing = re.search(rb"libcob: [^\n]*module '([^']+)' not found", outs["SYSOUT"]) if missing: raise RuntimeError(f"the program CALLs {missing.group(1).decode()!r}, which no model provides " @@ -447,6 +459,27 @@ def selected_faults(case: dict[str, Any], arg: Optional[str]) -> list[dict[str, return [f for f in faults if f["name"] in names] +POSITIVE_OVERPUNCH = b"{ABCDEFGHI" # a positive zoned sign digit 0-9 (C zone), as the ASCII data writes it + + +def accept_unsigned_positive(a: bytes, b: bytes) -> tuple[bytes, bytes, int]: + """oracle_assumptions.md C10, for the datasets a case names in `"zoned_sign_equivalent"`: a byte that is a + plain digit on one side and the same digit with the positive overpunch on the other (an F zone against a C + zone: the same positive value; GnuCOBOL's INITIALIZE / VALUE ZERO leave the F, z/OS writes the preferred C) + is taken as equal. Both are made the overpunch; the count of such bytes is reported, never hidden.""" + if len(a) != len(b): + return a, b, 0 + x, y, n = bytearray(a), bytearray(b), 0 + for i, (p, q) in enumerate(zip(a, b)): + if p != q: + for digit, over in ((p, q), (q, p)): + if 0x30 <= digit <= 0x39 and over == POSITIVE_OVERPUNCH[digit - 0x30]: + x[i] = y[i] = over + n += 1 + break + return bytes(x), bytes(y), n + + def compare_run(case: dict[str, Any], corpus: Path, cobol: dict[str, bytes], java: dict[str, bytes], fault: Optional[dict[str, Any]] = None) -> dict[str, Any]: # fmt: skip """One run of the step on both sides: equal when both abend with the same code, or neither does and their @@ -475,9 +508,22 @@ def compare_run(case: dict[str, Any], corpus: Path, cobol: dict[str, bytes], jav for dd, spec in case["datasets"].items(): if not spec.get("compare"): continue + left, right = cobol.get(dd, b""), java.get(dd, b"") + if dd in case.get("zoned_sign_equivalent", []): # C10, declared by the case + left, right, n = accept_unsigned_positive(left, right) + if n: + run.setdefault("sign_equivalent", {})[dd] = n + if spec.get("record_format") == "V": # variable-length: each record against its length's layout + layouts = {int(n): layout_fields(corpus, spec["copybook"], rec) for n, rec in spec["layouts"].items()} + d = diff_varseq(left, right, layouts, case.get("code_page", "cp037"), + data_encoding(case)) # fmt: skip + run["outputs"][dd] = d + if d["equal"] != d["records"] or d["diffs"]: + why.append(f"{dd}: {d['equal']}/{d['records']} records equal") + continue fields = layout_fields(corpus, spec["copybook"], spec.get("record")) - d = diff_records(cobol.get(dd, b""), java.get(dd, b""), spec["reclen"], fields, - case.get("code_page", "cp037"), data_encoding(case)) # fmt: skip + d = diff_records(left, right, spec["reclen"], fields, case.get("code_page", "cp037"), + data_encoding(case)) # fmt: skip if d["layout_bytes"] != spec["reclen"]: # #3820: the copybook's layout does not fill the record print(f"{case['program']} {dd}: layout is {d['layout_bytes']} bytes, reclen {spec['reclen']}") run["outputs"][dd] = d @@ -498,6 +544,9 @@ def compare_run(case: dict[str, Any], corpus: Path, cobol: dict[str, bytes], jav ends = (f"both ABEND {abend['cobol']}" if abend["cobol"] and abend["cobol"] == abend["java"] else f"both end RETURN-CODE {rc['cobol']}" if not why else "") # fmt: skip run["summary"] = "; ".join(why) if why else ends + if run.get("sign_equivalent"): + run["summary"] += " (C10: " + ", ".join(f"{dd} {n} sign bytes F/C" for dd, n in run["sign_equivalent"].items()) + run["summary"] += ")" return run diff --git a/tests/tools/equivalence_common.py b/tests/tools/equivalence_common.py index 3dafe2f66b..d9f31f5933 100644 --- a/tests/tools/equivalence_common.py +++ b/tests/tools/equivalence_common.py @@ -426,6 +426,60 @@ def _as_text(raw: bytes, enc: str) -> Any: return raw +def split_varseq(data: bytes) -> list[bytes]: + """A variable-length sequential file (RECORDING MODE V / RECORD VARYING) as GnuCOBOL writes it + (COB_VARSEQ_FORMAT 0, its default): per record a 4-byte header -- the data length as a big-endian halfword, + then two zero bytes -- and the data. Unlike a z/OS RDW, the length does not count the header. Both sides of + a comparison are framed this way; a framing error is a difference, never skipped.""" + recs, i = [], 0 + while i < len(data): + if i + 4 > len(data) or data[i + 2 : i + 4] != b"\0\0": + raise ValueError(f"not a GnuCOBOL variable-length record header at byte {i}: {data[i : i + 4]!r}") + n = int.from_bytes(data[i : i + 2], "big") + if i + 4 + n > len(data): + raise ValueError(f"record at byte {i} claims {n} bytes; {len(data) - i - 4} remain") + recs.append(data[i + 4 : i + 4 + n]) + i += 4 + n + return recs + + +def diff_varseq( + left: bytes, + right: bytes, + layouts: dict[int, list[dict[str, Any]]], + code_page: str = "cp037", + data_encoding: str = DEFAULT_DATA_ENCODING, +) -> dict[str, Any]: + """diff_records for a variable-length file: records paired in order, each compared field by field against the + layout its length selects (`layouts`: length -> fields; a length with no layout is compared byte for byte). A + record of another length, a missing record, or a framing error is a difference.""" + out: dict[str, Any] = {"records": 0, "equal": 0, "diffs": [], "filler_differs": 0, "layout_bytes": 0} + try: + lrecs, rrecs = split_varseq(left), split_varseq(right) + except ValueError as e: + out["diffs"].append({"record": 0, "framing": str(e)}) + return out + out["records"] = max(len(lrecs), len(rrecs)) + out["layout_bytes"] = max(layouts, default=0) + for n in range(out["records"]): + a = lrecs[n] if n < len(lrecs) else None + b = rrecs[n] if n < len(rrecs) else None + if a is None or b is None: + out["diffs"].append({"record": n + 1, "missing": "cobol" if a is None else "java"}) + continue + if len(a) != len(b): + out["diffs"].append({"record": n + 1, "fields": [{"field": "(record length)", "cobol": str(len(a)), + "java": str(len(b))}]}) # fmt: skip + continue + d = diff_records(a, b, len(a), layouts.get(len(a), []), code_page, data_encoding) + out["filler_differs"] += d["filler_differs"] + if d["diffs"]: + out["diffs"].append({"record": n + 1, "fields": d["diffs"][0]["fields"]}) + else: + out["equal"] += 1 + return out + + def diff_records( left: bytes, right: bytes, From 7274d4e874b31c957d45b553e55545d226f19c69 Mon Sep 17 00:00:00 2001 From: squid-protocol Date: Fri, 2 Oct 2026 23:37:56 -0400 Subject: [PATCH 2/5] oracle register: C10 (zoned sign tolerance, scoped), F3 (RECFM=VB exercised), L3 (WORKING-STORAGE with no VALUE), A1 (COBDATFT) The case notes cite A1 for the COBDATFT model, L3 for its two unwritten bytes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RZE6us7fUL6Hynk59rWLvZ --- docs/language_status/oracle_assumptions.md | 49 ++++++++++++++++++- tests/equivalence/carddemo-readacct/case.json | 2 +- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/docs/language_status/oracle_assumptions.md b/docs/language_status/oracle_assumptions.md index bedf9283db..d5e6fc3806 100644 --- a/docs/language_status/oracle_assumptions.md +++ b/docs/language_status/oracle_assumptions.md @@ -40,12 +40,13 @@ verdict, but it limits what the proof says about inputs outside the scenarios. | C7 | compiler | COMP-5 byte order: little-endian vs z/OS big-endian | DIFFERS | read as numbers only | | C8 | compiler | DISPLAY of signed zoned, binary and packed items | MATCHED | yes | | C9 | compiler | POINTER is 8 bytes in GnuCOBOL (x86-64), 4 on z/OS | DIFFERS | only NULL, trailing (CBSA) | +| 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) | | 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 | | F1 | files | Natural FILE STATUS values come from GnuCOBOL's BDB files | ASSUMED | yes (00, 10, 23, 22) | | F2 | files | Fault FILE STATUS values are injected on both sides | MATCHED | yes | -| F3 | files | Fixed-length records only; RECFM=VB/RDW not exercised | ASSUMED | — | +| F3 | files | RECFM=VB: records compared by content, framed as GnuCOBOL frames them, not as a z/OS RDW | ASSUMED | yes (CardDemo READACCT VBRCFILE) | | F4 | files | JCL utility steps (SORT, IDCAMS, IEBGENER) are not run | — | — | | X1 | CICS | Commands, RESP/RESP2 and EIB from IBM's API reference | ASSUMED | yes | | X2 | CICS | Screens compared as the symbolic map, not the 3270 stream | ASSUMED | yes | @@ -58,6 +59,8 @@ verdict, but it limits what the proof says about inputs outside the scenarios. | X9 | CICS | Named counters (GET COUNTER) | MATCHED | yes (GenApp LGACDB01) | | L1 | LE | CEEDAYS: documented pictures only | MATCHED / REFUSED | yes | | L2 | LE | CEE3ABD abend codes | MATCHED | yes | +| L3 | LE | WORKING-STORAGE with no VALUE clause: GnuCOBOL's spaces vs LE's STORAGE option on z/OS | ASSUMED | yes (CardDemo READACCT OUTFILE, 2 bytes) | +| A1 | assembler | CardDemo's COBDATFT, translated instruction for instruction; load-module-dependent paths refused | MATCHED / REFUSED | yes (CardDemo READACCT) | | Q1 | Db2 | Db2 for Linux runs the SQL, not Db2 for z/OS | ASSUMED | yes | | Q2 | Db2 | EXEC SQL keeps RETURN-CODE | ASSUMED | yes | | Q3 | Db2 | The Java side's unit of work: one per CICS task; batch commits each statement | MATCHED (CICS) / DIFFERS (batch) | CICS: yes (CBSA XFRFUN); batch: no | @@ -135,6 +138,20 @@ verdict, but it limits what the proof says about inputs outside the scenarios. - **Waiting on it.** CBSA's INQACCCU, DELCUS and CREACC pass COMMAREAs with data after a POINTER. They need the COBOL side on 4-byte pointers (a 32-bit GnuCOBOL build) before they can be proven. +### C10. Zoned signs written by INITIALIZE and VALUE ZERO — DIFFERS (tolerated where declared) +- **What.** GnuCOBOL's INITIALIZE and VALUE ZERO leave a signed zoned item's last byte with the unsigned `F` zone; + Enterprise COBOL writes the preferred sign, `C`. Both are the same positive value; the bytes differ. +- **Where it shows.** CardDemo's CBACT01C writes ARRYFILE entries that INITIALIZE leaves untouched. +- **Tolerance, scoped.** A case lists the datasets it applies to in `"zoned_sign_equivalent"` (only + `carddemo-readacct`'s ARRYFILE today). In those datasets, and only there, a byte is taken as equal when it is a + plain digit on one side and the same digit with the positive overpunch on the other (`{`, `A`–`I`: the ASCII + data's form of an F zone against a C zone; `equivalence.accept_unsigned_positive`). The check is by byte value, + not by field position, which is why it is declared per dataset rather than applied everywhere. Every such byte is + counted in the run's summary (`C10: ARRYFILE n sign bytes F/C`), so the tolerance is visible, never silent. + Everywhere else, signs are compared byte for byte. +- **Not tolerated.** A negative overpunch (`}`, `J`–`R`) against either form, a different digit, or any byte in an + undeclared dataset. + ### 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 @@ -187,7 +204,14 @@ verdict, but it limits what the proof says about inputs outside the scenarios. - The values are the ones IBM documents for the condition. What a real device failure would give is not modelled. ### F3. Record formats — ASSUMED -- The cases' datasets are fixed-length records. Variable-length records (RECFM=VB, the RDW) are not exercised. +- Most cases' datasets are fixed-length records. +- **Variable-length records (RECFM=VB)** are exercised since `carddemo-readacct` (CBACT01C's VBRCFILE: a 12-byte and + a 39-byte record per account, `RECORD VARYING DEPENDING ON`). Records are compared by content and length, framed + as GnuCOBOL writes a variable sequential file: a 2-byte big-endian length, two zero bytes, then the data + (`equivalence_common.split_varseq`). Unlike a z/OS RDW, that length does not count the 4-byte header. +- **Assumed.** That a record's content and length are what z/OS would write; block descriptors (BDW) and spanned + records are not modelled. The det translator writes the same framing (`DetFiles`); before this it wrote VB records + padded to the maximum length, which no case had exercised. ### F4. JCL utility steps — out of scope - A batch case runs one program step. @@ -273,6 +297,27 @@ The length READQ TS returns on ITEMERR or QIDERR is not documented, so it is not ### L2. CEE3ABD — MATCHED - `ggabend.c` records `ABEND Unnnn`, and the Java side's CobolAbend gives the same. +### L3. WORKING-STORAGE with no VALUE clause — ASSUMED +- **What.** GnuCOBOL initialises such an alphanumeric item to spaces. On z/OS its first contents follow the LE + runtime option STORAGE (commonly binary zeros, or whatever the storage held): the program's source does not say. +- **Where it shows.** CBACT01C moves the 10-byte `CODATECN-0UT-DATE` to `OUT-ACCT-REISSUE-DATE`, but COBDATFT (A1) + writes only its first 8 bytes and `CODATECN-REC` has no VALUE clause. The last 2 bytes of each OUTFILE record's + reissue date are GnuCOBOL's spaces, and the Java side writes the same. +- **Assumed** to be spaces. A z/OS run with STORAGE(00) would give X'0000' there. + +## Assembler routines + +### A1. COBDATFT — MATCHED where the source decides, REFUSED otherwise +- **What.** CardDemo's date routine `app/asm/COBDATFT.asm` (S/370 assembler), CALLed by CBACT01C. GnuCOBOL cannot run + it, so the harness runs a translation of it, instruction for instruction: `tests/equivalence/le/cobdatft.c` for the + COBOL side and the det runtime's `Cobdatft.java` for the Java side, the same bytes on both. +- **Modelled.** Input type `'2'` (YYYY-MM-DD) to output type `'2'` (YYYYMMDD): `COOUTDT(4) = COINPDT(4)`, + `COOUTDT+4(2) = COINPDT+5(2)`, `COOUTDT+6(2) = COINPDT+8(2)`; every other byte is left as it was (see L3); R15 = 0. +- **Refused (exit 98).** Input type `'1'`: its `CLC COINPDT+4,=C'-'` has no explicit length, so it compares 20 + bytes against a 1-byte literal and whatever the literal pool holds after it. The error path: an `MVC` of 38 bytes + from a 13-byte literal. What both do depends on the load module, not the source, so no proof rests on them. + CBACT01C never takes either. + ## Db2 (`equivalence_sql.py`, `ggsql.c`, `DetSql`) ### Q1. Db2 for Linux, not z/OS — ASSUMED diff --git a/tests/equivalence/carddemo-readacct/case.json b/tests/equivalence/carddemo-readacct/case.json index 3b44ca97a3..eec17e5d93 100644 --- a/tests/equivalence/carddemo-readacct/case.json +++ b/tests/equivalence/carddemo-readacct/case.json @@ -48,7 +48,7 @@ } } }, - "notes": "Job READACCT step STEP05: CBACT01C reads CardDemo's account master (the ACCTDATA KSDS, acctdata.txt) in key order, DISPLAYs each account, and writes three files per account: OUTFILE (RECFM=FB, LRECL=107; the reissue date reformatted by the assembler routine COBDATFT, CURR-CYC-DEBIT as COMP-3, 2525.00 when it is zero), ARRYFILE (LRECL=110: a 5-entry table, three entries set, the rest as INITIALIZE leaves them) and VBRCFILE (RECFM=VB: a 12-byte and a 39-byte record per account, RECORD VARYING DEPENDING ON WS-RECD-LEN). COBDATFT is the harness's model of app/asm/COBDATFT.asm (tests/equivalence/le/cobdatft.c; oracle_assumptions.md L3). OUT-ACCT-REISSUE-DATE carries two bytes COBDATFT never writes (oracle_assumptions.md L3: GnuCOBOL's spaces). ARRYFILE's untouched table entries are INITIALIZE's zeros, unsigned under GnuCOBOL, with the preferred C sign on z/OS: zoned_sign_equivalent (oracle_assumptions.md C10). The program never CLOSEs its three output files (GOBACK does). Every input is the corpus's data. Derived from CardDemo (Apache-2.0; Copyright Amazon.com, Inc. or its affiliates): LICENSE and NOTICE here are the source's own.", + "notes": "Job READACCT step STEP05: CBACT01C reads CardDemo's account master (the ACCTDATA KSDS, acctdata.txt) in key order, DISPLAYs each account, and writes three files per account: OUTFILE (RECFM=FB, LRECL=107; the reissue date reformatted by the assembler routine COBDATFT, CURR-CYC-DEBIT as COMP-3, 2525.00 when it is zero), ARRYFILE (LRECL=110: a 5-entry table, three entries set, the rest as INITIALIZE leaves them) and VBRCFILE (RECFM=VB: a 12-byte and a 39-byte record per account, RECORD VARYING DEPENDING ON WS-RECD-LEN). COBDATFT is the harness's model of app/asm/COBDATFT.asm (tests/equivalence/le/cobdatft.c; oracle_assumptions.md A1). OUT-ACCT-REISSUE-DATE carries two bytes COBDATFT never writes (oracle_assumptions.md L3: GnuCOBOL's spaces). ARRYFILE's untouched table entries are INITIALIZE's zeros, unsigned under GnuCOBOL, with the preferred C sign on z/OS: zoned_sign_equivalent (oracle_assumptions.md C10). The program never CLOSEs its three output files (GOBACK does). Every input is the corpus's data. Derived from CardDemo (Apache-2.0; Copyright Amazon.com, Inc. or its affiliates): LICENSE and NOTICE here are the source's own.", "zoned_sign_equivalent": [ "ARRYFILE" ], From 8bd393436cfc9ae6c96769e6221dbbb984b18ae7 Mon Sep 17 00:00:00 2001 From: squid-protocol Date: Fri, 2 Oct 2026 23:42:16 -0400 Subject: [PATCH 3/5] tests: variable-length framing and comparison (F3), the C10 tolerance's bounds, RECORD VARYING parsing, COBDATFT as a library call Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RZE6us7fUL6Hynk59rWLvZ --- tests/cobol_mainframe/test_varrec_cobdatft.py | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/cobol_mainframe/test_varrec_cobdatft.py diff --git a/tests/cobol_mainframe/test_varrec_cobdatft.py b/tests/cobol_mainframe/test_varrec_cobdatft.py new file mode 100644 index 0000000000..c738433782 --- /dev/null +++ b/tests/cobol_mainframe/test_varrec_cobdatft.py @@ -0,0 +1,78 @@ +"""CBACT01C's new ground (carddemo-readacct): variable-length records (oracle_assumptions.md F3), the scoped zoned +sign tolerance (C10) and the COBDATFT library routine (A1) -- the harness's framing and comparison, the det +translator's reading of an FD's RECORD VARYING clause, and the runtime routine it calls.""" +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path[:0] = [str(ROOT / "tests" / "tools"), str(ROOT)] +import equivalence as eq # noqa: E402 +import equivalence_common as ec # noqa: E402 + + +def frame(*records: bytes) -> bytes: + return b"".join(len(r).to_bytes(2, "big") + b"\0\0" + r for r in records) + + +def test_variable_records_split_as_gnucobol_frames_them(): + assert ec.split_varseq(frame(b"A" * 12, b"B" * 39)) == [b"A" * 12, b"B" * 39] + assert ec.split_varseq(b"") == [] + with pytest.raises(ValueError, match="header"): + ec.split_varseq(b"\x00\x0c\x01\x00" + b"A" * 12) # the two bytes after the length are not zero + with pytest.raises(ValueError, match="claims"): + ec.split_varseq(b"\x00\x50\x00\x00" + b"A" * 12) # a length past the end of the data + + +def test_variable_records_compare_by_length_and_content(): + same = ec.diff_varseq(frame(b"A" * 12), frame(b"A" * 12), {}) + assert same["records"] == 1 and not same["diffs"] + longer = ec.diff_varseq(frame(b"A" * 12), frame(b"A" * 13), {}) + assert longer["diffs"][0]["fields"][0]["field"] == "(record length)" + padded = ec.diff_varseq(frame(b"A" * 12), b"A" * 80, {}) # an unframed 80-byte record: a framing error + assert "framing" in padded["diffs"][0] + missing = ec.diff_varseq(frame(b"A" * 12, b"B" * 39), frame(b"A" * 12), {}) + assert missing["diffs"] == [{"record": 2, "missing": "java"}] + + +def test_c10_takes_only_an_unsigned_digit_against_its_positive_overpunch(): + a, b, n = eq.accept_unsigned_positive(b"000", b"00{") # INITIALIZE's F zone against z/OS's preferred C + assert (a, b, n) == (b"00{", b"00{", 1) + assert eq.accept_unsigned_positive(b"5", b"E")[2] == 1 + for left, right in ((b"5", b"N"), (b"5", b"F"), (b"0", b"}"), (b"5", b"6")): # negative, other digit, other value + a, b, n = eq.accept_unsigned_positive(left, right) + assert n == 0 and a != b + assert eq.accept_unsigned_positive(b"00", b"0")[2] == 0 # lengths differ: left alone + + +def test_the_translator_reads_record_varying_and_recording_mode_v(tmp_path): + pytest.importorskip("tree_sitter_language_pack") + from gitgalaxy.tools.cobol_to_java.det import program as P + from gitgalaxy.tools.cobol_to_java.det import source as SRC + + src = "\n".join([ + " IDENTIFICATION DIVISION.", " PROGRAM-ID. VB.", " ENVIRONMENT DIVISION.", + " INPUT-OUTPUT SECTION.", " FILE-CONTROL.", " SELECT VBFILE ASSIGN TO VBRCFILE.", + " SELECT FBFILE ASSIGN TO OUTFILE.", " DATA DIVISION.", " FILE SECTION.", + " FD VBFILE", " RECORDING MODE IS V", + " RECORD IS VARYING IN SIZE FROM 10 TO 80", # within column 72: fixed format ignores 73-80 + " DEPENDING ON WS-RECD-LEN.", + " 01 VB-REC PIC X(80).", " FD FBFILE.", " 01 FB-REC PIC X(107).", + " WORKING-STORAGE SECTION.", " 01 WS-RECD-LEN PIC 9(4) COMP.", " PROCEDURE DIVISION.", + " GOBACK.", + ]) + "\n" # fmt: skip + (tmp_path / "VB.cbl").write_text(src, encoding="ascii") + fds = P.fd_entries(SRC.program_lines(tmp_path / "VB.cbl", [])) + assert fds["VBFILE"] == {"varying": {"min": 10, "max": 80, "depending": "WS-RECD-LEN"}, "mode_v": True} + assert fds["FBFILE"] == {"varying": None, "mode_v": False} + + +def test_cobdatft_is_a_runtime_library_call(): + from gitgalaxy.tools.cobol_to_java.det import gen as G + + assert G.LIBRARY["COBDATFT"] == ("__PACKAGE__.cobolrt.le.Cobdatft.call", 1) + rt = ROOT / "gitgalaxy" / "tools" / "cobol_to_java" / "det" / "cobolrt" / "le" / "Cobdatft.java" + assert "oracle_assumptions.md A1" in rt.read_text(encoding="utf-8") From e9a5d90bc1c13a72a30a09241fcc2a2c917eb121 Mon Sep 17 00:00:00 2001 From: squid-protocol Date: Fri, 2 Oct 2026 23:59:22 -0400 Subject: [PATCH 4/5] devin_port: fix the hex-packed decoder (it never advanced past the text before a packed field); a z/OS RDW decoder for codev's VBRCFILE; CBACT01C results doc Results on carddemo-readacct (base run, 6 environments): codev #13/#14 proven; eval arms A and B, #229, #168 and #214 write every data file byte for byte (#214 not under ar-EG/th-TH); #167 partial (VB records unframed); #220 crashes on the overpunch. docs/research/devin-cbact01c-harness.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RZE6us7fUL6Hynk59rWLvZ --- docs/research/devin-cbact01c-harness.md | 93 ++++++++++++++++++++++++ tests/cobol_mainframe/test_devin_port.py | 15 ++++ tests/tools/devin_port.py | 31 ++++++-- 3 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 docs/research/devin-cbact01c-harness.md diff --git a/docs/research/devin-cbact01c-harness.md b/docs/research/devin-cbact01c-harness.md new file mode 100644 index 0000000000..82a5f63fed --- /dev/null +++ b/docs/research/devin-cbact01c-harness.md @@ -0,0 +1,93 @@ +# Devin's CBACT01C ports through the equivalence harness + +*Run 2026-10-03 with `tests/tools/devin_port.py`, against our new case `carddemo-readacct` (CardDemo's CBACT01C). +Part of the observational study: `cobol-java-translation-observational-study.md`. Survey and pinned commits: +`devin-carddemo-survey.md` / `devin-carddemo-inventory.json`. The ports are Apache-2.0 workshop exercises; each is +judged against what it attempted, unmodified, through a thin adapter.* + +## The case + +CBACT01C reads CardDemo's account master and writes three files per account: OUTFILE (107-byte records with a +COMP-3 field and a date reformatted by the assembler routine COBDATFT), ARRYFILE (110 bytes, a table INITIALIZE +partly fills) and VBRCFILE (variable-length: a 12-byte and a 39-byte record per account). Until this case the harness +could not run it: COBDATFT is S/370 assembler. It now runs against a translation of that routine +(`oracle_assumptions.md` A1), with variable-length records (F3), GnuCOBOL's spaces in two bytes COBDATFT never +writes (L3), and a declared tolerance for INITIALIZE's unsigned zero (C10). + +**Our det port proves on it:** 13 runs (base plus fault injection), 16/16 paragraphs, 41/44 branches. Translated +three times with the cache off, its 14 Java files are byte-identical each time. + +## Results (base run, 6 JVM locale environments) + +"Data" = OUTFILE, ARRYFILE, VBRCFILE and RETURN-CODE. "Log" = SYSOUT (the DISPLAY lines). Base-run coverage for +every port: 14/16 paragraphs, 24/44 branches (no port has a seam for the case's fault runs). + +| Port (pinned) | Data | Log | Verdict | Root cause of differences | +|---|---|---|---|---| +| codev #13 (and #14, identical Java) | equal, all 6 | equal, all 6 | **PROVEN** | — (VBRCFILE written with a z/OS RDW; re-framed, see adapter) | +| eval arm A | equal, all 6 | 250/752 lines differ | data equal | signed numbers DISPLAYed with a trailing sign (`000000019400+`) where COBOL shows the overpunch (`00000001940{`) | +| eval arm B (+ AWS Transform analysis) | equal, all 6 | same as arm A | data equal | same as arm A | +| #229 (its own GnuCOBOL parity run) | equal, all 6 | 250 differ | data equal | signed numbers DISPLAYed edited (`+0000000194.00`) | +| #168 | equal, all 6 | 750 differ | data equal | DISPLAYs whole raw records, not the program's field lines | +| #214 | equal in 4; **differ under ar-EG / th-TH** | 717 differ | data equal (default locales) | `String.format("%0"+n+"d")` without a locale (`CobolFieldParser.java:94`); edited numbers in the log (`194.00`) | +| #167 | OUTFILE, ARRYFILE equal in 4; differ under ar-EG / th-TH; **VBRCFILE unframed** | 724 differ | partial | VB records written back to back, no length (`vb-unframed`); `String.format` without a locale (`CobolDecimalUtils.java:158`) | +| #220 | none written | none | **crashes** (RC 1) | `new BigDecimal(...)` on a field holding the overpunched sign `{` (`AccountRecord.java:87`) | + +ARRYFILE: under C10, several ports (like GnuCOBOL) leave INITIALIZE's zeros unsigned; 100 such bytes per run are +counted and reported, never hidden. + +**In short:** of 9 byte-comparable runs (8 distinct programs), 1 is proven end to end, 4 more write every data file +byte for byte (in default locales), 1 is partial, 1 crashes. Every log difference is a choice of how to show a +signed number or what to DISPLAY; the program's DISPLAY lines are part of its observable behaviour, so they count. + +## Adapter scope + +As #4190: their code unmodified, fetched at pinned SHAs; the case's inputs fed in each port's declared form; outputs +decoded only where the port writes the same bytes in another framing, declared per port (`DECODERS`): +- `vb-lines` (#168: newline-separated VB records), `vb-len2` (2-byte length, no header), `vb-rdw` (codev: a z/OS RDW, + whose length counts the 4-byte header) — each re-framed as GnuCOBOL frames VB records; +- `lines-hex-packed` (#214: COMP-3 fields written as hex digits) — the hex turned back into the packed bytes. +- `vb-unframed` (#167): nothing marks where a record ends, so it is compared as written and reported as a framing + error. + +A decoder never changes content; a record of the wrong length after decoding is compared as written. During this +run a bug in our `lines-hex-packed` decoder (it did not advance past the text before a packed field) made #214's +OUTFILE look wrong; it was fixed and tested before the results above. + +## Structure against verdict + +The 26 CBACT01C attempts (scanned with GitGalaxy after #4193), grouped by what the harness found: + +| Group | n (distinct) | files | code lines | functions | branches | I/O | guards | codec roles present | +|---|---|---|---|---|---|---|---|---| +| Byte-proven (codev) | 2 (1) | 20 | 970 | 101 | 88 | 21 | 18 | charset, packed, zoned, record codec | +| Data equal, log differs (#229, #168, #214) | 3 | 6 | 406 | 29 | 33 | 19 | 4 | packed, zoned, record codec in 1 of 3 | +| Partial (#167) | 1 | 6 | 403 | 22 | 36 | 27 | 2 | record codec | +| Crash (#220) | 1 | 7 | 541 | 92 | 42 | 17 | 13 | none | +| Text output, not byte-comparable by design | 19 | 9 | 371 | 30 | 29 | 20 | 3 | zoned in 5, record codec in 1 | + +(Medians. The eval arms port three programs in one project and are not in this set.) + +**What can be stated, carefully (n is tiny):** +- The one fully proven program is also the largest and the only one carrying a complete, explicit codec kit (EBCDIC + charset, packed, zoned, record framing) and the most guards. Its 970 lines are close to our det port's 1,047-line + service for the same program: the only Devin attempt that reproduces CBACT01C byte for byte converged on roughly + the det port's size. +- It is also the attempt built around an independent oracle (codev #14 adds a Python program that derives the + expected bytes). The two other attempts that ran GnuCOBOL or a golden harness (#229, the eval arms) also get + every data file right. +- Size alone does not predict correctness: the crashing attempt (#220) is the second-largest by functions. +- Structure does not separate "data equal" from "partial": #167 and #168 are near-identical in size and shape; one + frames its VB records and one does not. That is a byte-level property only execution sees. + +## Caveats + +Base runs only (24/44 branches); GnuCOBOL is the oracle (`oracle_assumptions.md`), and L3's two bytes are GnuCOBOL's +spaces; the survey's 8 fixed-width ports (a field-by-field tier) were not run in this pass. + +## Reproducing + +```sh +python tests/tools/det_port.py run carddemo-readacct --faults all --work +python tests/tools/devin_port.py --work --case carddemo-readacct --jdk21 +``` diff --git a/tests/cobol_mainframe/test_devin_port.py b/tests/cobol_mainframe/test_devin_port.py index d94b1162b1..75d6db8836 100644 --- a/tests/cobol_mainframe/test_devin_port.py +++ b/tests/cobol_mainframe/test_devin_port.py @@ -42,3 +42,18 @@ def test_a_signed_number_shown_gnucobols_way_is_classed_apart(): java = b"BAL :000000019400+\nCOUNT :000000004\n" c = dp.sysout_classes(CASE, cobol, java) assert (c["sign-display"], c["other"]) == (1, 1) + + +def test_hex_packed_lines_become_the_copybook_record(): + """A packed field written as hex digits (#214's OUT-ACCT-CURR-CYC-DEBIT) back to its 7 bytes, the text around it + kept: the record is the copybook's 107 bytes.""" + line = b"A" * 90 + b"0000000252500C" + b" " * 10 + b"\n" + rec = dp.lines_hex_packed(line, 107, [(90, 7)]) + assert len(rec) == 107 and rec[90:97] == bytes.fromhex("0000000252500C") and rec[97:] == b" " * 10 + assert dp.lines_hex_packed(b"A" * 90 + b"ZZZZZZZZZZZZZZ" + b" " * 10 + b"\n", 107, [(90, 7)]).startswith(b"A" * 90 + b"Z") + + +def test_a_zos_rdw_is_reframed_as_gnucobol_frames_it(): + rdw = b"\x00\x10\x00\x00" + b"x" * 12 + b"\x00\x2b\x00\x00" + b"y" * 39 # the length counts the 4-byte header + assert dp.vb_rdw(rdw) == dp.gnucobol_frame([b"x" * 12, b"y" * 39]) + assert dp.vb_rdw(b"\x00\x02\x00\x00") == b"\x00\x02\x00\x00" # a length below the header's: not an RDW diff --git a/tests/tools/devin_port.py b/tests/tools/devin_port.py index 09abc6f3ac..9d483813c7 100644 --- a/tests/tools/devin_port.py +++ b/tests/tools/devin_port.py @@ -129,6 +129,7 @@ def _dd_launcher(program: str) -> list[str]: "repo": CODEV, "branch": "devin/1788345561-cbact01c-batch-flow", "sha": "73e9452dda39d56d564f5456092705ed84864983", "pr": 13, "about": "codev series: the batch flow", "build": ("classes", "java", None), "jdk": 17, "main": "com.carddemo.batch.cbact01c.Cbact01c", "input": "fixed", + "decode": {"VBRCFILE": "vb-rdw"}, "programs": {"CBACT01C": ("carddemo-readacct", ["--acctfile", "{dd:ACCTFILE}", "--outfile", "{dd:OUTFILE}", "--arryfile", "{dd:ARRYFILE}", "--vbrcfile", "{dd:VBRCFILE}", "--charset", "ASCII", "--display"])}, @@ -138,6 +139,7 @@ def _dd_launcher(program: str) -> list[str]: "sha": "2f5c0773b96ae98b4046783f8de2e4e96e79cf1c", "pr": 14, "about": "codev series: the golden master (a Python oracle for the expected bytes)", "build": ("classes", "java", None), "jdk": 17, "main": "com.carddemo.batch.cbact01c.Cbact01c", "input": "fixed", + "decode": {"VBRCFILE": "vb-rdw"}, "programs": {"CBACT01C": ("carddemo-readacct", ["--acctfile", "{dd:ACCTFILE}", "--outfile", "{dd:OUTFILE}", "--arryfile", "{dd:ARRYFILE}", "--vbrcfile", "{dd:VBRCFILE}", "--charset", "ASCII", "--display"])}, @@ -171,19 +173,19 @@ def vb_len2(data: bytes) -> bytes: def lines_hex_packed(data: bytes, reclen: int, packed: list[tuple[int, int]]) -> bytes: """Newline-terminated records whose packed-decimal fields (offset, bytes) are written as 2*bytes hex digits: - each record's bytes as the copybook lays them out.""" + each record's bytes as the copybook lays them out. Anything else is compared as written.""" out = [] for line in data.split(b"\n"): if not line: continue - rec, pos = bytearray(), 0 + rec, pos = bytearray(), 0 # rec: the record so far; pos: where in the line the next byte is read for off, n in sorted(packed): - rec += line[pos : pos + off - len(rec)] - pos += off - (len(rec) - n * 0) # advance to the hex digits - pos = pos if len(rec) == off else pos + text = off - len(rec) # the bytes before this packed field, copied as they are + rec += line[pos : pos + text] + pos += text try: rec += bytes.fromhex(line[pos : pos + 2 * n].decode("ascii")) - except ValueError: + except (ValueError, UnicodeDecodeError): return data # not hex where a packed field sits: compared as written pos += 2 * n rec += line[pos:] @@ -193,6 +195,21 @@ def lines_hex_packed(data: bytes, reclen: int, packed: list[tuple[int, int]]) -> return b"".join(out) +def vb_rdw(data: bytes) -> bytes: + """Records each led by a z/OS RDW -- a 2-byte length that counts the 4-byte header, then two zero bytes -- + re-framed as GnuCOBOL frames them (oracle_assumptions.md F3: GnuCOBOL's length does not count the header).""" + recs, i = [], 0 + while i + 4 <= len(data): + n = int.from_bytes(data[i : i + 2], "big") + if n < 4 or data[i + 2 : i + 4] != b"\0\0": + return data # not an RDW: compared as written (the comparison reports the framing error) + recs.append(data[i + 4 : i + n]) + i += n + if i != len(data): + return data + return gnucobol_frame(recs) + + def decode(spec: Any, data: bytes) -> bytes: if spec is None: return data @@ -200,6 +217,8 @@ def decode(spec: Any, data: bytes) -> bytes: return vb_lines(data) if spec == "vb-len2": return vb_len2(data) + if spec == "vb-rdw": + return vb_rdw(data) if spec == "vb-unframed": return data # nothing marks where a record ends: compared as written if isinstance(spec, tuple) and spec[0] == "lines-hex-packed": From d8c29ec26f4c521b1f4b124f0ae034ba52fb27f3 Mon Sep 17 00:00:00 2001 From: squid-protocol Date: Sat, 3 Oct 2026 00:13:00 -0400 Subject: [PATCH 5/5] ruff format: det gen.py / program.py (formatting only) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RZE6us7fUL6Hynk59rWLvZ --- gitgalaxy/tools/cobol_to_java/det/gen.py | 5 ++++- gitgalaxy/tools/cobol_to_java/det/program.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/gitgalaxy/tools/cobol_to_java/det/gen.py b/gitgalaxy/tools/cobol_to_java/det/gen.py index 9fe310a50e..3165a0fe57 100644 --- a/gitgalaxy/tools/cobol_to_java/det/gen.py +++ b/gitgalaxy/tools/cobol_to_java/det/gen.py @@ -47,7 +47,10 @@ def __init__(self, names): # Library routines the runtime models (each the twin of the harness's COBOL-side model): program -> (Java, args) -LIBRARY = {"CEEDAYS": ("__PACKAGE__.cobolrt.le.Ceedays.call", 4), "COBDATFT": ("__PACKAGE__.cobolrt.le.Cobdatft.call", 1)} +LIBRARY = { + "CEEDAYS": ("__PACKAGE__.cobolrt.le.Ceedays.call", 4), + "COBDATFT": ("__PACKAGE__.cobolrt.le.Cobdatft.call", 1), +} def jstr(s: str) -> str: diff --git a/gitgalaxy/tools/cobol_to_java/det/program.py b/gitgalaxy/tools/cobol_to_java/det/program.py index c19df7e5bb..39182c22cf 100644 --- a/gitgalaxy/tools/cobol_to_java/det/program.py +++ b/gitgalaxy/tools/cobol_to_java/det/program.py @@ -95,7 +95,10 @@ def fd_entries(lines: list[Line]) -> dict[str, dict[str, Any]]: return out for fd in re.finditer(r"\b[FS]D\s+([A-Z0-9-]+)(.*?)(?=\s0?1\s+[A-Z0-9-]+|\s[FS]D\s|\Z)", m.group(1), re.I | re.S): e = fd.group(2) - d: dict[str, Any] = {"varying": None, "mode_v": bool(re.search(r"\bRECORDING\s+(?:MODE\s+)?(?:IS\s+)?V\b", e, re.I))} + d: dict[str, Any] = { + "varying": None, + "mode_v": bool(re.search(r"\bRECORDING\s+(?:MODE\s+)?(?:IS\s+)?V\b", e, re.I)), + } v = re.search(r"\bRECORD\s+(?:IS\s+)?VARYING\b(.*?)(?:\.\s*$|$)", e, re.I | re.S) if v: lo = re.search(r"\bFROM\s+(\d+)", v.group(1), re.I)