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
+687 -96
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import logging
import os
import re
import subprocess
import sqlite3
from dataclasses import dataclass, field
@@ -11,18 +12,21 @@ from pathlib import Path
from typing import Optional
from config import Config
from config.program_schema import ProgramSchema, load_schema
from config.program_schema import ProgramSchema, load_schema, ScenarioDef
from cobol_testgen import extract_structure, generate_data
from cobol_testgen.flatfile import write_all_files, write_sysin_file
from cobol_testgen.file_io import read_output_file
from cobol_testgen.read import preprocess, resolve_copybooks, resolve_sql_includes, parse_file_control, parse_file_section, parse_data_division, extract_data_division, scan_open_statements
from cobol_testgen.read import preprocess, resolve_copybooks, resolve_sql_includes, parse_file_control, parse_file_section, parse_data_division, extract_data_division, extract_procedure_division, scan_open_statements
from cobol_testgen.read import strip_exec_sql_from_data_div
from cobol_testgen.gcov import run_gcov
from cobol_testgen.coverage import run_coverage, generate_coverage_index
from cobol_testgen.design_mcdc import enum_paths as mcdc_enum_paths
from cobol_testgen.to_sql import collect_sql_meta, build_db_input
from cobol_testgen.core import extract_sql_assignments
from cobol_testgen.core import extract_sql_assignments, classify_field_roles, _init_child_names
from cobol_testgen import expand_occurs
from cobol_testgen.design import get_term_type, generate_records
from cobol_testgen.output import output_json
from cobol_testgen.pipeline_bridge import build_branch_tree_fallback
import shutil
from data.diff_result import VerificationRun, FieldResult
from runners.gixsql_runner import GixsqlCobolRunner, GixsqlTableData
@@ -80,6 +84,8 @@ class GixsqlOrchestrator:
self.pp_path: Optional[Path] = None
self.exe_path: Optional[Path] = None
self.java_input_path: Optional[Path] = None
self._current_db_path: Optional[Path] = None # scenario-specific DB path
self._multi_run_gcov_data: dict[int, int] | None = None # merged multi-run gcov data
self.java_output_path: Optional[Path] = None
self.generated_records: list[dict] = []
self.generated_structure: dict | None = None
@@ -110,7 +116,13 @@ class GixsqlOrchestrator:
flat_cpy.append(str(dst))
# Copy SUB programs
sub_dirs = [self.cobol_src_dir, self.cobol_src_dir.parent / "sub"]
v3_root = Path(__file__).parent
sub_dirs = [
self.cobol_src_dir,
self.cobol_src_dir.parent / "sub",
v3_root.parent / "cobol-tna-system" / "sub",
v3_root.parent / "production" / "sub",
]
for sub in self.schema.subprograms:
found = False
for sd in sub_dirs:
@@ -160,75 +172,393 @@ class GixsqlOrchestrator:
# ── Step 2: 入力データ生成 ──
def step2_generate_inputs(self) -> DbPipelineResult:
"""テストデータ生成 + フラットファイル出力 + DB初期化"""
def step2_generate_inputs(self, scenario: ScenarioDef | None = None) -> DbPipelineResult:
"""テストデータ生成 + フラットファイル出力 + DB初期化
Args:
scenario: 多輪実行時のシナリオ定義。None=単輪(従来動作)。
"""
try:
src_text = self.src_path.read_text(encoding="utf-8-sig")
# Use the pre-gixpp source for Lark parsing (gixpp output contains SQLCA etc.)
parse_text = self.pp_path.read_text(encoding="utf-8") if self.pp_path and self.pp_path.exists() else src_text
# COBOL 解析 + テストデータ生成
# COBOL 解析 + テストデータ生成(白盒 + 機能 + 策略 統合)
cbd = [str(d) for d in self.copybook_dirs]
st = extract_structure(src_text, copybook_dirs=cbd)
self.generated_structure = st
recs = generate_data(src_text, st, copybook_dirs=cbd)
from cobol_testgen.data_merger import generate_all_data
v3_root = Path(__file__).parent
design_doc_dir = v3_root / "詳細設計書"
if not design_doc_dir.exists():
design_doc_dir = None
# LLMClient は API key が必要な場合のみ初期化(未設定時は None → スキップ)
llm = None
if hasattr(self.config, 'llm_model') and self.config.llm_model:
from agents.llm import LLMClient
try:
llm = LLMClient(model=self.config.llm_model, timeout=self.config.llm_timeout)
except Exception:
pass
recs = generate_all_data(
program_id=self.program_id,
src_text=src_text,
st=st,
copybook_dirs=cbd,
design_doc_dir=str(design_doc_dir) if design_doc_dir else None,
llm_client=llm,
config=self.config,
)
# Post-process: link R02 cancel APPL-IDs to matching R01 insert APPL-IDs
for rec in recs:
if 'R02APPL-ID' in rec and 'R01APPL-ID' in rec:
rec['R02APPL-ID'] = rec['R01APPL-ID']
# DB 初期データ構築: single DB under V3 runtime/ dir
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_database(self.db_path)
# シナリオに応じた DB パス
if scenario:
db_path = Path("C:/Temp/gix") / f"{self.program_id}_{scenario.id}.db"
self._current_db_path = db_path
else:
db_path = self.db_path
self._current_db_path = None
# DB 初期データ構築: clean stale DB first
if db_path.exists():
db_path.unlink()
db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_database(db_path)
# DB 初期行投入(DELETE/UPDATE が作用する行、SELECT が返す行)
self._populate_database(self.db_path, src_text, recs)
self._populate_database(db_path, src_text, recs)
# P5: inject duplicate-PK rows (scenario で制御)
if scenario is None or scenario.inject_duplicate_pk:
self._inject_sql_error_rows(db_path, recs)
# フラットファイル書き出し
input_dir = self.work_dir / "input"
# Patch R01LINE records with EMP-IDs matching the record's own EMP-ID
for i, rec in enumerate(recs):
line = rec.get('R01LINE', '')
if not line:
continue
parts = line.split(',', 1)
if len(parts) != 2:
continue
# Get EMP-ID from the record's own field (e.g., R01EMP-ID)
emp_id = rec.get('R01EMP-ID', '')
if not emp_id or emp_id == '00000000':
emp_id = rec.get('HV-EMP-ID', '')
if not emp_id or emp_id == '00000000':
emp_id = f"EMP{str(i).zfill(5)}"
# First record: empty EMP-ID to trigger R01EMP-ID = SPACE path (DP#12)
if i == 0:
rec['R01LINE'] = f"{' '*8},{parts[1]}"
rec['R01EMP-ID'] = ' ' * len(emp_id)
else:
rec['R01LINE'] = f"{emp_id.ljust(8)},{parts[1]}"
rec['R01EMP-ID'] = emp_id
# Inject duplicate EMP-IDs for last 3 records to trigger AGG UPDATE
# path (DP#19-#20). Use the LARGEST EMP-ID from the last 8 of the
# sorted unique list, so the dup ID falls in the last TARGET card
# batch (which sets TARGET-COUNT). Set R01DATE to keep YEAR_MONTH
# the same but different day avoids PK conflict in DAILY_RECORDS.
# Works for both FIXED (KIN07REC) and LINE SEQUENTIAL formats.
if len(recs) > 3:
# Collect unique EMP-IDs (non-blank, non-zero)
all_ids = set()
for r in recs:
eid = r.get('R01EMP-ID', '')
if eid and eid.strip() and eid != '00000000':
all_ids.add(eid)
sorted_ids = sorted(all_ids)
# Pick from the last chunk (matches last TARGET card batch)
# T chunk = 8 per card, so last chunk index = len % 8 or 8
n = len(sorted_ids)
last_chunk_start = n - (n % 8 or 8)
dup_eid = sorted_ids[last_chunk_start] if sorted_ids else ''
# Find a record with this EMP-ID for its DATE
src_date = ''
for r in recs:
if r.get('R01EMP-ID', '') == dup_eid:
src_date = r.get('R01DATE', '')
break
dup_date = src_date
for j in range(max(1, len(recs)-3), len(recs)):
rec = recs[j]
if not dup_eid:
continue
rec['R01EMP-ID'] = dup_eid
# FIXED format: keep same YEAR_MONTH but different day
# to avoid PK conflict in DAILY_RECORDS INSERT.
if dup_date and len(dup_date) >= 6:
dup_ym = dup_date[:6]
orig_date = rec.get('R01DATE', '')
if orig_date and len(orig_date) >= 8:
rec['R01DATE'] = dup_ym + orig_date[6:8]
else:
rec['R01DATE'] = dup_ym + '01'
# LINE SEQUENTIAL format: patch R01LINE
line = rec.get('R01LINE', '')
if line:
parts = line.split(',', 1)
if len(parts) == 2:
rec['R01LINE'] = f"{dup_eid.ljust(8)},{parts[1]}"
# 出力先ディレクトリ(シナリオ毎に分離)
run_label = f"run_{scenario.id}" if scenario else ""
output_root = self.work_dir / run_label if scenario else self.work_dir
input_dir = output_root / "input"
input_dir.mkdir(parents=True, exist_ok=True)
# フラットファイル書き出し(全シナリオ同一)
flats = write_all_files(recs, src_text, input_dir,
copybook_dirs=[str(d) for d in self.copybook_dirs])
# SYSIN 設定ファイル生成(プログラム固有のカード形式
# SYSIN 設定ファイル生成(シナリオ毎に run_cfg を渡す
run_cfg = None
if scenario:
run_cfg = {
"period": scenario.sysin.period,
"include_invalid_period": scenario.sysin.include_invalid_period,
"modes": scenario.sysin.modes,
}
sysin_path = write_sysin_file(recs, src_text, input_dir,
copybook_dirs=[str(d) for d in self.copybook_dirs])
copybook_dirs=[str(d) for d in self.copybook_dirs],
run_cfg=run_cfg)
if sysin_path:
logger.info(f" SYSIN file written: {sysin_path}")
flats.append(("SYSIN", sysin_path, 0))
# Pre-populate MONTHLY_ABSENCE with matching EMP-ID/YEAR-MONTH for
# SELECT COUNT(*) → HV-CNT > 0 → UPDATE path (DP#27). Must run AFTER
# write_all_files (so the R01 flat file exists).
db_for_seed = self._current_db_path or self.db_path
self._seed_matching_monthly_rows(db_for_seed, recs, max_seed=20,
r01_dir=input_dir)
# ── JSON 出力(Java 検証用) ──
try:
pp = preprocess(src_text, extra_search_paths=cbd)
data_div = extract_data_division(pp)
data_fields = parse_data_division(data_div) if data_div else []
fdict = []
for f in data_fields:
fdict.append({
'name': f.name, 'level': f.level, 'pic': f.pic,
'pic_info': {
'type': f.pic_info.type if f.pic_info else 'unknown',
'digits': f.pic_info.digits if f.pic_info else 0,
'decimal': f.pic_info.decimal if f.pic_info else 0,
'length': f.pic_info.length if f.pic_info else 0,
'signed': f.pic_info.signed if f.pic_info else False,
},
'section': f.section, 'occurs': f.occurs_count,
'occurs_depending': f.occurs_depending,
'value': f.value, 'values': f.values,
'redefines': f.redefines, 'usage': f.usage,
})
fdict = expand_occurs(fdict)
proc_div = extract_procedure_division(pp)
branch_tree, assignments = build_branch_tree_fallback(proc_div, fdict)
sql_assigns = extract_sql_assignments(src_text)
for tgt, asgn_list in sql_assigns.items():
for asgn in asgn_list:
assignments.setdefault(tgt, []).append(asgn)
# fd_fields / field_to_fd
file_sec = parse_file_section(pp) or {}
fd_fields = {}
field_to_fd = {}
for fd_name, rec_names in file_sec.items():
fds = []
seen = set()
for rec in rec_names:
if rec not in seen:
fds.append(rec)
seen.add(rec)
for child in _init_child_names(rec, fdict):
if child not in seen:
fds.append(child)
seen.add(child)
fd_fields[fd_name] = fds
for child in fds:
field_to_fd[child] = fd_name
open_dir = scan_open_statements(proc_div) if proc_div else {}
# Roles + path info + termination types
roles = classify_field_roles(branch_tree, assignments, fdict,
source=src_text,
proc_text=proc_div)
branch_paths = mcdc_enum_paths(branch_tree, fdict)
path_infos = [(c, a, get_term_type(c)[1]) for c, a in branch_paths]
json_records, _, term_types = generate_records(
path_infos, fdict, assignments, file_sec=file_sec)
# DB input for JSON
data_div2, declared_columns = strip_exec_sql_from_data_div(data_div)
sql_meta = collect_sql_meta(assignments, declared_columns)
db_input = None
if sql_meta:
db_input = build_db_input(
branch_paths, fdict, assignments,
sql_meta, declared_columns, records=recs)
# Write main JSON(シナリオ毎に分離)
json_outdir = output_root / "json"
json_outdir.mkdir(parents=True, exist_ok=True)
json_path = json_outdir / f"{self.program_id}.json"
output_json(json_records, json_path, roles,
fd_fields=fd_fields, field_to_fd=field_to_fd,
open_dir=open_dir, term_types=term_types,
db_input=db_input, data_fields=fdict)
logger.info(f" JSON output: {json_path}")
flats.append(("JSON", json_path, 0))
except Exception as ej:
logger.warning(f" JSON output skipped: {ej}")
self.generated_records = recs
db_for_result = str(self._current_db_path or self.db_path)
return DbPipelineResult(
self.program_id, 2, True,
data={"records": len(recs), "flat_files": len(flats),
"db_path": str(self.db_path)},
"db_path": db_for_result},
)
except Exception as e:
return DbPipelineResult(self.program_id, 2, False, str(e))
# ── Step 3: COBOL 実行 ──
def step3_run_cobol(self) -> DbPipelineResult:
"""COBOL DB プログラム実行"""
def step3_run_cobol(self, scenario: ScenarioDef | None = None) -> DbPipelineResult:
"""COBOL DB プログラム実行(環境変数で入出力先を振り分け)
Args:
scenario: 多輪実行時のシナリオ。None=単輪。
"""
if not self.exe_path or not self.exe_path.exists():
return DbPipelineResult(self.program_id, 3, False,
"exe not found (run step1 first)")
# Subprogram DLLs are in cobol-tna-system/bin/
# シナリオ毎の出力先
run_label = f"run_{scenario.id}" if scenario else ""
run_dir = self.runtime_dir / run_label if scenario else self.runtime_dir
input_dir = run_dir / "input"
output_dir = run_dir / "output"
gcov_dir = self.runtime_dir / "gcov"
input_dir.mkdir(parents=True, exist_ok=True)
output_dir.mkdir(parents=True, exist_ok=True)
gcov_dir.mkdir(parents=True, exist_ok=True)
# シナリオ毎の CWD = run_{id}/、単輪時は runtime_dir 直下
cwd = run_dir
# 入力ファイル(work_dir/run_{id}/input/ → runtime/run_{id}/input/
gen_input_dir = self.work_dir / f"run_{scenario.id}" / "input" if scenario else self.work_dir / "input"
if gen_input_dir.exists():
for f in gen_input_dir.iterdir():
if f.is_file():
(input_dir / f.name).write_bytes(f.read_bytes())
# JSON 出力(work_dir/run_{id}/json/ → runtime/run_{id}/json/
gen_json_dir = self.work_dir / f"run_{scenario.id}" / "json" if scenario else self.work_dir / "json"
if gen_json_dir.exists():
json_dir = run_dir / "json"
json_dir.mkdir(parents=True, exist_ok=True)
for f in gen_json_dir.iterdir():
if f.is_file() and f.suffix.lower() == '.json':
(json_dir / f.name).write_bytes(f.read_bytes())
# Scan ASSIGN TO + OPEN direction → build env overrides
assign_map = self._scan_assign_to()
env_overrides = {}
for fname, direction in assign_map.items():
if direction == "INPUT":
env_overrides[fname] = os.path.join("input", fname)
else:
env_overrides[fname] = os.path.join("output", fname)
# シナリオ毎の DB パス
db_path = self._current_db_path or self.db_path
# GIXSQL_DB_PATH が効かないため、デフォルト DB にシナリオ DB をコピーする
if scenario is not None and db_path != self.db_path:
if db_path.exists():
if self.db_path.exists():
self.db_path.unlink()
shutil.copy2(str(db_path), str(self.db_path))
db_path = self.db_path
# .gcda は CWD= run_dir)に書き出されるので、実行後に gcov/run_{id}/ に移動する
# Subprogram DLLs
cobol_bin = Path(self.cobol_src_dir).parent / "bin"
self.runtime_dir.mkdir(parents=True, exist_ok=True)
result = self.runner.run(
self.exe_path, self.runtime_dir,
self.db_path,
input_dir=self.work_dir / "input",
self.exe_path, cwd,
db_path,
input_dir=None,
cobol_lib_path=str(cobol_bin) if cobol_bin.exists() else None,
env_overrides=env_overrides,
)
# .gcda を gcov/ にコピー(シナリオ毎に gcov/run_{id}/
# GnuCOBOL は .gcno が生成された CWD (= compile CWD = exe_dir) に .gcda を書き出す。
# 複数シナリオで .gcno は共有されるため COPY で行う(MOVE 不可)。
gcda_src_dirs = [cwd] # ランタイム CWD
exe_dir_for_gcda = self.work_dir / "bin"
if exe_dir_for_gcda.exists() and exe_dir_for_gcda not in gcda_src_dirs:
gcda_src_dirs.append(exe_dir_for_gcda)
if scenario is None:
gcda_src_dirs.append(self.runtime_dir) # 従来互換
gcda_dst_dir = gcov_dir / run_label if scenario else gcov_dir
gcda_dst_dir.mkdir(parents=True, exist_ok=True)
for ext in (".gcda", ".gcno"):
for sd in gcda_src_dirs:
for f in sd.glob(f"*{ext}"):
if f.is_file() and f.stat().st_size > 0:
dst = gcda_dst_dir / f.name
if not dst.exists() or f.stat().st_mtime > dst.stat().st_mtime:
shutil.copy2(str(f), str(dst))
return DbPipelineResult(
self.program_id, 3, result.success,
data={"returncode": result.returncode, "log": result.log[:500]},
data={"returncode": result.returncode, "log": result.log[:500],
"input_dir": str(input_dir), "output_dir": str(output_dir),
"gcov_dir": str(gcov_dir)},
)
# ── マルチラン gcov マージ ──
def _merge_multi_run_gcov(self) -> dict[int, int] | None:
"""Run gcov per scenario, parse results, merge {line: count} dicts.
Returns merged gcov_data or None if no multi-run data available.
"""
from cobol_testgen.gcov import run_gcov, parse_cbl_gcov
gcov_dir = self.runtime_dir / "gcov"
run_dirs = sorted(gcov_dir.glob("run_*"))
if len(run_dirs) <= 1:
return None
# Ensure .gcno is in each run dir (copy from compile CWD if needed)
bin_gcno = self.work_dir / "bin"
if bin_gcno.exists():
for sd in run_dirs:
for f in bin_gcno.glob("*.gcno"):
dst = sd / f.name
if not dst.exists():
shutil.copy2(str(f), str(dst))
merged_data: dict[int, int] = {}
for sd in run_dirs:
data = run_gcov(f"{self.program_id}_pp", str(sd))
if data:
for line, count in data.items():
merged_data[line] = max(merged_data.get(line, 0), count)
logger.info(f" Merged gcov from {len(run_dirs)} runs ({len(merged_data)} lines)")
return merged_data
# ── カバレッジレポート(パイプライン外、オプション) ──
def generate_coverage_report(self,
@@ -236,33 +566,68 @@ class GixsqlOrchestrator:
"""COBOL 実行後:gcov データ収集 + 静的パスとマージし HTML レポート"""
try:
if not self.exe_path or not self.exe_path.exists():
return DbPipelineResult(self.program_id, 0, False,
"exe not found (run step3 first)")
# Fallback: look for exe in standard build location
fallback = self.work_dir / "bin" / f"{self.program_id}.exe"
if fallback.exists():
self.exe_path = fallback
else:
return DbPipelineResult(self.program_id, 0, False,
f"exe not found at {self.exe_path} or {fallback}")
if output_dir is None:
v3_root = Path(__file__).parent
output_dir = v3_root / "reports" / self.program_id / "coverage"
output_dir = Path(output_dir)
# 1. Copy .gcno + .gcda from CWD (compile-time cwd) to runtime_dir
# cobc generates .gcno in CWD; at runtime, program writes .gcda to same CWD
gcno_gcda_count = 0
for ext in (".gcno", ".gcda"):
for f in Path.cwd().glob(f"*{ext}"):
if f.stat().st_size > 0:
shutil.copy2(str(f), str(self.runtime_dir / f.name))
gcno_gcda_count += 1
if gcno_gcda_count == 0:
return DbPipelineResult(self.program_id, 0, False,
f"no .gcno/.gcda found in CWD (--coverage missing?)")
# 1. Use pre-merged multi-run gcov data if available (skip gcov re-run)
if self._multi_run_gcov_data is not None:
gcov_data = self._multi_run_gcov_data
# Also merge subprogram gcov data from each scenario
from cobol_testgen.gcov import run_gcov as _run_gcov
gcov_dir = self.runtime_dir / "gcov"
for sub in self.schema.subprograms:
sub_merged: dict[int, int] = {}
for sd in sorted(gcov_dir.glob("run_*")):
sub_data = _run_gcov(sub, str(sd))
if sub_data:
for line, cnt in sub_data.items():
sub_merged[line] = max(sub_merged.get(line, 0), cnt)
if sub_merged:
gcov_data.update(sub_merged)
else:
# Single-run: collect .gcno/.gcda and run gcov
gcov_dir = self.runtime_dir / "gcov"
gcov_dir.mkdir(parents=True, exist_ok=True)
v3_root = Path(__file__).parent
extra_search = list(gcov_dir.glob("run_*")) + [self.work_dir / "bin"]
for search_dir in (v3_root, self.work_dir, self.runtime_dir, gcov_dir, Path.home(), *extra_search):
for ext in (".gcno", ".gcda"):
for f in search_dir.glob(f"*{ext}"):
if f.stat().st_size > 0:
dst = gcov_dir / f.name
if not dst.exists() or f.stat().st_mtime > dst.stat().st_mtime:
shutil.copy2(str(f), str(dst))
# Count what we have
gcno_gcda_count = 0
for ext in (".gcno", ".gcda"):
for f in gcov_dir.glob(f"*{ext}"):
if f.stat().st_size > 0 and (f.name.startswith(self.program_id) or f.name.startswith("SUB")):
gcno_gcda_count += 1
if gcno_gcda_count == 0:
for sd in (v3_root, self.work_dir, self.runtime_dir, gcov_dir):
for ext2 in (".gcno", ".gcda"):
files = list(sd.glob(f"*{ext2}"))
logger.error(f"gcov-check: {sd}\\*{ext2} -> {len(files)} files: {[f.name for f in files[:5]]}")
return DbPipelineResult(self.program_id, 0, False,
"no .gcno/.gcda found (--coverage missing?)")
# 3. Parse gcov data
gcov_data = run_gcov(f"{self.program_id}_pp", str(self.runtime_dir))
if not gcov_data:
gcov_data = run_gcov(self.program_id, str(self.runtime_dir))
for sub in self.schema.subprograms:
sd = run_gcov(sub, str(self.runtime_dir))
if sd:
gcov_data.update(sd)
# 3. Parse gcov data
gcov_data = run_gcov(f"{self.program_id}_pp", str(gcov_dir))
if not gcov_data:
gcov_data = run_gcov(self.program_id, str(gcov_dir))
for sub in self.schema.subprograms:
sd = run_gcov(sub, str(gcov_dir))
if sd:
gcov_data.update(sd)
# 4. Static branch tree from step2
st = self.generated_structure
@@ -319,13 +684,16 @@ class GixsqlOrchestrator:
)
generate_coverage_index([cov_result], str(output_dir.parent))
# Clean up .gcno/.gcda from CWD (avoid accumulation)
for ext in (".gcno", ".gcda"):
for f in Path.cwd().glob(f"*{ext}"):
try:
f.unlink()
except PermissionError:
pass
# Clean up .gcno/.gcda from v3_root + CWD (avoid accumulation)
for clean_dir in (v3_root, Path.cwd()):
if clean_dir == gcov_dir:
continue
for ext in (".gcno", ".gcda"):
for f in clean_dir.glob(f"*{ext}"):
try:
f.unlink()
except PermissionError:
pass
total = cov_result.get("total_branches", 0)
covered = cov_result.get("covered_branches", 0)
@@ -348,12 +716,13 @@ class GixsqlOrchestrator:
def step4_extract_intermediate(self) -> DbPipelineResult:
"""SQLite → JSON 中介データ抽出(Step 4: DB→Java中介データ)"""
if not self.db_path or not self.db_path.exists():
db_path = self._current_db_path or self.db_path
if not db_path or not db_path.exists():
return DbPipelineResult(self.program_id, 4, False,
"db not found (run step3 first)")
try:
conn = sqlite3.connect(str(self.db_path))
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
# Read from actual COBOL SQL tables (using sql_name or name)
@@ -421,17 +790,18 @@ class GixsqlOrchestrator:
def step6_verify(self) -> VerificationRun:
"""Java 出力と COBOL 期待値を比較"""
db_path = self._current_db_path or self.db_path
vr = VerificationRun(
program=self.program_id,
runner="gixsql",
gixsql_version="0.9.1",
sqlite_path=str(self.db_path) if self.db_path else "",
sqlite_path=str(db_path) if db_path else "",
step_reached=6,
)
if self.db_path and self.db_path.exists():
if db_path and db_path.exists():
after_tables = self.runner.read_db_tables(
self.db_path,
db_path,
[t.name for t in self.schema.db_tables],
)
for table_data in after_tables:
@@ -451,58 +821,123 @@ class GixsqlOrchestrator:
def run_all(self, skip_steps: set[int] | None = None,
generate_coverage: bool = True) -> VerificationRun:
"""Step 1 → 6 を順次実行(skip_jvm=True で Step 5/6 をスキップ)"""
"""Step 1 → 6 を順次実行(skip_jvm=True で Step 5/6 をスキップ)
多輪実行:schema.runs が定義されていれば各シナリオを順次実行し、最後に gcov をマージ。
schema.runs が空の場合は単輪(従来動作)。
"""
skip = set(skip_steps or [])
if self.skip_jvm:
skip.update({5, 6})
steps = [
(1, self.step1_setup_environment),
(2, self.step2_generate_inputs),
(3, self.step3_run_cobol),
(4, self.step4_extract_intermediate),
]
if not self.skip_jvm:
steps.extend([
(5, self.step5_run_java),
(6, self.step6_verify),
])
results = []
last_step = max(s for s, _ in steps)
for step_num, step_fn in steps:
if step_num in skip:
continue
logger.info(f" Step {step_num}...")
result = step_fn()
results.append(result)
if not result.success and step_num < last_step:
vr = VerificationRun(
scenarios = self.schema.runs or [ScenarioDef(id="default")]
is_multi = len(scenarios) > 1 or (len(scenarios) == 1 and scenarios[0].id != "default")
# Step 1: compile once
if 1 not in skip:
logger.info(" Step 1 (compile)...")
r1 = self.step1_setup_environment()
if not r1.success:
return VerificationRun(
program=self.program_id, runner="gixsql",
status="BLOCKED", exit_code=2,
step_reached=step_num,
debug={"step_results": [r.__dict__ for r in results]},
step_reached=1,
)
return vr
# Optional coverage report (non-blocking, not part of numbered pipeline)
cv_flags = getattr(self.config, 'gixsql_compile_flags', '')
if '--coverage' in cv_flags and generate_coverage:
self.generate_coverage_report()
# Each scenario: generate inputs + run COBOL
for scenario in scenarios:
label = f" [{scenario.id}]" if is_multi else ""
logger.info(f" Step 2 (generate inputs){label}...")
r2 = self.step2_generate_inputs(scenario if is_multi else None)
if not r2.success:
return VerificationRun(
program=self.program_id, runner="gixsql",
status="BLOCKED", exit_code=2,
step_reached=2,
)
logger.info(f" Step 3 (run COBOL){label}...")
r3 = self.step3_run_cobol(scenario if is_multi else None)
if not r3.success:
return VerificationRun(
program=self.program_id, runner="gixsql",
status="BLOCKED", exit_code=2,
step_reached=3,
)
# Step 4: extract intermediate (last scenario wins for DB path)
if 4 not in skip:
logger.info(" Step 4 (extract)...")
self.step4_extract_intermediate()
if not self.skip_jvm:
vr = results[-1] # step6_verify returned VerificationRun
vr.debug["step_results"] = [r.__dict__ for r in results[:-1] if r]
else:
vr = VerificationRun(
program=self.program_id, runner="gixsql",
status="PASS", exit_code=0,
step_reached=last_step,
debug={"step_results": [r.__dict__ for r in results]},
)
steps_remaining = [5, 6]
for step_num in steps_remaining:
if step_num in skip:
continue
logger.info(f" Step {step_num}...")
if step_num == 5:
self.step5_run_java()
elif step_num == 6:
vr = self.step6_verify()
# Optional coverage report (non-blocking)
cv_flags = getattr(self.config, 'gixsql_compile_flags', '')
if '--coverage' in cv_flags and generate_coverage:
if is_multi:
merged = self._merge_multi_run_gcov()
self._multi_run_gcov_data = merged
self.generate_coverage_report()
vr = VerificationRun(
program=self.program_id, runner="gixsql",
status="PASS", exit_code=0,
step_reached=6 if not self.skip_jvm else 4,
)
return vr
# ── Internal helpers ──
def _scan_assign_to(self) -> dict[str, str]:
"""Scan COBOL source for SELECT/ASSIGN-TO + OPEN direction.
Returns {filename: direction} where direction is 'INPUT' or 'OUTPUT'.
Works for both quoted (\"KIN08R01\") and bare (KIN01R01) ASSIGN.
"""
src_text = self.src_path.read_text(encoding="utf-8-sig")
assign_map: dict[str, str] = {}
# First pass: collect all SELECT/ASSIGN-TO mappings
select_to_file: dict[str, str] = {}
for m in re.finditer(
r'SELECT\s+(\w+)\s+ASSIGN\s+TO\s+"?([^"\s.]+)',
src_text, re.IGNORECASE
):
sel_name = m.group(1)
fname = m.group(2).strip().rstrip('"')
select_to_file[sel_name] = fname
assign_map[fname] = "UNKNOWN"
# Second pass: determine direction from OPEN statements
# Handle both simple (OPEN INPUT X) and compound (OPEN INPUT X OUTPUT Y)
for m in re.finditer(
r'OPEN\s+((?:INPUT|OUTPUT|I-O|EXTEND)\s+\w+)'
r'((?:\s+(?:INPUT|OUTPUT|I-O|EXTEND)\s+\w+)*)',
src_text, re.IGNORECASE
):
# Parse the OPEN payload: "INPUT X" + " OUTPUT Y"
payload = m.group(1) + m.group(2)
for part in re.finditer(
r'(INPUT|OUTPUT|I-O|EXTEND)\s+(\w+)', payload, re.IGNORECASE
):
direction = part.group(1).upper()
sel_name = part.group(2)
if sel_name in select_to_file:
fname = select_to_file[sel_name]
if direction in ("INPUT", "I-O"):
assign_map[fname] = "INPUT"
else:
assign_map[fname] = "OUTPUT"
return assign_map
def _init_database(self, db_path: Path):
"""Create tables from schema + COBOL EXEC SQL table definitions."""
self._create_tables(db_path)
@@ -599,3 +1034,159 @@ class GixsqlOrchestrator:
conn.commit()
conn.close()
logger.info(f" DB populated: {db_path}")
def _inject_sql_error_rows(self, db_path: Path, records: list[dict] | None = None):
"""Insert duplicate-PK rows to trigger SQL error handling paths in COBOL."""
conn = sqlite3.connect(str(db_path))
for table in self.schema.db_tables:
pk_cols = [c.name for c in table.columns if c.primary_key]
if not pk_cols:
continue
col_names = [c.name for c in table.columns]
try:
rows = conn.execute(f"SELECT * FROM [{table.name}] LIMIT 2").fetchall()
if len(rows) < 1:
# For empty tables, generate synthetic error rows from test record data
synthetic = self._make_synthetic_error_rows(table, records)
if synthetic:
rows = synthetic
else:
continue
quoted = ", ".join(f"[{c}]" for c in col_names)
ph = ", ".join("?" for _ in col_names)
for row in rows:
vals = tuple(str(rows[0][i]) if c in pk_cols else "X" for i, c in enumerate(col_names))
conn.execute(f"INSERT OR IGNORE INTO [{table.name}] ({quoted}) VALUES ({ph})", vals)
logger.info(f" SQL error test row injected into {table.name}")
except Exception as e:
logger.debug(f" SQL error row injection skipped: {e}")
conn.commit()
conn.close()
def _seed_matching_monthly_rows(self, db_path: Path, records: list[dict] | None,
max_seed: int = 1,
r01_dir: Path | None = None):
"""Pre-populate MONTHLY_ABSENCE with rows matching actual R01 record data.
Reads the generated R01 flat file (200-byte fixed records, KIN07REC layout),
extracts unique (EMP_ID, YEAR_MONTH) pairs, and inserts a SUBSET of them.
This ensures some AGG entries find HV-CNT > 0 (UPDATE, DP#27) and
others find HV-CNT = 0 (INSERT, DP#28)."""
r01_path = (r01_dir or self.work_dir / "input") / "KIN08R01"
if not r01_path.exists():
logger.info(" R01 file not found, skipping MONTHLY_ABSENCE seed")
return
conn = sqlite3.connect(str(db_path))
monthly_table = None
for t in self.schema.db_tables:
if t.name == "MONTHLY_ABSENCE":
monthly_table = t
break
if not monthly_table:
conn.close()
return
col_names = [c.name for c in monthly_table.columns]
quoted = ", ".join(f"[{c}]" for c in col_names)
ph = ", ".join("?" for _ in col_names)
seen = set()
pairs = []
rows_inserted = 0
# KIN07REC layout (each record is 200 bytes):
# EMP-ID PIC 9(008) offset 0, 8 bytes
# DATE PIC 9(008) offset 8, 8 bytes
# ... remaining fields (not needed)
rec_size = 200
with open(str(r01_path), 'rb') as f:
data = f.read()
num_recs = len(data) // rec_size
for i in range(num_recs):
off = i * rec_size
emp_id = data[off:off+8].decode('ascii', errors='replace').strip()
date = data[off+8:off+16].decode('ascii', errors='replace').strip()
year_month = date[:6] if len(date) >= 6 else date
if not emp_id or not year_month or emp_id == '00000000':
continue
key = (emp_id, year_month)
if key in seen:
continue
seen.add(key)
pairs.append((emp_id, year_month))
# Sort by EMP_ID for deterministic behavior, then seed only `max_seed` pairs
pairs.sort(key=lambda x: x[0])
conn = sqlite3.connect(str(db_path))
monthly_table = None
for t in self.schema.db_tables:
if t.name == "MONTHLY_ABSENCE":
monthly_table = t
break
if not monthly_table:
conn.close()
return
col_names = [c.name for c in monthly_table.columns]
quoted = ", ".join(f"[{c}]" for c in col_names)
ph = ", ".join("?" for _ in col_names)
for seed_idx, (emp_id, year_month) in enumerate(pairs):
if seed_idx >= max_seed:
break
vals = {
"EMP_ID": emp_id,
"YEAR_MONTH": year_month,
"ANNUAL_LEAVE_H": "0",
"PERSONAL_LEAVE_H": "0",
"OFFICIAL_LEAVE_H": "0",
"SICK_LEAVE_H": "0",
"UNAPPROVED_ABSENT_H": "0",
"UPDATED_AT": "2026-01-01 00:00:00",
}
row = tuple(vals.get(c, "") for c in col_names)
try:
conn.execute(f"INSERT OR IGNORE INTO [MONTHLY_ABSENCE] ({quoted}) VALUES ({ph})", row)
rows_inserted += 1
except Exception:
pass
conn.commit()
conn.close()
if rows_inserted:
logger.info(f" MONTHLY_ABSENCE: {rows_inserted}/{len(pairs)} matching rows seeded (DP#27 F + DP#28 F)")
elif pairs:
logger.info(f" MONTHLY_ABSENCE: 0 seeded — all AGG entries will INSERT (DP#28 F)")
def _make_synthetic_error_rows(self, table, records: list[dict] | None) -> list[tuple] | None:
"""Build synthetic error rows for an empty table from test record data."""
if not records or len(records) < 2:
return None
pk_cols = [c.name for c in table.columns if c.primary_key]
if not pk_cols:
return None
# Map COBOL host-variable names to table column names
# KIN08DBU DAILY_RECORDS: EMP_ID=HV-EMP-ID, TARGET_DATE=HV-TARGET-DATE
# KIN08DBU MONTHLY_ABSENCE: EMP_ID=HV-EMP-ID, YEAR_MONTH=HV-YEAR-MONTH
hv_map = {
'EMP_ID': ('HV-EMP-ID', 'R01EMP-ID', ''),
'TARGET_DATE': ('HV-TARGET-DATE', ''),
'YEAR_MONTH': ('HV-YEAR-MONTH', ''),
'TIME_IN': ('HV-TIME-IN', ''),
'TIME_OUT': ('HV-TIME-OUT', ''),
'ANNUAL_LEAVE_H': ('HV-ANNUAL-H', ''),
'PERSONAL_LEAVE_H': ('HV-PERSONAL-H', ''),
'OFFICIAL_LEAVE_H': ('HV-OFFICIAL-H', ''),
'SICK_LEAVE_H': ('HV-SICK-H', ''),
'UNAPPROVED_ABSENT_H': ('HV-ABSENT-H', ''),
}
result = []
for idx in range(min(2, len(records))):
rec = records[idx]
vals = []
for col in table.columns:
val = None
if col.name in hv_map:
for key in hv_map[col.name]:
if key and key in rec:
val = rec[key]
break
if val is None:
val = ' ' if col.name in pk_cols else ''
vals.append(str(val) if val is not None else '')
result.append(tuple(vals))
return result if result else None