"""Legacy code inventory — phase 1 of the discovery playbook.
Walks a legacy repository and classifies every artifact by platform and type
(RPG, DDS, CL, COBOL, copybook, JCL, VSAM/CICS, SQL, ...), separating genuine
legacy source from modern glue and docs/build/binary noise — so you know the
shape of the system before spending context reading any single program.
Classification matches the extension cheat-sheets in the skill's decoders:
references/ibm-i-as400.md, references/mainframe-cobol.md, references/cics-online.md
python3 inventory.py <repo_dir> [--out <dir>]
Always prints a Markdown summary to stdout. With --out, also writes
<out>/inventory.md (summary + per-file table) and <out>/inventory.csv.
No third-party dependencies; Python 3.8+.
from __future__ import annotations
from collections import defaultdict
# Legacy platform families (everything else is "Modern" or "Non-source").
LEGACY_PLATFORMS = {"IBM i", "Mainframe/COBOL", "Mainframe", "CICS", "Database"}
# extension -> (human type, platform family)
".rpg": ("RPG III (fixed / OPM)", "IBM i"),
".rpg36": ("RPG/36", "IBM i"),
".rpg38": ("RPG/38", "IBM i"),
".rpgle": ("RPG IV / free (ILE)", "IBM i"),
".sqlrpgle": ("SQLRPGLE (RPG + embedded SQL)", "IBM i"),
".clp": ("CL (OPM)", "IBM i"),
".clle": ("CL (ILE)", "IBM i"),
".cblle": ("COBOL (ILE, IBM i)", "IBM i"),
".pf": ("DDS physical file (table)", "IBM i"),
".lf": ("DDS logical file (view/index)", "IBM i"),
".dspf": ("DDS display file (green screen)", "IBM i"),
".prtf": ("DDS printer file (report)", "IBM i"),
".dds": ("DDS (unspecified)", "IBM i"),
".bnd": ("Binder source", "IBM i"),
".bnddir": ("Binding directory", "IBM i"),
# --- Mainframe COBOL / z/OS ---
".cbl": ("COBOL program", "Mainframe/COBOL"),
".cob": ("COBOL program", "Mainframe/COBOL"),
".cobol": ("COBOL program", "Mainframe/COBOL"),
".cpy": ("Copybook (record layout)", "Mainframe/COBOL"),
".cpybk": ("Copybook (record layout)", "Mainframe/COBOL"),
".copy": ("Copybook (record layout)", "Mainframe/COBOL"),
".jcl": ("JCL job", "Mainframe/COBOL"),
".job": ("JCL job", "Mainframe/COBOL"),
".prc": ("JCL procedure (PROC)", "Mainframe/COBOL"),
".proc": ("JCL procedure (PROC)", "Mainframe/COBOL"),
".pli": ("PL/I program", "Mainframe"),
".pl1": ("PL/I program", "Mainframe"),
".asm": ("Assembler (HLASM)", "Mainframe"),
".mlc": ("Assembler macro", "Mainframe"),
".rexx": ("REXX script", "Mainframe"),
".rex": ("REXX script", "Mainframe"),
".bms": ("BMS map (CICS screen)", "CICS"),
".csd": ("CICS resource definitions", "CICS"),
".sql": ("SQL DDL/DML", "Database"),
# Modern programming languages / scripts — relevant as glue/UI, not legacy.
".java", ".kt", ".scala", ".groovy", ".js", ".mjs", ".cjs", ".ts", ".tsx",
".jsx", ".vue", ".py", ".go", ".rb", ".php", ".cs", ".fs", ".swift",
".c", ".cc", ".cpp", ".cxx", ".h", ".hpp", ".rs", ".sh", ".bash", ".zsh",
".ps1", ".bat", ".cmd_", ".pl", ".pm", ".lua", ".r",
# Docs / build / config / data / images / binaries — noise for discovery.
".md", ".markdown", ".txt", ".rst", ".adoc", ".pdf", ".rtf",
# web/style (static assets)
".html", ".htm", ".css", ".scss", ".less",
# config / build / project
".json", ".yaml", ".yml", ".xml", ".properties", ".toml", ".ini", ".cfg",
".conf", ".lock", ".gradle", ".sln", ".csproj", ".vbproj", ".mfdirset",
".mod", ".ctl", ".cmd", ".editorconfig", ".gitignore", ".gitattributes", ".dockerignore",
".makefile", ".mk", ".cmake", ".bnddir_", ".j2", ".tpl", ".template",
".csv", ".tsv", ".data", ".dat", ".dai", ".log", ".out", ".parquet",
".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".bmp", ".tiff", ".webp",
".mp4", ".mov", ".woff", ".woff2", ".ttf", ".eot",
".zip", ".tar", ".gz", ".tgz", ".bz2", ".7z", ".jar", ".war", ".ear",
".dll", ".so", ".dylib", ".exe", ".bin", ".class", ".o", ".a", ".obj",
SKIP_DIRS = {".git", ".svn", ".hg", "node_modules", "__pycache__",
".idea", ".vscode", ".mvn", "target", "dist", "build", ".gradle"}
def read_bytes(path: str, n: int = 200_000) -> bytes:
with open(path, "rb") as fh:
def is_binary(sample: bytes) -> bool:
return b"\x00" in sample[:4096]
def sniff_type(text: str) -> tuple[str, str]:
"""Guess (type, platform) from content — thresholded to avoid false hits.
A single stray character in column 6 must NOT classify a file; we require
several matching lines (or a definitive banner) before committing.
lines = text.splitlines()
head_upper = "\n".join(lines[:80]).upper()
# Definitive banners first.
if "IDENTIFICATION DIVISION" in head_upper:
return ("COBOL program", "Mainframe/COBOL")
if "DFHMSD" in head_upper or "DFHMDI" in head_upper or "DFHMDF" in head_upper:
return ("BMS map (CICS screen)", "CICS")
if "**FREE" in head_upper or "CTL-OPT" in head_upper or "DCL-PROC" in head_upper:
return ("RPG IV / free (ILE)", "IBM i")
# Thresholded fixed-format detection over the first 200 lines.
if s.startswith("//") and " JOB " in (s.upper() + " "):
if len(s) >= 6 and s[:5] == " " and s[5] == "A":
if len(s) >= 6 and s[:5] == " " and s[5].upper() in "HFDICOP":
if st == "PGM" or st.startswith("PGM ") or st == "ENDPGM":
return ("JCL job", "Mainframe/COBOL")
return ("DDS (unspecified)", "IBM i")
return ("RPG (fixed-format)", "IBM i")
if "CREATE TABLE" in head_upper or "EXEC SQL" in head_upper:
return ("SQL DDL/DML", "Database")
return ("Unknown / unclassified", "?")
def classify(path: str) -> tuple[str, str, bool]:
"""Return (type, platform, is_cics)."""
ext = os.path.splitext(path)[1].lower()
if ext in NON_SOURCE_EXT:
return ("Non-source (docs/build/data/binary)", "Non-source", False)
return ("Modern source (UI/glue/scripts)", "Modern", False)
if typ.startswith("COBOL program"):
up = read_bytes(path).decode("utf-8", "replace").upper()
return ("COBOL program (CICS online)", "CICS", True)
return ("COBOL program (batch)", plat, False)
return (typ, plat, False)
# Unknown / no extension: skip binaries, else sniff by content.
sample = read_bytes(path)
return ("Empty / unreadable", "Non-source", False)
return ("Binary (non-source)", "Non-source", False)
text = sample.decode("utf-8", "replace")
typ, plat = sniff_type(text)
if typ.startswith("COBOL") and "EXEC CICS" in text.upper():
return ("COBOL program (CICS online)", "CICS", True)
return (typ, plat, False)
def count_lines(path: str) -> int:
with open(path, "rb") as fh:
return sum(1 for _ in fh)
for root, dirs, files in os.walk(repo):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
full = os.path.join(root, name)
rel = os.path.relpath(full, repo)
typ, plat, is_cics = classify(full)
"ext": os.path.splitext(name)[1].lower() or "(none)",
"lines": count_lines(full),
"cics": "yes" if is_cics else "-",
def summarize_md(rows: list[dict], repo: str) -> str:
by_type = defaultdict(lambda: {"count": 0, "lines": 0, "platform": "?"})
by_platform = defaultdict(int)
by_type[r["type"]]["count"] += 1
by_type[r["type"]]["lines"] += r["lines"]
by_type[r["type"]]["platform"] = r["platform"]
by_platform[r["platform"]] += 1
legacy = sum(n for p, n in by_platform.items() if p in LEGACY_PLATFORMS)
modern = by_platform.get("Modern", 0)
noise = by_platform.get("Non-source", 0) + by_platform.get("?", 0)
out = [f"# Code Inventory — `{os.path.abspath(repo)}`", ""]
out.append(f"**Total files:** {len(rows)} ")
out.append(f"**Legacy source artifacts:** {legacy} ")
out.append(f"**Modern glue/UI:** {modern} • **Docs/build/binary/unclassified:** {noise}")
legacy_plats = ", ".join(f"{p} ({n})" for p, n in sorted(by_platform.items(), key=lambda x: -x[1]) if p in LEGACY_PLATFORMS)
out.append(f"**Legacy platforms present:** {legacy_plats or '-'}")
out.append("## By artifact type")
out.append("| Type | Platform | Count | Lines |")
out.append("|---|---|---:|---:|")
for typ, agg in sorted(by_type.items(), key=lambda x: (x[1]["platform"] not in LEGACY_PLATFORMS, -x[1]["count"])):
out.append(f"| {typ} | {agg['platform']} | {agg['count']} | {agg['lines']:,} |")
out.append("| Path | Type | Platform | Lines | CICS |")
out.append("|---|---|---|---:|:--:|")
for r in sorted(rows, key=lambda x: (x["platform"] not in LEGACY_PLATFORMS, x["platform"], x["type"], x["path"])):
out.append(f"| `{r['path']}` | {r['type']} | {r['platform']} | {r['lines']:,} | {r['cics']} |")
def write_csv(rows: list[dict], path: str) -> None:
with open(path, "w", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=["path", "ext", "type", "platform", "lines", "cics"])
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description="Classify legacy code artifacts (phase 1 inventory).")
ap.add_argument("repo", help="path to the legacy repository")
ap.add_argument("--out", help="directory to write inventory.md and inventory.csv")
args = ap.parse_args(argv)
if not os.path.isdir(args.repo):
print(f"error: not a directory: {args.repo}", file=sys.stderr)
md = summarize_md(rows, args.repo)
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "inventory.md"), "w") as fh:
write_csv(rows, os.path.join(args.out, "inventory.csv"))
print(f"\n<!-- wrote {args.out}/inventory.md and inventory.csv -->", file=sys.stderr)
if __name__ == "__main__":