fix(cobol): detect fixed-format source that carries sequence numbers

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJbLfztSxm2tH5cJwBbe9q
This commit is contained in:
abhay-codes07andClaude Opus 4.8 committed 2026-09-24 23:14:35 +05:30
1 parent 4c735618f3
commit 96b9522bd5
2 files changed
+35 -1

No files matched your search

+10 -1
View File
@@ -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)
+25
View File
@@ -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")