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.
Files changed (54) hide show
  1. mainframe_modernization_toolkit/__init__.py +3 -0
  2. mainframe_modernization_toolkit/__main__.py +5 -0
  3. mainframe_modernization_toolkit/application.py +142 -0
  4. mainframe_modernization_toolkit/cli/__init__.py +1 -0
  5. mainframe_modernization_toolkit/cli/business_rule_extractor.py +263 -0
  6. mainframe_modernization_toolkit/cli/characterization_test_scaffolder.py +180 -0
  7. mainframe_modernization_toolkit/cli/cobol_to_python_skeleton.py +305 -0
  8. mainframe_modernization_toolkit/cli/copybook_to_contract.py +93 -0
  9. mainframe_modernization_toolkit/cli/copybook_to_dataclass.py +330 -0
  10. mainframe_modernization_toolkit/cli/dead_code_finder.py +152 -0
  11. mainframe_modernization_toolkit/cli/dependency_graph.py +174 -0
  12. mainframe_modernization_toolkit/cli/generate_copybook_fixtures.py +116 -0
  13. mainframe_modernization_toolkit/cli/generate_file_readers.py +406 -0
  14. mainframe_modernization_toolkit/cli/generate_program_capsule.py +945 -0
  15. mainframe_modernization_toolkit/cli/impact_analysis.py +178 -0
  16. mainframe_modernization_toolkit/cli/ir_to_pyspark.py +495 -0
  17. mainframe_modernization_toolkit/cli/jcl_flow_extractor.py +166 -0
  18. mainframe_modernization_toolkit/cli/migration_complexity_report.py +154 -0
  19. mainframe_modernization_toolkit/cli/migration_preflight.py +833 -0
  20. mainframe_modernization_toolkit/cli/sql_extractor.py +230 -0
  21. mainframe_modernization_toolkit/cli/validate_relational_ir.py +48 -0
  22. mainframe_modernization_toolkit/common/__init__.py +14 -0
  23. mainframe_modernization_toolkit/common/cli_context.py +205 -0
  24. mainframe_modernization_toolkit/common/cobol_parser.py +1199 -0
  25. mainframe_modernization_toolkit/common/comp_decode.py +173 -0
  26. mainframe_modernization_toolkit/common/file_reader_codegen.py +748 -0
  27. mainframe_modernization_toolkit/common/file_resolution.py +286 -0
  28. mainframe_modernization_toolkit/common/jcl_parser.py +281 -0
  29. mainframe_modernization_toolkit/common/migration_config.py +944 -0
  30. mainframe_modernization_toolkit/common/naming.py +24 -0
  31. mainframe_modernization_toolkit/common/pic_types.py +268 -0
  32. mainframe_modernization_toolkit/common/record_contract.py +698 -0
  33. mainframe_modernization_toolkit/common/record_layout.py +249 -0
  34. mainframe_modernization_toolkit/common/relational_ir.py +1733 -0
  35. mainframe_modernization_toolkit/common/synthetic_records.py +413 -0
  36. mainframe_modernization_toolkit/common/warnings_model.py +167 -0
  37. mainframe_modernization_toolkit/common/workspace_index.py +511 -0
  38. mainframe_modernization_toolkit/registry.py +48 -0
  39. mainframe_modernization_toolkit/resources/customizations/agents/mainframe-jcl-migrator.agent.md +47 -0
  40. mainframe_modernization_toolkit/resources/customizations/skills/mainframe-jcl-migration/SKILL.md +212 -0
  41. mainframe_modernization_toolkit/resources/customizations/skills/mainframe-jcl-migration/references/migration-checklist.md +193 -0
  42. mainframe_modernization_toolkit/resources/customizations/skills/mainframe-jcl-migration/references/tool-catalog.md +54 -0
  43. mainframe_modernization_toolkit/resources/mainframe-migration.json +31 -0
  44. mainframe_modernization_toolkit/resources/mainframe-migration.schema.json +214 -0
  45. mainframe_modernization_toolkit/resources/relational-ir.schema.json +504 -0
  46. mainframe_modernization_toolkit/resources/vsix/mainframe-migration-toolkit-0.1.0.vsix +0 -0
  47. mainframe_modernization_toolkit/resources/vsix/manifest.json +8 -0
  48. mainframe_modernization_toolkit/resources.py +100 -0
  49. mainframe_modernization_toolkit-0.1.0.dist-info/METADATA +311 -0
  50. mainframe_modernization_toolkit-0.1.0.dist-info/RECORD +54 -0
  51. mainframe_modernization_toolkit-0.1.0.dist-info/WHEEL +5 -0
  52. mainframe_modernization_toolkit-0.1.0.dist-info/entry_points.txt +19 -0
  53. mainframe_modernization_toolkit-0.1.0.dist-info/licenses/LICENSE +175 -0
  54. mainframe_modernization_toolkit-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """Deterministic mainframe modernization tools."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ from .application import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -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())