diff --git a/.claude/skills/cobol-modernization/SKILL.md b/.claude/skills/cobol-modernization/SKILL.md index 6daf66a5b..6ae79ea88 100644 --- a/.claude/skills/cobol-modernization/SKILL.md +++ b/.claude/skills/cobol-modernization/SKILL.md @@ -9,6 +9,16 @@ With `--galaxy-db/--scan` the refractor also writes `06_skeleton/` (#3614, `skel CICS programs (#3615, `cobol_to_java_transaction_forge.py`) are the first consumer of the skeleton. `GalaxyIR.program_interfaces()` resolves each program's COMMAREA layout: what the resolved callers pass, else its own fixed DFHCOMMAREA, else a stated gap. It also resolves its GET/PUT containers. From those, the forge generates `dto/cics/*` plus one endpoint per entry transaction, `/link` and `/channel`. Every class and field cites the fact and its field-testing status. Compile that path with `java_target_matrix.py --scan`; CI runs both paths. +Calls (#3616, `cobol_to_java_call_forge.py`): +- A resolved LINK/XCTL becomes `link`/`xctl`, calling the target's `handleLink`. +- A resolved CALL becomes `call(...)`, typed by the target's USING items (`program_interfaces().parameters`). +- A data-driven site becomes `dispatchL`, a switch over the candidates. +- A remote DPL LINK (`integration.remote_calls: http`) becomes a `RemoteClient`. +- Targets are injected as `ObjectProvider` because CICS screens XCTL in cycles. +- A site passing a record other than the one the target receives gets a mapping TODO. + +Program-ID lookups must go through `_program_index` / `_nearest_program` / `_program_file`. They skip CSD/BMS/JCL/DDL "program ids": the CSD deck used to shadow every program it DEFINEs. + **Neither side is the oracle.** The answer key is. ## Read first (canonical, don't re-derive) diff --git a/docs/language_status/cics_field_testing.md b/docs/language_status/cics_field_testing.md index 3f4717efe..fe65dba28 100644 --- a/docs/language_status/cics_field_testing.md +++ b/docs/language_status/cics_field_testing.md @@ -70,7 +70,7 @@ counted in it (their fact counts are not recorded here). |---|---|---|---|---|---|---|---| | 1 | zopeneditor-sample | public | 859 | 3 | 1 | 1 | 0 | | 2 | cics-banking-sample-application-cbsa | public | 3,142 | 37 | 0 | 1 | 0 | -| 3 | aws-mainframe-modernization-carddemo | public | 4,214 | 14 | 5 | 4 | 1 | +| 3 | aws-mainframe-modernization-carddemo | public | 4,214 | 14 | 6 | 4 | 1 | | 4 | cics-genapp | public | 2,025 | 10 | 2 | 1 | 0 | | 5 | zecs | public | 1,182 | 43 | 0 | 0 | 2 | | 6 | dsf | public | 1,406 | 4 | 5 | 1 | 0 | @@ -100,6 +100,7 @@ counted in it (their fact counts are not recorded here). | D019 | 6 | engine | fact | units | #3533 / #3548 | SPECIAL-NAMES / I-O-CONTROL read as procedure units; all-digit paragraph names (0000., 9999.) missed. | | D020 | 6 | forge | fact | units, dead | #3533 / #3548 | The forge lost every paragraph after `PROCEDURE DIVISION.` (multi-space) and missed inline headers; an AT END phrase counted as terminal. | | D021 | 6 | engine | fact | CICS resources, CICS task control | #3577 / #3582 | A PL/I CICS operand continued past a columns 73-80 sequence field read the sequence number as its value (9 of 3,251 DSF operations lost a name). | +| D022 | 3 | engine | attribute | dynamic call targets | #3616 / #3640 | dynamic_call_targets and _program_file indexed CSD / BMS / JCL / DDL class_data names as program declarers: 88 of 89 CardDemo candidates resolved to the CSD deck instead of the COBOL source (the keyed candidate names were right). | | B001 | 5 | brief | attribute | CICS resources | #3512 / #3561 | The brief said record_clause is 'the first of INTO / FROM / SET' (meant: that precedence); WEB CONVERSE codes both. | | B002 | 5 | brief | fact | CSD resources | #3575 / #3584 | The brief's name rule said 'A-Z'; CSD names are case-insensitive (`ZC@id@`). | | B003 | 3 | brief | fact | JCL resolved DSNs | #3575 / #3601 | The brief did not say override DDs (//PROCSTEP.DD) are out of scope, as key and engine define them. | diff --git a/gitgalaxy/cobol_to_java_controller.py b/gitgalaxy/cobol_to_java_controller.py index c240c2077..82bfa4f1e 100644 --- a/gitgalaxy/cobol_to_java_controller.py +++ b/gitgalaxy/cobol_to_java_controller.py @@ -49,6 +49,7 @@ generate_pom_xml, generate_settings_gradle, ) +from gitgalaxy.tools.cobol_to_java.cobol_to_java_call_forge import CallForge, merge_extras from gitgalaxy.tools.cobol_to_java.cobol_to_java_decoder_forge import ( generate_decoder_util, ) @@ -142,7 +143,9 @@ def generate_mock_service(subroutine_name: str, package_name: str) -> str: """ -def _write_skeleton_audit(f, skeletons: dict, cics: Optional[CicsForge] = None) -> None: +def _write_skeleton_audit( + f, skeletons: dict, cics: Optional[CicsForge] = None, calls: Optional[CallForge] = None +) -> None: """#3614: which engine facts the generated project was built against, and how far each is proven.""" fields: dict[str, dict] = {} for key, path in skeletons.items(): @@ -169,6 +172,15 @@ def _write_skeleton_audit(f, skeletons: dict, cics: Optional[CicsForge] = None) f"{sum(len(p.transactions) for p in cics.programs.values())} transaction endpoints, " f"{len(cics.dtos)} COMMAREA / channel DTOs\n" ) + if calls is not None: + methods = [m for ex in calls.extras.values() for m in ex.methods if m.startswith(" public ")] + link, xctl, call, dispatch, remote = ( + sum(1 for m in methods if f" {kind}" in m) for kind in ("link", "xctl", "call", "dispatch", "remote") + ) + f.write( + f" • Service calls (#3616) : {link} LINK, {xctl} XCTL, {call} CALL, {dispatch} data-driven dispatch, " + f"{remote} remote; {len(calls.clients)} remote-region clients\n" + ) f.write("\n") @@ -309,15 +321,26 @@ def main(): # #3614: the refractor's verified skeletons (present when it ran with --scan / --galaxy-db) skeleton_dir = clean_room_path / "06_skeleton" skeletons = {p.name[: -len("_skeleton.json")]: p for p in sorted(skeleton_dir.glob("*_skeleton.json"))} - # #3615: CICS programs get endpoints per entry transaction and COMMAREA / channel DTOs - cics = CicsForge(load_skeletons(skeleton_dir), args.pkg, target) if skeletons else None - if cics is not None and target.features.rest_controllers: - cics_dto_dir = java_dirs["dto"] / "cics" - cics_dto_dir.mkdir(parents=True, exist_ok=True) + # #3615: CICS programs get endpoints per entry transaction and COMMAREA / channel DTOs; + # #3616: every program's LINK / XCTL / CALL targets become service-to-service calls. + cics = calls = None + if skeletons and target.features.services: + loaded = load_skeletons(skeleton_dir) + cics = CicsForge(loaded, args.pkg, target) + calls = CallForge(loaded, cics, args.pkg, target) + contract_dir = java_dirs["dto"] / "contract" + contract_dir.mkdir(parents=True, exist_ok=True) for name, code in cics.dto_sources().items(): - (cics_dto_dir / f"{name}.java").write_text(java_header + code, encoding="utf-8") + (contract_dir / f"{name}.java").write_text(java_header + code, encoding="utf-8") stats["dtos"] += 1 - print(f" [+] Generated {len(cics.dtos)} CICS COMMAREA / channel DTOs for {len(cics.programs)} CICS programs") + client_dir = java_dirs["base_pkg"] / "client" + for name, code in calls.client_sources().items(): + client_dir.mkdir(parents=True, exist_ok=True) + (client_dir / f"{name}.java").write_text(java_header + code, encoding="utf-8") + print( + f" [+] Generated {len(cics.dtos)} program-contract DTOs for {len(cics.programs)} CICS programs and " + f"{len(calls.clients)} remote-region clients" + ) # 3. Generate REST Controllers & Service Layers from IR State Files ir_dir = clean_room_path / "04_ir_state_dumps" @@ -339,14 +362,18 @@ def main(): safe_file_name = java_class_base(raw_prog_id) cics_prog = None - if cics is not None and target.features.rest_controllers: - cics_prog = cics.programs.get(output_key(ir_file, "_ir")) - if cics_prog is not None and cics_prog.cls != safe_file_name: - cics_prog = None # a renamed key (legacy / legacy-service): keep the generic path + skeleton_key = output_key(ir_file, "_ir") + if calls is not None and calls.cls_of.get(skeleton_key) != safe_file_name: + skeleton_key = None # a renamed key (legacy / legacy-service): keep the generic path + if cics is not None and skeleton_key: + cics_prog = cics.programs.get(skeleton_key) # 3A. Generate the @Service Skeleton if target.features.services: - extras = cics.service_extras(cics_prog) if cics_prog is not None else None + extras = merge_extras( + cics.service_extras(cics_prog) if cics_prog is not None else None, + calls.service_extras(skeleton_key) if calls is not None and skeleton_key else None, + ) service_code = generate_service_skeleton( ir_state, args.pkg, unit_key=raw_prog_id, target=target, extras=extras ) @@ -359,7 +386,7 @@ def main(): # 3B. Generate the @RestController lineage = ir_state.get("analysis", {}).get("lineage", {}) wants_api = lineage.get("inputs") or lineage.get("outputs") or lineage.get("unresolved_calls") - if cics_prog is not None: + if cics_prog is not None and target.features.rest_controllers: java_code = cics.controller(cics_prog) if java_header: java_code = java_header + java_code @@ -458,7 +485,7 @@ def main(): f.write(f" • REST Controllers Generated : {stats['controllers']}\n") f.write(f" • AI Agent Tickets Generated : {stats['agent_jobs']}\n\n") if skeletons: - _write_skeleton_audit(f, skeletons, cics) + _write_skeleton_audit(f, skeletons, cics, calls) f.write("==========================================================\n") print("\n" + "=" * 70) diff --git a/gitgalaxy/tools/cobol_to_cobol/galaxy_ir.py b/gitgalaxy/tools/cobol_to_cobol/galaxy_ir.py index af3849ec6..d9e9a36ba 100644 --- a/gitgalaxy/tools/cobol_to_cobol/galaxy_ir.py +++ b/gitgalaxy/tools/cobol_to_cobol/galaxy_ir.py @@ -1479,7 +1479,7 @@ def api_surface(self) -> dict: program and side of a hand-coded EXEC CICS WEB exchange -- `file`, `side` (SERVER: a hand-written HTTP provider; CLIENT: an outbound session), `commands` and the resolved `endpoints` (URIMAP / HOST / PATH / header names).""" - by_pid = {pid.upper(): f.file_path for f in self.files.values() if f.is_program for pid in f.program_ids} + by_pid = self._program_index() copybooks: dict = {} for path, f in self.files.items(): if f.language == "cobol" and not f.is_program: @@ -1882,8 +1882,11 @@ def program_interfaces(self, language: str = "cobol") -> dict[str, dict]: `containers` -- every GET / PUT CONTAINER naming a resolved container: `container`, `channel` (None = the current channel), `direction` (`in` for a GET, `out` for a PUT / MOVE), `record` (the INTO / FROM area) and - its `layout` (None when that area is not found). Facts only: nothing here - is inferred from names. + its `layout` (None when that area is not found). + `parameters` (#3616) -- the PROCEDURE DIVISION USING items in order, each + `position`, `name`, `mode` (REFERENCE / CONTENT / VALUE), `record`, `file` + and `layout` (None when the item is not found): what a CALL passes. Facts + only: nothing here is inferred from names. """ incoming: dict[str, dict[tuple, dict]] = {} for row in self.commarea_contracts(language): @@ -1955,7 +1958,27 @@ def layout_of(ef: EngineFile, operand: Optional[str]) -> Optional[tuple]: "line": op.line, } ) - out[ef.file_path] = {"commarea": commarea, "commarea_gap": gap, "containers": containers} + parameters = [] + entry = next((e for e in ef.entry_points if e.kind == "PROCEDURE"), None) + for position, raw in enumerate(entry.parameters if entry else [], 1): + mode, _, name = raw.rpartition(":") # `CONTENT:X` / `VALUE:X`; reference by default + found = layout_of(ef, name) + parameters.append( + { + "position": position, + "name": name, + "mode": (mode or "REFERENCE").upper(), + "record": found[1] if found else name, + "file": found[0] if found else None, + "layout": found[2] if found else None, + } + ) + out[ef.file_path] = { + "commarea": commarea, + "commarea_gap": gap, + "containers": containers, + "parameters": parameters, + } return out # ---- #3351-#3354: CICS resource joins ------------------------------------ @@ -2140,11 +2163,52 @@ def tdqueue_lineage(self) -> list: """ return self._cics_ops_lineage("QUEUE", "tdqueue_datasets", "queue") + def _program_index(self) -> dict[str, str]: + """PROGRAM-ID (upper-cased) -> the file declaring it: executable sources only + (`_declares_programs`), the first by path when two do. A caller-relative lookup + is `_nearest_program`.""" + out: dict[str, str] = {} + for f in sorted(self.files.values(), key=lambda x: x.file_path): + if _declares_programs(f): + for pid in f.program_ids: + out.setdefault(pid.upper(), f.file_path) + return out + + def _nearest_program(self, name: str, from_file: str) -> Optional[str]: + """The file declaring PROGRAM-ID `name` as seen from `from_file`: the only + declarer, else the one sharing the longest directory prefix with `from_file` + (zOE's COBOL/SAM1 -> COBOL/SAM2, not multiroot/sam/SAM2); None when none + declares it or the nearest are tied.""" + hits = sorted( + f.file_path + for f in self.files.values() + if _declares_programs(f) and name.upper() in {p.upper() for p in f.program_ids} + ) + if len(hits) <= 1: + return hits[0] if hits else None + home = Path(from_file).parent.parts + + def shared(path: str) -> int: + n = 0 + for a, b in zip(home, Path(path).parent.parts): + if a != b: + break + n += 1 + return n + + best = max(shared(h) for h in hits) + nearest = [h for h in hits if shared(h) == best] + return nearest[0] if len(nearest) == 1 else None + def _program_file(self, name: Optional[str]) -> Optional[str]: """The file declaring PROGRAM-ID `name`, when exactly one does.""" if not name: return None - hits = [f.file_path for f in self.files.values() if name.upper() in {p.upper() for p in f.program_ids}] + hits = [ + f.file_path + for f in self.files.values() + if _declares_programs(f) and name.upper() in {p.upper() for p in f.program_ids} + ] return hits[0] if len(hits) == 1 else None def _transaction_file(self, transid: Optional[str]) -> Optional[str]: @@ -3141,7 +3205,6 @@ def dynamic_call_targets(self) -> list: `program`, `resolves_to` (its file, or None), `via` (value | table | moves) -- and `other_sources`: items MOVEd into the operand whose content is not known here (a COMMAREA field such as CDEMO-FROM-PROGRAM: "back to the caller").""" - by_pid = {pid.upper(): f.file_path for f in self.files.values() if f.is_program for pid in f.program_ids} includers: dict = {} for f in self.files.values(): for dep in f.copy_deps: @@ -3184,7 +3247,7 @@ def dynamic_call_targets(self) -> list: "verb": c.verb, "operand": c.operand, "candidates": [ - {"program": p, "resolves_to": by_pid.get(p.upper()), "via": via} + {"program": p, "resolves_to": self._nearest_program(p, f.file_path), "via": via} for p, via in sorted(cands.items()) ], "other_sources": sorted(others), @@ -3676,10 +3739,7 @@ def ims_databases(self) -> dict: def ims_program_psbs(self) -> dict: """Program file -> the PSB names it runs under: a JCL DFSRRC00 region step naming its PROGRAM-ID, and each EXEC DLI SCHD PSB resolved through VALUE.""" - by_pid: dict[str, str] = {} - for f in self.files.values(): - for pid in f.program_ids: - by_pid.setdefault(pid.upper(), f.file_path) + by_pid = self._program_index() out: dict[str, set] = {} for f in self.files.values(): for g in f.ims_gen: @@ -3790,6 +3850,17 @@ def _pic_positions(pic: str) -> Optional[list]: _PICLESS_USAGES = ("COMP-1", "COMPUTATIONAL-1", "COMP-2", "COMPUTATIONAL-2", "POINTER", "INDEX") +# Files whose class_data names are NOT programs: a CSD deck's DEFINE PROGRAM(...) entries, a +# BMS mapset, a JCL job name, a DDL table. Indexing them as declarers made the CSD deck the +# "file" of every program it defines (dynamic_call_targets) and made _program_file see two +# declarers and answer None. +_NON_PROGRAM_LANGUAGES = frozenset({"csd", "bms", "jcl", "db2_sql"}) + + +def _declares_programs(f: "EngineFile") -> bool: + return f.is_program and f.language not in _NON_PROGRAM_LANGUAGES + + # #3498: languages a CALL / LINK can reach that the call resolver does not link to. _CALLEE_LANGUAGES = frozenset({"hlasm", "assembly", "pli", "rexx", "c", "cpp", "java", "easytrieve"}) diff --git a/gitgalaxy/tools/cobol_to_java/cobol_to_java_call_forge.py b/gitgalaxy/tools/cobol_to_java/cobol_to_java_call_forge.py new file mode 100644 index 000000000..391bdcf82 --- /dev/null +++ b/gitgalaxy/tools/cobol_to_java/cobol_to_java_call_forge.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +# ============================================================================== +# GitGalaxy Tool: LINK / XCTL / CALL -> service-to-service calls (#3616) +# +# PURPOSE: +# Turns the program-to-program calls the engine resolved (the verified skeleton, +# #3614) into calls between the generated @Service beans: +# +# - a resolved LINK / XCTL -> link(...) / xctl(...), calling the +# target's handleLink (the #3615 /link contract) +# - a resolved CALL -> call(...), calling the target's +# handleCall(...) -- typed by the target's +# PROCEDURE DIVISION USING parameters +# - a data-driven target -> dispatchL(String program, ...): a +# switch over the candidates the engine found (VALUE, +# table, MOVEd literals); any other name throws +# - a remote (DPL) LINK -> with `integration.remote_calls: http`, a +# RemoteClient (RestTemplate) per CICS region +# the CSD routes to; `local` calls the bean in-process +# +# Targets are injected as ObjectProvider<...>: CICS screens XCTL to each other in +# cycles (menu <-> detail), which plain constructor injection cannot start. +# Every method names the call sites (file:line) and the field-testing status of +# the facts it rests on. A target outside the repository is left to the existing +# mock services; nothing is guessed. +# ============================================================================== +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Any + +from gitgalaxy.tools.cobol_to_java.cobol_to_java_names import java_class_base, java_url_segment +from gitgalaxy.tools.cobol_to_java.cobol_to_java_transaction_forge import ( + DTO_SUBPACKAGE, + CicsForge, + _status, + java_type, +) +from gitgalaxy.tools.cobol_to_java.java_target import JavaTarget + +_LINK_VERBS = ("LINK", "XCTL") +_OBJECT_PROVIDER = "import org.springframework.beans.factory.ObjectProvider;" + + +def _camel(name: str) -> str: + base = java_class_base(name) + return base[0].lower() + base[1:] + + +@dataclass +class Param: + """One USING parameter of a CALLed program, as its Java type.""" + + position: int + name: str + mode: str + jtype: str + note: str = "" + + +@dataclass +class Extras: + imports: set[str] = field(default_factory=set) + fields: dict[str, str] = field(default_factory=dict) # name -> type + methods: list[str] = field(default_factory=list) + + def as_dict(self) -> dict: + return {"imports": sorted(self.imports), "fields": [(t, n) for n, t in self.fields.items()], + "methods": self.methods} # fmt: skip + + +@dataclass +class RemoteClient: + system: str + cls: str + methods: dict[str, list[str]] = field(default_factory=dict) # method name -> lines + imports: set[str] = field(default_factory=set) + + +class CallForge: + """Plans every program's outbound calls, the handlers its callers need, and the remote clients.""" + + def __init__(self, skeletons: dict[str, dict], cics: CicsForge, package: str, + target: JavaTarget | None = None) -> None: # fmt: skip + self.cics = cics + self.package = package + self.target = target or JavaTarget() + self.skeletons = skeletons + self.key_of = {sk["program"]["file"]: key for key, sk in skeletons.items()} + self.cls_of = {key: java_class_base(key) for key in skeletons} + self.extras: dict[str, Extras] = {key: Extras() for key in skeletons} + self.clients: dict[str, RemoteClient] = {} + self._params: dict[str, list[Param]] = {} + self._call_handlers: set[str] = set() + for key in sorted(skeletons): + self._plan(key) + + # ---- the callee side ---------------------------------------------------- + def params(self, key: str) -> list[Param]: + """The Java parameters of a CALLed program's handleCall: its USING items, in order.""" + if key in self._params: + return self._params[key] + sk = self.skeletons[key] + cls = self.cls_of[key] + interface = (sk["sections"].get("interface") or {}).get("facts") or {} + status = _status(sk["sections"].get("interface")) + out: list[Param] = [] + for p in interface.get("parameters", []): + layout = p.get("layout") + fields = (layout or {}).get("fields", []) + if layout and len(fields) == 1 and fields[0].get("name") == p["record"]: + jtype, note = java_type(fields[0]), f"PIC {fields[0].get('pic')}" # an elementary item + elif layout: + doc = self.cics._record_doc(p["record"], p["file"], layout, status) + use = f"USING parameter {p['position']} of {cls}." + jtype, note = self.cics._dto_for(p["record"], p["file"], layout, cls, doc, use), "" + else: + jtype, note = "String", f"TODO: {p['name']} was not found in the DATA DIVISION; carried as text" + out.append(Param(p["position"], p["name"], p["mode"], jtype, note)) + self._params[key] = out + return out + + def _need_call_handler(self, key: str) -> None: + if key in self._call_handlers: + return + self._call_handlers.add(key) + params = self.params(key) + ex = self.extras[key] + ex.imports |= {self._dto_import(p.jtype) for p in params} - {""} + sig = ", ".join(f"{p.jtype} {_camel(p.name)}" for p in params) + using = ", ".join(f"{p.name}" + ("" if p.mode == "REFERENCE" else f" (BY {p.mode})") for p in params) + ex.methods.append(f" /** CALLed by another program{f' USING {using}' if using else ''}. " + "TODO: [AI AGENT] implement from the program's business rules. */") # fmt: skip + for p in params: + if p.note: + ex.methods.append(f" // {p.name}: {p.note}") + ex.methods += [f" public void handleCall({sig}) {{", f' log.info("{self.cls_of[key]}: handleCall");', + " }\n"] # fmt: skip + + def _dto_import(self, jtype: str) -> str: + if jtype in self.cics.dtos: + return f"import {self.package}.{DTO_SUBPACKAGE}.{jtype};" + if jtype == "BigDecimal": + return "import java.math.BigDecimal;" + return "" + + def _link_types(self, key: str) -> tuple[str | None, str | None]: + prog = self.cics.programs.get(key) + return self.cics.link_types(prog) if prog is not None else (None, None) + + def _mismatch(self, passed: str | None, callee: str) -> str | None: + """A note when the record a site passes is not the one the target was resolved to receive.""" + prog = self.cics.programs.get(callee) + commarea = prog.commarea if prog is not None else None + receives = commarea.get("record") if commarea else None + if not passed or not commarea or not receives: + return None + name = passed.split("(")[0].split(" OF ")[0].strip().upper() + if name == receives.upper(): + return None + return ( + f"TODO: this site passes {name}; {self.cls_of[callee].upper()} receives {receives} " + f"({commarea['file']}) -- map one layout onto the other" + ) + + def _has_link(self, key: str) -> bool: + prog = self.cics.programs.get(key) + return prog is not None and self.cics.has_link_handler(prog) + + # ---- the caller side ---------------------------------------------------- + def _inject(self, caller: str, callee: str) -> str: + """The expression reaching callee's service from caller's: `this` for itself.""" + if caller == callee: + return "this" + ex = self.extras[caller] + cls = self.cls_of[callee] + name = cls[0].lower() + cls[1:] + "Service" + ex.fields[name] = f"ObjectProvider<{cls}Service>" + ex.imports.add(_OBJECT_PROVIDER) + return f"{name}.getObject()" + + def _plan(self, key: str) -> None: + sk = self.skeletons[key] + sections = sk["sections"] + path = sk["program"]["file"] + ex = self.extras[key] + status = { + n: _status(sections.get(n)) for n in ("calls", "dynamic_call_targets", "remote_calls", "call_contracts") + } + remote = {r["line"]: r for r in (sections.get("remote_calls") or {}).get("facts", []) if r.get("file") == path} + + static: dict[tuple[str, str], list[dict]] = {} + for c in (sections.get("calls") or {}).get("facts", []): + if c.get("verb") not in (*_LINK_VERBS, "CALL"): + continue + callee = self.key_of.get(c.get("resolves_to") or "") + region = self._region(remote.get(c["line"])) + if region and self.target.integration.remote_calls == "http" and c["verb"] == "LINK": + self._remote(key, c, region, remote[c["line"]], status["remote_calls"]) + continue + if callee is None or c.get("form") == "identifier": + continue # outside the repository (the mock services), or data-driven (the dispatch below) + static.setdefault((c["verb"], callee), []).append(c) + + for (verb, callee), sites in sorted(static.items()): + where = ", ".join(f"{path}:{s['line']}" for s in sites) + written = (sites[0].get("target") or self.cls_of[callee]).upper() # the name as the COBOL writes it + gaps = sorted({g for s in sites if (g := self._mismatch(s.get("commarea"), callee))}) + target = self.cls_of[callee] + ref = self._inject(key, callee) + if verb in _LINK_VERBS: + req, resp = self._link_types(callee) + if not self._has_link(callee): + continue # no handleLink to call: the target is not a CICS program the forge saw + self._imports(ex, req, resp) + note = " XCTL transfers control: nothing after it runs in the caller." if verb == "XCTL" else "" + ex.methods.append(f" /** EXEC CICS {verb} PROGRAM({written}) at {where}.{note}") + ex.methods.append(f" * Call targets field testing: {status['calls']}. */") + ex.methods += [f" // {g}" for g in gaps] + ex.methods += self._delegate(f"{verb.lower()}{target}", ref, "handleLink", req, resp) + else: + self._need_call_handler(callee) + params = self.params(callee) + ex.imports |= {self._dto_import(p.jtype) for p in params} - {""} + sig = ", ".join(f"{p.jtype} {_camel(p.name)}" for p in params) + args = ", ".join(_camel(p.name) for p in params) + ex.methods.append(f" /** CALL '{written}' at {where}; the parameters are {target}'s USING items.") + ex.methods.append(f" * Call targets {status['calls']}; CALL USING {status['call_contracts']}. */") + ex.methods += [ + f" public void call{target}({sig}) {{", + f" {ref}.handleCall({args});", + " }\n", + ] + + for d in (sections.get("dynamic_call_targets") or {}).get("facts", []): + if d.get("file") == path and d.get("verb") in (*_LINK_VERBS, "CALL"): + self._dispatch(key, d, status["dynamic_call_targets"]) + + def _imports(self, ex: Extras, *types: str | None) -> None: + ex.imports |= {self._dto_import(t) for t in types if t} - {""} + + @staticmethod + def _delegate(method: str, ref: str, handler: str, req: str | None, resp: str | None) -> list[str]: + params = f"{req} request" if req else "" + arg = "request" if req else "" + if resp: + return [f" public {resp} {method}({params}) {{", f" return {ref}.{handler}({arg});", " }\n"] + return [f" public void {method}({params}) {{", f" {ref}.{handler}({arg});", " }\n"] + + def _dispatch(self, key: str, d: dict, status: str) -> None: + ex = self.extras[key] + passed = next( + (c.get("commarea") for c in (self.skeletons[key]["sections"].get("calls") or {}).get("facts", []) + if c.get("line") == d["line"] and c.get("verb") == d["verb"]), + None, + ) # fmt: skip + operand = d["operand"].split("(")[0].split(" OF ")[0].strip() + method = f"dispatch{java_class_base(operand)}L{d['line']}" + link = d["verb"] in _LINK_VERBS + where = f"{d['verb']} {'PROGRAM(' + operand + ')' if link else operand} at {d['file']}:{d['line']}" + cands = ", ".join(f"{c['program']} ({c['via']})" for c in d.get("candidates", [])) or "none found" + ex.methods.append(f" /** {where}: the target is data-driven. Candidates: {cands}.") + if d.get("other_sources"): + ex.methods.append(f" * Also MOVEd from {', '.join(d['other_sources'])}, whose content is not known " + "statically: those names reach the default branch.") # fmt: skip + ex.methods.append(f" * Dynamic call targets field testing: {status}. */") + sig = "String program, Object request" if link else "String program, Object... args" + ex.methods.append(f" public Object {method}({sig}) {{") + ex.methods.append(" switch (program.trim().toUpperCase()) {") + for c in d.get("candidates", []): + callee = self.key_of.get(c.get("resolves_to") or "") + label = f' case "{c["program"].upper()}":' + if callee is None: + ex.methods += [label, f' throw new UnsupportedOperationException("{c["program"]} is not ' + 'in this repository");'] # fmt: skip + continue + ref = self._inject(key, callee) + if link: + if not self._has_link(callee): + ex.methods += [label, f' throw new UnsupportedOperationException("{c["program"]} ' + 'has no LINK entry");'] # fmt: skip + continue + req, resp = self._link_types(callee) + self._imports(ex, req, resp) + gap = self._mismatch(passed, callee) + if gap: + label += f"\n // {gap}" + call = f"{ref}.handleLink({f'({req}) request' if req else ''})" + else: + self._need_call_handler(callee) + params = self.params(callee) + ex.imports |= {self._dto_import(p.jtype) for p in params} - {""} + call = f"{ref}.handleCall({', '.join(f'({p.jtype}) args[{i}]' for i, p in enumerate(params))})" + resp = None + if link and resp: + ex.methods += [label, f" return {call};"] + else: + ex.methods += [label, f" {call};", " return null;"] + ex.methods += [" default:", + f' throw new IllegalArgumentException("{where}: no known target " + program);', + " }", " }\n"] # fmt: skip + + # ---- remote regions ----------------------------------------------------- + @staticmethod + def _region(row: dict | None) -> str | None: + """The one CICS region a LINK ships to: its SYSID, else the CSD's REMOTESYSTEM.""" + if not row: + return None + systems = {row["sysid"]} if row.get("sysid") else {r.get("system") for r in row.get("remote", [])} + systems.discard(None) + return systems.pop() if len(systems) == 1 else None + + def _remote(self, key: str, c: dict, region: str, row: dict, status: str) -> None: + cls = java_class_base(region) + "RemoteClient" + client = self.clients.setdefault(region, RemoteClient(region, cls)) + program = (row.get("program") or c.get("target") or "").upper() + callee = self.key_of.get(c.get("resolves_to") or "") + req, resp = self._link_types(callee) if callee else (None, None) + name = f"link{java_class_base(program)}" + if name not in client.methods: + defs = "; ".join( + f"{r.get('defined_in')}:{r.get('line')} group {r.get('group')}" for r in row.get("remote", []) + ) + remote_name = next((r.get("remote_name") for r in row.get("remote", []) if r.get("remote_name")), program) + url = f'baseUrl + "/api/v1/{java_url_segment(remote_name)}/link"' + body = "request" if req else "null" + lines = [f" /** LINK to {remote_name} on {region} (CSD {defs or 'SYSID ' + region}). */"] + if resp: + lines += [f" public {resp} {name}({f'{req} request' if req else ''}) {{", + f" return rest.postForObject({url}, {body}, {resp}.class);", " }\n"] # fmt: skip + else: + lines += [f" public void {name}({f'{req} request' if req else ''}) {{", + f" rest.postForObject({url}, {body}, Void.class);", " }\n"] # fmt: skip + client.methods[name] = lines + client.imports |= {self._dto_import(t) for t in (req, resp) if t} - {""} + ex = self.extras[key] + field_name = cls[0].lower() + cls[1:] + ex.fields[field_name] = cls + ex.imports.add(f"import {self.package}.client.{cls};") + self._imports(ex, req, resp) + ex.methods.append(f" /** EXEC CICS LINK PROGRAM({program}) at {c.get('file', '')}{':' if c.get('file') else ''}" + f"{c.get('line')}: the CSD routes it to region " + f"{region} (distributed program link).") # fmt: skip + ex.methods.append(f" * Remote calls field testing: {status}. */") + ex.methods += self._delegate(f"remote{java_class_base(program)}L{c['line']}", field_name, name, req, resp) + + def client_sources(self) -> dict[str, str]: + """Remote client class name -> Java source (package .client).""" + out = {} + for region, client in sorted(self.clients.items()): + prop = re.sub(r"[^a-z0-9]+", "-", region.lower()).strip("-") or "region" + java = [f"package {self.package}.client;\n", + "import org.springframework.beans.factory.annotation.Value;", + "import org.springframework.stereotype.Component;", + "import org.springframework.web.client.RestTemplate;", + *sorted(client.imports), "", + "/**", + f" * CICS region {region}: the programs the CSD routes there (distributed program link), called", + f" * over HTTP. Point gitgalaxy.remote.{prop}.url at the service that hosts them.", + " */", + "@Component", + f"public class {client.cls} {{\n", + " private final RestTemplate rest = new RestTemplate();", + " private final String baseUrl;\n", + f' public {client.cls}(@Value("${{gitgalaxy.remote.{prop}.url:http://localhost:8080}}") ' + "String baseUrl) {", + " this.baseUrl = baseUrl;", + " }\n"] # fmt: skip + for name in sorted(client.methods): + java += client.methods[name] + java.append("}") + out[client.cls] = "\n".join(java) + return out + + def service_extras(self, key: str) -> dict[str, Any]: + return self.extras[key].as_dict() + + +def merge_extras(*parts: dict | None) -> dict | None: + """One service's extras from the transaction and call forges.""" + present: list[dict] = [p for p in parts if p and (p.get("imports") or p.get("fields") or p.get("methods"))] + if not present: + return None + imports = sorted({i for p in present for i in p.get("imports", [])}) + fields = [f for p in present for f in p.get("fields", [])] + methods = [m for p in present for m in p.get("methods", [])] + return {"imports": imports, "fields": fields, "methods": methods} diff --git a/gitgalaxy/tools/cobol_to_java/cobol_to_java_service_forge.py b/gitgalaxy/tools/cobol_to_java/cobol_to_java_service_forge.py index 9aa471566..bf9b89daf 100644 --- a/gitgalaxy/tools/cobol_to_java/cobol_to_java_service_forge.py +++ b/gitgalaxy/tools/cobol_to_java/cobol_to_java_service_forge.py @@ -36,7 +36,7 @@ def generate_service_skeleton( package_name: str, unit_key: Optional[str] = None, target: Optional[JavaTarget] = None, - extras: Optional[tuple[list[str], list[str]]] = None, + extras: Optional[dict] = None, ) -> str: """Generates the Spring Boot @Service skeleton and stages DAG dependencies. @@ -44,8 +44,10 @@ def generate_service_skeleton( Omitted, the class is named from the IR's own file name as before, which two same-stemmed programs share. - `extras` (#3615): (import lines, method lines) a CICS program's endpoints need, - from the transaction forge; None leaves the service as before. + `extras` (#3615, #3616): what the verified skeleton adds -- `imports` (lines), + `fields` ((type, name) dependencies injected through the constructor) and + `methods` (lines): the handlers a CICS program's endpoints call and the calls it + makes to other programs. None leaves the service as before. """ prog_id = program_key_from_ir(ir_state, unit_key) or "Unknown" camel_prog = java_class_base(prog_id, prefix="Legacy") @@ -56,14 +58,15 @@ def generate_service_skeleton( java = [] java.append(f"package {package_name}.service;\n") - lombok = (target or JavaTarget()).lombok # #3613: the service injects nothing, so plain needs no constructor + lombok = (target or JavaTarget()).lombok java.append("import org.springframework.stereotype.Service;") if lombok: java.append("import lombok.RequiredArgsConstructor;") java.append("import org.slf4j.Logger;") java.append("import org.slf4j.LoggerFactory;") - if extras and extras[0]: - java.extend(extras[0]) + extras = extras or {} + fields = extras.get("fields", []) + java.extend(extras.get("imports", [])) java.append("") java.append("@Service") @@ -72,6 +75,13 @@ def generate_service_skeleton( java.append(f"public class {camel_prog}Service {{\n") java.append(f" private static final Logger log = LoggerFactory.getLogger({camel_prog}Service.class);\n") + if fields: + java.extend(f" private final {jtype} {name};" for jtype, name in fields) + java.append("") + if not lombok: # #3613 plain: the constructor injection Lombok would have generated + java.append(f" public {camel_prog}Service({', '.join(f'{t} {n}' for t, n in fields)}) {{") + java.extend(f" this.{name} = {name};" for _, name in fields) + java.append(" }\n") # ========================================================================== # DEFENSIVE DESIGN (APPLICATION CONTEXT SHIELD): @@ -90,9 +100,9 @@ def generate_service_skeleton( java.append(f" public void execute{camel_prog}(/* Parameters mapped from Controller */) {{") java.append(f' log.info("Executing modernized business logic for {prog_id}");') java.append(" // TODO: [AI AGENT] Implement extracted business rules here.") - if extras and extras[1]: + if extras.get("methods"): java.append(" }\n") - java.extend(extras[1]) + java.extend(extras["methods"]) java.append("}") else: java.append(" }\n}") diff --git a/gitgalaxy/tools/cobol_to_java/cobol_to_java_transaction_forge.py b/gitgalaxy/tools/cobol_to_java/cobol_to_java_transaction_forge.py index 1e234c3f0..867400bca 100644 --- a/gitgalaxy/tools/cobol_to_java/cobol_to_java_transaction_forge.py +++ b/gitgalaxy/tools/cobol_to_java/cobol_to_java_transaction_forge.py @@ -31,7 +31,8 @@ from gitgalaxy.tools.cobol_to_java.cobol_to_java_spring_forge import _java_field_name, render_dto_class from gitgalaxy.tools.cobol_to_java.java_target import JavaTarget -DTO_SUBPACKAGE = "dto.cics" +# The records programs exchange -- COMMAREA, channel containers (#3615), CALL USING parameters (#3616). +DTO_SUBPACKAGE = "dto.contract" # Checked after the `(n)` repeat counts are stripped, so anything but 9 S V P is an editing symbol # (Z , . + - * $ CR DB B / and the insertion 0): a numeric-edited PIC is display text. _EDITED = re.compile(r"[^9SVP]") @@ -121,7 +122,7 @@ class CicsProgram: path: str program_ids: list[str] transactions: list[dict] = field(default_factory=list) # {transid, segment, method, definitions} - links: list[dict] = field(default_factory=list) # incoming LINK / XCTL contract rows + links: list[dict] = field(default_factory=list) # incoming LINK / XCTL sites: {caller, line, verb, via} commarea: dict | None = None commarea_dto: str | None = None commarea_gap: str | None = None @@ -131,9 +132,26 @@ class CicsProgram: status: dict[str, str] = field(default_factory=dict) # section -> field-testing text +def incoming_links(skeleton: dict) -> list[dict]: + """Every LINK / XCTL that reaches this program: a resolved COMMAREA contract, or a + `navigation` row -- which includes the data-driven sites whose candidates name it (#3616).""" + sections = skeleton.get("sections", {}) + path = skeleton["program"]["file"] + rows: dict[tuple, dict] = {} + for r in (sections.get("commarea_contracts") or {}).get("facts", []): + if r.get("callee") == path and r.get("verb") in ("LINK", "XCTL"): + rows.setdefault((r["caller"], r["line"]), {"caller": r["caller"], "line": r["line"], "verb": r["verb"], + "via": "static"}) # fmt: skip + for r in (sections.get("navigation") or {}).get("facts", []): + if r.get("to") == path and r.get("verb") in ("LINK", "XCTL"): + rows.setdefault((r["from"], r["line"]), {"caller": r["from"], "line": r["line"], "verb": r["verb"], + "via": r.get("via")}) # fmt: skip + return [rows[k] for k in sorted(rows)] + + def is_cics_program(skeleton: dict) -> bool: """A program the engine saw CICS evidence for: an entry transaction, an EXEC CICS resource, - a COMMAREA contract or a container.""" + a COMMAREA contract, a container, or a LINK / XCTL reaching it.""" sections = skeleton.get("sections", {}) interface = (sections.get("interface") or {}).get("facts") or {} return bool( @@ -141,6 +159,7 @@ def is_cics_program(skeleton: dict) -> bool: or (sections.get("cics_resources") or {}).get("facts") or (sections.get("commarea_contracts") or {}).get("facts") or interface.get("containers") + or incoming_links(skeleton) ) @@ -156,6 +175,7 @@ def __init__(self, skeletons: dict[str, dict], package: str, target: JavaTarget self.package = package self.target = target or JavaTarget() self.program_files = {sk["program"]["file"] for sk in skeletons.values()} + self._file_cls = {sk["program"]["file"]: java_class_base(key) for key, sk in skeletons.items()} self.dtos: dict[str, Dto] = {} self._by_signature: dict[tuple, str] = {} self.programs = {key: self._plan(key, sk) for key, sk in sorted(skeletons.items()) if is_cics_program(sk)} @@ -170,7 +190,10 @@ def _dto_for(self, record: str, file: str, layout: dict, owner_cls: str, javadoc self.dtos[name].uses.append(use) return name shared = file not in self.program_files and not layout.get("extended") and record.upper() != "DFHCOMMAREA" - name = java_class_base(record) if shared else owner_cls + java_class_base(record) + # A copybook record is named alone; a program's own record after the program declaring it + # (MENU's WS-COMM -> MenuWsComm, whichever program receives it); an extended copy after its owner. + declarer = self._file_cls.get(file) if not layout.get("extended") else None + name = java_class_base(record) if shared else (declarer or owner_cls) + java_class_base(record) base, n = name, 1 while name in self.dtos: n += 1 @@ -213,11 +236,7 @@ def _plan(self, key: str, sk: dict) -> CicsProgram: used.add(seg) prog.transactions.append({"transid": transid, "segment": seg, "definitions": by_transid[transid]}) - prog.links = [ - row - for row in (sections.get("commarea_contracts") or {}).get("facts", []) - if row.get("callee") == path and row.get("verb") in ("LINK", "XCTL") - ] + prog.links = incoming_links(sk) interface = (sections.get("interface") or {}).get("facts") or {} record_status = prog.status.get("interface", "untested") @@ -270,7 +289,7 @@ def _plan(self, key: str, sk: dict) -> CicsProgram: # ---- Java --------------------------------------------------------------- def dto_sources(self) -> dict[str, str]: - """DTO class name -> Java source (package .dto.cics).""" + """DTO class name -> Java source (package .dto.contract).""" return { name: render_dto_class( f"{self.package}.{DTO_SUBPACKAGE}", name, d.body, d.requires_list, self.target, javadoc=d.doc() @@ -284,6 +303,17 @@ def _body(self, prog: CicsProgram) -> tuple[str | None, str | None]: return prog.commarea_dto, prog.commarea_dto return None, None + def link_types(self, prog: CicsProgram) -> tuple[str | None, str | None]: + """(request, response) of the program's handleLink: its COMMAREA, else its channel.""" + req, resp = self._body(prog) + if not req and prog.channel_in: + req, resp = prog.channel_in, prog.channel_out + return req, resp + + @staticmethod + def has_link_handler(prog: CicsProgram) -> bool: + return bool(prog.links or not prog.transactions) + def controller(self, prog: CicsProgram) -> str: t, pkg, cls = self.target, self.package, prog.cls svc = cls[0].lower() + cls[1:] + "Service" @@ -322,9 +352,7 @@ def controller(self, prog: CicsProgram) -> str: if not t.lombok: java += [f" public {cls}Controller({cls}Service {svc}) {{", f" this.{svc} = {svc};", " }\n"] - req, resp = self._body(prog) - if not req and prog.channel_in: - req, resp = prog.channel_in, prog.channel_out + req, resp = self.link_types(prog) for txn in prog.transactions: defs = "; ".join( f"{d.get('defined_in')}:{d.get('line')}" + (f" group {d['group']}" if d.get("group") else "") @@ -336,7 +364,11 @@ def controller(self, prog: CicsProgram) -> str: json.dumps(txn["transid"]), req, resp) # fmt: skip if prog.links or not prog.transactions: if prog.links: - sites = ", ".join(f"{r['verb']} at {r['caller']}:{r['line']}" for r in prog.links) + sites = ", ".join( + f"{r['verb']} at {r['caller']}:{r['line']}" + + ("" if r["via"] == "static" else f" (data-driven, {r['via']})") + for r in prog.links + ) java.append(f" /** Program-to-program entry: {sites}. */") else: java.append(" /** Program-to-program entry: no CSD transaction enters this program. */") @@ -360,11 +392,9 @@ def _endpoint(method: str, svc: str, call: str, arg: str | None, req: str | None return [f" public ResponseEntity {method}({params}) {{", f" {svc}.{call}({args});", " return ResponseEntity.noContent().build();", " }\n"] # fmt: skip - def service_extras(self, prog: CicsProgram) -> tuple[list[str], list[str]]: - """(imports, methods) the program's @Service gains: the handlers its endpoints call.""" - req, resp = self._body(prog) - if not req and prog.channel_in: - req, resp = prog.channel_in, prog.channel_out + def service_extras(self, prog: CicsProgram) -> dict: + """The imports and methods the program's @Service gains: the handlers its endpoints call.""" + req, resp = self.link_types(prog) names = {n for n in (req, resp, prog.channel_in, prog.channel_out) if n} imports = [f"import {self.package}.{DTO_SUBPACKAGE}.{n};" for n in sorted(names)] methods: list[str] = [] @@ -382,11 +412,11 @@ def handler(name: str, first: str | None, rq: str | None, rs: str | None, what: if prog.transactions: handler("handleTransaction", "String transid", req, resp, "A CICS transaction entered the program.") - if prog.links or not prog.transactions: + if self.has_link_handler(prog): handler("handleLink", None, req, resp, "Another program LINKed / XCTLed to this one.") if (prog.channel_in or prog.channel_out) and (req, resp) != (prog.channel_in, prog.channel_out): handler("handleChannel", None, prog.channel_in, prog.channel_out, "The program's channel.") - return imports, methods + return {"imports": imports, "fields": [], "methods": methods} def load_skeletons(skeleton_dir: Path) -> dict[str, dict[str, Any]]: diff --git a/gitgalaxy/tools/cobol_to_java/java_target.py b/gitgalaxy/tools/cobol_to_java/java_target.py index 40d07d239..6ea32f719 100644 --- a/gitgalaxy/tools/cobol_to_java/java_target.py +++ b/gitgalaxy/tools/cobol_to_java/java_target.py @@ -31,6 +31,7 @@ DTO_STYLES = ("class", "record") # a transient record (DFHCOMMAREA): a class, or a Java record DATABASES = ("postgresql", "db2", "oracle", "mysql", "h2") DDL_AUTO = ("none", "validate", "update", "create", "create-drop") +REMOTE_CALLS = ("http", "local") # a DPL LINK to another region: an HTTP client, or the in-process bean # Per database: (Maven groupId, artifactId) of the JDBC driver -- versions come from the # Spring Boot BOM -- the driver class, the Hibernate dialect, and a JDBC URL template. @@ -96,6 +97,11 @@ class Features: agent_tickets: bool = True # bounded AI-agent tickets for the business logic +@dataclass +class Integration: + remote_calls: str = "http" # #3616: a LINK the CSD routes to another region (REMOTESYSTEM / SYSID) + + @dataclass class JavaTarget: project: Project = field(default_factory=Project) @@ -103,6 +109,7 @@ class JavaTarget: spring_boot: SpringBoot = field(default_factory=SpringBoot) database: Database = field(default_factory=Database) features: Features = field(default_factory=Features) + integration: Integration = field(default_factory=Integration) @property def lombok(self) -> bool: @@ -115,7 +122,14 @@ def driver(self) -> tuple[str, str, str, str, str]: return DATABASE_DRIVERS[self.database.engine] -_SECTIONS = {"project": Project, "java": Java, "spring_boot": SpringBoot, "database": Database, "features": Features} +_SECTIONS = { + "project": Project, + "java": Java, + "spring_boot": SpringBoot, + "database": Database, + "features": Features, + "integration": Integration, +} def _check(target: JavaTarget) -> None: @@ -133,6 +147,7 @@ def _check(target: JavaTarget) -> None: ("java.dto_style", j.dto_style, DTO_STYLES), ("database.engine", d.engine, DATABASES), ("database.ddl_auto", d.ddl_auto, DDL_AUTO), + ("integration.remote_calls", target.integration.remote_calls, REMOTE_CALLS), ): if value not in allowed: raise ConfigError(f"{key} {value!r} is not supported; choose one of {', '.join(map(str, allowed))}") @@ -228,4 +243,8 @@ def load_target(path: Path | None) -> JavaTarget: ebcdic_decoder: true # the EBCDIC / COMP-3 decoder utility mock_services: true # mock services for unresolved external calls agent_tickets: true # bounded AI-agent tickets for the business logic + +integration: + remote_calls: http # {" | ".join(REMOTE_CALLS)} (a LINK the CSD routes to another region: + # http = a RestTemplate client per region, local = call the bean in-process) """ # noqa: S608 -- a YAML template: "update | create" are ddl-auto values, not SQL diff --git a/tests/cobol_mainframe/field_testing.json b/tests/cobol_mainframe/field_testing.json index 9e4f69204..cb842ab41 100644 --- a/tests/cobol_mainframe/field_testing.json +++ b/tests/cobol_mainframe/field_testing.json @@ -382,6 +382,20 @@ "fixed_by": "#3582", "summary": "A PL/I CICS operand continued past a columns 73-80 sequence field read the sequence number as its value (9 of 3,251 DSF operations lost a name)." }, + { + "id": "D022", + "estate": "aws-mainframe-modernization-carddemo", + "side": "engine", + "fields": [ + "dynamic call targets" + ], + "language": "cobol", + "severity": "attribute", + "found_by": "Java generator (#3616)", + "issue": 3616, + "fixed_by": "#3640", + "summary": "dynamic_call_targets and _program_file indexed CSD / BMS / JCL / DDL class_data names as program declarers: 88 of 89 CardDemo candidates resolved to the CSD deck instead of the COBOL source (the keyed candidate names were right)." + }, { "id": "B001", "estate": "zecs", diff --git a/tests/cobol_mainframe/test_cics_endpoints.py b/tests/cobol_mainframe/test_cics_endpoints.py index 774b9846f..c1527bd28 100644 --- a/tests/cobol_mainframe/test_cics_endpoints.py +++ b/tests/cobol_mainframe/test_cics_endpoints.py @@ -183,7 +183,7 @@ def test_a_transaction_becomes_an_endpoint_and_a_link_target_takes_the_commarea( acct = (src / "controller/AcctinqController.java").read_text(encoding="utf-8") assert '@PostMapping("/link")' in acct and "LINK at cbl/MENU.cbl:8" in acct assert "public ResponseEntity link(@RequestBody AcctCommarea request)" in acct - dto = (src / "dto/cics/AcctCommarea.java").read_text(encoding="utf-8") + dto = (src / "dto/contract/AcctCommarea.java").read_text(encoding="utf-8") assert "// CA-ACCT-ID: PIC 9(11), offset 0, 11 bytes (cpy/ACCTCOM.cpy)" in dto assert "private Long caAcctId;" in dto and "private BigDecimal caBalance;" in dto assert "private String caName;" in dto and "filler" not in dto.lower().split("*/")[1] @@ -193,7 +193,7 @@ def test_a_transaction_becomes_an_endpoint_and_a_link_target_takes_the_commarea( chan = (src / "controller/ChanpgmController.java").read_text(encoding="utf-8") assert "ResponseEntity transactionCHAN(@RequestBody ChanpgmChannelIn request)" in chan - chan_in = (src / "dto/cics/ChanpgmChannelIn.java").read_text(encoding="utf-8") + chan_in = (src / "dto/contract/ChanpgmChannelIn.java").read_text(encoding="utf-8") assert "private ChanpgmWsReq reqData;" in chan_in and "CONTAINER(REQ.DATA)" in chan_in assert not (src / "controller/BatchController.java").exists() # batch: the generic path, unchanged @@ -204,7 +204,7 @@ def test_a_transaction_becomes_an_endpoint_and_a_link_target_takes_the_commarea( def test_the_target_style_reaches_the_cics_dtos(scanned, tmp_path): _, src = _java(scanned, tmp_path, {"java": {"data_classes": "plain", "dto_style": "record"}}) - dto = (src / "dto/cics/AcctCommarea.java").read_text(encoding="utf-8") + dto = (src / "dto/contract/AcctCommarea.java").read_text(encoding="utf-8") assert "public record AcctCommarea(" in dto and "lombok" not in dto acct = (src / "controller/AcctinqController.java").read_text(encoding="utf-8") assert "public AcctinqController(AcctinqService acctinqService)" in acct diff --git a/tests/cobol_mainframe/test_service_calls.py b/tests/cobol_mainframe/test_service_calls.py new file mode 100644 index 000000000..f5fb6e996 --- /dev/null +++ b/tests/cobol_mainframe/test_service_calls.py @@ -0,0 +1,205 @@ +"""#3616: LINK / XCTL / CALL -> service-to-service calls, plus the engine fix it found. + +A real galaxyscope scan of a small estate backs the tests: +- MENU LINKs to ACCTINQ (static), LINKs to AUDIT (which the CSD routes to region AOR1), + CALLs SUBPGM USING two items, and XCTLs to a program name MOVEd from literals. +- The CSD deck also DEFINEs every program, which used to make the deck the "file" of each + program in dynamic_call_targets. +- Two SUBPGMs in different directories exercise the nearest-declarer rule. + +Compiling the generated Java is the compile matrix's job (java_target_matrix.py --scan). +""" + +import json +import shutil +from unittest.mock import patch + +import pytest + +import gitgalaxy.cobol_refractor_controller as refractor +from gitgalaxy.tools.cobol_to_cobol.galaxy_ir import load_galaxy_ir, scan_to_db +from gitgalaxy.tools.cobol_to_java.java_target import ConfigError, target_from_dict + +MENU = """\ + IDENTIFICATION DIVISION. + PROGRAM-ID. MENU. + DATA DIVISION. + WORKING-STORAGE SECTION. + 01 WS-COMM. + 05 WS-ACCT-ID PIC 9(11). + 01 WS-A PIC X(8). + 01 WS-B. + 05 WS-B1 PIC 9(4). + 05 WS-B2 PIC X(10). + 01 WS-NEXT PIC X(8). + 01 WS-OPT PIC 9. + PROCEDURE DIVISION. + 000-MAIN. + EXEC CICS LINK PROGRAM('ACCTINQ') COMMAREA(WS-COMM) END-EXEC. + EXEC CICS LINK PROGRAM('AUDIT') COMMAREA(WS-COMM) END-EXEC. + CALL 'SUBPGM' USING WS-A WS-B. + IF WS-OPT = 1 + MOVE 'ACCTINQ' TO WS-NEXT + ELSE + MOVE 'ACCTUPD' TO WS-NEXT + END-IF. + EXEC CICS XCTL PROGRAM(WS-NEXT) COMMAREA(WS-COMM) END-EXEC. + EXEC CICS RETURN END-EXEC. +""" + + +def _callee(name: str) -> str: + return f"""\ + IDENTIFICATION DIVISION. + PROGRAM-ID. {name}. + DATA DIVISION. + LINKAGE SECTION. + 01 DFHCOMMAREA. + 05 LK-ACCT-ID PIC 9(11). + PROCEDURE DIVISION. + 000-MAIN. + EXEC CICS RETURN END-EXEC. +""" + + +SUBPGM = """\ + IDENTIFICATION DIVISION. + PROGRAM-ID. SUBPGM. + DATA DIVISION. + LINKAGE SECTION. + 01 LK-A PIC X(8). + 01 LK-B. + 05 LK-B1 PIC 9(4). + 05 LK-B2 PIC X(10). + PROCEDURE DIVISION USING LK-A LK-B. + 000-MAIN. + GOBACK. +""" + +CSD = """\ + DEFINE TRANSACTION(MENU) GROUP(APP) + PROGRAM(MENU) + DEFINE PROGRAM(MENU) GROUP(APP) + DEFINE PROGRAM(ACCTINQ) GROUP(APP) + DEFINE PROGRAM(ACCTUPD) GROUP(APP) + DEFINE PROGRAM(AUDIT) GROUP(APP) + REMOTESYSTEM(AOR1) +""" + + +@pytest.fixture(scope="module") +def scanned(tmp_path_factory): + base = tmp_path_factory.mktemp("service_calls") + repo = base / "estate" + files = { + "cbl/MENU.cbl": MENU, + "cbl/ACCTINQ.cbl": _callee("ACCTINQ"), + "cbl/ACCTUPD.cbl": _callee("ACCTUPD"), + "cbl/AUDIT.cbl": _callee("AUDIT"), + "cbl/SUBPGM.cbl": SUBPGM, + "other/deep/SUBPGM.cbl": SUBPGM, + "csd/APP.csd": CSD, + } + for rel, text in files.items(): + (repo / rel).parent.mkdir(parents=True, exist_ok=True) + (repo / rel).write_text(text, encoding="utf-8") + return repo, scan_to_db(repo, base / "scan") + + +# ---- the engine fix --------------------------------------------------------------------------- +def test_a_csd_deck_is_not_the_file_of_the_programs_it_defines(scanned): + _, db = scanned + ir = load_galaxy_ir(db) + assert set(ir.files["csd/APP.csd"].program_ids) >= {"ACCTINQ", "ACCTUPD"} # the deck names them ... + (site,) = [d for d in ir.dynamic_call_targets() if d["file"] == "cbl/MENU.cbl"] + assert [(c["program"], c["resolves_to"]) for c in site["candidates"]] == [ + ("ACCTINQ", "cbl/ACCTINQ.cbl"), # ... but the COBOL source declares them + ("ACCTUPD", "cbl/ACCTUPD.cbl"), + ] + assert ir._program_file("ACCTINQ") == "cbl/ACCTINQ.cbl" # one declarer, not two + + +def test_two_declarers_resolve_to_the_nearest_or_to_none(scanned): + _, db = scanned + ir = load_galaxy_ir(db) + assert ir._nearest_program("SUBPGM", "cbl/MENU.cbl") == "cbl/SUBPGM.cbl" + assert ir._nearest_program("SUBPGM", "other/deep/X.cbl") == "other/deep/SUBPGM.cbl" + assert ir._nearest_program("SUBPGM", "third/X.cbl") is None # tied: not guessed + assert ir._nearest_program("NOSUCH", "cbl/MENU.cbl") is None + + +def test_program_interfaces_carry_the_using_parameters(scanned): + _, db = scanned + params = load_galaxy_ir(db).program_interfaces()["cbl/SUBPGM.cbl"]["parameters"] + assert [(p["position"], p["name"], p["mode"], p["layout"]["bytes"]) for p in params] == [ + (1, "LK-A", "REFERENCE", 8), + (2, "LK-B", "REFERENCE", 14), + ] + + +# ---- the Java --------------------------------------------------------------------------------- +def _java(scanned, tmp_path, config=None): + from gitgalaxy import cobol_to_java_controller + + repo, db = scanned + work = tmp_path / "estate" + shutil.copytree(repo, work) + with patch("sys.argv", ["refract", str(work), "--galaxy-db", str(db)]): + refractor.main() + (clean,) = tmp_path.glob("estate_gitgalaxy_clean_*") + argv = ["cobol-to-java", str(clean), "--header", str(tmp_path / "none.txt")] + if config: + cfg = tmp_path / "t.json" + cfg.write_text(json.dumps(config), encoding="utf-8") + argv += ["--config", str(cfg)] + with patch("sys.argv", argv): + cobol_to_java_controller.main() + (java,) = tmp_path.glob("estate_gitgalaxy_java_spring_*") + return java, java / "src/main/java/com/gitgalaxy/modernized" + + +def test_calls_become_service_calls(scanned, tmp_path): + java, src = _java(scanned, tmp_path) + menu = (src / "service/MenuService.java").read_text(encoding="utf-8") + # a static LINK: the target's handleLink, through a cycle-safe ObjectProvider + assert "private final ObjectProvider acctinqService;" in menu + # ACCTINQ receives what MENU passes (WS-COMM, named after MENU, which declares it): no mapping needed + assert "public MenuWsComm linkAcctinq(MenuWsComm request) {" in menu + assert "return acctinqService.getObject().handleLink(request);" in menu + assert "/** EXEC CICS LINK PROGRAM(ACCTINQ) at cbl/MENU.cbl:" in menu + # a CALL: typed by the callee's USING items (an elementary item maps to its Java type). Two + # SUBPGMs exist; the engine resolved this one to cbl/SUBPGM.cbl, the clean room keys it cbl__SUBPGM + assert "/** CALL 'SUBPGM' at cbl/MENU.cbl:" in menu + assert "public void callCblSubpgm(String lkA, CblSubpgmLkB lkB) {" in menu + assert "cblSubpgmService.getObject().handleCall(lkA, lkB);" in menu + sub = (src / "service/CblSubpgmService.java").read_text(encoding="utf-8") + assert "public void handleCall(String lkA, CblSubpgmLkB lkB) {" in sub + # the data-driven XCTL: a switch over the candidates, anything else refused + assert "public Object dispatchWsNextL" in menu + assert 'case "ACCTUPD":' in menu and "acctupdService.getObject().handleLink((AcctupdDfhcommarea) request)" in menu + # ACCTUPD is reached only through the data-driven XCTL, so it was resolved to its own DFHCOMMAREA: + # the record this site passes differs, and the case says so rather than hiding it + assert "TODO: this site passes WS-COMM; ACCTUPD receives DFHCOMMAREA (cbl/ACCTUPD.cbl)" in menu + assert "no known target" in menu + # the remote DPL LINK: a client for region AOR1 + assert "private final Aor1RemoteClient aor1RemoteClient;" in menu + assert "return aor1RemoteClient.linkAudit(request);" in menu + client = (src / "client/Aor1RemoteClient.java").read_text(encoding="utf-8") + assert '@Value("${gitgalaxy.remote.aor1.url:http://localhost:8080}")' in client + assert 'rest.postForObject(baseUrl + "/api/v1/audit/link", request, MenuWsComm.class)' in client + audit = (java / "java_migration_audit.txt").read_text(encoding="utf-8") + assert "Service calls (#3616) : 1 LINK, 0 XCTL, 1 CALL, 1 data-driven dispatch, 1 remote; 1 remote" in audit + + +def test_local_remote_calls_and_plain_constructors(scanned, tmp_path): + _, src = _java(scanned, tmp_path, {"integration": {"remote_calls": "local"}, "java": {"data_classes": "plain"}}) + menu = (src / "service/MenuService.java").read_text(encoding="utf-8") + assert not (src / "client").exists() + assert "return auditService.getObject().handleLink(request);" in menu # in-process + assert "public MenuService(ObjectProvider cblSubpgmService, " in menu # plain: explicit ctor + assert "this.acctinqService = acctinqService;" in menu + + +def test_the_remote_calls_setting_is_validated(): + with pytest.raises(ConfigError, match="integration.remote_calls"): + target_from_dict({"integration": {"remote_calls": "grpc"}})