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
7 changes: 3 additions & 4 deletions .claude/skills/cobol-modernization/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ Attribute every difference to one of the differential doc's four causes: old-par
- **`edge_data` holds COPY/INCLUDE edges only.** It has no CALL, `EXEC PGM=` or SELECT/OPEN edges (#3200, #3201).
- **The engine stores OS-native path separators.** `galaxy_ir` normalises them to `/` on load. Read the DB through it (`load_galaxy_ir`, then `lookup(file, root)`), not raw `sqlite3`, or Windows scans won't join.
- **The forge's reachability pass and the answer key's `draft` share one control-flow model** (#3219). The forge's 680/680 and 62/62 on CBSA are therefore not independent evidence. A model defect would be invisible, so hand-check the source when a change touches reachability.
- **Program keys:** outputs are keyed by stem when unique, else `a__b__STEM` (#3218). `cobol-to-java` still keys by file name (#3221).
- **Program keys:** outputs are keyed by stem when unique, else `a__b__STEM` (#3218), and the Java forges
name every class from that key (#3221) via `cobol_to_java/cobol_to_java_names.py`. Never re-derive a
generated name from `metadata.file_name` or a schema `title` -- neither is unique across a repository.
- **Determinism:**
- Sort every set you serialise and every `glob`/`rglob` you iterate.
- Sort by `p.parts` or `p.name`, never bare `Path`, because WindowsPath sorts case-insensitively (#3223 went red on Windows over exactly this).
Expand All @@ -79,14 +81,11 @@ Attribute every difference to one of the differential doc's four causes: old-par

| issue | side | gap | unblocks |
|---|---|---|---|
| #3197 | engine | Area-B continuation lines ending in `.` become paragraphs (the 26 extra CBSA units) | clean unit inventory |
| #3198 | engine | `usage_status`: entry flagged, case-sensitive, `NAME-EXIT` counts as a reference to `NAME` | dead-code switch (#3120) |
| #3199 | engine | resolver drops ambiguous COPY targets (78/114 CBSA copybooks) | copybook switch |
| #3200 | engine | no CALL / CICS LINK / XCTL / `EXEC PGM=` edges | lineage switch (#3120) |
| #3201 | engine | no named SELECT/ASSIGN, OPEN modes or DD bindings | lineage switch (#3120) |
| #3202 | engine | `calls_out_to` meaningless for COBOL | — |
| #3211 | tooling | differential deltas carry no cause code; no unexplained-count gate | gating engine changes |
| #3221 | forge | `cobol-to-java` collapses same-name programs | — |
| #3222 | forge | 3 quadratic regexes (baselined) | — |
| #3121 | CI | generated Spring Boot is never compiled | "generates" = "compiles" |

Expand Down
41 changes: 41 additions & 0 deletions docs/refraction_engine_differential.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,3 +250,44 @@ reachability pass, which the answer key scores at 62/62 · 10/10 non-trivial on
CBSA. A future engine-side reachability signal would be a new field beside
`usage_status`, not a redefinition of it, and would need its own issue and
evidence.

## Update: Java names come from the clean-room key (#3221)

The Java half of #3218. The clean room had already been keyed per path, but the
forges under `gitgalaxy/tools/cobol_to_java/` re-derived their names downstream:
a service and a controller from the IR's `metadata.file_name`, an entity from
the schema's `title`. Neither is unique across a repository, so generation was
not one output file per input file — the later file in filename order simply
overwrote the earlier, with no warning.

`cobol_to_java_names.py` now derives every generated name, and only, from the
key (`output_key` → `java_class_base` / `java_url_segment`). A key that is a
plain stem produces exactly the name the old code produced, so only a genuinely
ambiguous program is renamed.

| corpus | IR dumps | services (was) | schemas | entities (was) |
|---|---|---|---|---|
| zopeneditor-sample | 5 | 6 (3) | 5 | **5** (2) |
| cics-banking-sample-application-cbsa | 31 | 31 (31) | 29 | **29** (3) |
| aws-mainframe-modernization-carddemo | 39 | 44 (44) | 36 | **36** (13) |

Service counts include mock services for unresolved CALLs, which is why they can
exceed the IR count. Entities now match schemas exactly on all three corpora; 54
programs' data layouts were being discarded, 26 of them on CBSA alone, because
every CICS program titles its record `DFHCOMMAREA`.

Two consequences worth naming:

- **The `@Table` name is unchanged.** Only the Java class is disambiguated; the
COBOL 01-level it maps keeps its own name. CBSA therefore generates 26 entity
classes that all declare `@Table(name = "DFHCOMMAREA")`. That is a faithful
report of the source — they really are 26 different layouts of one CICS
communication area — and deciding what table each should map to is a semantic
question, tracked separately, not a naming one.
- **An ambiguous CALL target now resolves to a mock.** zopeneditor's two SAM1
programs both `CALL SAM2`, and there are two SAM2 programs. Before, the class
named `Sam2Service` happened to be whichever real SAM2 was written last, so the
call silently bound to one of them. The real services are now
`CobolSam2Service` and `MultirootSamSam2Service`, and `Sam2Service` is the
generated mock for the unresolved call. The ambiguity was always there; it is
now visible in the output instead of resolved by file-write order.
48 changes: 22 additions & 26 deletions gitgalaxy/cobol_to_java_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,17 @@
from gitgalaxy.tools.cobol_to_java.cobol_to_java_decoder_forge import (
generate_decoder_util,
)
from gitgalaxy.tools.cobol_to_java.cobol_to_java_names import (
java_class_base,
output_key,
)
from gitgalaxy.tools.cobol_to_java.cobol_to_java_service_forge import (
generate_service_skeleton,
)

# Current Imports
from gitgalaxy.tools.cobol_to_java.cobol_to_java_spring_forge import (
entity_class_name,
generate_java_entity,
)

Expand Down Expand Up @@ -200,24 +205,13 @@ def main():
for schema_file in sorted(schema_dir.glob("*_schema.json"), key=lambda p: p.name):
try:
schema = json.loads(schema_file.read_text(encoding="utf-8"))
java_code = generate_java_entity(schema, args.pkg)
unit_key = output_key(schema_file, "_schema")
java_code = generate_java_entity(schema, args.pkg, unit_key=unit_key)
if java_header:
java_code = java_header + java_code
class_name = "".join(word.capitalize() for word in schema.get("title", "Entity").split("_"))

# Apply the exact same reserved word sanitization to the file name
reserved_classes = {
"Entity",
"Class",
"System",
"Object",
"String",
"Enum",
"Record",
"Thread",
}
if class_name in reserved_classes:
class_name = "Legacy" + class_name
# #3221: named from the schema's own clean-room key, so two
# programs that both declare a DFHCOMMAREA get two classes.
class_name = entity_class_name(schema, unit_key)

out_path = java_dirs["entity"] / f"{class_name}.java"
out_path.write_text(java_code, encoding="utf-8")
Expand All @@ -232,22 +226,22 @@ def main():
for ir_file in sorted(ir_dir.glob("*_ir.json"), key=lambda p: p.name):
try:
ir_state = json.loads(ir_file.read_text(encoding="utf-8"))
raw_prog_id = ir_state.get("metadata", {}).get("file_name", "Unknown").split(".")[0]
# #3221: the clean room already disambiguated same-stemmed programs
# into COBOL__SAM2 / multiroot__sam__SAM2 (#3218). Name the Java
# from that key, not from metadata.file_name, which is SAM2.cbl for
# both and made the later one overwrite the earlier.
raw_prog_id = output_key(ir_file, "_ir")

# 🛡️ Prevent collision with Spring Boot's @Service annotation AND handle empty names
if not raw_prog_id or raw_prog_id.strip() == "":
raw_prog_id = "legacy"
elif raw_prog_id.lower() == "service":
raw_prog_id = "legacy-service"

# ⚠️ CRITICAL: Inject the safe raw name back into IR State so the generators process it correctly
ir_state.setdefault("metadata", {})["file_name"] = raw_prog_id + ".cbl"

# Ensure file names are perfectly camel-cased with no hyphens for this controller
safe_file_name = "".join(word.capitalize() for word in raw_prog_id.split("-"))
safe_file_name = java_class_base(raw_prog_id)

# 3A. Generate the @Service Skeleton
service_code = generate_service_skeleton(ir_state, args.pkg)
service_code = generate_service_skeleton(ir_state, args.pkg, unit_key=raw_prog_id)
if java_header:
service_code = java_header + service_code
out_path_svc = java_dirs["service"] / f"{safe_file_name}Service.java"
Expand All @@ -257,7 +251,7 @@ def main():
# 3B. Generate the @RestController
lineage = ir_state.get("analysis", {}).get("lineage", {})
if lineage and (lineage.get("inputs") or lineage.get("outputs") or lineage.get("unresolved_calls")):
java_code = generate_rest_controller(ir_state, args.pkg)
java_code = generate_rest_controller(ir_state, args.pkg, unit_key=raw_prog_id)
if java_header:
java_code = java_header + java_code
out_path_ctrl = java_dirs["controller"] / f"{safe_file_name}Controller.java"
Expand All @@ -272,7 +266,7 @@ def main():
if not sub or not sub.strip():
continue

safe_sub_name = "".join(word.capitalize() for word in sub.replace("-", "_").split("_"))
safe_sub_name = java_class_base(sub, prefix="")

# If it stripped down to nothing, skip it to prevent writing "Service.java"
if not safe_sub_name:
Expand All @@ -296,7 +290,9 @@ def main():
for slice_file in sorted(slice_dir.glob("*_slice.json"), key=lambda p: p.name):
try:
slice_data = json.loads(slice_file.read_text(encoding="utf-8"))
prog_id = slice_file.name.split("_")[0]
# #3221: `name.split("_")[0]` returned COBOL for COBOL__SAM2_slice.json,
# so the IR never resolved and two programs shared one job file.
prog_id = output_key(slice_file, "_slice")
ir_file = ir_dir / f"{prog_id}_ir.json"
ir_state = json.loads(ir_file.read_text(encoding="utf-8")) if ir_file.exists() else None

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,25 @@
import json
import sys
from pathlib import Path
from typing import Optional

from gitgalaxy.tools.cobol_to_java.cobol_to_java_names import (
java_class_base,
java_url_segment,
output_key,
program_key_from_ir,
)

def generate_rest_controller(ir_state: dict, package_name: str) -> str:
"""Generates the API endpoints and auto-wires the Service layer."""
prog_id = ir_state.get("metadata", {}).get("file_name", "Unknown").split(".")[0]
camel_prog = "".join(word.capitalize() for word in prog_id.split("-"))

def generate_rest_controller(ir_state: dict, package_name: str, unit_key: Optional[str] = None) -> str:
"""Generates the API endpoints and auto-wires the Service layer.

`unit_key` is the clean-room output key this IR was written under (#3221);
it names the class AND the `@RequestMapping` path, because two programs that
share a stem must not share a URL either.
"""
prog_id = program_key_from_ir(ir_state, unit_key) or "Unknown"
camel_prog = java_class_base(prog_id, prefix="Legacy")
service_var = camel_prog[0].lower() + camel_prog[1:] if camel_prog else "unknown"

analysis = ir_state.get("analysis", {})
Expand All @@ -50,7 +63,7 @@ def generate_rest_controller(ir_state: dict, package_name: str) -> str:
java.append("")

java.append("@RestController")
java.append(f'@RequestMapping("/api/v1/{prog_id.lower()}")')
java.append(f'@RequestMapping("/api/v1/{java_url_segment(prog_id)}")')
java.append("@RequiredArgsConstructor")
java.append(f"public class {camel_prog}Controller {{\n")

Expand Down Expand Up @@ -149,9 +162,9 @@ def main():

try:
ir_state = json.loads(ir_path.read_text(encoding="utf-8"))
java_code = generate_rest_controller(ir_state, args.pkg)
prog_id = ir_state.get("metadata", {}).get("file_name", "Unknown").split(".")[0].capitalize()
out_path = ir_path.parent / f"{prog_id}Controller.java"
unit_key = output_key(ir_path, "_ir")
java_code = generate_rest_controller(ir_state, args.pkg, unit_key=unit_key)
out_path = ir_path.parent / f"{java_class_base(unit_key)}Controller.java"
out_path.write_text(java_code, encoding="utf-8")
print(f"🌐 API Contract Generated: {out_path.name}")
except Exception as e:
Expand Down
86 changes: 86 additions & 0 deletions gitgalaxy/tools/cobol_to_java/cobol_to_java_names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
# ==============================================================================
# GitGalaxy Tool: Clean-Room Key -> Java Name
#
# PURPOSE:
# One place to turn a clean-room output key into the Java identifiers generated
# from it, so every forge names the same program the same way.
#
# ARCHITECTURAL DECISION (#3221):
# The clean room keys its outputs per path: the program's stem when that stem is
# unique in the repository, else the path under the target flattened with `__`
# (`multiroot__sam__SAM2`) -- see `_output_keys` in cobol_refractor_controller.py
# (#3218). The Java forges used to re-derive their class names from the IR's
# `metadata.file_name` (or a schema's `title`) instead, which throws that
# disambiguation away: two programs named SAM2.cbl both produced Sam2Service.java
# and the later one in filename order silently overwrote the earlier.
#
# Deriving the name from the key -- and only from the key -- restores the
# clean room's one-output-file-per-input-file invariant on the Java side. A key
# that is just a unique stem produces exactly the name the old code produced, so
# only a genuinely colliding program is renamed.
# ==============================================================================
import re
from pathlib import Path
from typing import Optional

# `COBOL__SAM2` -> ["COBOL", "SAM2"]; `WS-CUST_REC` -> ["WS", "CUST", "REC"].
_SEPARATORS = re.compile(r"[^A-Za-z0-9]+")

# A Java class name that would shadow something every generated file imports.
RESERVED_CLASSES = frozenset(
{
"Entity",
"Class",
"System",
"Object",
"String",
"Enum",
"Record",
"Thread",
}
)


def output_key(path: Path, suffix: str) -> str:
"""The clean-room key a generated artifact was written under.

`suffix` is the artifact's trailing tag, e.g. `_ir` for `COBOL__SAM2_ir.json`
or `_slice` for `COBOL__SAM2_slice.json`. Splitting on `_` does not work: a
flattened key contains `__`, so `name.split("_")[0]` returns `COBOL`.
"""
stem = path.stem
return stem[: -len(suffix)] if suffix and stem.endswith(suffix) else stem


def java_class_base(key: str, prefix: str = "Legacy") -> str:
"""`COBOL__SAM2` -> `CobolSam2`; `SAM1LIB` -> `Sam1lib`; `BNK1CAC` -> `Bnk1cac`.

A key that is a plain stem yields exactly what `"".join(w.capitalize() ...)`
over the old program id yielded, which is why non-colliding programs keep
their current class names.
"""
name = "".join(word.capitalize() for word in _SEPARATORS.split(key) if word)
if not name or name[0].isdigit() or name in RESERVED_CLASSES:
# An empty, digit-leading or shadowing name is not a legal or safe class
# name; `prefix` is what the entity forge already used for the last case.
name = prefix + name
return name


def java_url_segment(key: str) -> str:
"""The `@RequestMapping` path for a program: `COBOL__SAM2` -> `cobol-sam2`.

Two programs that share a stem must not share a URL either -- Spring refuses
to start on an ambiguous mapping.
"""
return "-".join(word.lower() for word in _SEPARATORS.split(key) if word) or "legacy"


def program_key_from_ir(ir_state: dict, explicit: Optional[str] = None) -> str:
"""`explicit` (the caller's clean-room key) when given, else the IR's own file
name. The fallback keeps the single-file CLIs and older callers working."""
if explicit:
return explicit
raw = ir_state.get("metadata", {}).get("file_name", "") or ""
return raw.split(".")[0]
26 changes: 19 additions & 7 deletions gitgalaxy/tools/cobol_to_java/cobol_to_java_service_forge.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,24 @@
import json
import sys
from pathlib import Path
from typing import Optional

from gitgalaxy.tools.cobol_to_java.cobol_to_java_names import (
java_class_base,
output_key,
program_key_from_ir,
)

def generate_service_skeleton(ir_state: dict, package_name: str) -> str:
"""Generates the Spring Boot @Service skeleton and stages DAG dependencies."""
prog_id = ir_state.get("metadata", {}).get("file_name", "Unknown").split(".")[0]
camel_prog = "".join(word.capitalize() for word in prog_id.split("-"))

def generate_service_skeleton(ir_state: dict, package_name: str, unit_key: Optional[str] = None) -> str:
"""Generates the Spring Boot @Service skeleton and stages DAG dependencies.

`unit_key` is the clean-room output key this IR was written under (#3221).
Omitted, the class is named from the IR's own file name as before, which two
same-stemmed programs share.
"""
prog_id = program_key_from_ir(ir_state, unit_key) or "Unknown"
camel_prog = java_class_base(prog_id, prefix="Legacy")

analysis = ir_state.get("analysis", {})
lineage = analysis.get("lineage", {})
Expand Down Expand Up @@ -83,9 +95,9 @@ def main():

try:
ir_state = json.loads(ir_path.read_text(encoding="utf-8"))
java_code = generate_service_skeleton(ir_state, args.pkg)
prog_id = ir_state.get("metadata", {}).get("file_name", "Unknown").split(".")[0].capitalize()
out_path = ir_path.parent / f"{prog_id}Service.java"
unit_key = output_key(ir_path, "_ir")
java_code = generate_service_skeleton(ir_state, args.pkg, unit_key=unit_key)
out_path = ir_path.parent / f"{java_class_base(unit_key)}Service.java"
out_path.write_text(java_code, encoding="utf-8")
print(f"⚙️ Service Skeleton Generated: {out_path.name}")
except Exception as e:
Expand Down
Loading
Loading