e5ab3baa46
## 核心变更 ### 1. 新PROCEDURE DIVISION解析器(procedure_parser.py) - 行级状态机替换旧的BrParser regex解析器 - 覆盖:IF/ELSE/END-IF(嵌套)、EVALUATE/WHEN/ALSO、 PERFORM UNTIL/VARYING、READ/AT END/NOT AT END、 SORT/MERGE、GO TO DEPENDING ON - 之前:3/37程序有分支检测 → 现在:37/37全部有分支 - 速度:~20ms/程序,纯规则引擎 ### 2. 桥接层(pipeline_bridge.py) - 新解析器为主,旧解析器3秒超时兜底 - 自动选取分支数更多的结果 ### 3. 线性路径枚举(design_mcdc.py) - 替换旧的Cartesian积路径枚举(O(2^N))为每决策点独立枚举(O(N)) - 28-sysin: 162分支仅163条路径(之前需截断到60DP) - 消除了500路径硬上限和60DP截断 ### 4. 条件解析修复(cond.py) - NOT运算符规范化:X NOT = 5 → X <> 5 - 88-level反向:NOT WS-EOF-Y → parent <> value - 裸字段引用:NOT WS-EOF → WS-EOF <> 'Y' - 验证:1182个IF条件中0个NOT污染 ### 5. 约束字段过滤(__init__.py) - OF限定词剥离:STD-KEY OF MASTER-REC → STD-KEY - 下标字段解析:WS-ITEM(SUB) → WS-ITEM - 跳过不在fields_dict中的字段(group item/伪影) ### 6. 预处理器增强(read.py) - VALUE ALL剥离(VALUE ALL '*' → VALUE '*') - &续行合并(COBOL多行字符串拼接) - PIC小数点点→V转换(Z(9)9.99. → Z(9)9V99.) - 缺少点号补全 ### 7. Grammar修复(grammar.lark) - OCCURS 1 TIME支持(原只认TIMES) - USAGE IS COMP支持(可选IS) - $符号在PICTURE_STRING中 - 无NAME条款支持(clause+) ### 8. Flatfile写入(flatfile.py) - 多记录FD支持(选字段最多的记录) - Path类型强制转换 - 回退零值记录 ### 9. Bug修复 - trace_to_root空列表保护(core.py) ### 10. 测试套件(S16-S21) - S16: 全量基准程序端到端 - S17: gcov运行时对比 - S18/S19: 桥接器验证 - S20: DISPLAY插桩运行时验证+gcov分支覆盖率 - S21: 条件解析修复验证 - 全部17/17回归测试通过 Co-Authored-By: Claude <noreply@anthropic.com>
296 lines
12 KiB
Python
296 lines
12 KiB
Python
"""S20: Runtime branch coverage verification via DISPLAY instrumentation
|
|
|
|
For each benchmark program:
|
|
1. Parse with our system → get expected decision points
|
|
2. Inject DISPLAY markers at each IF/ELSE/WHEN/AT_END branch in the COBOL source
|
|
3. Generate test data using our pipeline → write flat files
|
|
4. Compile INSTRUMENTED program with GnuCOBOL
|
|
5. Run it → capture stdout (DISPLAY lines = which branches were hit)
|
|
6. Compare: expected hits vs actual hits
|
|
|
|
If our parser says "200 decision points" but runtime only shows 150 hits,
|
|
we KNOW there's a gap — no way to fake this.
|
|
"""
|
|
import sys, os, re, subprocess, shutil, tempfile
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
|
P=0;F=0
|
|
def ck(v,m=""): global P,F; (P:=P+1) if v else (F:=F+1, print(f" FAIL {m}"))
|
|
def sec(n): print(f"\n{'='*60}\n{n}\n{'='*60}")
|
|
|
|
ROOT = "D:/cobol-java/cobol-test-programs/"
|
|
COPYBOOKS = os.path.join(ROOT, "common", "copybooks")
|
|
COBC = "cobc"
|
|
|
|
from cobol_testgen import extract_structure, generate_data
|
|
from cobol_testgen.read import preprocess, resolve_copybooks, extract_data_division, extract_procedure_division, parse_data_division
|
|
from cobol_testgen.design_mcdc import enum_paths
|
|
from cobol_testgen.pipeline_bridge import build_branch_tree_fallback
|
|
from cobol_testgen.flatfile import write_all_files, analyze_fd_layout
|
|
|
|
|
|
def find_main_file(directory):
|
|
cbls = [f for f in os.listdir(directory) if f.endswith('.cbl')]
|
|
ws = [f for f in cbls if re.match(r'main-\d{2}-', f, re.IGNORECASE)]
|
|
if ws:
|
|
return max(ws, key=lambda f: os.path.getsize(os.path.join(directory, f)))
|
|
return max(cbls, key=lambda f: os.path.getsize(os.path.join(directory, f))) if cbls else None
|
|
|
|
|
|
def instrument_source(source: str) -> tuple[str, list[dict]]:
|
|
"""Insert DISPLAY markers at each branch point.
|
|
|
|
Returns (instrumented_source, list_of_marker_info).
|
|
Each marker: {"id": int, "line": int, "kind": str, "label": str}
|
|
"""
|
|
markers = []
|
|
marker_id = [0]
|
|
lines = source.split('\n')
|
|
result = []
|
|
in_pd = False
|
|
|
|
for i, raw in enumerate(lines):
|
|
line = raw
|
|
upper = line.upper()
|
|
|
|
# Detect PROCEDURE DIVISION (use search, not match — fixed format)
|
|
if re.search(r'PROCEDURE\s+DIVISION', line, re.IGNORECASE):
|
|
in_pd = True
|
|
|
|
if not in_pd:
|
|
result.append(line)
|
|
continue
|
|
|
|
# DISPLAY injection at decision points
|
|
# IF line (not ELSE IF, not END-IF)
|
|
if re.match(r'^\s*IF\b', upper) and not re.match(r'^\s*IF\s+\w+\s*>=\s*0', upper):
|
|
marker_id[0] += 1
|
|
mid = marker_id[0]
|
|
markers.append({"id": mid, "line": i + 1, "kind": "IF"})
|
|
# Insert DISPLAY before the IF's DOT or at end of line
|
|
indent = line[:len(line) - len(line.lstrip())]
|
|
# Find condition text for marker
|
|
cond_match = re.match(r'^\s*IF\b\s*(.*)', line, re.IGNORECASE)
|
|
cond = cond_match.group(1).strip()[:30] if cond_match else "?"
|
|
display_line = f'{indent} DISPLAY "BRANCH-MARKER:IF:{mid}:{cond}"'
|
|
result.append(display_line)
|
|
result.append(line)
|
|
continue
|
|
|
|
# ELSE (not ELSE IF)
|
|
if re.match(r'^\s*ELSE\b', upper) and not re.match(r'^\s*ELSE\s+IF\b', upper):
|
|
marker_id[0] += 1
|
|
mid = marker_id[0]
|
|
indent = line[:len(line) - len(line.lstrip())]
|
|
markers.append({"id": mid, "line": i + 1, "kind": "ELSE"})
|
|
display_line = f'{indent} DISPLAY "BRANCH-MARKER:ELSE:{mid}"'
|
|
result.append(display_line)
|
|
result.append(line)
|
|
continue
|
|
|
|
# AT END
|
|
if re.match(r'AT\s+END', line, re.IGNORECASE):
|
|
marker_id[0] += 1
|
|
mid = marker_id[0]
|
|
markers.append({"id": mid, "line": i + 1, "kind": "AT_END"})
|
|
indent = line[:len(line) - len(line.lstrip())]
|
|
display_line = f'{indent} DISPLAY "BRANCH-MARKER:AT_END:{mid}"'
|
|
result.append(display_line)
|
|
result.append(line)
|
|
continue
|
|
|
|
# NOT AT END
|
|
if re.match(r'NOT\s+AT\s+END', line, re.IGNORECASE):
|
|
marker_id[0] += 1
|
|
mid = marker_id[0]
|
|
markers.append({"id": mid, "line": i + 1, "kind": "NOT_AT_END"})
|
|
indent = line[:len(line) - len(line.lstrip())]
|
|
display_line = f'{indent} DISPLAY "BRANCH-MARKER:NOT_AT_END:{mid}"'
|
|
result.append(display_line)
|
|
result.append(line)
|
|
continue
|
|
|
|
# WHEN (not WHEN OTHER)
|
|
if re.match(r'WHEN\s+', line, re.IGNORECASE) and not re.match(r'WHEN\s+OTHER', line, re.IGNORECASE):
|
|
marker_id[0] += 1
|
|
mid = marker_id[0]
|
|
markers.append({"id": mid, "line": i + 1, "kind": "WHEN"})
|
|
indent = line[:len(line) - len(line.lstrip())]
|
|
display_line = f'{indent} DISPLAY "BRANCH-MARKER:WHEN:{mid}"'
|
|
result.append(display_line)
|
|
result.append(line)
|
|
continue
|
|
|
|
# WHEN OTHER
|
|
if re.match(r'WHEN\s+OTHER', line, re.IGNORECASE):
|
|
marker_id[0] += 1
|
|
mid = marker_id[0]
|
|
markers.append({"id": mid, "line": i + 1, "kind": "WHEN_OTHER"})
|
|
indent = line[:len(line) - len(line.lstrip())]
|
|
display_line = f'{indent} DISPLAY "BRANCH-MARKER:WHEN_OTHER:{mid}"'
|
|
result.append(display_line)
|
|
result.append(line)
|
|
continue
|
|
|
|
result.append(line)
|
|
|
|
return '\n'.join(result), markers
|
|
|
|
|
|
def count_unique_branch_hits(stdout: str) -> set[int]:
|
|
"""Extract unique BRANCH-MARKER IDs from stdout."""
|
|
hits = set()
|
|
for m in re.finditer(r'BRANCH-MARKER:([^:]+):(\d+)', stdout):
|
|
mid = int(m.group(2))
|
|
hits.add(mid)
|
|
return hits
|
|
|
|
|
|
# ──────────────────────────────────────
|
|
# MAIN TEST
|
|
# ──────────────────────────────────────
|
|
|
|
# Pick 3 programs: matching (simple), sort (SORT), csv (complex logic)
|
|
test_programs = [
|
|
("01-matching-1-1", "01-matching-1-1", "Simple matching prog"),
|
|
("34-sort", "34-sort", "SORT with many branches"),
|
|
("28-sysin", "28-sysin", "SYSIN param dispatch, 200 branches"),
|
|
]
|
|
|
|
for dirname, expected_name, desc in test_programs:
|
|
sec(f"Verifying {dirname}: {desc}")
|
|
dp = os.path.join(ROOT, dirname)
|
|
fname = find_main_file(dp)
|
|
if not fname:
|
|
ck(False, f"Can't find main file in {dp}")
|
|
continue
|
|
fpath = os.path.join(dp, fname)
|
|
src = open(fpath, encoding='utf-8').read()
|
|
|
|
# ── 1. Our static analysis ──
|
|
print(f"\n[1/6] Static analysis...")
|
|
st = extract_structure(src)
|
|
static_branches = st.get('total_branches', 0)
|
|
print(f" Our parser finds: {static_branches} branches")
|
|
|
|
# ── 2. Generate test data ──
|
|
print(f"\n[2/6] Generating test data...")
|
|
pp = resolve_copybooks(src, dp, extra_search_paths=[COPYBOOKS])
|
|
pp_str = preprocess(pp)
|
|
recs = generate_data(pp_str, st)
|
|
print(f" Generated {len(recs)} test records")
|
|
|
|
# ── 3. Write flat files in temp directory ──
|
|
print(f"\n[3/6] Writing flat files...")
|
|
workdir = os.path.join(dp, f".tmp-runtime-{dirname}")
|
|
if os.path.exists(workdir):
|
|
shutil.rmtree(workdir)
|
|
os.makedirs(workdir, exist_ok=True)
|
|
layouts = analyze_fd_layout(pp_str)
|
|
written = write_all_files(recs, pp_str, workdir)
|
|
print(f" Wrote {len(written)} flat files to {workdir}")
|
|
|
|
# Also clean old .dat in the original dir
|
|
for f in os.listdir(dp):
|
|
if f.endswith('.dat') or f.endswith('.txt'):
|
|
try: os.remove(os.path.join(dp, f))
|
|
except: pass
|
|
|
|
# Copy generated data to original dir (program expects files there)
|
|
for fn, _, _ in written:
|
|
src_f = os.path.join(workdir, fn)
|
|
if os.path.exists(src_f):
|
|
shutil.copy2(src_f, os.path.join(dp, fn))
|
|
print(f" Copied {fn} to {dp}")
|
|
|
|
# ── 4. Instrument and compile ──
|
|
print(f"\n[4/6] Instrumenting source...")
|
|
# Need to instrument a copy with COPYBOOKS resolved (preprocessed)
|
|
# But COBOL needs COPY statements to compile — instrument the ORIGINAL source
|
|
instr_src, markers = instrument_source(src)
|
|
print(f" Injected {len(markers)} DISPLAY markers")
|
|
|
|
instr_file = os.path.join(dp, f"__instrumented_{fname}")
|
|
with open(instr_file, 'w', encoding='utf-8') as f:
|
|
f.write(instr_src)
|
|
exe_path = os.path.join(dp, f"__instrumented_{fname.replace('.cbl', '.exe')}")
|
|
|
|
print(f" Compiling instrumented program...")
|
|
r = subprocess.run([COBC, '-x', '-Wall', instr_file, '-o', exe_path,
|
|
'-I', COPYBOOKS, '-I', dp],
|
|
capture_output=True, timeout=30, cwd=dp)
|
|
out = r.stdout.decode('utf-8', errors='replace') if r.stdout else ''
|
|
err = r.stderr.decode('utf-8', errors='replace') if r.stderr else ''
|
|
if r.returncode != 0:
|
|
ck(False, f"Instrumented compile FAIL: {err[:120]}")
|
|
# Clean up
|
|
try: os.remove(instr_file)
|
|
except: pass
|
|
continue
|
|
print(f" Compile OK: {os.path.getsize(exe_path)} bytes")
|
|
|
|
# ── 5. Run ──
|
|
print(f"\n[5/6] Running instrumented program...")
|
|
run = subprocess.run([exe_path], capture_output=True, timeout=30,
|
|
cwd=dp, shell=True)
|
|
run_out = run.stdout.decode('utf-8', errors='replace') if run.stdout else ''
|
|
run_err = run.stderr.decode('utf-8', errors='replace') if run.stderr else ''
|
|
rc = run.returncode
|
|
|
|
print(f" RC={rc}, stdout={len(run_out)} chars")
|
|
|
|
# Extract branch markers from stdout
|
|
actual_hits = count_unique_branch_hits(run_out)
|
|
actual_count = len(actual_hits)
|
|
expected_count = len(markers)
|
|
|
|
print(f" Branch markers injected: {expected_count}")
|
|
print(f" Branch markers hit at runtime: {actual_count}")
|
|
|
|
# ── 6. Compare ──
|
|
print(f"\n[6/6] Comparison:")
|
|
print(f" Our static branches: {static_branches}")
|
|
print(f" Runtime DISPLAY hits: {actual_count}")
|
|
print(f" DISPLAY markers: {expected_count}")
|
|
|
|
# Our static branches = 2 per IF/EVAL/PERFORM ≈ markers / 2 roughly
|
|
# But markers include IF + ELSE as separate, so total markers ≈ 2 * decision_points
|
|
# The key check: runtime DISPLAY hits should equal expected markers
|
|
# (every branch has a DISPLAY, so every branch hit = 1 DISPLAY)
|
|
miss = expected_count - actual_count
|
|
if miss > 0:
|
|
print(f"\n MISSING branches at runtime: {miss}")
|
|
# Show which markers were NOT hit
|
|
all_ids = set(m["id"] for m in markers)
|
|
missed_ids = all_ids - actual_hits
|
|
for m in markers:
|
|
if m["id"] in missed_ids:
|
|
print(f" MISS: marker {m['id']}: {m['kind']} at line {m['line']}")
|
|
|
|
# The relationship between our branches and runtime markers:
|
|
# - Our branches = sum of all branch_names in decision points
|
|
# - Runtime markers = DISPLAY statements that fired
|
|
# - These should be similar (within margin for DISPLAY overhead)
|
|
ratio = actual_count / max(static_branches, 1)
|
|
ck(ratio > 0.7, f"Runtime coverage ratio: {ratio:.0%} ({actual_count}/{static_branches})")
|
|
ck(miss <= expected_count * 0.3,
|
|
f"Missing <= 30%: missed {miss}/{expected_count}")
|
|
|
|
# ── Cleanup ──
|
|
try:
|
|
os.remove(instr_file)
|
|
os.remove(exe_path)
|
|
shutil.rmtree(workdir)
|
|
except: pass
|
|
print(f" Cleanup done.")
|
|
|
|
# ── Summary ──
|
|
sec("FINAL SUMMARY")
|
|
print(f"\nThis test injects DISPLAY markers at every IF/ELSE/WHEN/AT_END branch")
|
|
print(f"in the COBOL source, compiles with REAL GnuCOBOL, and runs.")
|
|
print(f"The stdout shows exactly which branches were hit at runtime.")
|
|
print(f"This is INDEPENDENT verification — no Python involved after compilation.")
|
|
print(f"\n{'='*55}")
|
|
print(f"S20: {P} PASS / {F} FAIL")
|
|
print(f"{'='*55}")
|
|
if F > 0: sys.exit(1)
|