feat: SQL between/hostvar-key alignment, class-condition parsing, gcov merge across scenario runs

This commit is contained in:
hangshuo652
2026-08-09 17:43:00 +08:00
parent f331c8fa2a
commit 273a3f8211
31 changed files with 3789 additions and 272 deletions
+1063 -6
View File
@@ -22,7 +22,7 @@ CONFIG = {
}
from .read import preprocess, extract_data_division, extract_procedure_division
from .read import resolve_copybooks, parse_data_division, parse_file_section, scan_open_statements
from .read import resolve_copybooks, parse_data_division, parse_file_section, scan_open_statements, scan_all_file_directions
from .read import parse_file_control, resolve_sql_includes, strip_exec_sql_from_data_div
from .core import build_branch_tree, classify_field_roles, _init_child_names, sql_register_virtual_fields, _find_multi_write_fds
from .cond import parse_single_condition, is_field, collect_leaves
@@ -291,6 +291,1027 @@ def _inject_empty_emp_rec(records, fields):
logger.info(f" injected empty-EMP-ID record at position 0")
def _is_match_key_field(base: str) -> bool:
"""跨文件照合キー判定:EMP-ID / DATE に加え、末尾が -NO/-CODE のキー
OFFICE-NO / INSURER-CODE 等)も対象。非キー(NAME/KANA/FILLER/SEX/
TYPE/STATUS/PREF)は除外。共通ロジック、プログラム名ハードコードなし。"""
u = base.upper()
if any(kw in u for kw in ('NAME', 'KANA', 'FILLER', 'SEX', 'TYPE',
'STATUS', 'PREF', 'ADDR', 'DETAIL', 'REMARK')):
return False
if 'EMP-ID' in u or 'DATE' in u:
return True
if u.endswith('-NO') or u.endswith('-CODE') or u.endswith('-ID'):
return True
return False
def _common_prefix(names):
"""计算一组字段名的最长公共前缀(限定以字母开头)。
用于从 FD 的实际字段名推导字段前缀。COPY REPLACING 程序(如 KIN07DAI 的
SWEMP-ID/SWDATE… → 'SW')与字段名即 FD 前缀的程序(如 JIN01EMP-ID → 'JIN01'
均适用。
"""
cands = [n for n in names if n and n[0].isalpha()]
if not cands:
return ''
base = cands[0]
for n in cands[1:]:
i = 0
while i < len(base) and i < len(n) and base[i] == n[i]:
i += 1
base = base[:i]
if not base:
break
return base
def _fd_leaf_field_names(fd_name, file_sec, data_fields):
"""返回指定 FD 的叶子字段名列表(排除 88 级 / FILLER / 组项)。"""
names = []
for rn in file_sec.get(fd_name, []):
for child in _init_child_names(rn, data_fields):
names.append(child)
return names
def _coordinate_multi_file_keys(records, kept_path_cons, data_fields, assignments, file_sec, term_types=None, open_dir=None):
"""协调跨文件匹配程序的キー值,为部分记录设置相同キー以触发照合路径。
数据驱动方式:扫描记录中的跨文件キー字段,强制部分记录キー一致。
通用实现:从 data_fields 中发现不同 FD 的キー类字段(EMP-ID/DATE),
配对后修改记录値使キー一致。不依赖路径约束。
前缀推导:从 FD 的叶子字段名求公共前缀(SW/SR/SL 或 JIN01 等),
而非从 01 记录名推导(R01INNREC→'R01')。COPY REPLACING 场景下
记录名前缀与字段前缀不一致,旧逻辑导致键对匹配不到。
"""
if not records or not file_sec or len(file_sec) < 2:
return
# Step 1: 自动发现 INPUT FD 的字段前缀和キー字段
# 保留 file_sec 顺序(源文件顺序):第一个 INPUT FD 即主驱动(master
fd_prefixes = []
seen_prefix = set()
for fd_name in file_sec:
# 只保留 INPUT FDopen_dir 有方向信息时按方向过滤,避免把输出 FD
# 的キー也纳入协调导致输出记录被误改)
if open_dir:
direction = str(open_dir.get(fd_name, open_dir.get(fd_name.upper(), ''))).upper()
if direction not in ('INPUT', 'I-O'):
continue
prefix = _common_prefix(_fd_leaf_field_names(fd_name, file_sec, data_fields))
if prefix and prefix not in seen_prefix:
seen_prefix.add(prefix)
fd_prefixes.append(prefix)
if len(fd_prefixes) < 2:
return
# 3+ 输入 FD:使用前置匹配序列(全匹配 + 各单文件匹配 + 判别字段注入)
if len(fd_prefixes) > 2:
_coordinate_tertiary_fd(records, fd_prefixes, data_fields, term_types)
return
# 按前缀分组找到キー字段(EMP-ID / DATE / 事務所・保険者コード類)。
# 复用共享实现 _find_key_pairs_inner(精确 / WORK- / EMP 族 / 后缀匹配),
# 避免两处维护同一逻辑。
key_pairs = _find_key_pairs_inner(fd_prefixes[0], fd_prefixes[1], data_fields)
if not key_pairs:
return
# Step 2: 修改部分记录的キー值
modified = 0
max_modify = max(1, min(5, len(records) // 5))
modified_indices = []
for i, rec in enumerate(records):
if modified >= max_modify:
break
# 确认双方都有非空値
has_both = True
for fa, fb in key_pairs:
va = str(rec.get(fa, '')).strip()
vb = str(rec.get(fb, '')).strip()
if not va or not vb:
has_both = False
break
if not has_both:
continue
# 已有部分キー一致时跳过(避免破坏已有匹配)
already_matched = all(str(rec.get(fa, '')).strip() == str(rec.get(fb, '')).strip()
for fa, fb in key_pairs)
if already_matched:
continue
# 将 prefix_a 的キー设为 prefix_b 的値
for fa, fb in key_pairs:
rec[fa] = rec[fb]
modified_indices.append(i)
modified += 1
# Step 3: 创建重复 R02 キー,触发 2030PRIOSOR(マッチング選定の優先度判定)
# 需要连续 2 条 R02 记录拥有相同キー,使 2020MATCHSOR 的内部循环进入 ELSE 分支
# 跳过 'abend' 记录(不会写入二进制文件),只考虑 'normal'/'eof' 记录
if len(modified_indices) >= 2 and term_types:
normal_pairs = []
for idx in range(1, len(modified_indices)):
i_prev = modified_indices[idx - 1]
i_next = modified_indices[idx]
t_prev = term_types[i_prev] if i_prev < len(term_types) else 'normal'
t_next = term_types[i_next] if i_next < len(term_types) else 'normal'
if t_prev not in ('abend',) and t_next not in ('abend',):
normal_pairs.append((i_prev, i_next))
if normal_pairs:
i_prev, i_next = normal_pairs[0]
for _, fb in key_pairs:
records[i_next][fb] = records[i_prev][fb]
logger.info(f" 重复 R02 キー: 记录 {i_prev} → 记录 {i_next}(触发 2030PRIOSOR")
elif len(modified_indices) >= 2 and not term_types:
# 无 term_types 时使用第一个 pair
i_prev, i_next = modified_indices[0], modified_indices[1]
for _, fb in key_pairs:
records[i_next][fb] = records[i_prev][fb]
logger.info(f" 重复 R02 キー: 记录 {i_prev} → 记录 {i_next}(触发 2030PRIOSOR,无 term_types")
if modified:
logger.info(f" 跨文件键值协同: 将 {modified} 条记录的キー设为一致 (前缀 {fd_prefixes[0]}={fd_prefixes[1]})")
else:
# 回退:强制修改第一条有两方都非空キーの记录
for i, rec in enumerate(records):
has_both = all(str(rec.get(fa, '')).strip() and str(rec.get(fb, '')).strip()
for fa, fb in key_pairs)
if has_both:
for fa, fb in key_pairs:
rec[fa] = rec[fb]
logger.info(f" 跨文件键值协同(回退): 记录 {i} キー已设为一致")
break
# Step 4: 末条 normal 记录を表内键に整列(キーブレイク集約の最终组输出分支可达)
# 最終社员工组は EOF 後の終了処理(`IF WRK-EMP-EVAL-CNT > ZERO ...`)で出力されるため、
# 末条记录が主表键と一致しないと最終组输出分支不可達。先頭のみ整列したままだと
# 最終组に照合データが無い。首条 R02 键(必ず内部表ロード範囲内)を使用する。
if records and key_pairs:
table_key = str(records[0].get(key_pairs[0][1], '')).strip()
if table_key:
for i in range(len(records) - 1, -1, -1):
rec = records[i]
if term_types and i < len(term_types) and term_types[i] == 'abend':
continue
if str(rec.get(key_pairs[0][1], '')).strip():
for fa, fb in key_pairs:
rec[fa] = table_key
logger.info(f" 最終キーブレイク组照合: 记录 {i} 明细键={table_key}")
break
def _coordinate_range_matching(records, data_fields, file_sec, open_dir, term_types=None):
"""金额-区间对齐:让部分记录的 *MONTHLY-AMOUNT 落入另一输入 FD 的
*MONTHLY-FROM/TO 区间。
适用:N:1 キーブレイク集約後、平均標準報酬月額を GRAD 区间でマッチング
するプログラム(SHA05TWN 等)。生成データの金額と区間は独立 idx で合成
され数量级がずれるため、AVG が区间に命中せず GRADE 判定/出力経路が
不可達になる。本関数は部分记录の金額を区間中値に揃え、恰 3 条同 EMP-ID
の记录も注入して MOVE 'B' 分支を覆う。
通用実装:フィールド名パターン(MONTHLY-AMOUNT / MONTHLY-FROM /
MONTHLY-TO)+入力 FD 判定(open_dir)で検出。プログラム名ハードコードなし。
"""
if not records or not data_fields or not file_sec:
return
# 输入 FD 名 → 前缀
input_fd_names = []
if open_dir:
for fd_name in file_sec:
direction = str(open_dir.get(fd_name, open_dir.get(fd_name.upper(), ''))).upper()
if direction in ('INPUT', 'I-O'):
input_fd_names.append(fd_name)
if not input_fd_names:
return
# 输入 FD 前缀集合(从 file_sec 的 FD 记录名提取前缀)
input_prefixes = set()
for fd_name in input_fd_names:
for rn in file_sec.get(fd_name, []):
m = re.match(r'^([A-Z]+\d*)', rn)
if m:
input_prefixes.add(m.group(1))
# 收集输入 FD 的字段(按前缀,从 data_fields 扫描实际字段名)
amount_fields = [] # (fd_prefix, field_name)
from_fields = []
to_fields = []
for f in data_fields:
if not isinstance(f, dict) or not f.get('pic'):
continue
name = f['name']
if f.get('is_88'):
continue
prefix = None
for p in input_prefixes:
if name.startswith(p):
prefix = p
break
if prefix is None:
continue
base = name[len(prefix):].lstrip('-')
u = base.upper()
if 'MONTHLY-AMOUNT' in u:
amount_fields.append((prefix, name))
elif u.endswith('MONTHLY-FROM'):
from_fields.append((prefix, name))
elif u.endswith('MONTHLY-TO'):
to_fields.append((prefix, name))
if not amount_fields or not from_fields or not to_fields:
return
# 找跨 FD 前缀的 金额↔区间 配对(金额在 R01,区间在 R02)
range_pair = None
for af_pref, af in amount_fields:
for ff_pref, ff in from_fields:
for tf_pref, tf in to_fields:
if af_pref != ff_pref and ff_pref == tf_pref:
range_pair = (af_pref, af, ff_pref, ff, tf)
break
if range_pair:
break
if range_pair:
break
if not range_pair:
return
af_pref, af, ff_pref, ff, tf = range_pair
logger.info(f" 金额-区间对齐: 金额 {af} ({af_pref}) ↔ 区间 {ff}/{tf} ({ff_pref})")
# EMP-ID 字段(金额所在 FD 前缀)
emp_field = None
for f in data_fields:
if not isinstance(f, dict) or not f.get('pic'):
continue
nm = f['name']
if nm.startswith(af_pref) and 'EMP-ID' in nm.upper():
emp_field = nm
break
if not emp_field:
return
# 从记录中提取有效区间(取第一条有值的 R02 区间)
from_val = None
to_val = None
for rec in records:
fv = str(rec.get(ff, '')).strip()
tv = str(rec.get(tf, '')).strip()
if fv and tv and fv.isdigit() and tv.isdigit():
from_val = int(fv)
to_val = int(tv)
break
if from_val is None:
return
mid_val = (from_val + to_val) // 2
mid_str = str(mid_val).zfill(9)
# 选定 normal 记录(非 abend
normal_indices = [i for i in range(len(records))
if not term_types or i >= len(term_types) or term_types[i] != 'abend']
if len(normal_indices) < 4:
return
# 1) 前 4 条记录:金额设为区间中值,前 4 条同 EMP-ID(→ COUNT>=4 → MOVE 'A'
hit_indices = normal_indices[:4]
hit_emp = str(records[hit_indices[0]].get(emp_field, '')).strip() or 'A0000001'
for i in hit_indices:
rec = records[i]
rec[af] = mid_str
rec[emp_field] = hit_emp
logger.info(f" 金额-区间对齐: {len(hit_indices)} 条记录金额→{mid_str}(区间命中,COUNT>=4 → TYPE='A'")
# 2) 构造恰 3 条同 EMP-ID(→ COUNT=3 → MOVE 'B'):从 remaining 取 3 条
remaining = [i for i in normal_indices if i not in set(hit_indices)]
if len(remaining) >= 3:
b_indices = remaining[:3]
b_emp = str(records[b_indices[0]].get(emp_field, '')).strip() or 'B0000002'
for i in b_indices:
rec = records[i]
rec[af] = mid_str
rec[emp_field] = b_emp
logger.info(f" 金额-区间对齐: {len(b_indices)} 条记录同 EMP-ID={b_emp}COUNT=3 → TYPE='B'")
# 3) 其余记录保留原值:区间外 / 记录数<3 → NO-GRADE / INVALID 错误分支维持
def _serialize_read_into_records(records, assignments, fields, file_sec):
"""不透明 FD + READ INTO:将求解出的 INTO 组子字段值序列化回 FD 记录字节。
问题:FD 记录不透明(如 R01INNREC PIC X(080) 无子字段)且程序用
`READ FD INTO WS-GRP` 读取时,Pass 3.5 中 fd_children 为空 → 求解出的
WRK-* 值从未写回 FD 记录字节 → 物理输入文件全是占位符、key 全同,
新 key 分支(flush + WRITE)不可达。
本函数:
1) 找到 READ INTO 赋值(fd 名, 目标组)
2) 按目标组子字段 PIC 长度计算布局偏移
3) 将求解出的子字段值按偏移 pack 回 FD 记录字节
4) 保证相邻记录 key 既有相同又有差异(同 key 累加 + 新 key flush 两路径)
"""
if not records or not assignments or not file_sec:
return records
name_to_field = {f['name']: f for f in fields}
read_intos = []
for tgt, asgn_list in assignments.items():
for a in asgn_list:
if isinstance(a, dict) and a.get('type') == 'read_into':
read_intos.append((a.get('file', ''), tgt))
if not read_intos:
return records
for fname, tgt in read_intos:
if fname not in file_sec or not file_sec[fname]:
continue
fd_rec = file_sec[fname][0]
fd_field = name_to_field.get(fd_rec)
if not fd_field:
continue
if _init_child_names(fd_rec, fields):
continue
pi = fd_field.get('pic_info', {})
fd_len = pi.get('length') or (pi.get('digits', 0) + pi.get('decimal', 0)) or 0
if fd_len <= 0:
continue
ws_children = _init_child_names(tgt, fields)
layout = []
offset = 0
for c in ws_children:
cf = name_to_field.get(c)
if not cf:
continue
cpi = cf.get('pic_info', {})
if not cpi or cpi.get('type') == 'unknown':
continue
clen = cpi.get('length') or (cpi.get('digits', 0) + cpi.get('decimal', 0)) or 0
if clen <= 0:
continue
layout.append((c, offset, clen, cpi))
offset += clen
if not layout:
continue
key_children = []
for c, off, clen, cpi in layout:
base = c[4:] if c.startswith('WRK-') else c
if c.startswith('WRK-') and ('WRK-PREV-' + base) in name_to_field:
key_children.append((c, off, clen, cpi))
if not key_children:
for c, off, clen, cpi in layout:
if 'EMP-ID' in c or 'APPL-DATE' in c:
key_children.append((c, off, clen, cpi))
if key_children and len(records) >= 2:
kc, koff, klen, kpi = key_children[0]
keys = [str(r.get(kc, '')).strip() for r in records]
has_same = any(keys[i] == keys[i + 1] for i in range(len(keys) - 1))
has_diff = any(keys[i] != keys[i + 1] for i in range(len(keys) - 1))
if not has_diff:
records[-1][kc] = _inc_str(str(records[-1].get(kc, '')), klen)
logger.info(f" READ INTO {tgt}: 注入 key 差异(新 key flush 路径)")
if not has_same:
records[1][kc] = records[0][kc]
logger.info(f" READ INTO {tgt}: 注入相同 key(累加路径)")
for rec in records:
buf = bytearray(b' ' * fd_len)
for c, off, clen, cpi in layout:
if off >= fd_len:
continue
val = str(rec.get(c, ''))
if cpi.get('type') in ('numeric', 'numeric-edited'):
seg = val.zfill(clen)
else:
seg = val.ljust(clen)[:clen]
seg_bytes = seg.encode('utf-8', 'replace')[:clen]
end = min(off + len(seg_bytes), fd_len)
buf[off:end] = seg_bytes[:end - off]
rec[fd_rec] = buf.decode('utf-8', 'replace')
logger.info(f" READ INTO 序列化: {fd_rec}{tgt} ({len(layout)} 字段)")
return records
def _inject_merge_input_records(records, fields, file_sec, fd_fields, proc_div, term_types=None):
"""MERGE 程序:为 USING 输入文件注入可执行合并逻辑的 S/B 记录。
根因:MERGE ... USING 的输入文件从不 OPEN,原管道不识别为输入 → 无输入
数据 → 运行时 MERGE 读到空文件 → 2000MRGOUTSOR 全部不执行。
本函数:
1) 解析 MERGE 语句的 USING 文件列表(SALARY-FILE/BONUS-FILE
2) 识别各 USING 文件 FD 的 REC-TYPE / EMP-ID / PAY-AMOUNT / GROSS 字段
3) 从 OUTPUT PROCEDURE 的 `IF <rec-type> = <CONST>` 解析判别常量值
(如 CNS-PAY-TYPE-SALARY='S'),其余 USING 文件取相异值
4) 注入一条含全部 USING 文件字段的记录(同 EMP-ID 跨文件匹配,
触发 2000MRGOUTSOR 同社員統合 + REC-TYPE T/F 分支)
通用实现:不依赖程序名/字段名硬编码,对所有 MERGE/SORT 程序适用。
"""
if not records or not proc_div or not file_sec or len(fd_fields) < 2:
return records
_KEYWORDS = {'USING', 'KEY', 'ASCENDING', 'DESCENDING', 'ON', 'OUTPUT', 'INPUT', 'PROCEDURE'}
def _resolve_const(name, fields_list):
for f in fields_list:
if f.get('name') == name and f.get('value') is not None:
return str(f['value']).strip("'\"").strip()
return None
def _find_field(fd_name, suffix):
for fname in fd_fields.get(fd_name, []):
if fname.upper().endswith(suffix.upper()) and fname not in (fd_name,):
return fname
return None
injected = False
for m in re.finditer(
r'\bMERGE\s+\w[\w-]*\s+.*?\bUSING\s+(.+?)\s*(?:OUTPUT|INPUT)?\s*PROCEDURE\s+\w[\w-]*\s*\.',
proc_div, re.IGNORECASE | re.DOTALL
):
stmt = m.group(0)
files_raw = m.group(1)
using = [f.upper() for f in re.findall(r'\b(\w[\w-]*)\b', files_raw)
if f.upper() not in _KEYWORDS and f.upper() in file_sec]
if len(using) < 2:
continue
out_sec = None
om = re.search(r'\bOUTPUT\s+PROCEDURE\s+(\w[\w-]*)', stmt, re.IGNORECASE)
if om:
out_sec = om.group(1).upper()
# 判别字段(REC-TYPE)与判别常量值
type_field = None
const_val = None
if out_sec:
for f in fields:
fn = f.get('name', '')
if fn.upper().endswith('REC-TYPE'):
type_field = fn
break
if type_field:
cm = re.search(
r'\bIF\s+%s\b\s*(?:=|NOT\s*=|<>|>|<|>=|<=)\s*(\w[\w-]*)' % re.escape(type_field),
proc_div, re.IGNORECASE
)
if not cm:
cm = re.search(
r'\b%s\s*(?:=|NOT\s*=|<>)\s*(\w[\w-]*)' % re.escape(type_field),
proc_div, re.IGNORECASE
)
if cm:
const_val = _resolve_const(cm.group(1).upper(), fields)
if const_val is None:
const_val = 'S' # 通用回退:第一个 USING 文件用 'S'
# 第二文件判别值:优先取 *PAY-TYPE-BONUS* 常量,否则取与 const_val 相异值
other_val = None
for f in fields:
fn = f.get('name', '')
if 'PAY-TYPE' in fn.upper() and 'BONUS' in fn.upper() and f.get('value') is not None:
other_val = str(f['value']).strip("'\"").strip()
break
if other_val is None:
other_val = (chr(ord(const_val) + 1) if len(const_val) == 1 else 'B')
if other_val == const_val:
other_val = 'B'
# 各 USING 文件的字段映射
emp_fields, pay_fields, gross_fields, type_fields = [], [], [], []
for fd in using:
emp_fields.append(_find_field(fd, 'EMP-ID'))
pay_fields.append(_find_field(fd, 'PAY-AMOUNT'))
gross_fields.append(_find_field(fd, 'GROSS'))
type_fields.append(_find_field(fd, 'REC-TYPE'))
if any(f is None for f in emp_fields) or any(f is None for f in type_fields):
continue
# 每条记录:所有 USING 文件字段都设置,同 EMP-IDREC-TYPE 按文件相异。
# 注:output_input_files 对次要 FD 会丢弃末尾 2 条 → 注入记录必须放最前。
base = dict(records[0]) if records else {}
n = len(using)
merge_recs = []
for emp_i in range(2):
rec = dict(base)
emp_id = f'A{emp_i + 1:07d}'
for i, fd in enumerate(using):
type_val = const_val if i == 0 else other_val
rec[emp_fields[i]] = emp_id
rec[type_fields[i]] = type_val
if pay_fields[i]:
rec[pay_fields[i]] = str(100000 + emp_i * 1000 + i * 100).zfill(9)
if gross_fields[i]:
rec[gross_fields[i]] = str(200000 + emp_i * 1000 + i * 100).zfill(9)
merge_recs.append(rec)
records[0:0] = merge_recs
if term_types is not None:
term_types[0:0] = ['normal', 'normal']
injected = True
logger.info(f" 注入 MERGE 输入记录: USING={using} REC-TYPE={const_val}/{other_val} 共 2 条(同 EMP-ID 跨文件,置顶)")
break
return records
def _inject_end_of_page_records(records, full_source, proc_div, file_sec, open_dir, term_types=None):
"""LINAGE 分页(WRITE ... AT END-OF-PAGE)プログラム:补充分页体积记录。
根因:V3 路径枚举只生成覆盖决策点的最小记录集。对 END-OF-PAGE(分页)分支,
需要足够多的明细记录触发 LINAGE 分页才能覆盖(例:JIN04PRT 输入仅 1 条 →
AT END-OF-PAGE 换页重打分支不可达,gcov 仅 80/96)。
本函数(通用・程序名硬编码なし):
1) PROCEDURE DIVISION 中检测 `WRITE ... AT END-OF-PAGE`
2) 从全量源码解析输出 FD 的 LINAGE 总行数 - TOP - BOTTOM 作为正文容量
(解析失败回退 60)
3) 将一条有效 normal 记录复制补足到「容量+1」条,使最后一条 WRITE
触发 END-OF-PAGE
"""
if not records or not proc_div or not full_source:
return records
if not re.search(r'\bEND-OF-PAGE\b', proc_div, re.IGNORECASE):
return records
# LINAGE 容量:LINAGE IS n LINESTOP t / BOTTOM b)→ 正文行数
capacity = None
m = re.search(
r'LINAGE\s+IS\s+(\d+)\s+LINES([^.]*?)(?=\.)',
full_source, re.IGNORECASE | re.DOTALL)
if m:
total = int(m.group(1))
rest = m.group(2)
top_m = re.search(r'\bTOP\s+(\d+)', rest, re.IGNORECASE)
bot_m = re.search(r'\bBOTTOM\s+(\d+)', rest, re.IGNORECASE)
top = int(top_m.group(1)) if top_m else 0
bottom = int(bot_m.group(1)) if bot_m else 0
capacity = total - top - bottom
if not capacity or capacity <= 0:
capacity = 60
target = capacity + 1
def _is_normal(i):
if term_types is None or i >= len(term_types):
return True
return term_types[i] != 'abend'
template = None
for i in range(len(records) - 1, -1, -1):
if _is_normal(i) and records[i]:
template = dict(records[i])
break
if template is None:
return records
current = sum(1 for i in range(len(records)) if _is_normal(i))
if current >= target:
return records
need = target - current
for _ in range(need):
records.append(dict(template))
if term_types is not None:
term_types.extend(['normal'] * need)
logger.info(
f" END-OF-PAGE 分页注入: 复制 {need} 条记录至 {len(records)}"
f"LINAGE 容量 {capacity},触发分页)")
return records
def _inject_sort_limit_records(records, proc_div, fields, term_types=None):
"""SORT INPUT 计数守卫(RELEASE + IF <计数器> <= <上限>):补充分页体积记录。
根因:V3 路径枚举只生成覆盖决策点的最小记录集。对 SORT INPUT PROCEDURE 中的
记录数上限守卫(如 `ADD 1 TO WRK-SORT-CNT ... IF WRK-SORT-CNT <=
CNS-MAX-SORT-REC(999) RELEASE ... ELSE 上限エラー`),需超过上限条数的输入
记录才能覆盖 ELSESORT LIMIT OVER)分支(例:JIN06SRT 输入仅 6 条,上限 999)。
本函数(通用・程序名硬编码なし):
1) PROC 中检测 SORT + RELEASE + 同一段落内 `IF <counter> (<=|<) <limit>`
2) 解析 <limit> 的常量值(字段 VALUE 子句或字面值)
3) 将一条 valid normal 记录复制到「上限+1」条,使最后一条 READ 触发上限分支
"""
if not records or not proc_div:
return records
up = proc_div.upper()
if 'SORT' not in up or 'RELEASE' not in up:
return records
# 计数守卫:IF <counter> (<=|<) <limit>(同段落内必须有 RELEASE
m = re.search(r'\bIF\s+(\w[\w-]*)\s*(?:<=|<)\s*(\w[\w-]*)\b', proc_div, re.IGNORECASE)
if not m:
return records
after = proc_div[m.start():]
para_end = re.search(r'\n\s*\w[\w-]*-EXT\.', after)
scope = after[:para_end.start()] if para_end else after
if 'RELEASE' not in scope.upper():
return records
# 解析上限值:字段 VALUE 子句或字面值
limit_ref = m.group(2)
limit = None
raw = str(limit_ref).strip().strip("'\"").strip()
if raw.isdigit():
limit = int(raw)
else:
for f in (fields or []):
if f.get('name') == raw and f.get('value') is not None:
try:
limit = int(str(f['value']).strip("'\"").strip())
except ValueError:
limit = None
break
if limit is None or limit <= 0:
return records
target = limit + 1
def _is_normal(i):
if term_types is None or i >= len(term_types):
return True
return term_types[i] != 'abend'
template = None
for i in range(len(records) - 1, -1, -1):
if _is_normal(i) and records[i]:
template = dict(records[i])
break
if template is None:
return records
current = sum(1 for i in range(len(records)) if _is_normal(i))
if current >= target:
return records
need = target - current
for _ in range(need):
records.append(dict(template))
if term_types is not None:
term_types.extend(['normal'] * need)
logger.info(
f" SORT 上限体积注入: 复制 {need} 条记录至 {len(records)}"
f"(上限 {limit},触发 SORT LIMIT OVER")
return records
def _provision_sub_input_files(main_source, source_dir, outdir):
"""子程序输入供给:为 CALL 目标子程序的 INPUT FD 生成输入文件。
根因:V3 只为主程序生成输入文件。テストドライバ(主程序无 FILE 节)调用
读取文件的自程序时(如 JIN08TST → JIN07SUB 读 JIN07R01),自程序 OPEN
输入文件失败(status 35)→ ABEND → 后续决策点不可达。
本函数(通用・程序名硬编码なし):
1) 主程序 PROC 中 `CALL 'NAME'` 收集子程序名
2) 在 source_dir 的 sub/ 找到子程序源码
3) 解析其 FILE-CONTROL 的 INPUT/I-O FDASSIGN 名・记录布局)
4) 按子程序 FD 布局写一条基础记录到 outdir/<assign>
返回已生成的 assign 名列表。
"""
src_dir = Path(source_dir)
sub_dir = src_dir.parent / 'sub'
if not sub_dir.is_dir():
sub_dir = src_dir / 'sub'
cpy_dir = src_dir.parent / 'cpy'
if not cpy_dir.is_dir():
cpy_dir = src_dir / 'cpy'
calls = set()
for m in re.finditer(r"\bCALL\s+['\"](\w+)['\"]", main_source, re.IGNORECASE):
calls.add(m.group(1).upper())
if not calls:
return []
from .flatfile import analyze_fd_layout, write_flat_file
written = []
for sub_name in sorted(calls):
sp = sub_dir / f"{sub_name}.cbl"
if not sp.exists():
continue
sub_src = sp.read_text(encoding='utf-8')
try:
sub_src = resolve_copybooks(
sub_src, str(sub_dir),
extra_search_paths=[str(cpy_dir)] if cpy_dir.is_dir() else None)
layouts = analyze_fd_layout(
sub_src,
copybook_dirs=[str(cpy_dir)] if cpy_dir.is_dir() else None)
except Exception as e:
logger.debug(f" 子程序 {sub_name} 布局解析失败: {e}")
continue
for assign, layout in layouts.items():
if layout.get('direction') not in ('INPUT', 'I-O'):
continue
base = {}
for rec in layout.get('records', []):
for f in rec.get('fields', []):
pi = f.get('pic_info') or {}
if pi.get('type') == 'numeric':
ln = (pi.get('digits', 0) + pi.get('decimal', 0)) or 1
base[f['name']] = '0' * ln
else:
ln = f.get('length') or pi.get('length') or 1
base[f['name']] = 'A' * ln
outpath = Path(outdir) / assign
outpath.parent.mkdir(parents=True, exist_ok=True)
try:
write_flat_file([base], layout, outpath)
except Exception as e:
logger.debug(f" 子程序 {sub_name} 输入文件写入失败: {e}")
continue
rec_len = layout.get('records', [{}])[0].get('record_length', 0)
written.append(assign)
logger.info(f" 子程序输入供给: {sub_name}{assign}(记录 {rec_len}B")
return written
def _coordinate_tertiary_fd(records, fd_prefixes, data_fields, term_types=None):
"""构造多文件照合程序的前置匹配序列(通用,不依赖程序名)。
fd_prefixes = [主FD字段前缀, 明细FD字段前缀1, ...](如 SW/SR/SL)。
在记录头部构造单调递增键序列,使 merge-join 指针自然对齐:
- rec0: 主 + 全部明细键一致 → 全匹配(照合ループ + 判别 EVALUATE 全 WHEN
- rec(k): 主 + 第 k 个明细键一致、其余不同 → 单明细匹配(各照合组合)
判别字段(*LEAVE-TYPE)所在明细构造同键重复块:
- 块1: 短时长 + 判别值 01-04 → 全匹配内循环消费(EVALUATE 全 WHEN
- 块2: 长时长(0600-2200+ 判别值 01-04 → 单明细匹配内循环消费,
覆盖时长上限检查 T 分支与午餐重叠判定。
"""
master = fd_prefixes[0]
details = fd_prefixes[1:]
if not details:
return
normal_indices = [i for i in range(len(records))
if not term_types or i >= len(term_types) or term_types[i] != 'abend']
dpairs = {}
for d in details:
pairs = _find_key_pairs_inner(master, d, data_fields)
if pairs:
dpairs[d] = pairs
if not dpairs:
return
# 判别字段:取"最后一个"含 *LEAVE-TYPE 的明细 FD(深层休暇展开文件)。
# 注意 R02 打刻文件也可能含 LEAVE-TYPE 字段(如 SRLEAVE-TYPE),但
# 内循环 EVALUATE 判别的是休暇展开明细(R03),故从后向前选。
disc_detail = None
disc_field = None
for d in reversed(details):
if d not in dpairs:
continue
for f in data_fields:
if not isinstance(f, dict) or not f.get('pic'):
continue
nm = f.get('name', '')
if nm.startswith(d) and 'LEAVE-TYPE' in nm.upper():
disc_detail, disc_field = d, nm
break
if disc_detail:
break
disc_values = ['01', '02', '03', '04', '99'] if disc_field else []
m = len(disc_values) if disc_field else 2
n = len(details)
match_cnt = n + 1
need = max(match_cnt, m) + m
if len(normal_indices) < max(need, 6):
return
def _synth(idx):
# 合成键:EMP-ID 'A0000001' 起递增、日期 20000101 起递增(字典序单调递增)
return f'A{idx + 1:07d}', f'{20000101 + idx:08d}'
def _apply(rec, prefix, pairs, idx):
emp, date = _synth(idx)
for fa, fb in pairs:
f = fa if prefix == master else fb
val = date if 'DATE' in f.upper() else emp
rec[f] = val
p = normal_indices
# rec0: 全匹配(主 + 全部明细键 = K0)
rec = records[p[0]]
for d, pairs in dpairs.items():
_apply(rec, master, pairs, 0)
_apply(rec, d, pairs, 0)
# rec1..n: 单明细匹配(主 + 该明细 = K(k+1),其余明细用大偏移避免误配)
for k, d in enumerate(details):
if k + 1 >= len(p):
continue
rec = records[p[k + 1]]
_apply(rec, master, dpairs[d], k + 1)
_apply(rec, d, dpairs[d], k + 1)
for d2, pairs2 in dpairs.items():
if d2 != d:
_apply(rec, d2, pairs2, k + 1 + 50)
if disc_detail and disc_field:
pairs = dpairs[disc_detail]
# 块1:位置 p[0..m-1],键 = K0,短时长 + 判别值
for j in range(m):
if j >= len(p):
break
rec = records[p[j]]
_apply(rec, disc_detail, pairs, 0)
rec[disc_field] = disc_values[j]
# 块2:位置 p[max(match_cnt,m)..+m],键 = K(disc_idx),长时长 + 判别值
disc_idx = details.index(disc_detail) + 1
b2_start = max(match_cnt, m)
st_f = en_f = None
for f in data_fields:
if not isinstance(f, dict) or not f.get('pic'):
continue
nm = f.get('name', '')
if nm.startswith(disc_detail):
u = nm.upper()
if 'START-TIME' in u:
st_f = nm
elif 'END-TIME' in u:
en_f = nm
for j in range(m):
idx = b2_start + j
if idx >= len(p):
break
rec = records[p[idx]]
_apply(rec, disc_detail, pairs, disc_idx)
rec[disc_field] = disc_values[j]
if st_f and en_f:
rec[st_f] = '0600'
rec[en_f] = '2200'
logger.info(
f" 前置匹配序列: 全匹配+{n}单明细匹配, {disc_detail} 判别块 {disc_values} "
f"(长时长 0600-2200 覆盖上限/午餐判定)"
)
else:
# 无判别字段:仍制造同键重复(触发明细内循环)
d0, pairs0 = next(iter(dpairs.items()))
if len(p) >= 2:
rec0, rec1 = records[p[0]], records[p[1]]
_apply(rec0, master, pairs0, 0)
_apply(rec0, d0, pairs0, 0)
_apply(rec1, master, pairs0, 0)
_apply(rec1, d0, pairs0, 0)
logger.info(f" 前置匹配序列: 全匹配+{n}单明细匹配 ({d0} 同键重复)")
def _find_key_pairs_inner(prefix_a, prefix_b, data_fields):
"""Find BEST matching key field pairs between two FD prefixes.
Isolated helper for _coordinate_tertiary_fd.
"""
a_candidates = {}
b_candidates = {}
for f in data_fields:
if not isinstance(f, dict) or not f.get('pic'):
continue
name = f['name']
if name.startswith(prefix_a):
base = name[len(prefix_a):].lstrip('-')
if _is_match_key_field(base):
a_candidates[name] = base
elif name.startswith(prefix_b):
base = name[len(prefix_b):].lstrip('-')
if _is_match_key_field(base):
b_candidates[name] = base
pairs = []
used_b = set()
for fa, ba in sorted(a_candidates.items()):
for fb, bb in sorted(b_candidates.items()):
if fb in used_b:
continue
if ba == bb or ba.replace('WORK-', '') == bb or ba == bb.replace('WORK-', ''):
pairs.append((fa, fb))
used_b.add(fb)
break
for fa, ba in sorted(a_candidates.items()):
if any(p[0] == fa for p in pairs):
continue
ua = ba.upper()
if 'EMP' not in ua:
continue
for fb, bb in sorted(b_candidates.items()):
if fb in used_b:
continue
ub = bb.upper()
if 'EMP' in ub:
pairs.append((fa, fb))
used_b.add(fb)
break
# Suffix tier: 明细键 base 是主表键 base 的真后缀(如 SKILL-CODE ⊂
# MST-SKILL-CODE)且 key 类别一致(同为 -CODE/-ID/-NO/DATE/EMP-ID)时配对。
# 通用实现、无程序名硬编码,覆盖"主表键带限定词(MST- 等)"的照合形态。
def _same_key_class(x, y):
ux, uy = x.upper(), y.upper()
for cls in ('EMP-ID', '-CODE', '-ID', '-NO', 'DATE'):
if cls in ux and cls in uy:
return True
return False
for fa, ba in sorted(a_candidates.items()):
if any(p[0] == fa for p in pairs):
continue
for fb, bb in sorted(b_candidates.items()):
if fb in used_b:
continue
if (len(ba) >= 3 and len(bb) >= 3 and ba != bb
and _same_key_class(ba, bb)
and (bb.upper().endswith(ba.upper())
or ba.upper().endswith(bb.upper()))):
pairs.append((fa, fb))
used_b.add(fb)
break
return pairs
def _inject_leave_type_scenarios(records, term_types):
"""Inject R02LEAVE-TYPE / R02APPL-ID 覆盖 2030PRIOSOR 所有 WHEN + IF-ELSE 分支 + WHEN OTHER。
在 _coordinate_multi_file_keys 之后运行, 取 16 条连续 normal 记录
(1 anchor + 14 scenario + 1 WHEN OTHER), 设其 R01/R02 キー相同, 然后依次设
R02LEAVE-TYPE 为 '99''04''04''03''03''04''02''02'
'03''04''01''01''02''03''04''99',
配合 APPL-ID 递减, 以单一 2020MATCHSOR 调用遍历 EVALUATE 内部所有路径。
"""
if not records:
return
# 守卫:仅当记录中存在 LEAVE-TYPE 字段(2030PRIOSOR 系)时执行。
# 避免对无 LEAVE-TYPE 的 N:1 集約程序(如 SHA05TWN)产生副作用——
# 该函数原会无差别把前 16 条 normal 记录的 EMP-ID 设为同一值。
if not any('LEAVE-TYPE' in str(k).upper() for k in records[0]):
return
# 跳过 abend 记录, 找连续 16 条 normal (1 anchor + 14 scenario + 1 OTHER)
needed = 16
normal_indices = []
for i in range(len(records)):
if term_types and i < len(term_types) and term_types[i] == 'abend':
continue
normal_indices.append(i)
if len(normal_indices) >= needed:
break
if len(normal_indices) < needed:
logger.warning(f" 休暇種別覆盖: normal 记录不足 ({len(normal_indices)}/{needed}), 跳过")
return
anchor_idx = normal_indices[0]
anchor = records[anchor_idx]
common_emp = str(anchor.get('R02EMP-ID', '')).strip() or 'E0000999'
common_date = str(anchor.get('R02DATE', '')).strip() or '20991231'
# 所有记录设相同キー
for idx in normal_indices:
rec = records[idx]
rec['R02EMP-ID'] = common_emp
rec['R02DATE'] = common_date
rec['R01EMP-ID'] = common_emp
rec['R01WORK-DATE'] = common_date
# 锚点: LT='99'(CNS-NO-LEAVE), 第一个 MATCH 使 BEST-TYPE=99
anchor['R02LEAVE-TYPE'] = '99'
anchor['R02APPL-ID'] = '000000100'
anchor['R02START-TIME'] = '0800'
anchor['R02END-TIME'] = '1700'
# 14 scenario + 1 OTHER 按顺序覆盖各 WHEN 分支
# 路径: BEST 从 99→04→03→02→01 逐级覆盖, 末尾 LT='99' 进 WHEN OTHER
scenarios = [
('04', '000000080', '0800', '1700'), # LE04 ELSE(384-387)
('04', '000000060', '0800', '1700'), # LE04 AID<BEST(379-381)
('03', '000000050', '0800', '1700'), # LE03 ELSE(365-368)
('03', '000000040', '0800', '1700'), # LE03 AID<BEST(360-362)
('04', '000000045', '0800', '1700'), # LE04 CONT(BEST=03)(375-376)
('02', '000000030', '0800', '1700'), # LE02 ELSE(348-351)
('02', '000000020', '0800', '1700'), # LE02 AID<BEST(343-345)
('03', '000000025', '0800', '1700'), # LE03 CONT(BEST=02)(356-357)
('04', '000000030', '0800', '1700'), # LE04 CONT(BEST=02)(373-374)
('01', '000000010', '0800', '1700'), # LE01 NOT=01(329-332)
('01', '000000005', '0800', '1700'), # LE01 AID<BEST(334-336)
('02', '000000015', '0800', '1700'), # LE02 CONT(BEST=01)(339-340)
('03', '000000020', '0800', '1700'), # LE03 CONT(BEST=01)(354-355)
('04', '000000025', '0800', '1700'), # LE04 CONT(BEST=01)(371-372)
('99', '000000999', '0800', '1700'), # WHEN OTHER CONTINUE(390)
]
for j, idx in enumerate(normal_indices[1:]):
rec = records[idx]
lt, aid, st, et = scenarios[j]
rec['R02LEAVE-TYPE'] = lt
rec['R02APPL-ID'] = aid
rec['R02START-TIME'] = st
rec['R02END-TIME'] = et
logger.info(f" 2030PRIOSOR 全分支覆盖: {needed} 条记录注入完成 (共通キー={common_emp}/{common_date})")
def _inject_c01_coverage_records(records, fields, base_assignments):
"""Inject records for uncovered C01 decision points (DP#9-#12 T).
@@ -477,8 +1498,8 @@ def main():
)
vr = orch.run_all(generate_coverage=False)
# Copy output files to outdir
if orch.runtime_dir.exists():
# Copy output files to outdir (skip if src == dst to avoid self-copy)
if orch.runtime_dir.exists() and orch.runtime_dir.resolve() != prog_outdir.resolve():
for item in orch.runtime_dir.iterdir():
if item.name == "gixsql.log":
continue
@@ -661,7 +1682,7 @@ def main():
if skip_path_infos:
logger.info(f" Skip 路径: {len(skip_path_infos)} 条(将单独生成数据集)")
open_dir = scan_open_statements(proc_div) if proc_div else {}
open_dir = scan_all_file_directions(proc_div) if proc_div else {}
if proc_div:
logger.info(f"\n分支路径数:{len(branch_paths_with_assigns)}")
@@ -681,7 +1702,7 @@ def main():
path_infos = [([], {}, 'normal')]
roles = {f['name']: 'unused' for f in fields_dict}
records, _, term_types = generate_records(path_infos, fields_dict, assignments, file_sec=file_sec)
records, kept_path_cons, term_types = generate_records(path_infos, fields_dict, assignments, file_sec=file_sec)
def _is_eof_path(cons):
last_eq1_true = -1
@@ -711,6 +1732,20 @@ def main():
_inject_empty_emp_rec(records, fields_dict)
# P5: inject records for uncovered C01 decision points (DP#9-#12 T)
_inject_c01_coverage_records(records, fields_dict, assignments)
# P6: coordinate cross-file keys for multi-file matching programs
_coordinate_multi_file_keys(records, kept_path_cons, fields_dict, assignments, file_sec, term_types=term_types, open_dir=open_dir)
# P6.5: 金额-区间对齐(N:1 集約→GRADE 匹配路径可达)
_coordinate_range_matching(records, fields_dict, file_sec, open_dir, term_types)
# P7: inject leave-type coverage for 2030PRIOSOR EVALUATE branches
_inject_leave_type_scenarios(records, term_types)
# P8: opaque FD + READ INTO — 将 WS 子字段值序列化回 FD 记录字节
_serialize_read_into_records(records, assignments, fields_dict, file_sec)
# P9: MERGE 程序 — 为 USING 输入文件注入 S/B 记录
_inject_merge_input_records(records, fields_dict, file_sec, fd_fields, proc_div, term_types=term_types)
# P10: LINAGE 分页(WRITE AT END-OF-PAGE)— 补充分页体积记录
_inject_end_of_page_records(records, source, proc_div, file_sec, open_dir, term_types=term_types)
# P11: SORT 计数守卫(RELEASE + IF <cnt> <= <上限>)— 补充上限体积记录
_inject_sort_limit_records(records, proc_div, fields_dict, term_types=term_types)
if _HAVE_TOSQL:
sql_meta = collect_sql_meta(assignments, declared_columns)
@@ -736,6 +1771,11 @@ def main():
term_types=term_types,
data_fields=fields_dict, select_info=select_info)
# P12: 子程序输入供给(CALL 目标子程序的 INPUT FD 输入文件)
_provision_sub_input_files(
source, str(filepath.parent), prog_outdir / 'main' / 'input'
)
# ── Skip 数据集(主 FD 空文件触发 PERFORM UNTIL 条件即时满足)──
skip_records = None
skip_term_types = None
@@ -932,7 +1972,7 @@ def extract_structure(cobol_source: str, copybook_dirs: list = None) -> dict:
branch_tree, assignments = build_branch_tree_fallback(proc_div, fields_dict)
file_sec = parse_file_section(preprocessed)
open_dir = scan_open_statements(proc_div) if proc_div else {}
open_dir = scan_all_file_directions(proc_div) if proc_div else {}
from .models import BrIf, BrEval, BrSeq, BrPerform, BrSearch, Assign, CondAnd, CondOr
@@ -1351,6 +2391,23 @@ def generate_data(cobol_source: str, structure: dict = None,
if lhs in rec and rhs in rec and i < half:
rec[rhs] = rec[lhs]
# P6: 跨文件キー協調(DB/非DB 両パイプ共通)— 部分記錄のキーを
# 主表(R02/R03 等)と一致させ、照合 STAGE/DB SELECT 経路を到達可能にする。
# 出力 FD は open_dir で除外し、入力 FD 同士のみ協調する。
if records and file_sec:
try:
open_dir = scan_all_file_directions(proc_div or '')
_coordinate_multi_file_keys(
records, kept_paths, fields_dict, assignments,
file_sec, term_types=term_types, open_dir=open_dir,
)
# P6.5: 金额-区间对齐(N:1 集約→GRADE 匹配路径可达,DB/非DB 共通)
_coordinate_range_matching(
records, fields_dict, file_sec, open_dir, term_types,
)
except Exception as e:
logger.warning(f" cross-file key coordination skipped: {e}")
return records