Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .claude/skills/cobol-modernization/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`/`xctl<T>`, calling the target's `handleLink`.
- A resolved CALL becomes `call<T>(...)`, typed by the target's USING items (`program_interfaces().parameters`).
- A data-driven site becomes `dispatch<Operand>L<line>`, a switch over the candidates.
- A remote DPL LINK (`integration.remote_calls: http`) becomes a `<Region>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)
Expand Down
3 changes: 2 additions & 1 deletion docs/language_status/cics_field_testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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. |
Expand Down
57 changes: 42 additions & 15 deletions gitgalaxy/cobol_to_java_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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():
Expand All @@ -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")


Expand Down Expand Up @@ -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"
Expand All @@ -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
)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
93 changes: 82 additions & 11 deletions gitgalaxy/tools/cobol_to_cobol/galaxy_ir.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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 ------------------------------------
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"})

Expand Down
Loading
Loading