From 96b9522bd5a1733570b0e04ac2edaa674b1ae14b Mon Sep 17 00:00:00 2001 From: abhay-codes07 Date: Thu, 24 Sep 2026 23:14:35 +0530 Subject: [PATCH] fix(cobol): detect fixed-format source that carries sequence numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixed/free format detector counted a line as fixed-format only when its first six columns were blank (`^ {6}[ *\-/ ]`). Legacy mainframe COBOL — the main reason to have a COBOL extractor — puts a sequence NUMBER in columns 1-6, so every such file scored zero fixed markers and was classified free-format. In free mode the code is taken verbatim (no column-8 slice), so the sequence number stays glued to the statement: `000400 MAIN-PARA.` no longer matches the paragraph anchor `^NAME .? .$`, and the whole procedure layer disappears. A seven-line program with two paragraphs and a PERFORM collapsed to just the file and program nodes — zero paragraphs, zero `calls` edges — silently. Match a sequence area that is six spaces OR six digits, followed by a valid indicator column (space = code, `*`/`/` = comment, `-` = continuation, `D` = debug). Once the file is correctly read as fixed-format the existing column-8..72 slice strips the sequence number and paragraphs/PERFORMs resolve as they should. Blank-sequence fixed-format and genuinely free-format source are unaffected (a 6-digit prefix is not something free-format code begins a line with). Regression test: a sequence-numbered program now yields its program, both paragraphs, and the PERFORM `calls` edge — and fails on the old detector. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JJbLfztSxm2tH5cJwBbe9q --- graphify/extractors/cobol.py | 11 ++++++++++- tests/test_cobol_extractor.py | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) 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")