diff --git a/graphify/extractors/cobol.py b/graphify/extractors/cobol.py index ec3b0e88b..91e1929fa 100644 --- a/graphify/extractors/cobol.py +++ b/graphify/extractors/cobol.py @@ -40,8 +40,17 @@ def _code_lines(source: str) -> list[tuple[int, str]]: forced_free = bool( re.search(r">>\s*SOURCE\s+FORMAT\s+(?:IS\s+)?FREE", source, re.IGNORECASE) ) + # A fixed-format line is a 6-column sequence area followed by the indicator + # in column 7. The sequence area is blank on some files but carries a + # sequence NUMBER on legacy mainframe source (the historical norm), so match + # both: six spaces, or six digits, followed by a valid indicator (space = + # code, `*`/`/` = comment, `-` = continuation, `D` = debug). Requiring six + # blank columns misclassified every sequence-numbered file as free-format, + # which then kept the sequence number in the code and dropped every paragraph + # (its `^NAME.$` anchor no longer matched) and PERFORM edge. fixed_markers = sum( - bool(re.match(r"^ {6}[ *\-/ ]", line)) for line in physical if line.strip() + bool(re.match(r"^(?: {6}|\d{6})[ *\-/dD]", line)) + for line in physical if line.strip() ) nonempty = sum(bool(line.strip()) for line in physical) free = forced_free or fixed_markers < max(1, nonempty // 2) diff --git a/tests/test_cobol_extractor.py b/tests/test_cobol_extractor.py index 49f36e03b..f08c5e7a8 100644 --- a/tests/test_cobol_extractor.py +++ b/tests/test_cobol_extractor.py @@ -131,3 +131,28 @@ def test_cobol_malformed_tail_comments_and_strings_do_not_create_phantoms(tmp_pa labels = {node["label"].casefold() for node in result["nodes"]} assert 'kept' in labels assert labels.isdisjoint({'ghost'}) + + +def test_cobol_fixed_format_with_sequence_numbers(tmp_path): + """Legacy fixed-format source carries a sequence NUMBER in columns 1-6, not + blanks. The format detector required six blank columns, so a sequence-numbered + file was misread as free-format — the sequence number stayed in the code and + every paragraph (its `^NAME.$` anchor no longer matched) and PERFORM edge was + lost, leaving only the file and program nodes.""" + source = tmp_path / "legacy.cob" + source.write_text( + "000100 IDENTIFICATION DIVISION.\n" + "000200 PROGRAM-ID. PAYROLL.\n" + "000300 PROCEDURE DIVISION.\n" + "000400 MAIN-PARA.\n" + "000500 PERFORM INIT-PARA.\n" + "000600 INIT-PARA.\n" + "000700 DISPLAY 'HELLO'.\n", + encoding="utf-8", + ) + + result = extract([source], cache_root=tmp_path) + + labels = {node["label"] for node in result["nodes"]} + assert {"PAYROLL", "MAIN-PARA", "INIT-PARA"} <= labels + assert ("MAIN-PARA", "INIT-PARA") in _edge_labels(result, "calls")