mainframe-modernization-toolkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mainframe_modernization_toolkit/__init__.py +3 -0
- mainframe_modernization_toolkit/__main__.py +5 -0
- mainframe_modernization_toolkit/application.py +142 -0
- mainframe_modernization_toolkit/cli/__init__.py +1 -0
- mainframe_modernization_toolkit/cli/business_rule_extractor.py +263 -0
- mainframe_modernization_toolkit/cli/characterization_test_scaffolder.py +180 -0
- mainframe_modernization_toolkit/cli/cobol_to_python_skeleton.py +305 -0
- mainframe_modernization_toolkit/cli/copybook_to_contract.py +93 -0
- mainframe_modernization_toolkit/cli/copybook_to_dataclass.py +330 -0
- mainframe_modernization_toolkit/cli/dead_code_finder.py +152 -0
- mainframe_modernization_toolkit/cli/dependency_graph.py +174 -0
- mainframe_modernization_toolkit/cli/generate_copybook_fixtures.py +116 -0
- mainframe_modernization_toolkit/cli/generate_file_readers.py +406 -0
- mainframe_modernization_toolkit/cli/generate_program_capsule.py +945 -0
- mainframe_modernization_toolkit/cli/impact_analysis.py +178 -0
- mainframe_modernization_toolkit/cli/ir_to_pyspark.py +495 -0
- mainframe_modernization_toolkit/cli/jcl_flow_extractor.py +166 -0
- mainframe_modernization_toolkit/cli/migration_complexity_report.py +154 -0
- mainframe_modernization_toolkit/cli/migration_preflight.py +833 -0
- mainframe_modernization_toolkit/cli/sql_extractor.py +230 -0
- mainframe_modernization_toolkit/cli/validate_relational_ir.py +48 -0
- mainframe_modernization_toolkit/common/__init__.py +14 -0
- mainframe_modernization_toolkit/common/cli_context.py +205 -0
- mainframe_modernization_toolkit/common/cobol_parser.py +1199 -0
- mainframe_modernization_toolkit/common/comp_decode.py +173 -0
- mainframe_modernization_toolkit/common/file_reader_codegen.py +748 -0
- mainframe_modernization_toolkit/common/file_resolution.py +286 -0
- mainframe_modernization_toolkit/common/jcl_parser.py +281 -0
- mainframe_modernization_toolkit/common/migration_config.py +944 -0
- mainframe_modernization_toolkit/common/naming.py +24 -0
- mainframe_modernization_toolkit/common/pic_types.py +268 -0
- mainframe_modernization_toolkit/common/record_contract.py +698 -0
- mainframe_modernization_toolkit/common/record_layout.py +249 -0
- mainframe_modernization_toolkit/common/relational_ir.py +1733 -0
- mainframe_modernization_toolkit/common/synthetic_records.py +413 -0
- mainframe_modernization_toolkit/common/warnings_model.py +167 -0
- mainframe_modernization_toolkit/common/workspace_index.py +511 -0
- mainframe_modernization_toolkit/registry.py +48 -0
- mainframe_modernization_toolkit/resources/customizations/agents/mainframe-jcl-migrator.agent.md +47 -0
- mainframe_modernization_toolkit/resources/customizations/skills/mainframe-jcl-migration/SKILL.md +212 -0
- mainframe_modernization_toolkit/resources/customizations/skills/mainframe-jcl-migration/references/migration-checklist.md +193 -0
- mainframe_modernization_toolkit/resources/customizations/skills/mainframe-jcl-migration/references/tool-catalog.md +54 -0
- mainframe_modernization_toolkit/resources/mainframe-migration.json +31 -0
- mainframe_modernization_toolkit/resources/mainframe-migration.schema.json +214 -0
- mainframe_modernization_toolkit/resources/relational-ir.schema.json +504 -0
- mainframe_modernization_toolkit/resources/vsix/mainframe-migration-toolkit-0.1.0.vsix +0 -0
- mainframe_modernization_toolkit/resources/vsix/manifest.json +8 -0
- mainframe_modernization_toolkit/resources.py +100 -0
- mainframe_modernization_toolkit-0.1.0.dist-info/METADATA +311 -0
- mainframe_modernization_toolkit-0.1.0.dist-info/RECORD +54 -0
- mainframe_modernization_toolkit-0.1.0.dist-info/WHEEL +5 -0
- mainframe_modernization_toolkit-0.1.0.dist-info/entry_points.txt +19 -0
- mainframe_modernization_toolkit-0.1.0.dist-info/licenses/LICENSE +175 -0
- mainframe_modernization_toolkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Umbrella command for the mainframe modernization toolkit."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .cli.migration_preflight import run_preflight
|
|
13
|
+
from .registry import TOOL_MODULES, run_tool
|
|
14
|
+
from .resources import export_vsix, resource_root, verify_vsix
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
WORKSPACE_RESOURCES = (
|
|
18
|
+
("mainframe-migration.json", "mainframe-migration.json"),
|
|
19
|
+
("mainframe-migration.schema.json", "mainframe-migration.schema.json"),
|
|
20
|
+
("relational-ir.schema.json", "relational-ir.schema.json"),
|
|
21
|
+
("customizations/skills/mainframe-jcl-migration/SKILL.md", ".github/skills/mainframe-jcl-migration/SKILL.md"),
|
|
22
|
+
("customizations/skills/mainframe-jcl-migration/references/migration-checklist.md", ".github/skills/mainframe-jcl-migration/references/migration-checklist.md"),
|
|
23
|
+
("customizations/skills/mainframe-jcl-migration/references/tool-catalog.md", ".github/skills/mainframe-jcl-migration/references/tool-catalog.md"),
|
|
24
|
+
("customizations/agents/mainframe-jcl-migrator.agent.md", ".github/agents/mainframe-jcl-migrator.agent.md"),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def initialize_workspace(root: str | Path, *, force: bool, dry_run: bool) -> dict[str, list[str]]:
|
|
29
|
+
target_root = Path(root).expanduser().resolve()
|
|
30
|
+
if target_root.exists() and not target_root.is_dir():
|
|
31
|
+
raise ValueError(f"Workspace root is not a directory: {target_root}")
|
|
32
|
+
copied: list[str] = []
|
|
33
|
+
skipped: list[str] = []
|
|
34
|
+
resources = resource_root()
|
|
35
|
+
for source_name, destination_name in WORKSPACE_RESOURCES:
|
|
36
|
+
destination = target_root / destination_name
|
|
37
|
+
if destination.exists() and not force:
|
|
38
|
+
skipped.append(destination_name)
|
|
39
|
+
continue
|
|
40
|
+
copied.append(destination_name)
|
|
41
|
+
if not dry_run:
|
|
42
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
destination.write_bytes(resources.joinpath(*source_name.split("/")).read_bytes())
|
|
44
|
+
return {"copied": copied, "skipped": skipped}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def doctor(workspace: str | Path | None) -> dict[str, Any]:
|
|
48
|
+
checks: list[dict[str, Any]] = []
|
|
49
|
+
checks.append({
|
|
50
|
+
"name": "python",
|
|
51
|
+
"ok": sys.version_info >= (3, 10),
|
|
52
|
+
"detail": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
|
|
53
|
+
})
|
|
54
|
+
missing = [name for name, _ in WORKSPACE_RESOURCES if not resource_root().joinpath(*name.split("/")).is_file()]
|
|
55
|
+
checks.append({"name": "package_resources", "ok": not missing, "detail": missing or "available"})
|
|
56
|
+
try:
|
|
57
|
+
vsix_result = verify_vsix()
|
|
58
|
+
except (FileNotFoundError, KeyError, OSError, json.JSONDecodeError) as error:
|
|
59
|
+
vsix_result = {"ok": False, "errors": [str(error)]}
|
|
60
|
+
checks.append({"name": "embedded_vsix", "ok": vsix_result["ok"], "detail": vsix_result.get("errors") or "verified"})
|
|
61
|
+
code_path = shutil.which("code")
|
|
62
|
+
checks.append({"name": "vscode_cli", "ok": code_path is not None, "required": False, "detail": code_path or "not found"})
|
|
63
|
+
|
|
64
|
+
if workspace is not None:
|
|
65
|
+
root = Path(workspace).expanduser().resolve()
|
|
66
|
+
config = root / "mainframe-migration.json"
|
|
67
|
+
if not config.is_file():
|
|
68
|
+
checks.append({"name": "workspace_preflight", "ok": False, "detail": f"missing {config}"})
|
|
69
|
+
else:
|
|
70
|
+
report = run_preflight(root)
|
|
71
|
+
checks.append({
|
|
72
|
+
"name": "workspace_preflight",
|
|
73
|
+
"ok": not report.blocked,
|
|
74
|
+
"detail": {"blocked": report.blocked, "findings": len(report.findings)},
|
|
75
|
+
})
|
|
76
|
+
required_checks = [check for check in checks if check.get("required", True)]
|
|
77
|
+
return {"ok": all(check["ok"] for check in required_checks), "checks": checks}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
81
|
+
parser = argparse.ArgumentParser(prog="mainframe-toolkit")
|
|
82
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
83
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
84
|
+
|
|
85
|
+
run_parser = commands.add_parser("run", help="Run one packaged migration tool")
|
|
86
|
+
run_parser.add_argument("tool", choices=tuple(TOOL_MODULES))
|
|
87
|
+
run_parser.add_argument("arguments", nargs=argparse.REMAINDER)
|
|
88
|
+
|
|
89
|
+
vsix_parser = commands.add_parser("vsix", help="Export or verify the bundled VSIX")
|
|
90
|
+
vsix_commands = vsix_parser.add_subparsers(dest="vsix_command", required=True)
|
|
91
|
+
export_parser = vsix_commands.add_parser("export")
|
|
92
|
+
export_parser.add_argument("--output", required=True)
|
|
93
|
+
verify_parser = vsix_commands.add_parser("verify")
|
|
94
|
+
verify_parser.add_argument("path", nargs="?")
|
|
95
|
+
|
|
96
|
+
doctor_parser = commands.add_parser("doctor", help="Check package and workspace readiness")
|
|
97
|
+
doctor_parser.add_argument("--workspace")
|
|
98
|
+
doctor_parser.add_argument("--format", choices=("json", "text"), default="text")
|
|
99
|
+
|
|
100
|
+
workspace_parser = commands.add_parser("workspace", help="Manage workspace templates")
|
|
101
|
+
workspace_commands = workspace_parser.add_subparsers(dest="workspace_command", required=True)
|
|
102
|
+
init_parser = workspace_commands.add_parser("init")
|
|
103
|
+
init_parser.add_argument("root")
|
|
104
|
+
init_parser.add_argument("--force", action="store_true")
|
|
105
|
+
init_parser.add_argument("--dry-run", action="store_true")
|
|
106
|
+
return parser
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main(argv: list[str] | None = None) -> int:
|
|
110
|
+
args = build_parser().parse_args(argv)
|
|
111
|
+
if args.command == "run":
|
|
112
|
+
tool_arguments = args.arguments[1:] if args.arguments[:1] == ["--"] else args.arguments
|
|
113
|
+
return run_tool(args.tool, tool_arguments)
|
|
114
|
+
if args.command == "vsix":
|
|
115
|
+
if args.vsix_command == "export":
|
|
116
|
+
print(export_vsix(args.output))
|
|
117
|
+
return 0
|
|
118
|
+
result = verify_vsix(args.path)
|
|
119
|
+
print(json.dumps(result, indent=2, ensure_ascii=True))
|
|
120
|
+
return 0 if result["ok"] else 1
|
|
121
|
+
if args.command == "doctor":
|
|
122
|
+
result = doctor(args.workspace)
|
|
123
|
+
if args.format == "json":
|
|
124
|
+
print(json.dumps(result, indent=2, ensure_ascii=True))
|
|
125
|
+
else:
|
|
126
|
+
for check in result["checks"]:
|
|
127
|
+
status = "OK" if check["ok"] else "WARN" if not check.get("required", True) else "FAIL"
|
|
128
|
+
print(f"{status:4} {check['name']}: {check['detail']}")
|
|
129
|
+
return 0 if result["ok"] else 1
|
|
130
|
+
if args.command == "workspace" and args.workspace_command == "init":
|
|
131
|
+
try:
|
|
132
|
+
result = initialize_workspace(args.root, force=args.force, dry_run=args.dry_run)
|
|
133
|
+
except (OSError, ValueError) as error:
|
|
134
|
+
print(f"workspace init failed: {error}", file=sys.stderr)
|
|
135
|
+
return 1
|
|
136
|
+
action = "Would copy" if args.dry_run else "Copied"
|
|
137
|
+
for path in result["copied"]:
|
|
138
|
+
print(f"{action}: {path}")
|
|
139
|
+
for path in result["skipped"]:
|
|
140
|
+
print(f"Skipped existing: {path}")
|
|
141
|
+
return 0
|
|
142
|
+
return 2
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Console command implementations."""
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""business_rule_extractor.py - Mine IF/EVALUATE decision logic out of a
|
|
3
|
+
COBOL program into a structured, human- and agent-readable rule list.
|
|
4
|
+
|
|
5
|
+
Why this matters for migration
|
|
6
|
+
-------------------------------
|
|
7
|
+
The hardest part of any COBOL->Python rewrite isn't syntax translation,
|
|
8
|
+
it's *rule harvesting*: recovering the tangle of decades-old IF/EVALUATE
|
|
9
|
+
conditions that encode the actual business logic before anyone can safely
|
|
10
|
+
reimplement it. Big modernization vendors (Micro Focus, IBM, Advanced)
|
|
11
|
+
sell "business rule extraction" as a discrete phase that happens *before*
|
|
12
|
+
code generation - see docs/RESEARCH.md. This script gives an AI agent a
|
|
13
|
+
deterministic first pass at that phase: every IF/WHEN condition, its
|
|
14
|
+
consequence, and the paragraph/COBOL comment context around it, so a human
|
|
15
|
+
or a follow-up LLM step can confirm/refine the rule in plain English
|
|
16
|
+
instead of re-deriving it from raw COBOL.
|
|
17
|
+
|
|
18
|
+
What it does
|
|
19
|
+
------------
|
|
20
|
+
For a single COBOL file:
|
|
21
|
+
1. Splits into paragraphs (using PROCEDURE DIVISION paragraph headers).
|
|
22
|
+
2. Within each paragraph, extracts top-level IF/ELSE/END-IF and
|
|
23
|
+
EVALUATE/WHEN/END-EVALUATE constructs with their condition text.
|
|
24
|
+
3. Attaches any COBOL comment lines immediately preceding the construct as
|
|
25
|
+
"documented_intent" (many legacy shops *did* write down the "why").
|
|
26
|
+
4. Emits a structured rule list ready for a Python `if`/dict-dispatch
|
|
27
|
+
rewrite, or for a business analyst to review.
|
|
28
|
+
|
|
29
|
+
Edge cases (never silently mis-extracted)
|
|
30
|
+
--------------------------------------------
|
|
31
|
+
An IF/EVALUATE block that never reaches its matching END-IF/END-EVALUATE
|
|
32
|
+
before end of file (e.g. a missing terminator, or nesting deeper than
|
|
33
|
+
this line-scanner tracks) is still emitted as a rule, but marked
|
|
34
|
+
`"truncated": true` with a `review_note` explaining that its
|
|
35
|
+
condition/branches may be incomplete - never silently merged into the
|
|
36
|
+
next rule or dropped.
|
|
37
|
+
|
|
38
|
+
Usage
|
|
39
|
+
-----
|
|
40
|
+
python3 business_rule_extractor.py <file.cbl> [--format json|markdown]
|
|
41
|
+
"""
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import argparse
|
|
45
|
+
import json
|
|
46
|
+
import re
|
|
47
|
+
import sys
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
|
|
50
|
+
from ..common.cli_context import add_config_argument, single_file_context_from_args # noqa: E402
|
|
51
|
+
from ..common.cobol_parser import strip_source_line # noqa: E402
|
|
52
|
+
from ..common.warnings_model import DEFAULT_TODO_PREFIX, render_todo # noqa: E402
|
|
53
|
+
|
|
54
|
+
RE_PARAGRAPH_HEADER = re.compile(r"^\s{0,10}([A-Z0-9][\w-]*)\.\s*$", re.IGNORECASE)
|
|
55
|
+
RE_IF = re.compile(r"^\s*IF\s+(.+?)(?:\s+THEN)?\s*$", re.IGNORECASE)
|
|
56
|
+
RE_ELSE = re.compile(r"^\s*ELSE\b", re.IGNORECASE)
|
|
57
|
+
RE_END_IF = re.compile(r"^\s*END-IF\b", re.IGNORECASE)
|
|
58
|
+
RE_EVALUATE = re.compile(r"^\s*EVALUATE\s+(.+)$", re.IGNORECASE)
|
|
59
|
+
RE_WHEN = re.compile(r"^\s*WHEN\s+(.+)$", re.IGNORECASE)
|
|
60
|
+
RE_END_EVALUATE = re.compile(r"^\s*END-EVALUATE\b", re.IGNORECASE)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_comment(raw_line: str) -> bool:
|
|
64
|
+
stripped = raw_line.lstrip()
|
|
65
|
+
return stripped.startswith("*") and not stripped.startswith("*>")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def comment_text(raw_line: str) -> str:
|
|
69
|
+
stripped = raw_line.lstrip()
|
|
70
|
+
return stripped.lstrip("*").strip()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def extract_rules(text: str) -> list[dict]:
|
|
74
|
+
lines = text.splitlines()
|
|
75
|
+
rules: list[dict] = []
|
|
76
|
+
current_paragraph = "(no paragraph / top level)"
|
|
77
|
+
pending_comments: list[str] = []
|
|
78
|
+
|
|
79
|
+
i = 0
|
|
80
|
+
while i < len(lines):
|
|
81
|
+
raw = lines[i]
|
|
82
|
+
i += 1
|
|
83
|
+
if is_comment(raw):
|
|
84
|
+
pending_comments.append(comment_text(raw))
|
|
85
|
+
continue
|
|
86
|
+
code = strip_source_line(raw)
|
|
87
|
+
if not code.strip():
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
para_m = RE_PARAGRAPH_HEADER.match(code)
|
|
91
|
+
if para_m and not any(
|
|
92
|
+
kw in code.upper() for kw in ("END-IF", "END-EVALUATE", "END-PERFORM")
|
|
93
|
+
):
|
|
94
|
+
current_paragraph = para_m.group(1).upper()
|
|
95
|
+
pending_comments = []
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
if_m = RE_IF.match(code)
|
|
99
|
+
if if_m:
|
|
100
|
+
branch_lines = [code.strip()]
|
|
101
|
+
depth = 1
|
|
102
|
+
has_else = False
|
|
103
|
+
start_line = i
|
|
104
|
+
while i < len(lines) and depth > 0:
|
|
105
|
+
nxt_raw = lines[i]
|
|
106
|
+
i += 1
|
|
107
|
+
if is_comment(nxt_raw):
|
|
108
|
+
continue
|
|
109
|
+
nxt = strip_source_line(nxt_raw)
|
|
110
|
+
if not nxt.strip():
|
|
111
|
+
continue
|
|
112
|
+
if RE_IF.match(nxt):
|
|
113
|
+
depth += 1
|
|
114
|
+
elif RE_END_IF.match(nxt):
|
|
115
|
+
depth -= 1
|
|
116
|
+
if depth == 0:
|
|
117
|
+
break
|
|
118
|
+
elif RE_ELSE.match(nxt) and depth == 1:
|
|
119
|
+
has_else = True
|
|
120
|
+
branch_lines.append(nxt.strip())
|
|
121
|
+
|
|
122
|
+
truncated = depth > 0
|
|
123
|
+
rule: dict = {
|
|
124
|
+
"kind": "IF",
|
|
125
|
+
"paragraph": current_paragraph,
|
|
126
|
+
"condition": if_m.group(1).strip().rstrip("."),
|
|
127
|
+
"has_else": has_else,
|
|
128
|
+
"documented_intent": " ".join(pending_comments) if pending_comments else None,
|
|
129
|
+
"raw_snippet": "\n".join(branch_lines[:12]),
|
|
130
|
+
"truncated": truncated,
|
|
131
|
+
}
|
|
132
|
+
if truncated:
|
|
133
|
+
rule["review_note"] = (
|
|
134
|
+
f"This IF block starting at line {start_line} never reached a matching "
|
|
135
|
+
"END-IF before end of file (or before this scanner's nesting tracking gave "
|
|
136
|
+
"up) - the condition/branches captured above may be incomplete. Verify "
|
|
137
|
+
"manually against the source before trusting this rule."
|
|
138
|
+
)
|
|
139
|
+
rules.append(rule)
|
|
140
|
+
pending_comments = []
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
eval_m = RE_EVALUATE.match(code)
|
|
144
|
+
if eval_m:
|
|
145
|
+
subject = eval_m.group(1).strip()
|
|
146
|
+
whens: list[str] = []
|
|
147
|
+
depth = 1
|
|
148
|
+
start_line = i
|
|
149
|
+
snippet_lines = [code.strip()]
|
|
150
|
+
while i < len(lines) and depth > 0:
|
|
151
|
+
nxt_raw = lines[i]
|
|
152
|
+
i += 1
|
|
153
|
+
if is_comment(nxt_raw):
|
|
154
|
+
continue
|
|
155
|
+
nxt = strip_source_line(nxt_raw)
|
|
156
|
+
if not nxt.strip():
|
|
157
|
+
continue
|
|
158
|
+
if RE_EVALUATE.match(nxt):
|
|
159
|
+
depth += 1
|
|
160
|
+
elif RE_END_EVALUATE.match(nxt):
|
|
161
|
+
depth -= 1
|
|
162
|
+
if depth == 0:
|
|
163
|
+
snippet_lines.append(nxt.strip())
|
|
164
|
+
break
|
|
165
|
+
when_m = RE_WHEN.match(nxt)
|
|
166
|
+
if when_m and depth == 1:
|
|
167
|
+
whens.append(when_m.group(1).strip())
|
|
168
|
+
snippet_lines.append(nxt.strip())
|
|
169
|
+
|
|
170
|
+
truncated = depth > 0
|
|
171
|
+
rule = {
|
|
172
|
+
"kind": "EVALUATE",
|
|
173
|
+
"paragraph": current_paragraph,
|
|
174
|
+
"subject": subject,
|
|
175
|
+
"when_clauses": whens,
|
|
176
|
+
"documented_intent": " ".join(pending_comments) if pending_comments else None,
|
|
177
|
+
"raw_snippet": "\n".join(snippet_lines[:20]),
|
|
178
|
+
"truncated": truncated,
|
|
179
|
+
}
|
|
180
|
+
if truncated:
|
|
181
|
+
rule["review_note"] = (
|
|
182
|
+
f"This EVALUATE block starting at line {start_line} never reached a matching "
|
|
183
|
+
"END-EVALUATE before end of file - the WHEN clauses captured above may be "
|
|
184
|
+
"incomplete. Verify manually against the source before trusting this rule."
|
|
185
|
+
)
|
|
186
|
+
rules.append(rule)
|
|
187
|
+
pending_comments = []
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
pending_comments = []
|
|
191
|
+
|
|
192
|
+
return rules
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def add_rule_todos(
|
|
196
|
+
rules: list[dict], todo_prefix: str = DEFAULT_TODO_PREFIX
|
|
197
|
+
) -> None:
|
|
198
|
+
for rule in rules:
|
|
199
|
+
if not rule.get("truncated"):
|
|
200
|
+
continue
|
|
201
|
+
code = f"TRUNCATED_{rule['kind']}_BLOCK"
|
|
202
|
+
rule["todo"] = render_todo(
|
|
203
|
+
code,
|
|
204
|
+
rule["review_note"],
|
|
205
|
+
"Verify the complete source control-flow block before implementing this rule.",
|
|
206
|
+
comment_prefix="",
|
|
207
|
+
todo_prefix=todo_prefix,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def render_markdown(
|
|
212
|
+
rules: list[dict],
|
|
213
|
+
file_label: str,
|
|
214
|
+
todo_prefix: str = DEFAULT_TODO_PREFIX,
|
|
215
|
+
) -> str:
|
|
216
|
+
add_rule_todos(rules, todo_prefix)
|
|
217
|
+
lines = [f"# Business rules extracted from `{file_label}`", ""]
|
|
218
|
+
for idx, rule in enumerate(rules, start=1):
|
|
219
|
+
title = f"## Rule {idx} - {rule['kind']} in paragraph `{rule['paragraph']}`"
|
|
220
|
+
if rule.get("truncated"):
|
|
221
|
+
title += " :warning: TRUNCATED - MANUAL REVIEW REQUIRED"
|
|
222
|
+
lines.append(title)
|
|
223
|
+
if rule.get("todo"):
|
|
224
|
+
lines.append(f"> {rule['todo']}")
|
|
225
|
+
if rule.get("documented_intent"):
|
|
226
|
+
lines.append(f"> Original comment: {rule['documented_intent']}")
|
|
227
|
+
if rule["kind"] == "IF":
|
|
228
|
+
lines.append(f"- Condition: `{rule['condition']}`")
|
|
229
|
+
lines.append(f"- Has ELSE branch: {rule['has_else']}")
|
|
230
|
+
else:
|
|
231
|
+
lines.append(f"- Subject: `{rule['subject']}`")
|
|
232
|
+
for w in rule["when_clauses"]:
|
|
233
|
+
lines.append(f" - WHEN `{w}`")
|
|
234
|
+
lines.append("")
|
|
235
|
+
lines.append("```cobol")
|
|
236
|
+
lines.append(rule["raw_snippet"])
|
|
237
|
+
lines.append("```")
|
|
238
|
+
lines.append("")
|
|
239
|
+
return "\n".join(lines)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def main() -> int:
|
|
243
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
244
|
+
parser.add_argument("file", help="Path to a single COBOL source file")
|
|
245
|
+
parser.add_argument("--format", choices=["json", "markdown"], default="markdown")
|
|
246
|
+
add_config_argument(parser)
|
|
247
|
+
args = parser.parse_args()
|
|
248
|
+
|
|
249
|
+
context = single_file_context_from_args(parser, args, "cobol")
|
|
250
|
+
path = context.path
|
|
251
|
+
text = path.read_text(encoding=context.source_encoding, errors="replace")
|
|
252
|
+
rules = extract_rules(text)
|
|
253
|
+
add_rule_todos(rules, context.todo_prefix)
|
|
254
|
+
|
|
255
|
+
if args.format == "json":
|
|
256
|
+
print(json.dumps(rules, indent=2))
|
|
257
|
+
else:
|
|
258
|
+
print(render_markdown(rules, str(path), context.todo_prefix))
|
|
259
|
+
return 0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
if __name__ == "__main__":
|
|
263
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""characterization_test_scaffolder.py - Generate a "golden master" /
|
|
3
|
+
characterization-test scaffold for a COBOL program, so its Python port can
|
|
4
|
+
be verified against the original's observed behavior before cutover.
|
|
5
|
+
|
|
6
|
+
Why this matters for migration
|
|
7
|
+
-------------------------------
|
|
8
|
+
"Golden master testing" (a.k.a. characterization testing) is the single
|
|
9
|
+
most-cited safety practice for legacy rewrites (Michael Feathers'
|
|
10
|
+
"Working Effectively with Legacy Code"; adopted at scale by IBM, Micro
|
|
11
|
+
Focus, and every serious COBOL migration factory - see
|
|
12
|
+
docs/RESEARCH.md). The idea: capture many real input/output pairs from
|
|
13
|
+
the *existing* system, then run the same inputs through the newly-written
|
|
14
|
+
Python code and diff the outputs byte-for-byte. This tool doesn't run the
|
|
15
|
+
mainframe (out of scope/impossible here) - it scaffolds the *pytest*
|
|
16
|
+
harness plus a fixtures directory convention, driven off:
|
|
17
|
+
- the program's LINKAGE SECTION (its CALL/USING contract), and
|
|
18
|
+
- any known sample data files placed under test-fixtures/golden/<PROGRAM>/
|
|
19
|
+
|
|
20
|
+
so an agent (or engineer) only needs to drop in captured
|
|
21
|
+
input/output pairs to get a working regression suite immediately.
|
|
22
|
+
|
|
23
|
+
Usage
|
|
24
|
+
-----
|
|
25
|
+
python3 characterization_test_scaffolder.py <file.cbl> [--out FILE]
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from ..common.cli_context import add_config_argument, single_file_context_from_args # noqa: E402
|
|
34
|
+
from ..common.cobol_parser import parse_cobol_source # noqa: E402
|
|
35
|
+
from ..common.warnings_model import DEFAULT_TODO_PREFIX, render_todo # noqa: E402
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def to_snake(name: str) -> str:
|
|
39
|
+
"""Program names may start with a digit; prefix so the generated
|
|
40
|
+
Python module reference stays a valid identifier."""
|
|
41
|
+
snake = name.lower().replace("-", "_")
|
|
42
|
+
if snake and snake[0].isdigit():
|
|
43
|
+
snake = f"p_{snake}"
|
|
44
|
+
return snake
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def render_pytest_scaffold(
|
|
48
|
+
program, todo_prefix: str = DEFAULT_TODO_PREFIX
|
|
49
|
+
) -> str:
|
|
50
|
+
name = program.program_id or "UNKNOWN"
|
|
51
|
+
module = to_snake(name)
|
|
52
|
+
linkage = program.linkage_items or ["<no LINKAGE SECTION found - review manually>"]
|
|
53
|
+
|
|
54
|
+
lines = [
|
|
55
|
+
f'"""Golden-master characterization tests for {name}.',
|
|
56
|
+
"",
|
|
57
|
+
"How to use this scaffold:",
|
|
58
|
+
f"1. Capture real (or representative synthetic) input/output pairs from",
|
|
59
|
+
f" the existing mainframe run of {name} - e.g. dump the LINKAGE SECTION",
|
|
60
|
+
" record before/after a batch run - and drop them as JSON files under:",
|
|
61
|
+
f" test-fixtures/golden/{name}/case_<n>.json",
|
|
62
|
+
' each shaped as {"input": {...}, "expected_output": {...}}.',
|
|
63
|
+
"2. Implement / import the migrated Python equivalent below where",
|
|
64
|
+
" marked TODO.",
|
|
65
|
+
"3. Run: pytest this file. Every golden case is parametrized",
|
|
66
|
+
" automatically - no code changes needed as you add more fixtures.",
|
|
67
|
+
"",
|
|
68
|
+
f"LINKAGE SECTION contract observed in the COBOL source: {linkage}",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
if program.warnings:
|
|
72
|
+
lines.append("")
|
|
73
|
+
lines.append("Migration TODOs from the deterministic parser:")
|
|
74
|
+
for w in program.warnings:
|
|
75
|
+
lines.append(
|
|
76
|
+
" " + render_todo(
|
|
77
|
+
w.code,
|
|
78
|
+
w.message,
|
|
79
|
+
w.suggested_action,
|
|
80
|
+
comment_prefix="",
|
|
81
|
+
todo_prefix=todo_prefix,
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
if program.program_id is None:
|
|
85
|
+
lines.append(
|
|
86
|
+
" NOTE: no PROGRAM-ID was found, so 'UNKNOWN' is used as a placeholder "
|
|
87
|
+
"throughout this file - fix the source and regenerate before relying on this."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
lines += [
|
|
91
|
+
'"""',
|
|
92
|
+
"from __future__ import annotations",
|
|
93
|
+
"",
|
|
94
|
+
"import json",
|
|
95
|
+
"from pathlib import Path",
|
|
96
|
+
"",
|
|
97
|
+
"import pytest",
|
|
98
|
+
"",
|
|
99
|
+
render_todo(
|
|
100
|
+
"CHARACTERIZATION_IMPORT",
|
|
101
|
+
f"The migrated implementation for {name} is not imported.",
|
|
102
|
+
"Import the approved implementation after its callable contract is established.",
|
|
103
|
+
todo_prefix=todo_prefix,
|
|
104
|
+
),
|
|
105
|
+
f"# from {module} import {module}_main",
|
|
106
|
+
"",
|
|
107
|
+
f'GOLDEN_DIR = Path(__file__).parent / "golden" / "{name}"',
|
|
108
|
+
"",
|
|
109
|
+
"",
|
|
110
|
+
"def _load_golden_cases() -> list[dict]:",
|
|
111
|
+
" if not GOLDEN_DIR.exists():",
|
|
112
|
+
" return []",
|
|
113
|
+
' return [json.loads(p.read_text()) for p in sorted(GOLDEN_DIR.glob("case_*.json"))]',
|
|
114
|
+
"",
|
|
115
|
+
"",
|
|
116
|
+
"GOLDEN_CASES = _load_golden_cases()",
|
|
117
|
+
"",
|
|
118
|
+
"",
|
|
119
|
+
"@pytest.mark.skipif(not GOLDEN_CASES, reason=\"No golden fixtures captured yet - see module docstring\")",
|
|
120
|
+
'@pytest.mark.parametrize("case", GOLDEN_CASES, ids=lambda c: c.get("name", "case"))',
|
|
121
|
+
"def test_matches_golden_master(case: dict) -> None:",
|
|
122
|
+
f' """Replays a captured {name} input and diffs against the recorded',
|
|
123
|
+
" mainframe output. This is the safety net that allows the COBOL",
|
|
124
|
+
" source to eventually be retired.",
|
|
125
|
+
' """',
|
|
126
|
+
" " + render_todo(
|
|
127
|
+
"CHARACTERIZATION_EXECUTION",
|
|
128
|
+
f"Golden cases are not yet executed through {module}_main.",
|
|
129
|
+
"Call the migrated implementation with the verified input adapter.",
|
|
130
|
+
todo_prefix=todo_prefix,
|
|
131
|
+
),
|
|
132
|
+
" actual_output = None # placeholder until the port is implemented",
|
|
133
|
+
" error_msg = (",
|
|
134
|
+
f' f"{name} Python port diverges from golden master for case "',
|
|
135
|
+
' f"{case.get(\'name\')}"',
|
|
136
|
+
" )",
|
|
137
|
+
" assert actual_output == case[\"expected_output\"], error_msg",
|
|
138
|
+
"",
|
|
139
|
+
"",
|
|
140
|
+
"def test_golden_fixtures_present() -> None:",
|
|
141
|
+
f' """Fails loudly if nobody has captured fixtures for {name} yet -',
|
|
142
|
+
" better than a silently-skipped, false-green test suite.",
|
|
143
|
+
' """',
|
|
144
|
+
" assert GOLDEN_CASES, (",
|
|
145
|
+
' f"No golden-master fixtures found under {GOLDEN_DIR}. Capture real "',
|
|
146
|
+
' "input/output pairs before porting business logic."',
|
|
147
|
+
" )",
|
|
148
|
+
]
|
|
149
|
+
return "\n".join(lines)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def main() -> int:
|
|
153
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
154
|
+
parser.add_argument("file")
|
|
155
|
+
parser.add_argument("--out", help="Write scaffold to this file instead of stdout")
|
|
156
|
+
add_config_argument(parser)
|
|
157
|
+
args = parser.parse_args()
|
|
158
|
+
|
|
159
|
+
context = single_file_context_from_args(parser, args, "cobol")
|
|
160
|
+
path = context.path
|
|
161
|
+
text = path.read_text(encoding=context.source_encoding, errors="replace")
|
|
162
|
+
program = parse_cobol_source(text, file_path=str(path))
|
|
163
|
+
blocking = [warning for warning in program.warnings if warning.severity == "ERROR"]
|
|
164
|
+
if blocking:
|
|
165
|
+
parser.error(
|
|
166
|
+
"Characterization scaffold generation is blocked by parser errors: "
|
|
167
|
+
+ "; ".join(f"[{warning.code}] {warning.message}" for warning in blocking)
|
|
168
|
+
)
|
|
169
|
+
scaffold = render_pytest_scaffold(program, context.todo_prefix)
|
|
170
|
+
|
|
171
|
+
if args.out:
|
|
172
|
+
Path(args.out).write_text(scaffold, encoding="utf-8")
|
|
173
|
+
print(f"Wrote characterization test scaffold to {args.out}")
|
|
174
|
+
else:
|
|
175
|
+
print(scaffold)
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
raise SystemExit(main())
|