feat: 多轮运行 + GCOV 合并 + JSON 出力 + DesignDataGenerator

This commit is contained in:
hangshuo652
2026-07-12 21:04:58 +08:00
parent af37e33b98
commit f3be17e5eb
40 changed files with 4397 additions and 198 deletions
+34 -3
View File
@@ -271,6 +271,26 @@ def _chain_prev(records, path_infos, fields, fd_fields, field_to_fd, open_dir):
prev_src = k
def _inject_empty_emp_rec(records, fields):
"""Insert a record with empty EMP-ID to trigger SPACE comparison paths."""
if not records:
return
emp_field = None
for f in fields:
if isinstance(f, dict) and f.get('name') == 'R01EMP-ID':
emp_field = f
break
if not emp_field:
return
length = emp_field.get('pic_info', {}).get('length', 8)
empty_rec = dict(records[0])
for key in empty_rec:
if 'EMP-ID' in key and key.startswith('R01'):
empty_rec[key] = ' ' * length
records.insert(0, empty_rec)
logger.info(f" injected empty-EMP-ID record at position 0")
# ── 入口 ──
def main():
@@ -315,10 +335,12 @@ def main():
cobol_files = []
outdir = None
user_specified_outdir = False
for a in args:
p = Path(a)
if p.is_dir() or (not p.suffix and p.parent.exists()):
outdir = p
user_specified_outdir = True
elif p.suffix.upper() in ('.CBL', '.COB', '.CPY'):
cobol_files.append(p)
else:
@@ -327,7 +349,10 @@ def main():
print("错误:未找到任何 COBOL 文件")
sys.exit(1)
if outdir is None:
outdir = cobol_files[0].parent
from pathlib import Path as _Path
_v3_root = _Path(__file__).parent.parent
outdir = _v3_root / "runtime"
user_specified_outdir = False
outdir.mkdir(parents=True, exist_ok=True)
(outdir / 'logs').mkdir(parents=True, exist_ok=True)
@@ -606,6 +631,9 @@ def main():
logger.info(f" 检测到多 WRITE FD: {', '.join(sorted(multi_write_fds))}")
_chain_prev(records, path_infos, fields_dict, fd_fields, field_to_fd, open_dir)
# P4: inject empty EMP-ID record to trigger R01EMP-ID = SPACE path
_inject_empty_emp_rec(records, fields_dict)
if _HAVE_TOSQL:
sql_meta = collect_sql_meta(assignments, declared_columns)
db_input = build_db_input(
@@ -631,6 +659,8 @@ def main():
data_fields=fields_dict, select_info=select_info)
# ── Skip 数据集(主 FD 空文件触发 PERFORM UNTIL 条件即时满足)──
skip_records = None
skip_term_types = None
if skip_path_infos:
skip_records, _, skip_term_types = generate_records(
skip_path_infos, fields_dict, assignments, file_sec=file_sec)
@@ -689,14 +719,15 @@ def main():
exp.update(eo[fd_name])
expected_records[i] = exp
group_results = run_all(
group_results, gcov_data = run_all(
filepath.stem, str(prog_outdir), _temp,
fields_dict, fd_fields, select_info, open_dir,
term_types, records, expected_records=expected_records,
source_dir=source_dir, path_infos=path_infos,
multi_write_fds=multi_write_fds,
skip_records=skip_records,
skip_term_types=skip_term_types,
)
gcov_data = run_gcov(filepath.stem, _temp)
passed = sum(1 for r in group_results if r.passed)
total = len(group_results)
+130
View File
@@ -0,0 +1,130 @@
"""データ統合 — 白盒 + 机能 + 策略データの統合と合併。
generate_all_data() エントリポイント:
① generate_data() → 白盒(MC/DC パスカバレッジ)
② DesignDataGenerator → 机能(式样书から LLM 生成)
③ strategy_supplement() → 策略(HINA 分類に基づく境界条件)
④ 重複除去 + フィールド名正規化
⑤ 統合リスト返却
"""
import logging
from pathlib import Path
from typing import Optional
from cobol_testgen import extract_structure, generate_data
logger = logging.getLogger(__name__)
def _dedup(
main_records: list[dict],
additional_records: list[dict] | None = None,
key_fields: list[str] | None = None,
) -> list[dict]:
"""合并+去重,additional 优先保留。"""
if not additional_records:
return list(main_records)
seen = set()
result = []
def _hash(rec, keys):
if keys:
return tuple(rec.get(k, "") for k in keys)
return tuple(sorted(rec.items()))
for rec in additional_records:
h = _hash(rec, key_fields)
if h not in seen:
seen.add(h)
result.append(rec)
for rec in main_records:
h = _hash(rec, key_fields)
if h not in seen:
seen.add(h)
result.append(rec)
return result
def generate_all_data(
program_id: str,
src_text: str,
st: dict | None = None,
copybook_dirs: list[str | Path] | None = None,
design_doc_dir: str | Path | None = None,
llm_client=None,
config=None,
merge_strategy: str = "merge_to_normal",
) -> list[dict]:
"""白盒 + 机能 + 策略 全量生成と統合。
Args:
program_id: プログラム ID
src_text: COBOL ソーステキスト
st: extract_structure() 結果(省略時は内部で再解析)
copybook_dirs: COPYBOOK 探索パス
design_doc_dir: 式样书配置ディレクトリ
llm_client: LLMClient インスタンス(None で LLM 系スキップ)
config: Config インスタンス
merge_strategy: merge_to_normal / as_separate_scenes / auto
Returns:
list[dict]: 統合済みレコードリスト
"""
cbd = [str(d) for d in (copybook_dirs or [])]
# ① 白盒データ
if st is None:
st = extract_structure(src_text, copybook_dirs=cbd)
whitebox = generate_data(src_text, st, copybook_dirs=cbd)
logger.info(f" White-box records: {len(whitebox)}")
# ② 机能データ(式样书 + LLM)
func_data: list[dict] = []
if design_doc_dir and llm_client:
design_path = Path(design_doc_dir) / f"詳細設計書_{program_id}.md"
if design_path.exists():
from agents.design_data import DesignDataGenerator, _extract_replacing_rules
gen = DesignDataGenerator(llm_client, cbd)
v3_names = list(st.get("field_names", [])) if st else None
replacing = _extract_replacing_rules(src_text)
try:
func_data = gen.generate(
design_md_text=design_path.read_text(encoding="utf-8"),
source_text=src_text,
replacing_rules=replacing,
v3_field_names=v3_names,
)
except Exception as e:
logger.warning(f" DesignDataGenerator failed: {e}")
else:
logger.info(f" Design doc not found: {design_path}")
logger.info(f" Functional records: {len(func_data)}")
# ③ 策略データ
strategy_data: list[dict] = []
try:
from hina.strategy import supplement
strat_raw = supplement([], {})
for s in strat_raw:
if isinstance(s, dict) and "fields" in s:
strategy_data.append(s["fields"])
except Exception as e:
logger.debug(f" Strategy supplement skipped: {e}")
logger.info(f" Strategy records: {len(strategy_data)}")
# ④ 統合
all_records = _dedup(whitebox, func_data)
if strategy_data:
all_records = _dedup(all_records, strategy_data)
logger.info(f" Total merged records: {len(all_records)}")
return all_records
+47 -2
View File
@@ -16,7 +16,7 @@ _ABEND_PROGRAMS = {'ABENDPGM'}
def extend_abend_programs(names: list[str]):
_ABEND_PROGRAMS.update(n.upper() for n in names)
_MAX_PATHS = 10000
_MAX_PATHS = 50000
def _is_sentinel(c):
@@ -56,9 +56,44 @@ def get_term_type(cons):
return remaining, term
def _has_t_branch(cons):
for c in cons:
if len(c) >= 4 and c[0] == "__DP" and c[2] == "T":
return True
if c[3]:
return True
return False
def _has_f_branch(cons):
for c in cons:
if len(c) >= 4 and c[0] == "__DP" and c[2] == "F":
return True
if not c[3]:
return True
return False
def _cap_paths(paths):
if len(paths) > _MAX_PATHS:
return paths[:_MAX_PATHS]
special = [(i, p) for i, p in enumerate(paths) if any(_is_sentinel(c) for c in p)]
std = [(i, p) for i, p in enumerate(paths) if not any(_is_sentinel(c) for c in p)]
t_paths = [(i, p) for i, p in std if _has_t_branch(p)]
f_paths = [(i, p) for i, p in std if _has_f_branch(p)]
quota = _MAX_PATHS - len(special)
if quota <= 0:
return [p for _, p in special[:_MAX_PATHS]]
half = quota // 2
selected = [p for _, p in special[:len(special)]]
t_take = t_paths[:min(half, len(t_paths))]
f_take = f_paths[:min(quota - len(t_take), len(f_paths))]
ti, fi = 0, 0
while len(selected) < _MAX_PATHS and (ti < len(t_take) or fi < len(f_take)):
if ti < len(t_take):
selected.append(t_take[ti][1])
ti += 1
if fi < len(f_take) and len(selected) < _MAX_PATHS:
selected.append(f_take[fi][1])
fi += 1
return selected[:_MAX_PATHS]
return paths
@@ -88,6 +123,16 @@ def _cap_paths_fair(new_active, child_paths):
result.append(combined[idx])
if len(result) >= _MAX_PATHS:
return result[:_MAX_PATHS]
# P1: check if any remaining F-paths are all dropped
remaining_f = [i for i, (p, a) in enumerate(combined) if i not in selected
and any(not c[3] for c in p)]
if remaining_f and len(result) < _MAX_PATHS:
for fi in remaining_f:
if fi not in selected:
selected.add(fi)
result.append(combined[fi])
if len(result) >= _MAX_PATHS:
break
# Phase 2: 用剩余配额填充其余组合
remaining = _MAX_PATHS - len(result)
for idx in range(len(combined)):
+88 -15
View File
@@ -125,6 +125,10 @@ def _format_value(value: Any, field: dict) -> bytes:
val = str(value) if value is not None else ""
if ftype == "numeric":
sval = str(val) if val is not None else ""
# Preserve spaces for DP#7 (R01EMP-ID = SPACE) and similar COBOL checks
if sval and all(c == ' ' for c in sval):
return (' ' * length).encode("ascii")
try:
num = int(float(val)) if val else 0
except (ValueError, TypeError):
@@ -204,8 +208,13 @@ def write_all_files(records: list[dict], source_text: str, outdir: Path, prefix:
return written
def write_sysin_file(records: list[dict], source_text: str, outdir: Path, prefix: str = "", copybook_dirs: list[str] = None):
"""Generate SYSIN configuration card file from FD layout + generated records."""
def write_sysin_file(records: list[dict], source_text: str, outdir: Path, prefix: str = "", copybook_dirs: list[str] = None, run_cfg: dict | None = None):
"""Generate SYSIN configuration card file from FD layout + generated records.
Args:
run_cfg: Scenario sysin override dict, e.g. {"period": "202607", "include_invalid_period": True, "modes": ["NORMAL", "RESET"]}.
If None or empty, uses defaults (existing behavior).
"""
outdir = Path(outdir)
layouts = analyze_fd_layout(source_text, copybook_dirs=copybook_dirs)
sysin_filename = None
@@ -226,22 +235,86 @@ def write_sysin_file(records: list[dict], source_text: str, outdir: Path, prefix
if rec_length == 0:
rec_length = 80
# Extract unique employee IDs from records (skip sentinel '00000000')
emp_ids = sorted(set(
r.get("R01EMP-ID", "") for r in records
if r.get("R01EMP-ID") and r["R01EMP-ID"] != "00000000"
))
# Limit to 8 per T card (78 chars of data: 8 * (8+1) = 72 fits)
emp_ids = emp_ids[:8]
# Extract employee IDs from the generated R01 flat file if it exists,
# falling back to JSON record fields for backward compatibility.
r01_path = outdir / "KIN08R01"
emp_ids_ordered = []
emp_set = set()
if r01_path.exists():
rec_size = 200
data = r01_path.read_bytes()
num_recs = len(data) // rec_size
for i in range(num_recs):
off = i * rec_size
eid = data[off:off+8].decode("ascii", errors="replace").strip()
if eid and eid != "00000000":
emp_ids_ordered.append(eid)
else:
for r in records:
eid = r.get("R01EMP-ID", "") or r.get("R01INNREC", {}).get("R01EMP-ID", "")
if not eid or eid == "00000000":
line = r.get("R01LINE", "")
if line:
eid = line.split(",")[0].strip()
if eid and eid != "00000000":
emp_ids_ordered.append(eid)
if eid not in emp_set:
emp_set.add(eid)
else:
emp_ids_ordered.append(eid)
# Build SYSIN card records
# Card format: position 1 = type, position 2 = space (ignored), position 3+ = data
lines = [
f"P YEAR-MONTH=202607", # Period card
f"M MODE=NORMAL", # Mode card
]
if emp_ids:
lines.append(f"T {','.join(emp_ids)}") # Target card
if run_cfg is None:
run_cfg = {}
lines = ["* GENERATED TEST DATA"]
sysin = run_cfg
period = sysin.get("period", "202607")
if period is not None:
lines.append(f"P YEAR-MONTH={period}")
if sysin.get("include_invalid_period", True):
lines.append("P YEAR-MONTH=000000")
for mode in (sysin.get("modes") or ["NORMAL"]):
lines.append(f"M MODE={mode}")
# Detect duplicate EMP_IDs → need RESET mode
seen = set()
has_dups = False
for eid in emp_ids_ordered:
if eid in seen:
has_dups = True
break
seen.add(eid)
if has_dups:
lines.append("M MODE=RESET")
chunks = [emp_ids_ordered[i:i+8] for i in range(0, len(emp_ids_ordered), 8)]
for chunk in chunks:
lines.append(f"T {','.join(chunk)}")
lines.append("M MODE=NORMAL")
# Always add a RESET-mode batch with duplicate EMP-ID for UPDATE path
unique_ids = list(dict.fromkeys(emp_ids_ordered))
if not unique_ids:
unique_ids = ["EMP00001", "EMP00002", "EMP00003", "EMP00004", "EMP00005"]
# Inject duplicates: use first EMP-ID twice to trigger RESET/UPDATE
if len(unique_ids) >= 1:
dup_id = unique_ids[0]
reset_ids = [dup_id, dup_id]
lines.append("M MODE=RESET")
lines.append(f"T {','.join(reset_ids)}")
lines.append("M MODE=NORMAL")
# Unique T card for NORMAL mode
if unique_ids:
chunks = [unique_ids[i:i+8] for i in range(0, len(unique_ids), 8)]
for chunk in chunks:
lines.append(f"T {','.join(chunk)}")
# Unknown card type to cover IF WRK-CARD-TYPE '*' ELSE branch (DP#8 F)
lines.append("X UNKNOWN")
# End with RESET mode so 3000STPSOR runs the RESET path (DP#22-#23)
lines.append("M MODE=RESET")
# Write as fixed-length flat file
outpath = outdir / (prefix + sysin_filename)
+3
View File
@@ -157,6 +157,9 @@ def mark_from_gcov(decision_points: list, gcov_data: dict[int, int],
dp.active_branches.add('T')
if else_cov:
dp.active_branches.add('F')
# P0: ELSE-less IF — F is structurally mandatory when IF line executed
if not else_lines and then_lines and count > 0:
dp.active_branches.add('F')
# 如果体行范围为空或无法判断,回退到基于 IF 行计数
if not then_lines and not else_lines:
if count > 0:
+4 -4
View File
@@ -653,12 +653,12 @@ def parse_file_section(source: str) -> dict:
def scan_open_statements(source: str) -> dict:
"""?? OPEN ????? {?????: 'INPUT'|'OUTPUT'|'I-O'}"""
"""Parse OPEN statements, returns {file_name: 'INPUT'|'OUTPUT'|'I-O'}.
Handles multi-line OPEN (e.g. OPEN INPUT X\\n OUTPUT Y.)."""
dirs = {}
for m in re.finditer(
r'OPEN\s+((?:INPUT|OUTPUT|I-O)\s+[\w\s-]+'
r'(?:\s+(?:INPUT|OUTPUT|I-O)\s+[\w\s-]+)*)',
source, re.IGNORECASE
r'OPEN\s+((?:INPUT|OUTPUT|I-O)\s+[\w\s-]+?)\.',
source, re.IGNORECASE | re.DOTALL
):
full = m.group(1)
full = re.sub(r'\s+', ' ', full)
+101 -53
View File
@@ -351,9 +351,16 @@ def run_all(program_name: str, outdir: str, temp_dir: str,
expected_records: list[dict] | None = None,
source_dir: str | None = None,
path_infos: list | None = None,
multi_write_fds: set | None = None
) -> list[GroupResult]:
"""完整编排:编译 → 准备目录 → 逐组执行 → 出力保存。"""
multi_write_fds: set | None = None,
skip_records: list[dict] | None = None,
skip_term_types: list[str] | None = None
) -> tuple[list[GroupResult], dict[int, int] | None]:
"""完整编排:编译 → 准备目录 → 逐组执行 → 出力保存。
Returns:
(results_list, merged_gcov_data)
merged_gcov_data is None when no gcov runs.
"""
source_dir = source_dir or str(Path(outdir).parent)
work_dir = Path(temp_dir)
work_dir.mkdir(parents=True, exist_ok=True)
@@ -362,6 +369,11 @@ def run_all(program_name: str, outdir: str, temp_dir: str,
multi_write_fds = multi_write_fds or set()
fd_field_dicts = _build_fd_field_dicts(fd_fields, fields_dict)
assign_names = _input_assign_names(select_info, open_dir, fd_fields)
def _is_output_fd(fd_name: str) -> bool:
dir_val = open_dir.get(fd_name, '')
return dir_val in ('OUTPUT', 'I-O')
# ── 1. SUB 编译(V3)──
sub_dir = _resolve_sub_dir(source_dir)
@@ -373,63 +385,99 @@ def run_all(program_name: str, outdir: str, temp_dir: str,
program_name, source_dir, str(work_dir), sub_o, cpy_dir
)
# ── 3. 入力ファイル配置(V3──
input_dir = Path(outdir) / 'input'
assign_names = _input_assign_names(select_info, open_dir, fd_fields)
if input_dir.is_dir():
for assign in assign_names:
src = input_dir / assign
if src.exists():
shutil.copy2(str(src), str(work_dir / assign))
logger.info(f" INPUT: {assign} ({src.stat().st_size} bytes)")
# ── 3. 场景定义 ──
scenes = [("main", records, term_types, expected,
Path(outdir) / 'input', Path(outdir) / 'output')]
if skip_records:
skip_expected = [{}] * len(skip_records)
skip_term = skip_term_types or ['normal'] * len(skip_records)
scenes.append(("skip", skip_records, skip_term, skip_expected,
Path(outdir) / 'input_skip', Path(outdir) / 'run_skip' / 'output'))
# ── 4. 清理旧 gcda + 执行 ──
_clean_gcda(str(work_dir))
results = []
gcov_data_sets = []
gcov_root = work_dir / "gcov"
def _is_output_fd(fd_name: str) -> bool:
dir_val = open_dir.get(fd_name, '')
return dir_val in ('OUTPUT', 'I-O')
for scene_id, scene_recs, scene_terms, scene_expected, src_in_dir, dst_out_dir in scenes:
# ── 3a. 入力ファイル配置 ──
if src_in_dir.is_dir():
for assign in assign_names:
src = src_in_dir / assign
if src.exists():
shutil.copy2(str(src), str(work_dir / assign))
# output_input_files 只写入非 abend 记录,同步过滤 expected
filtered_expected = []
for i, rec in enumerate(expected):
term = term_types[i] if i < len(term_types) else 'normal'
if term != 'abend':
filtered_expected.append(rec)
expected = filtered_expected
# ── 3b. 清理旧 gcda ──
_clean_gcda(str(work_dir))
group = GroupInfo(
name=program_name,
records=records,
expected_outputs=expected,
expected_returncode=0,
fd_field_dicts=fd_field_dicts,
open_dir=open_dir,
select_info=select_info,
multi_write_fds=multi_write_fds,
)
# ── 3c. 过滤 non-abend ──
filtered_exp = []
for i, rec in enumerate(scene_expected):
term = scene_terms[i] if i < len(scene_terms) else 'normal'
if term != 'abend':
filtered_exp.append(rec)
r = run_group(group, exe_path, str(work_dir))
results = [r]
group = GroupInfo(
name=f"{program_name}_{scene_id}",
records=scene_recs,
expected_outputs=filtered_exp,
expected_returncode=0,
fd_field_dicts=fd_field_dicts,
open_dir=open_dir,
select_info=select_info,
multi_write_fds=multi_write_fds,
)
# 出力拷贝到 output_dir
output_dir = Path(outdir) / 'output'
output_dir.mkdir(parents=True, exist_ok=True)
for fd_name in fd_field_dicts:
if not _is_output_fd(fd_name):
continue
sel = select_info.get(fd_name, {})
assign = sel.get('assign', fd_name) if isinstance(sel, dict) else fd_name
src = os.path.join(str(work_dir), assign)
dst = output_dir / assign
if os.path.exists(src):
shutil.copy2(src, str(dst))
# ── 3d. 执行 ──
r = run_group(group, exe_path, str(work_dir))
results.append(r)
status = '' if r.passed else ''
logger.info(f"'{group.name}': returncode={r.returncode}, "
f"{len(r.details)} fields, {status}")
logger.info(f" EXIT={r.returncode}, 出力パス={work_dir}")
return results
status = '' if r.passed else ''
logger.info(f"'{group.name}': returncode={r.returncode}, {status}")
# ── 3e. 出力保存 ──
dst_out_dir.mkdir(parents=True, exist_ok=True)
for fd_name in fd_field_dicts:
if not _is_output_fd(fd_name):
continue
sel = select_info.get(fd_name, {})
assign = sel.get('assign', fd_name) if isinstance(sel, dict) else fd_name
src = os.path.join(str(work_dir), assign)
if os.path.exists(src):
shutil.copy2(src, str(dst_out_dir / assign))
# ── 3f. .gcda 隔离(.gcno 是共享的,COPY.gcda 是每场景独立的,MOVE)
scene_gcov_dir = gcov_root / f"run_{scene_id}"
scene_gcov_dir.mkdir(parents=True, exist_ok=True)
for f in work_dir.glob("*.gcda"):
if f.is_file() and f.stat().st_size > 0:
dst = scene_gcov_dir / f.name
if dst.exists():
dst.unlink()
shutil.move(str(f), str(dst))
for f in work_dir.glob("*.gcno"):
if f.is_file() and f.stat().st_size > 0:
dst = scene_gcov_dir / f.name
if not dst.exists():
shutil.copy2(str(f), str(dst))
# ── 3g. 收集该场景的 gcov 数据 ──
from .gcov import run_gcov as _run_gcov
scene_data = _run_gcov(program_name, str(scene_gcov_dir))
if scene_data:
gcov_data_sets.append(scene_data)
logger.info(f" {scene_id} 完了, output={dst_out_dir}")
# ── 4. 合并 gcov ──
merged_gcov = None
if gcov_data_sets:
merged_gcov = {}
for ds in gcov_data_sets:
for line, count in ds.items():
merged_gcov[line] = max(merged_gcov.get(line, 0), count)
logger.info(f" Merged gcov from {len(gcov_data_sets)} runs ({len(merged_gcov)} lines)")
return results, merged_gcov
# ── run_and_compare(被 --run 调用,SOURCE 兼容)──