From 273a3f821174c0503b8078dbd60ae6c5048d9b6d Mon Sep 17 00:00:00 2001 From: hangshuo652 Date: Sun, 9 Aug 2026 17:43:00 +0800 Subject: [PATCH] feat: SQL between/hostvar-key alignment, class-condition parsing, gcov merge across scenario runs --- cobol_testgen/__init__.py | 1069 +++++++++++++++++++++++++++++- cobol_testgen/cond.py | 108 ++- cobol_testgen/core.py | 158 ++++- cobol_testgen/coverage.py | 96 ++- cobol_testgen/data_merger.py | 8 +- cobol_testgen/design.py | 211 +++++- cobol_testgen/design_mcdc.py | 177 ++++- cobol_testgen/file_io.py | 17 +- cobol_testgen/flatfile.py | 17 +- cobol_testgen/grammar.lark | 2 +- cobol_testgen/output.py | 5 +- cobol_testgen/pipeline_bridge.py | 71 +- cobol_testgen/read.py | 73 +- cobol_testgen/runner.py | 43 +- cobol_testgen/to_sql.py | 678 ++++++++++++++++++- config/program_schema.py | 9 + config/programs/JIN05UPD.yaml | 34 + config/programs/KIN06CLD.yaml | 16 + config/programs/KIN08DBU.yaml | 19 + config/programs/KIN09CSV.yaml | 24 + config/programs/KYU02REG.yaml | 32 + config/programs/KYU04CAL.yaml | 50 ++ config/programs/KYU05DED.yaml | 36 + config/programs/KYU06UPD.yaml | 29 + config/programs/SHA02MNC.yaml | 45 ++ config/programs/SHA03MNP.yaml | 32 + config/programs/SHA04TWO.yaml | 19 + config/programs/SHA06TWM.yaml | 30 + config/programs/SHA07KBR.yaml | 34 + orchestrator_db.py | 727 +++++++++++++++++--- runners/gixsql_runner.py | 192 +++++- 31 files changed, 3789 insertions(+), 272 deletions(-) create mode 100644 config/programs/JIN05UPD.yaml create mode 100644 config/programs/KYU02REG.yaml create mode 100644 config/programs/KYU04CAL.yaml create mode 100644 config/programs/KYU05DED.yaml create mode 100644 config/programs/KYU06UPD.yaml create mode 100644 config/programs/SHA02MNC.yaml create mode 100644 config/programs/SHA03MNP.yaml create mode 100644 config/programs/SHA04TWO.yaml create mode 100644 config/programs/SHA06TWM.yaml create mode 100644 config/programs/SHA07KBR.yaml diff --git a/cobol_testgen/__init__.py b/cobol_testgen/__init__.py index aaea8dd..d03bf79 100644 --- a/cobol_testgen/__init__.py +++ b/cobol_testgen/__init__.py @@ -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 FD(open_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 = ` 解析判别常量值 + (如 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-ID,REC-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 LINES(TOP 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 上限エラー`),需超过上限条数的输入 + 记录才能覆盖 ELSE(SORT LIMIT OVER)分支(例:JIN06SRT 输入仅 6 条,上限 999)。 + + 本函数(通用・程序名硬编码なし): + 1) PROC 中检测 SORT + RELEASE + 同一段落内 `IF (<=|<) ` + 2) 解析 的常量值(字段 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 (<=|<) (同段落内必须有 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 FD(ASSIGN 名・记录布局) + 4) 按子程序 FD 布局写一条基础记录到 outdir/ + + 返回已生成的 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 <= <上限>)— 补充上限体积记录 + _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 diff --git a/cobol_testgen/cond.py b/cobol_testgen/cond.py index 6aeb006..500e8fe 100644 --- a/cobol_testgen/cond.py +++ b/cobol_testgen/cond.py @@ -98,10 +98,15 @@ def parse_single_condition(text, fields=None): if ' OF ' in text.upper(): text = text.split(' OF ')[0].strip() - # COBOL class condition: WS-KEY-DGT-N NUMERIC - if re.match(r'^[A-Z][A-Z0-9_-]*(?:\([^)]*\))?\s+(NUMERIC|ALPHABETIC|ALPHABETIC-UPPER|POSITIVE|NEGATIVE|ZERO)\s*$', text, re.IGNORECASE): - m = re.match(r'^([A-Z][A-Z0-9_-]*(?:\([^)]*\))?)\s+(NUMERIC|ALPHABETIC|ALPHABETIC-UPPER|POSITIVE|NEGATIVE|ZERO)\s*$', text, re.IGNORECASE) - return (m.group(1), '=', m.group(2).upper()) + # COBOL class condition: WS-KEY-DGT-N [IS] [NOT] NUMERIC/ALPHABETIC/... + # Return (field, 'IS', CLASS, want) — want=True → field must satisfy the class, + # want=False → field must NOT satisfy the class (IS NOT / NOT). + m = re.match( + r'^([A-Z][A-Z0-9_-]*(?:\([^)]*\))?)\s+(?:IS\s+)?' + r'(NOT\s+)?(NUMERIC|ALPHABETIC|ALPHABETIC-UPPER|ALPHABETIC-LOWER|POSITIVE|NEGATIVE|ZERO)\s*$', + text, re.IGNORECASE) + if m: + return (m.group(1), 'IS', m.group(3).upper(), not bool(m.group(2))) # Bare field reference (no operator, no NOT): WS-EOF → WS-EOF = 'Y' if re.match(r'^[A-Z][A-Z0-9_-]*(?:\([^)]*\))?\s*$', text, re.IGNORECASE): @@ -150,7 +155,7 @@ def parse_single_condition(text, fields=None): if text.upper().startswith('FUNCTION '): # After not_map normalization, NOT = has been converted to <> func_match = re.match( - r'^FUNCTION\s+(\w+)\(([^)]*)\)\s*(>=|<=|<>|>|<|=)\s*(.*)$', + r'^FUNCTION\s+(\w+)\s*\(([^)]*)\)\s*(>=|<=|<>|>|<|=)\s*(.*)$', normalized, re.IGNORECASE ) if func_match: @@ -244,6 +249,11 @@ def parse_compound_condition(text, fields=None): # Leaf condition parsed = parse_single_condition(text, fields) if parsed: + if len(parsed) == 4: + # class condition (field, 'IS', CLASS, want): represent negation via CondNot + field, op, cls, want = parsed + leaf = CondLeaf(field, op, cls) + return leaf if want else CondNot(leaf) return CondLeaf(*parsed) return None @@ -332,12 +342,69 @@ def mcdc_sets(tree, fields=None): # ── 值计算 ── +def evaluate_class_value(value, class_name): + """COBOL class-condition evaluation on a raw field value. + + Returns True if `value` belongs to `class_name`. + class_name ∈ {NUMERIC, ALPHABETIC, ALPHABETIC-UPPER, ALPHABETIC-LOWER, + POSITIVE, NEGATIVE, ZERO}. Empty / all-space values never + satisfy the ALPHABETIC* / NUMERIC classes (COBOL class-condition semantics). + """ + s = str(value) + cls = str(class_name).upper() + if cls == 'ALPHABETIC-UPPER': + return bool(s) and all('A' <= ch <= 'Z' for ch in s) + if cls == 'ALPHABETIC-LOWER': + return bool(s) and all('a' <= ch <= 'z' for ch in s) + if cls == 'ALPHABETIC': + return bool(s) and all(ch.isalpha() for ch in s) + if cls == 'NUMERIC': + return bool(s) and all('0' <= ch <= '9' for ch in s) + if cls in ('POSITIVE', 'NEGATIVE', 'ZERO'): + try: + n = float(str(s).strip()) + except (ValueError, TypeError): + return False + if cls == 'POSITIVE': + return n > 0 + if cls == 'NEGATIVE': + return n < 0 + return n == 0 + return False + + def satisfying_value(field_info: dict, operator: str, value, want_true: bool) -> str: ftype = field_info.get('type', 'unknown') digits = field_info.get('digits', 0) decimal = field_info.get('decimal', 0) total = digits + decimal + # COBOL class-condition constraint: generate a value that is / is not in the class + if operator == 'IS': + cls = str(value).upper() + length = field_info.get('length', 1) + if cls in ('ALPHABETIC', 'ALPHABETIC-UPPER'): + if want_true: + return 'A' * length + return ('A' * (length - 1) + '0') if length > 1 else '0' + if cls == 'ALPHABETIC-LOWER': + if want_true: + return 'a' * length + return ('a' * (length - 1) + '0') if length > 1 else '0' + if cls == 'NUMERIC': + if want_true: + return '0' * length + return ('0' * max(length - 1, 1) + 'A') if length > 1 else 'A' + if cls == 'ZERO': + return '0' * max(total, 1) + if cls == 'POSITIVE': + return str(1).zfill(total) if total else '1' + if cls == 'NEGATIVE': + if field_info.get('signed'): + return '-' + '1'.zfill(max(total - 1, 1)) + return '0' * max(total, 1) + return '0'.zfill(max(total, 1)) + if ftype == 'numeric': try: val_str = str(value) @@ -376,13 +443,31 @@ def satisfying_value(field_info: dict, operator: str, value, want_true: bool) -> elif ftype in ('alphanumeric', 'alphabetic'): length = field_info.get('length', 1) - base_chr = value[0].upper() if isinstance(value, str) and value else 'A' + # 图式常量解析:SPACES/SPACE→空格、ZERO(S)/ZEROES→'0'、LOW-VALUES→\x00、 + # HIGH-VALUES→\xff、QUOTE(S)→"'"。否则按值首字符作基础字符。 + _uv = str(value).strip().upper() if isinstance(value, str) else '' + _FIG = { + 'SPACE': ' ', 'SPACES': ' ', + 'ZERO': '0', 'ZEROS': '0', 'ZEROES': '0', + 'LOW-VALUE': '\x00', 'LOW-VALUES': '\x00', + 'HIGH-VALUE': '\xff', 'HIGH-VALUES': '\xff', + 'QUOTE': "'", 'QUOTES': "'", + } + base_chr = _FIG.get(_uv) or (value[0].upper() if isinstance(value, str) and value else 'A') + # 精确值目标:字面值(或图式常量)截断/右对齐到字段长。 + # 例如 (= '0001', want=True) / (<> '0001', want=False) 应得到 '0001', + # 而非按首字符填充的 '0000'(旧行为错误)。 + if _uv in _FIG: + _exact = (_FIG[_uv] * length)[:length] + else: + _exact = str(value)[:length].ljust(length) + _other = chr(65 + (ord(base_chr) - 64) % 26) + _diff = _other.ljust(length, _other) if want_true: if operator in ('=', '=='): - return base_chr.ljust(length, base_chr) + return _exact elif operator in ('<>', '!='): - other = chr(65 + (ord(base_chr) - 64) % 26) - return other.ljust(length, other) + return _diff elif operator == '>': sv = str(value)[:length].ljust(length) chars = list(sv) @@ -403,10 +488,9 @@ def satisfying_value(field_info: dict, operator: str, value, want_true: bool) -> return ''.join(chars) else: if operator in ('=', '=='): - other = chr(65 + (ord(base_chr) - 64) % 26) - return other.ljust(length, other) + return _diff elif operator in ('<>', '!='): - return base_chr.ljust(length, base_chr) + return _exact elif operator in ('>', '<'): return str(value)[:length].ljust(length) diff --git a/cobol_testgen/core.py b/cobol_testgen/core.py index 5678e6e..c9bbc56 100644 --- a/cobol_testgen/core.py +++ b/cobol_testgen/core.py @@ -161,9 +161,17 @@ class _BrParser: return self.lines[self.pos].strip() return '' + def peek_next(self): + if self.pos + 1 < len(self.lines): + return self.lines[self.pos + 1].strip() + return '' + def clean(self): return self.peek().rstrip('.').strip() + def clean_next(self): + return self.peek_next().rstrip('.').strip() + def advance(self): self.pos += 1 @@ -217,6 +225,38 @@ class _BrParser: if perf_node: seq.add(perf_node) continue + m_sm = re.match(r'^(MERGE|SORT)\s+(\w[\w-]*)\s*$', line) + if m_sm: + sort_file = m_sm.group(2).strip() + self.advance() + # 收集 MERGE/SORT 语句的续行(直到以 . 结尾) + stmt_parts = [line] + while self.pos < len(self.lines): + raw = self.peek() + stmt_parts.append(self.clean()) + self.advance() + # clean() 会剥离末尾句点,须用原始行判断语句终止; + # 否则该循环永不退出,吞掉 SORT/MERGE 后的全部语句。 + if raw.rstrip().endswith('.'): + break + stmt_text = ' '.join(stmt_parts) + # 提取 INPUT/OUTPUT PROCEDURE 段名并内联(如同 PERFORM 段) + for pm in re.finditer( + r'\b(?:INPUT|OUTPUT)\s+PROCEDURE\s+(\w[\w-]*)', + stmt_text, re.IGNORECASE + ): + sec = pm.group(1).strip().upper() + if sec in self.paragraphs: + start, end = self.paragraphs[sec] + para_lines = self.raw_lines[start:end + 1] + sub = _BrParser( + [l for l in para_lines if l.strip()], + self.paragraphs, self.raw_lines, self.assignments, self.fields + ) + sub_seq = sub.parse_seq() + for child in sub_seq.children: + seq.add(child) + continue m_search = re.match(r'^SEARCH\b(?:\s+(ALL))?\s+(\w[\w-]*)(?:\s+VARYING\s+(\w[\w-]*))?', line, re.IGNORECASE) if m_search: seq.add(self._parse_search(m_search)) @@ -277,6 +317,23 @@ class _BrParser: break self.advance() continue + m = re.match(r'^READ\s+(\w[\w-]*)\s*$', line, re.IGNORECASE) + if m and self.peek_next().startswith('INTO'): + tgt = self.clean_next().replace('INTO', '', 1).strip().upper() + if re.match(r'^\w[\w-]*$', tgt): + info = {'type': 'read_into', 'file': m.group(1).strip().upper(), 'source_vars': []} + self.assignments.setdefault(tgt, []).append(info) + seq.add(Assign(tgt, info)) + self.advance() + self.advance() + # 跳过 READ 语句剩余行(AT END / NOT AT END / END-READ) + while self.pos < len(self.lines): + cl = self.clean() + if cl in ('END-READ', 'END-READ.'): + self.advance() + break + self.advance() + continue m_set_false = re.match(r'^SET\s+(\w[\w-]*)\s+TO\s+FALSE\s*$', line, re.IGNORECASE) if m_set_false: seq.add(self._parse_set_false(m_set_false.group(1))) @@ -796,6 +853,16 @@ class _BrParser: def _parse_perform(self): line = self.clean() + # PERFORM WITH TEST AFTER/BEFORE UNTIL ...(可换行) + m = re.match(r'^PERFORM\s+(?:WITH\s+TEST\s+(?:AFTER|BEFORE)\s+)?UNTIL\s+(.+?)\s*$', line) + if m: + node = BrPerform('until', condition=m.group(1).strip()) + self.advance() + node.body_seq = self.parse_seq(end_check=lambda l: l == 'END-PERFORM') + if self.clean() == 'END-PERFORM': + self.advance() + return node + m = re.match(r'^PERFORM\s+UNTIL\s+(.+?)\s*$', line) if m: node = BrPerform('until', condition=m.group(1).strip()) @@ -965,6 +1032,28 @@ class _BrParser: self._inline_perform(node, target) return node + # PERFORM WITH TEST AFTER/BEFORE(UNTIL 在下一行) + m = re.match(r'^PERFORM\s+WITH\s+TEST\s+(?:AFTER|BEFORE)\s*$', line) + if m: + save_pos = self.pos + condition = None + self.advance() + if self.pos < len(self.lines): + nxt = self.clean() + um = re.match(r'^UNTIL\s+(.+)$', nxt) + if um: + condition = um.group(1).strip() + self.advance() + if condition: + node = BrPerform('until', condition=condition) + node.body_seq = self.parse_seq(end_check=lambda l: l == 'END-PERFORM') + if self.clean() == 'END-PERFORM': + self.advance() + return node + self.pos = save_pos + self.advance() + return None + self.advance() return None @@ -1189,24 +1278,24 @@ class _BrParser: _RE_SELECT_INTO = re.compile( r'SELECT\s+(.*?)\s+INTO\s+(:\w[\w-]*(?:\s*,\s*:\w[\w-]*(?::\w[\w-]*)?)*)' - r'\s+FROM\s+(\w[\w-]*)', + r'\s+FROM\s+([\w-]+(?:\.[\w-]+)?)', re.IGNORECASE ) _RE_WHERE = re.compile(r'\bWHERE\b\s+(.*)', re.IGNORECASE) _RE_SQL_INSERT = re.compile( - r'INSERT\s+INTO\s+(\w[\w-]*)\s*\(([^)]+)\)\s+VALUES\s*\(([^)]+)\)', + r'INSERT\s+INTO\s+([\w-]+(?:\.[\w-]+)?)\s*\(([^)]+)\)\s+VALUES\s*\(([^)]+)\)', re.IGNORECASE ) _RE_SQL_DELETE = re.compile( - r'DELETE\s+FROM\s+(\w[\w-]*)(?:\s+WHERE\s+(.+))?', + r'DELETE\s+FROM\s+([\w-]+(?:\.[\w-]+)?)(?:\s+WHERE\s+(.+))?', re.IGNORECASE ) _RE_SQL_UPDATE = re.compile( - r'UPDATE\s+(\w[\w-]*)\s+SET\s+(.+?)(?:\s+WHERE\s+(.+))?$', + r'UPDATE\s+([\w-]+(?:\.[\w-]+)?)\s+SET\s+(.+?)(?:\s+WHERE\s+(.+))?$', re.IGNORECASE ) @@ -1229,7 +1318,7 @@ class _BrParser: result = re.sub(r'\s+', ' ', result) return result - def _parse_sql(self, sql_text: str): + def _parse_sql(self, sql_text: str, pos: int = None): """Parse SQL text from EXEC SQL block. Returns Assign node or None.""" # 1) SELECT ... INTO ... FROM m = self._RE_SELECT_INTO.search(sql_text) @@ -1259,6 +1348,7 @@ class _BrParser: 'into_vars': into_vars, 'where': where_clause, 'sql_text': sql_text, + 'pos': pos, } for var in into_vars: @@ -1288,6 +1378,7 @@ class _BrParser: 'raw_values': values_str, 'host_vars': host_vars, 'sql_text': sql_text, + 'pos': pos, } synthetic = f'__SQL_INSERT_{table}' self.assignments.setdefault(synthetic, []).append(info) @@ -1307,6 +1398,7 @@ class _BrParser: 'where': where_clause, 'host_vars': [h.upper() for h in host_vars], 'sql_text': sql_text, + 'pos': pos, } synthetic = f'__SQL_DELETE_{table}' self.assignments.setdefault(synthetic, []).append(info) @@ -1314,7 +1406,7 @@ class _BrParser: # 4a) DECLARE CURSOR ... FOR SELECT ... FROM ... m = re.search( - r'DECLARE\s+(\w[\w-]*)\s+CURSOR\s+FOR\s+SELECT\s+(.*?)\s+FROM\s+(\w[\w-]*)\s*(.*)', + r'DECLARE\s+(\w[\w-]*)\s+CURSOR\s+FOR\s+SELECT\s+(.*?)\s+FROM\s+([\w-]+(?:\.[\w-]+)?)\s*(.*)', sql_text, re.IGNORECASE ) if m: @@ -1336,6 +1428,7 @@ class _BrParser: 'into_vars': [], 'where': where_clause, 'sql_text': sql_text, + 'pos': pos, } synthetic = f'__SQL_CURSOR_{from_table}' self.assignments.setdefault(synthetic, []).append(info) @@ -1365,6 +1458,7 @@ class _BrParser: 'cursor_name': cursor_name, 'into_vars': into_vars, 'sql_text': sql_text, + 'pos': pos, } for var in into_vars: self.assignments.setdefault(var, []).append(info) @@ -1396,6 +1490,7 @@ class _BrParser: 'where': where_clause, 'host_vars': host_vars, 'sql_text': sql_text, + 'pos': pos, } synthetic = f'__SQL_UPDATE_{table}' self.assignments.setdefault(synthetic, []).append(info) @@ -1477,13 +1572,54 @@ def trace_to_root(field_name, assignments, fields, path_assign=None): var = next_var if next_var not in assignments: break - elif len(sv) >= 2 and asgn.get('op') == '+': - # 多源加法:取第一个源变量继续追溯 + elif len(sv) >= 2 and asgn.get('op') in ('+', '-'): + # 多源加减:取第一个源变量继续追溯; + # 若其余源为常量字段(有 VALUE),折叠为单源 compute 供 invert 使用 + folded = _fold_constants(asgn, fields) + if folded is not None: + chain[-1] = (var, folded) var = sv[0] else: break return var, chain +def _fold_constants(asgn, fields): + """将 2 源 compute 折叠为单源 compute + const,供链式反演使用。 + + 例如: T = A - CST(CST 为 WORKING-STORAGE 常量字段) + → 折叠为: T = A op const,其中 op 反转为 +(invert 时把 const 加回)。 + 返回新 asgn dict;若无法解析常量则返回 None(保持原 asgn)。 + """ + if not asgn.get('source_vars') or len(asgn['source_vars']) != 2: + return None + sv = asgn['source_vars'] + second = sv[1] + + def _resolve(name): + for f in fields: + fname = f['name'] if isinstance(f, dict) else getattr(f, 'name', '') + if fname == name: + val = f.get('value', None) if isinstance(f, dict) else getattr(f, 'value', None) + if val is None: + val = (f.get('values') or [None])[0] if isinstance(f, dict) else \ + getattr(f, 'values', None) + if isinstance(val, list): + val = val[0] if val else None + if val is not None: + try: + return float(str(val).strip("'\"")) + except (ValueError, TypeError): + return None + return None + + c = _resolve(second) + if c is None: + return None + new = dict(asgn) + new['source_vars'] = [sv[0]] + new['const'] = c + return new + def invert_through_chain(root_var, chain, operator, value): op = operator @@ -1964,10 +2100,10 @@ def classify_field_roles(tree, assignments, fields, source=None, proc_text=None) # Phase 0: FD/OPEN 方向解析 fd_roles = {} if source and proc_text: - from .read import parse_file_control, parse_file_section, scan_open_statements + from .read import parse_file_control, parse_file_section, scan_all_file_directions file_ctl = parse_file_control(source) file_sec = parse_file_section(source) - open_dir = scan_open_statements(proc_text) + open_dir = scan_all_file_directions(proc_text) for iname, direction in open_dir.items(): if iname in file_sec: for rec_name in file_sec[iname]: @@ -2164,6 +2300,6 @@ def extract_sql_assignments(source: str) -> dict: for m in _RE_EXEC_SQL.finditer(source): sql_text = re.sub(r'\s+', ' ', m.group(1).strip()) - parser._parse_sql(sql_text) + parser._parse_sql(sql_text, pos=m.start()) return dict(parser.assignments) diff --git a/cobol_testgen/coverage.py b/cobol_testgen/coverage.py index 6348967..2cdd263 100644 --- a/cobol_testgen/coverage.py +++ b/cobol_testgen/coverage.py @@ -229,17 +229,25 @@ def _mark_if(dp, cons): simple = getattr(dp, 'parsed', None) if simple: - field, op, val = simple - inv_op = {'=': '<>', '<>': '=', '>': '<=', '<': '>=', '>=': '<', '<=': '>'}.get(op, op) - inv_simple = (field, inv_op, val) - for c in cons: - if _match_constraint(c, simple): - if c[3]: - dp.active_branches.add('T') - else: + if len(simple) == 4: + # class condition (field, 'IS', CLASS, base_want): + # path want == base_want → T branch; == not base_want → F branch + field, op, val, base_want = simple + for c in cons: + if _match_constraint(c, (field, op, val)): + dp.active_branches.add('T' if c[3] == base_want else 'F') + else: + field, op, val = simple + inv_op = {'=': '<>', '<>': '=', '>': '<=', '<': '>=', '>=': '<', '<=': '>'}.get(op, op) + inv_simple = (field, inv_op, val) + for c in cons: + if _match_constraint(c, simple): + if c[3]: + dp.active_branches.add('T') + else: + dp.active_branches.add('F') + elif _match_constraint(c, inv_simple): dp.active_branches.add('F') - elif _match_constraint(c, inv_simple): - dp.active_branches.add('F') elif dp.cond_tree and dp.cond_leaves: assignment = {} for leaf in dp.cond_leaves: @@ -411,17 +419,24 @@ def _mark_perform(dp, cons): simple = getattr(dp, 'parsed', None) if simple: - field, op, val = simple - inv_op = {'=': '<>', '<>': '=', '>': '<=', '<': '>=', '>=': '<', '<=': '>'}.get(op, op) - inv_simple = (field, inv_op, val) - for c in cons: - if _match_constraint(c, simple): - if c[3]: - dp.active_branches.add('Skip') - else: + if len(simple) == 4: + # class condition (field, 'IS', CLASS, base_want): path want == base_want → Skip + field, op, val, base_want = simple + for c in cons: + if _match_constraint(c, (field, op, val)): + dp.active_branches.add('Skip' if c[3] == base_want else 'Enter') + else: + field, op, val = simple + inv_op = {'=': '<>', '<>': '=', '>': '<=', '<': '>=', '>=': '<', '<=': '>'}.get(op, op) + inv_simple = (field, inv_op, val) + for c in cons: + if _match_constraint(c, simple): + if c[3]: + dp.active_branches.add('Skip') + else: + dp.active_branches.add('Enter') + elif _match_constraint(c, inv_simple): dp.active_branches.add('Enter') - elif _match_constraint(c, inv_simple): - dp.active_branches.add('Enter') elif dp.cond_tree and dp.cond_leaves: assignment = {} for leaf in dp.cond_leaves: @@ -482,7 +497,42 @@ def locate_decision_lines(decision_points, raw_source): if re.search(short_pat, lines[i]): dp.source_line = i + 1 used_indices[dp.label] = i + found = True break + # Multi-line fallback 2: 条件被换行拆分(如 IF MERGE-REC-TYPE / = CONST) + # 用条件首个字段定位 IF 起始行(gcov 分支标记需要源行) + if not found and dp.kind == 'IF': + first_field_pat = _build_first_field_if_pattern(dp) + if first_field_pat: + for i in range(start, len(lines)): + if re.search(first_field_pat, lines[i]): + dp.source_line = i + 1 + used_indices[dp.label] = i + break + + +def _build_first_field_if_pattern(dp): + """Build a pattern matching 'IF <首字段>' — 用于条件被换行拆分的 IF。 + + 例:条件 'MERGE-REC-TYPE = CNS-PAY-TYPE-SALARY' 在源码中写作 + IF MERGE-REC-TYPE + = CNS-PAY-TYPE-SALARY + 单行模式无法匹配;此模式仅匹配 'IF MERGE-REC-TYPE' 定位起始行。 + """ + if dp.kind != 'IF': + return None + label = dp.label or '' + cond = label[2:].strip() if label.upper().startswith('IF ') else label + cond = cond.strip() + if not cond: + return None + # 去除前导 NOT / 括号,取首字段名 + m = re.match(r"^(?:NOT\s+)?\(?([A-Z][A-Z0-9_-]*)", cond, re.IGNORECASE) + if not m: + return None + first = m.group(1) + esc = re.escape(first) + return r'\bIF\b\s+' + esc + r'\b' def _normalize(text): @@ -494,7 +544,11 @@ def _normalize(text): def _build_search_patterns(dp): texts = [] if dp.kind == 'IF': - texts.append((r'\bIF\b', dp.label)) + # dp.label is already like "IF SQLCODE = -803" — avoid emitting + # "\bIF\b\s+IF\s+..." which never matches a real source line. + label = dp.label or '' + cond = label[2:].strip() if label.upper().startswith('IF ') else label + texts.append((r'\bIF\b', cond)) elif dp.kind == 'EVALUATE': texts.append((r'\bEVALUATE\b', dp.label)) elif dp.kind == 'PERFORM': diff --git a/cobol_testgen/data_merger.py b/cobol_testgen/data_merger.py index ac2ff66..f5c8fef 100644 --- a/cobol_testgen/data_merger.py +++ b/cobol_testgen/data_merger.py @@ -31,16 +31,16 @@ def _dedup( def _hash(rec, keys): if keys: - return tuple(rec.get(k, "") for k in keys) - return tuple(sorted(rec.items())) + return tuple(str(rec.get(k, "")) for k in keys) + return tuple((k, str(v) if isinstance(v, (list, dict, set)) else v) for k, v in sorted(rec.items())) - for rec in additional_records: + for rec in main_records: h = _hash(rec, key_fields) if h not in seen: seen.add(h) result.append(rec) - for rec in main_records: + for rec in additional_records: h = _hash(rec, key_fields) if h not in seen: seen.add(h) diff --git a/cobol_testgen/design.py b/cobol_testgen/design.py index 2958b44..914c3dc 100644 --- a/cobol_testgen/design.py +++ b/cobol_testgen/design.py @@ -2,8 +2,9 @@ import re import logging +import os from .models import BrSeq, BrIf, BrEval, BrPerform, BrSearch, Assign, CallNode, CondNot, CondLeaf, ExitNode, GoTo -from .cond import parse_single_condition, parse_compound_condition, is_field, collect_leaves, mcdc_sets, satisfying_value +from .cond import parse_single_condition, parse_compound_condition, is_field, collect_leaves, mcdc_sets, satisfying_value, evaluate_class_value from .core import trace_to_root, invert_through_chain, propagate_assignments, _basename logger = logging.getLogger(__name__) @@ -60,7 +61,7 @@ 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]: + if len(c) >= 4 and c[3]: return True return False @@ -68,16 +69,16 @@ 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]: + if len(c) >= 4 and not c[3]: return True return False def _cap_paths(paths): if len(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)] + special = [(i, p) for i, p in enumerate(paths) if any(_is_sentinel(c) for c in p[0])] + std = [(i, p) for i, p in enumerate(paths) if not any(_is_sentinel(c) for c in p[0])] + t_paths = [(i, p) for i, p in std if _has_t_branch(p[0])] + f_paths = [(i, p) for i, p in std if _has_f_branch(p[0])] quota = _MAX_PATHS - len(special) if quota <= 0: return [p for _, p in special[:_MAX_PATHS]] @@ -210,7 +211,11 @@ def enum_paths(node, fields): continue for cp_cons, cp_assign in child_paths: merged_cons = p_cons + list(cp_cons) - sig = frozenset(_hashable_cons(merged_cons)) + # 签名需保留约束的多重性:同一 (字段,运算符,值,期望) 在顺序 + # IF 中重复出现(如连续 C01CHKRRC 校验)时,frozenset 会把 + # 多重出现的约束折叠为单元素,导致"前面全部通过、最后一个校验 + # 失败"等中间路径被去重丢失。改用按 repr 排序的元组签名。 + sig = tuple(sorted(_hashable_cons(merged_cons), key=repr)) if sig not in covered_sigs: covered_sigs.add(sig) merged = {} @@ -223,12 +228,24 @@ def enum_paths(node, fields): if not any(_is_sentinel(c) for c in pc): new_active.append((pc, dict(pa))) break - paths = new_active + paths = _cap_paths(new_active) return paths elif isinstance(node, BrIf): parsed = parse_single_condition(node.condition, fields) if parsed and is_field(parsed[0], fields): + if len(parsed) == 4: + # class condition (field, 'IS', CLASS, base_want): + # T branch requires leaf truth = base_want, F branch = not base_want + field, op, val, base_want = parsed + paths = [] + true_sub = _cap_paths(enum_paths(node.true_seq, fields)) + for sp_cons, sp_assign in (true_sub or [([], {})]): + paths.append(([(field, op, val, base_want)] + sp_cons, sp_assign)) + false_sub = _cap_paths(enum_paths(node.false_seq, fields)) + for fp_cons, fp_assign in (false_sub or [([], {})]): + paths.append(([(field, op, val, not base_want)] + fp_cons, fp_assign)) + return paths field, op, val = parsed paths = [] true_sub = _cap_paths(enum_paths(node.true_seq, fields)) @@ -275,7 +292,7 @@ def enum_paths(node, fields): paths.append(([(leaf.field, leaf.op, leaf.value, False)] + fp_cons, fp_assign)) return paths # Fallback: parsed condition but non-field (e.g. arithmetic expr) - if parsed: + if parsed and len(parsed) == 3: field, op, val = parsed paths = [] true_sub = enum_paths(node.true_seq, fields) @@ -430,7 +447,7 @@ def enum_paths(node, fields): elif node.perf_type in ('until', 'para_until', 'varying', 'para_varying'): # 尝试单条件(现有逻辑) parsed = parse_single_condition(node.condition, fields) - if parsed and is_field(parsed[0], fields): + if parsed and is_field(parsed[0], fields) and len(parsed) == 3: field, op, val = parsed paths = [] # Skip 路径放在首位,确保不被 _cap_paths 截断丢失 @@ -890,6 +907,8 @@ def _check_constraint_satisfied(rec, field_name, operator, value, want_true, fie val = rec.get(field_name) if val is None: return False + if operator == 'IS': + return evaluate_class_value(val, value) == want_true if operator == 'not_in': cases = value if isinstance(value, list) else [] return str(val) not in cases @@ -909,22 +928,32 @@ def _check_constraint_satisfied(rec, field_name, operator, value, want_true, fie return ok == want_true return True else: - s_val = str(val).strip().upper() - s_target = str(value).strip().upper() - eq = s_val == s_target + s_val = str(val) + s_target = str(value) + # 图式常量:SPACES/SPACE 按空白比较(不剥离),否则按去除两端空白比较 + _sv = s_val.strip().upper() + _tv = s_target.strip().upper() + if _tv in ('SPACE', 'SPACES'): + eq = (s_val.strip() == '') + elif _tv == 'LOW-VALUE': + eq = (s_val.strip('\x00') == '') + elif _tv == 'HIGH-VALUE': + eq = (s_val.strip('\xff') == '') + else: + eq = (_sv == _tv) if operator == '=': return eq == want_true elif operator == '<>': return (not eq) == want_true elif operator in ('>', '<', '>=', '<='): if operator == '>': - ok = s_val > s_target + ok = _sv > _tv elif operator == '<': - ok = s_val < s_target + ok = _sv < _tv elif operator == '>=': - ok = s_val >= s_target + ok = _sv >= _tv elif operator == '<=': - ok = s_val <= s_target + ok = _sv <= _tv return ok == want_true return True return False @@ -1226,6 +1255,19 @@ def apply_constraint(rec, field_name, operator, value, want_true, fields, assign # 如果当前值已满足该约束,跳过覆盖(保持先前约束的一致性) # 但零值时强制使用边界值(非 0/非 min) + # 特例:字母数字字段 "字段 <> 空白" 期望为真时,即使当前值已含非空白字符, + # 也要强制整字段填满非空白。这是因为源条件可能使用引用修改(如 + # WRK-C-INSURED-NO(11:)),仅"有非空白字符"不足以满足截取子串的判定。 + _uv2 = str(value).strip().upper() if isinstance(value, str) else '' + if (want_true and operator in ('<>', '!=') and _uv2 in ('SPACE', 'SPACES')): + _fd2 = next((f for f in fields if f['name'] == field_name), None) + if _fd2 and _fd2.get('pic_info', {}).get('type') in ('alphanumeric', 'alphabetic'): + _pi2 = _fd2['pic_info'] + _len2 = _pi2.get('length', 1) + _cur2 = str(rec.get(field_name, '')) + if _cur2.strip() and len(_cur2.rstrip()) < _len2: + rec[field_name] = ('U' * _len2) + return if _check_constraint_satisfied(rec, field_name, operator, value, want_true, fields): cur = str(rec.get(field_name, '')).strip('0') if (cur == '' or cur == '.') and ( @@ -1578,27 +1620,30 @@ def generate_records(path_infos, data_fields, base_assignments=None, file_sec=No pass if skip_impossible: continue - # Pass B.0: CALL 返回码一致性 — 将要求返回码非零的约束转为入参无效化 + # Pass B.0: CALL 返回码一致性 — 按调用级联顺序定位"首个失败"的校验入参。 + # 仅将导致该次校验失败的字段置为无效,其余级联字段修复为有效值, + # 使路径真正执行到目标校验分支(修复先前"全部入参无效化"导致提前分支底的问题)。 _b0_invalidated = set() new_cons = [] _b0_rrc_counter = 0 + _b0_cascade_plan = _plan_cascade_failures(path_cons, base_assignments) + # 对每个外部校验家系应用级联修复:首个"要求失败"的入参置无效, + # 其余入参修复为有效值;全通过(fail_pos=None)则全部修复为有效值。 + # 覆盖"前面校验通过、最后一项校验失败"与"全部通过"两类路径。 + for _fam, _plan in (_b0_cascade_plan or {}).items(): + _feeds = _plan.get('feeds') or [] + _fail_pos = _plan.get('fail_pos') + for _i, _src in enumerate(_feeds): + if _src not in rec: + continue + if _fail_pos is not None and _i == _fail_pos: + _set_invalid_value(rec, _src, data_fields, base_assignments) + _b0_invalidated.add(_src) + else: + _repair_valid_checked_field(rec, _src, data_fields) for c in path_cons: - if len(c) == 4 and c[1] == '<>' and c[3] and c[0].endswith('RRC'): - rrc_field = c[0] - prefix = rrc_field[:-3] + if len(c) == 4 and c[1] == '<>' and c[0].endswith('RRC'): _b0_rrc_counter += 1 - for tgt, asgn_list in base_assignments.items(): - if tgt.startswith(prefix) and tgt != rrc_field: - for asgn in asgn_list: - atyp = asgn.get('type', '').upper() - src = None - if atyp == 'MOVE': - src = asgn.get('src', asgn.get('source_vars', [None])[0] if asgn.get('source_vars') else None) - elif atyp == 'move' and asgn.get('source_vars'): - src = asgn['source_vars'][0] - if src and isinstance(src, str) and src in rec: - _set_invalid_value(rec, src, data_fields) - _b0_invalidated.add(src) continue new_cons.append(c) path_cons = new_cons @@ -1744,6 +1789,10 @@ def generate_records(path_infos, data_fields, base_assignments=None, file_sec=No _status_0or1 = str(rec.get('WRK-CSV-STATUS', '')).strip() in ('0', '1') if _emp_id_spaces and _status_0or1 and 'WRK-CSV-APPL-DATE' in rec: _is_c01chk_want_true = True + # 命令行馈入字段 → 运行时命令行参数(供运行器传递,覆盖 PARM 校验分支) + _cli_args = _collect_cli_args(rec, base_assignments, data_fields) + if _cli_args: + rec['__CLI_ARGS__'] = _cli_args records.append(rec) kept_path_cons.append(path_cons) term_types.append(term_type) @@ -1861,15 +1910,105 @@ def _sync_unstring_targets_from_output(rec, base_assignments, data_fields): rec[src] = tgt_val -def _set_invalid_value(rec, field_name, data_fields): +def _set_invalid_value(rec, field_name, data_fields, assignments=None): """将字段设为无效值,用于触发 CALL 返回码非零。""" + # 命令行馈入字段(ACCEPT ... FROM COMMAND-LINE):空白会被程序当作缺省值 + # 处理(如 IF x = SPACES MOVE '202605'),因此必须用非空白无效值; + # 否则运行时取缺省值导致 PARM 校验分支(ABEND 路径)无法覆盖。 + _is_cli = False + if assignments: + _al = assignments.get(field_name) + _al = _al if isinstance(_al, list) else ([_al] if _al else []) + for _a in _al: + if _a.get('type') == 'accept': + _is_cli = True + break for f in data_fields: if f['name'] == field_name: pi = f.get('pic_info', {}) ftype = pi.get('type', '') length = pi.get('length', 0) or pi.get('digits', 0) + pi.get('decimal', 0) if ftype in ('alphanumeric', 'alphabetic'): - rec[field_name] = ' ' * length + if _is_cli: + rec[field_name] = ('Z' * length) if length else 'ZZZZZZ' + else: + rec[field_name] = ' ' * length else: rec[field_name] = '9' * length return + + +def _collect_cli_args(rec, base_assignments, data_fields): + """收集命令行馈入字段(ACCEPT ... FROM COMMAND-LINE)的运行时参数值。 + + 返回 {字段名: 值},供运行器作为程序命令行参数传递。通用、无程序硬编码。 + """ + if not base_assignments: + return {} + cli = {} + for f in data_fields: + name = f['name'] + if name in rec: + _al = base_assignments.get(name) + _al = _al if isinstance(_al, list) else ([_al] if _al else []) + if any(_a.get('type') == 'accept' for _a in _al): + cli[name] = str(rec.get(name, '')) + return cli + + +def _plan_cascade_failures(path_cons, base_assignments): + """预计算每个外部校验家系(如 C01CHK)的"首个失败"校验位置与级联入参顺序。 + + 返回:{ family: {'feeds': [入参字段...], 'fail_pos': int|None} } + - feeds:按调用顺序排列的校验入参字段(C01CHKDAT 的 MOVE 源)。 + - fail_pos:本路径中首个要求返回码非零的校验下标(无则为 None —— 全通过)。 + 该映射与 target 无关、不含程序名硬编码,对所有使用外部校验子(SUB04 等)重命名的 family 通用。 + """ + plan = {} + feeds_map = {} + for tgt, alist in (base_assignments or {}).items(): + for a in alist: + atyp = str(a.get('type', '')).upper() + if atyp in ('MOVE', 'MOVE_LITERAL') and a.get('source_vars'): + for src in a.get('source_vars', []): + if isinstance(src, str): + feeds_map.setdefault(tgt, []).append(src) + # 返回码字段形如 family+'RRC';入参数据字段形如 family+'DAT'(<校验数据>)。 + # 汇总同一 family 的 需要/通过 标志,取首个"要求失败"处为 fail_pos。 + want_order = [] + for c in path_cons: + if len(c) == 4 and c[1] == '<>' and c[0].endswith('RRC'): + want_order.append((c[0][:-3], bool(c[3]))) + fam_wants = {} + for fam, want in want_order: + fam_wants.setdefault(fam, []).append(want) + for fam, wants in fam_wants.items(): + data_field = None + for tgt in feeds_map: + if tgt.startswith(fam) and 'DAT' in tgt.upper(): + data_field = tgt + break + feeds = feeds_map.get(data_field, []) if data_field else [] + fail_pos = None + for i, want in enumerate(wants): + if want: + fail_pos = i + break + plan[fam] = {'feeds': feeds, 'fail_pos': fail_pos} + return plan + + +def _repair_valid_checked_field(rec, field_name, data_fields): + """将校验入参修复为可通过外部校验器的有效值(日期/数字),供后续校验分支通过。""" + if field_name not in rec: + return + for f in data_fields: + if f['name'] == field_name: + pi = f.get('pic_info', {}) + ftype = pi.get('type', '') + length = pi.get('length', 0) or pi.get('digits', 0) + pi.get('decimal', 0) + if ftype == 'numeric': + rec[field_name] = '1' * length if length else rec[field_name] + elif _is_date_field(field_name): + rec[field_name] = '20240115' + return diff --git a/cobol_testgen/design_mcdc.py b/cobol_testgen/design_mcdc.py index 3f5dd26..9259529 100644 --- a/cobol_testgen/design_mcdc.py +++ b/cobol_testgen/design_mcdc.py @@ -35,6 +35,9 @@ def _invert_condition(parsed): """Invert a parsed condition (True ↔ False).""" if parsed is None: return None + if len(parsed) == 4: + # class condition (field, 'IS', CLASS, want) → flip want + return (parsed[0], parsed[1], parsed[2], not parsed[3]) field, op, val = parsed inv_op = {'=': '<>', '<>': '=', '>': '<=', '<': '>=', '>=': '<', '<=': '>'}.get(op, op) return (field, inv_op, val) @@ -78,13 +81,43 @@ def _collect_all_dps(node, fields, path_cons=None, path_assign=None, depth=0, _c t_cons = list(path_cons) f_cons = list(path_cons) if parsed: - field, op, val = parsed - t_cons.append((field, op, val, True)) - f_cons.append((field, op, val, False)) + if len(parsed) == 4: + field, op, val, base_want = parsed + t_cons.append((field, op, val, base_want)) + f_cons.append((field, op, val, not base_want)) + else: + field, op, val = parsed + t_cons.append((field, op, val, True)) + f_cons.append((field, op, val, False)) else: - # Synthetic constraint for coverage matching - t_cons.append(("__DP", str(dp_id), "T", True)) - f_cons.append(("__DP", str(dp_id), "F", True)) + # Generate MC/DC leaf-level constraints for compound conditions + cond_tree = parse_compound_condition(node.condition, fields) + if cond_tree: + sets = mcdc_sets(cond_tree, fields) + if sets: + t_set = f_set = None + for c, decision in sets: + if decision and t_set is None: + t_set = c + elif not decision and f_set is None: + f_set = c + if t_set is not None and f_set is not None: + break + if t_set: + t_cons.extend(t_set) + else: + t_cons.append(("__DP", str(dp_id), "T", True)) + if f_set: + f_cons.extend(f_set) + else: + f_cons.append(("__DP", str(dp_id), "F", True)) + dp["false_idx"] = 1 + else: + t_cons.append(("__DP", str(dp_id), "T", True)) + f_cons.append(("__DP", str(dp_id), "F", True)) + else: + t_cons.append(("__DP", str(dp_id), "T", True)) + f_cons.append(("__DP", str(dp_id), "F", True)) result.extend(_collect_all_dps(node.true_seq, fields, t_cons, path_assign, depth + 1, _counter)) result.extend(_collect_all_dps(node.false_seq, fields, f_cons, path_assign, depth + 1, _counter)) @@ -128,8 +161,12 @@ def _collect_all_dps(node, fields, path_cons=None, path_assign=None, depth=0, _c } result.append(dp) if parsed: - field, op, val = parsed - body_cons = list(path_cons) + [(field, op, val, False)] + if len(parsed) == 4: + field, op, val, base_want = parsed + body_cons = list(path_cons) + [(field, op, val, not base_want)] + else: + field, op, val = parsed + body_cons = list(path_cons) + [(field, op, val, False)] else: # Synthetic constraint for coverage matching body_cons = list(path_cons) + [("__DP", str(dp_id), "ENTER", True)] @@ -142,8 +179,10 @@ def _collect_all_dps(node, fields, path_cons=None, path_assign=None, depth=0, _c result.extend(_collect_all_dps(child, fields, path_cons, path_assign, depth, _counter)) elif isinstance(node, BrSearch): + dp_id = _counter[0] + _counter[0] += 1 dp = { - "node": node, "kind": "SEARCH", + "node": node, "kind": "SEARCH", "id": dp_id, "access_constraints": list(path_cons), } result.append(dp) @@ -165,17 +204,37 @@ def _make_path_for_branch(dp, branch_idx, fields): dp_id = dp.get("id", 0) want_true = (branch_idx == dp.get("true_idx", 0)) if parsed is None: - # Use synthetic __DP constraint for coverage matching - label = "T" if want_true else "F" - constraints.append(("__DP", str(dp_id), label, True)) + # Generate MC/DC leaf-level constraints for compound conditions + cond_tree = parse_compound_condition(dp["node"].condition, fields) + if cond_tree: + sets = mcdc_sets(cond_tree, fields) + if sets: + for c, decision in sets: + if decision == want_true: + constraints.extend(c) + break + else: + label = "T" if want_true else "F" + constraints.append(("__DP", str(dp_id), label, True)) + else: + label = "T" if want_true else "F" + constraints.append(("__DP", str(dp_id), label, True)) + else: + label = "T" if want_true else "F" + constraints.append(("__DP", str(dp_id), label, True)) node = dp["node"] body_seq = node.true_seq if branch_idx == 0 else node.false_seq else: - field, op, val = parsed - if not want_true: - field2, op2, val2 = _invert_condition(parsed) - field, op, val = field2, op2, val2 - constraints.append((field, op, val, True)) + if len(parsed) == 4: + field, op, val, base_want = parsed + leaf_want = base_want if want_true else (not base_want) + constraints.append((field, op, val, leaf_want)) + else: + field, op, val = parsed + if not want_true: + field2, op2, val2 = _invert_condition(parsed) + field, op, val = field2, op2, val2 + constraints.append((field, op, val, True)) node = dp["node"] body_seq = node.true_seq if branch_idx == 0 else node.false_seq return (constraints, {}) @@ -215,11 +274,38 @@ def _make_path_for_branch(dp, branch_idx, fields): label = "ENTER" if branch_idx == 0 else "SKIP" constraints.append(("__DP", str(dp_id), label, True)) return (constraints, {}) - field, op, val = parsed - if branch_idx == 0: - constraints.append((field, op, val, False)) + if len(parsed) == 4: + field, op, val, base_want = parsed + leaf_want = (not base_want) if branch_idx == 0 else base_want + constraints.append((field, op, val, leaf_want)) else: - constraints.append((field, op, val, True)) + field, op, val = parsed + if branch_idx == 0: + constraints.append((field, op, val, False)) + else: + constraints.append((field, op, val, True)) + return (constraints, {}) + + if kind == "SEARCH": + node = dp["node"] + dp_id = dp.get("id", 0) + n_when = len(node.when_list) + if branch_idx < n_when: + cond_text, _ = node.when_list[branch_idx] + cond_tree = node.cond_trees[branch_idx] if branch_idx < len(node.cond_trees) else None + if cond_tree and isinstance(cond_tree, CondLeaf): + base = re.sub(r"\s*\(.*?\)\s*$", "", cond_tree.field) + elem_key = f"{base}({branch_idx + 1})" + subj = cond_tree.value + if is_field(subj, fields): + # Field-to-field: constrain subject against the table element + constraints.append((subj, cond_tree.op, elem_key, True)) + else: + constraints.append((elem_key, cond_tree.op, str(subj).rstrip(), True)) + else: + constraints.append(("__DP", str(dp_id), "W%d" % branch_idx, True)) + return (constraints, {}) + # AT END branch (branch_idx == n_when): leave access constraints only return (constraints, {}) return ([], {}) @@ -245,12 +331,39 @@ def enum_paths(node, fields): kind = dp["kind"] if kind == "IF": - true_path = _make_path_for_branch(dp, dp.get("true_idx", 0), fields) - false_path = _make_path_for_branch(dp, dp.get("false_idx", 1) if dp.get("false_idx") is not None else 1, fields) - if true_path: - paths.append(true_path) - if false_path: - paths.append(false_path) + parsed = dp.get("parsed") + if parsed is None: + # Compound condition: add ALL MC/DC paths (one per leaf) + cond_tree = parse_compound_condition(dp["node"].condition, fields) + if cond_tree: + sets = mcdc_sets(cond_tree, fields) + if sets: + seen = set() + for c, decision in sets: + key = frozenset(c) + if key in seen: + continue + seen.add(key) + new_cons = list(dp.get("access_constraints", [])) + new_cons.extend(c) + paths.append((new_cons, {})) + else: + true_path = _make_path_for_branch(dp, dp.get("true_idx", 0), fields) + if true_path: paths.append(true_path) + false_path = _make_path_for_branch(dp, dp.get("false_idx", 1) if dp.get("false_idx") is not None else 1, fields) + if false_path: paths.append(false_path) + else: + true_path = _make_path_for_branch(dp, dp.get("true_idx", 0), fields) + if true_path: paths.append(true_path) + false_path = _make_path_for_branch(dp, dp.get("false_idx", 1) if dp.get("false_idx") is not None else 1, fields) + if false_path: paths.append(false_path) + else: + true_path = _make_path_for_branch(dp, dp.get("true_idx", 0), fields) + false_path = _make_path_for_branch(dp, dp.get("false_idx", 1) if dp.get("false_idx") is not None else 1, fields) + if true_path: + paths.append(true_path) + if false_path: + paths.append(false_path) elif kind == "EVALUATE": node = dp["node"] @@ -275,6 +388,16 @@ def enum_paths(node, fields): if enter_path: paths.append(enter_path) if skip_path: paths.append(skip_path) + elif kind == "SEARCH": + node = dp["node"] + n_when = len(node.when_list) + for i in range(n_when): + wp = _make_path_for_branch(dp, i, fields) + if wp: paths.append(wp) + if node.has_at_end: + at = _make_path_for_branch(dp, n_when, fields) + if at: paths.append(at) + if len(paths) >= MAX_PATH: paths = paths[:MAX_PATH] break diff --git a/cobol_testgen/file_io.py b/cobol_testgen/file_io.py index 227d555..486b6cf 100644 --- a/cobol_testgen/file_io.py +++ b/cobol_testgen/file_io.py @@ -29,7 +29,10 @@ def get_storage_length(field: dict) -> int: else: return 8 elif usage in ('COMP-3', 'PACKED-DECIMAL'): - return (digits + 2) // 2 + # 打包小数含小数位(如 9(3)V9(2)=5 位+符号 → 3 字节)。 + # 只算整数位(digits)会少算 1 字节,导致与 GnuCOBOL FD 布局错位。 + decimal = pi.get('decimal', 0) + return (digits + decimal + 2) // 2 else: raise ValueError(f"Unsupported USAGE: {usage}") @@ -74,10 +77,12 @@ def pack_value(value: str, field: dict) -> bytes: fmt = fmt_map[size] if not signed: fmt = fmt.upper() - return struct.pack('<' + fmt, int_val) + # 本 GnuCOBOL(GC32-BDB-SP1、主机兼容)的 COMP 按大端存储。 + return struct.pack('>' + fmt, int_val) elif usage in ('COMP-3', 'PACKED-DECIMAL'): - abs_str = str(abs(int_val)).zfill(digits) + # 打包长度含小数位(digits + decimal) + abs_str = str(abs(int_val)).zfill(digits + pi.get('decimal', 0)) nibbles = [int(ch) for ch in abs_str] if not signed: nibbles.append(0xF) @@ -112,7 +117,8 @@ def unpack_value(data: bytes, field: dict) -> str: fmt = fmt_map[size] if not signed: fmt = fmt.upper() - val = struct.unpack('<' + fmt, data)[0] + # 与 pack_value 一致:本 GnuCOBOL 的 COMP 为大端存储。 + val = struct.unpack('>' + fmt, data)[0] sign = '-' if val < 0 else '' return f"{sign}{str(abs(val)).zfill(digits)}" @@ -127,7 +133,8 @@ def unpack_value(data: bytes, field: dict) -> str: num_str = ''.join(chars).lstrip('0') or '0' if signed and sign == 0xD: num_str = '-' + num_str - return num_str.zfill(digits) + # 补零长度含小数位 + return num_str.zfill(digits + pi.get('decimal', 0)) else: raise ValueError(f"Unsupported USAGE: {usage}") diff --git a/cobol_testgen/flatfile.py b/cobol_testgen/flatfile.py index dd2350c..a2ab90b 100644 --- a/cobol_testgen/flatfile.py +++ b/cobol_testgen/flatfile.py @@ -132,7 +132,10 @@ def _format_value(value: Any, field: dict) -> bytes: try: num = int(float(val)) if val else 0 except (ValueError, TypeError): - num = 0 + # 非数字値を 0 に静かに丸めると '00000000' が合成され、 + # 複数レコードで主キー衝突(DAILY_RECORDS INSERT エラー→早期 ABEND)を + # 引き起こす。SPACE を書くことでプログラムの空キー判定に委ねる。 + return (' ' * length).encode("ascii") num = abs(num) max_val = 10 ** length - 1 if num > max_val: @@ -295,6 +298,12 @@ def write_sysin_file(records: list[dict], source_text: str, outdir: Path, prefix # Always add a RESET-mode batch with duplicate EMP-ID for UPDATE path unique_ids = list(dict.fromkeys(emp_ids_ordered)) + # Ensure a duplicated EMP-ID (from AGG path injection in orchestrator_db) + # appears in the last T card chunk. Without this, dict.fromkeys removes + # duplicates keeping only the first occurrence, which may not be in last 8. + dup_candidates = [e for e in unique_ids if emp_ids_ordered.count(e) > 1] + if dup_candidates and dup_candidates[0] not in unique_ids[-8:]: + unique_ids.append(dup_candidates[0]) if not unique_ids: unique_ids = ["EMP00001", "EMP00002", "EMP00003", "EMP00004", "EMP00005"] # Inject duplicates: use first EMP-ID twice to trigger RESET/UPDATE @@ -313,8 +322,10 @@ def write_sysin_file(records: list[dict], source_text: str, outdir: Path, prefix # 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") + # End with the configured final mode (default RESET → 3000STPSOR 実行 RESET path). + # final_mode='NORMAL' 时(如 drop_tables 场景),3000STPSOR 不执行 DELETE, + # 使 SELECT COUNT 等后续 SQL 错误分支可达。 + lines.append(f"M MODE={run_cfg.get('final_mode', 'RESET')}") # Write as fixed-length flat file outpath = outdir / (prefix + sysin_filename) diff --git a/cobol_testgen/grammar.lark b/cobol_testgen/grammar.lark index 6fe4d5e..f06cf90 100644 --- a/cobol_testgen/grammar.lark +++ b/cobol_testgen/grammar.lark @@ -28,7 +28,7 @@ occurs_clause: "OCCURS" INT ("TO" INT)? ("TIME" "S"?)? ("DEPENDING" "ON" NAME)? key_clause: ("ASCENDING" | "DESCENDING") "KEY" "IS"? NAME (","? NAME)* indexed_clause: "INDEXED" "BY" NAME (","? NAME)* usage_clause: "USAGE"? "IS"? USAGE_VAL -USAGE_VAL: "COMP" | "COMP-3" | "COMP-5" | "BINARY" | "PACKED-DECIMAL" | "DISPLAY" +USAGE_VAL: "COMP" | "COMP-3" | "COMP-4" | "COMP-5" | "BINARY" | "PACKED-DECIMAL" | "DISPLAY" | "POINTER" LEVEL: /0[1-9]|[0-4][0-9]|49|66|77|88|[0-9]+/ NAME: /[A-Z][A-Z0-9-]*/i PICTURE_STRING: /[0-9A-Z()+,\-*\/V\$]+/i diff --git a/cobol_testgen/output.py b/cobol_testgen/output.py index 6f6355b..57d6ee6 100644 --- a/cobol_testgen/output.py +++ b/cobol_testgen/output.py @@ -112,7 +112,7 @@ def output_json(records, outpath, roles=None, fd_fields=None, field_to_fd=None, entry = { 'input': inp, 'expected_output': out_exp, - 'working_storage': {k: v for k, v in ws.items() if k != '_assigned_fields'}, + 'working_storage': {k: v for k, v in ws.items() if k not in ('_assigned_fields', '__CLI_ARGS__')}, 'termination': term_types[i] if i < len(term_types) else 'normal', } @@ -176,8 +176,7 @@ def output_input_files(records, outdir, stem, roles, fd_fields, field_to_fd, ope bin_path = outdir / assign_name name_to_field = { f['name']: f for f in data_fields - if not f.get('is_88') and not f.get('is_filler') - and f.get('pic') + if not f.get('is_88') and f.get('pic') } field_dicts = [] seen = set() diff --git a/cobol_testgen/pipeline_bridge.py b/cobol_testgen/pipeline_bridge.py index 864eca2..bd7e9f2 100644 --- a/cobol_testgen/pipeline_bridge.py +++ b/cobol_testgen/pipeline_bridge.py @@ -26,12 +26,36 @@ def build_branch_tree_fallback(proc_text, fields=None): pass # New parser generates O(N) paths (not O(2^N)), so no cap needed. - # Just use it directly when it works. + # When new parser succeeds, also try the old parser with short timeout. + # If old parser succeeds and has more IF nodes, use it (new parser may miss + # nested ifs inside PERFORM UNTIL/SECTION blocks). + old_tree, old_assigns = None, {} if new_tree is not None: + old_fallback, old_fb_assigns = None, {} + try: + import threading + r, e, d = [None], [None], [False] + def run(): + try: + r[0] = old_build(proc_text, fields) + except Exception as ex: + e[0] = ex + d[0] = True + t = threading.Thread(target=run, daemon=True) + t.start(); t.join(3.0) + if d[0] and not e[0] and r[0]: + old_fallback, old_fb_assigns = r[0] + except Exception as e: + pass + if old_fallback is not None: + old_with_cond = _count_brif_with_cond(old_fallback) + new_with_cond = _count_brif_with_cond(new_tree) + if old_with_cond > new_with_cond: + logger.info(f" Old parser: {old_with_cond} BrIf with cond_tree vs new parser {new_with_cond}, using old parser") + return old_fallback, old_fb_assigns return new_tree, new_assigns # 2. Old parser with 3s timeout (fallback only) - old_tree, old_assigns = None, {} try: import threading r, e, d = [None], [None], [False] @@ -174,6 +198,49 @@ def _count_br_nodes(node) -> int: return count +def _count_brif(node): + """Count BrIf nodes in a branch tree.""" + from .models import BrIf, BrSeq, BrEval, BrPerform, BrSearch + count = 0 + if isinstance(node, BrIf): + count += 1 + if isinstance(node, BrSeq): + for c in node.children: count += _count_brif(c) + if isinstance(node, BrIf): + count += _count_brif(node.true_seq) + _count_brif(node.false_seq) + if isinstance(node, BrEval): + for _, s in node.when_list: count += _count_brif(s) + count += _count_brif(node.other_seq) + if isinstance(node, BrPerform): + count += _count_brif(node.body_seq) + if isinstance(node, BrSearch): + count += _count_brif(node.at_end_seq) + for _, s in node.when_list: count += _count_brif(s) + return count + + +def _count_brif_with_cond(node): + """Count BrIf nodes that have cond_tree set.""" + from .models import BrIf, BrSeq, BrEval, BrPerform, BrSearch + count = 0 + if isinstance(node, BrIf): + if node.cond_tree is not None: + count += 1 + if isinstance(node, BrSeq): + for c in node.children: count += _count_brif_with_cond(c) + if isinstance(node, BrIf): + count += _count_brif_with_cond(node.true_seq) + _count_brif_with_cond(node.false_seq) + if isinstance(node, BrEval): + for _, s in node.when_list: count += _count_brif_with_cond(s) + count += _count_brif_with_cond(node.other_seq) + if isinstance(node, BrPerform): + count += _count_brif_with_cond(node.body_seq) + if isinstance(node, BrSearch): + count += _count_brif_with_cond(node.at_end_seq) + for _, s in node.when_list: count += _count_brif_with_cond(s) + return count + + def _assigns_list_to_dict(assigns_list: list) -> dict: result = {} for a in assigns_list: diff --git a/cobol_testgen/read.py b/cobol_testgen/read.py index 9ffadfe..80d3790 100644 --- a/cobol_testgen/read.py +++ b/cobol_testgen/read.py @@ -207,13 +207,13 @@ _RE_SQL_INC = re.compile( _BUILTIN_SQLCA = """\ 01 SQLCA. 05 SQLCAID PIC X(8). - 05 SQLCABC PIC S9(9) COMP. - 05 SQLCODE PIC S9(9) COMP. + 05 SQLCABC PIC S9(9) COMP-5. + 05 SQLCODE PIC S9(9) COMP-5. 05 SQLERRM. - 10 SQLERRML PIC S9(4) COMP. + 10 SQLERRML PIC S9(4) COMP-5. 10 SQLERRMC PIC X(70). 05 SQLERRP PIC X(8). - 05 SQLERRD OCCURS 6 TIMES PIC S9(9) COMP. + 05 SQLERRD OCCURS 6 TIMES PIC S9(9) COMP-5. 05 SQLWARN. 10 SQLWARN0 PIC X. 10 SQLWARN1 PIC X. @@ -523,6 +523,11 @@ def parse_pic(pic_str: str) -> PicInfo: elif expanded[0] == 'A': info.type = 'alphabetic' info.length = len(expanded) + elif expanded[0] == 'N': + # PIC N(n) = national/DBCS: 每个字符占 2 字节(GnuCOBOL 实际存储)。 + # 按 n 字节计算会导致 FD 记录布局/偏移错位(如 N(040)=80B 却按 40B 写)。 + info.type = 'national' + info.length = len(expanded) * 2 elif expanded[0] in ('Z', '*', '$', '+', '-'): info.type = 'numeric-edited' info.digits = expanded.count('9') @@ -597,15 +602,17 @@ def parse_file_control(source: str) -> dict: result = {} for sel_m in re.finditer( r'SELECT\s+(\w[\w-]*)\s+[^.]*?\bASSIGN\s+TO\s+' - r'(?:(["\'])(.*?)\2|(\w[\w-]*))' + r'(?:(["\'])(.*?)\2|EXTERNAL\s+(\w[\w-]*)|(\w[\w-]*))' r'[^.]*\.', fc, re.IGNORECASE ): name = sel_m.group(1).upper() if sel_m.group(2): assign_to = sel_m.group(3).upper() - else: + elif sel_m.group(4): assign_to = sel_m.group(4).upper() + else: + assign_to = sel_m.group(5).upper() clause = sel_m.group(0) org_m = re.search(r'ORGANIZATION\s+(LINE\s+)?SEQUENTIAL', clause, re.IGNORECASE) if org_m and org_m.group(1): @@ -673,3 +680,57 @@ def scan_open_statements(source: str) -> dict: for fname in re.findall(r'\w[\w-]*', seg_m.group(2)): dirs[fname.upper()] = direction return dirs + + +def scan_sort_merge_directions(source: str) -> dict: + """Parse MERGE / SORT statements, returns {file_name: 'INPUT'|'OUTPUT'}. + + MERGE/SORT 的 USING 文件不会被 OPEN(由语句隐式打开),因此 + scan_open_statements 无法识别。此函数扫描 PROCEDURE DIVISION 中 + MERGE ... USING 与 SORT ... USING GIVING 子句, + 将 USING 文件识别为 INPUT、GIVING 文件识别为 OUTPUT。 + + 兼容性:对任何含 MERGE/SORT 的程序通用,非 MERGE/SORT 程序返回空 dict。 + """ + dirs = {} + _KEYWORDS = {'USING', 'GIVING', 'PROCEDURE', 'KEY', 'ASCENDING', 'DESCENDING', 'ON', 'OUTPUT', 'INPUT'} + for m in re.finditer( + r'\b(MERGE|SORT)\s+\w[\w-]*\s+.*?\b(?:USING|GIVING)\s+(.+?)\s*(?:OUTPUT|INPUT)?\s*PROCEDURE\s+\w[\w-]*\s*\.', + source, re.IGNORECASE | re.DOTALL + ): + stmt = m.group(0) + kind = m.group(1).upper() + files_raw = m.group(2) + files = re.findall(r'\b(\w[\w-]*)\b', files_raw) + for fn in files: + fn = fn.upper() + if fn not in _KEYWORDS: + dirs[fn] = 'INPUT' + if kind == 'SORT': + g = re.search(r'\bGIVING\s+(\w[\w-]*)', stmt, re.IGNORECASE) + if g: + dirs[g.group(1).upper()] = 'OUTPUT' + # 单行回退:无 PROCEDURE 的 MERGE/SORT(如 MERGE F ON KEY K USING A B.) + for m in re.finditer( + r'\b(MERGE|SORT)\s+\w[\w-]*\s+.*?\b(?:USING|GIVING)\s+(.+?)\.', + source, re.IGNORECASE + ): + kind = m.group(1).upper() + files_raw = m.group(2) + files = re.findall(r'\b(\w[\w-]*)\b', files_raw) + for fn in files: + fn = fn.upper() + if fn not in _KEYWORDS: + dirs[fn] = 'INPUT' + if kind == 'SORT': + g = re.search(r'\bGIVING\s+(\w[\w-]*)', m.group(0), re.IGNORECASE) + if g: + dirs[g.group(1).upper()] = 'OUTPUT' + return dirs + + +def scan_all_file_directions(source: str) -> dict: + """OPEN 方向 + MERGE/SORT USING/GIVING 方向的合并(输入方向全集)。""" + dirs = scan_open_statements(source) + dirs.update(scan_sort_merge_directions(source)) + return dirs diff --git a/cobol_testgen/runner.py b/cobol_testgen/runner.py index 7385d14..a434cbc 100644 --- a/cobol_testgen/runner.py +++ b/cobol_testgen/runner.py @@ -31,6 +31,7 @@ class GroupInfo: select_info: dict = field(default_factory=dict) overlap_mask: list[bool] = field(default_factory=list) multi_write_fds: set = field(default_factory=set) + command_args: list = field(default_factory=list) @dataclass @@ -335,7 +336,7 @@ def run_group(group: GroupInfo, exe_path: str, temp_dir: str, try: os.chdir(str(work_dir)) result = subprocess.run( - [str(exe)], capture_output=True, text=True, + [str(exe)] + list(group.command_args or []), capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=60, ) except subprocess.TimeoutExpired: @@ -434,26 +435,36 @@ def run_all(program_name: str, outdir: str, temp_dir: str, gcov_root = work_dir / "gcov" for scene_id, scene_recs, scene_terms, scene_expected, src_in_dir, dst_out_dir in scenes: - # ── 3a. 入力ファイル配置 ── + # ── 3a. 入力ファイル配置(主程序 + 被调子程序的输入文件全部复制)── + # 仅复制 assign_names 会漏掉子程序输入文件(测试驱动调用读文件自程序时 + # 会 OPEN 失败 ABEND)。复制目录内全部非 JSON 文件(.json 为 V3 元数据)。 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)) + for src in sorted(src_in_dir.iterdir()): + if not src.is_file() or src.suffix.lower() == '.json': + continue + shutil.copy2(str(src), str(work_dir / src.name)) # ── 3b. 清理旧 gcda ── _clean_gcda(str(work_dir)) # ── 3c. 过滤 non-abend ── filtered_exp = [] + normal_recs = [] + abend_pairs = [] # (rec, command_args) 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) + for i, rec in enumerate(scene_recs): + term = scene_terms[i] if i < len(scene_terms) else 'normal' + if term == 'abend': + abend_pairs.append((rec, list((rec.get('__CLI_ARGS__') or {}).values()))) + else: + normal_recs.append(rec) group = GroupInfo( name=f"{program_name}_{scene_id}", - records=scene_recs, + records=normal_recs, expected_outputs=filtered_exp, expected_returncode=0, fd_field_dicts=fd_field_dicts, @@ -469,6 +480,23 @@ def run_all(program_name: str, outdir: str, temp_dir: str, status = '✓' if r.passed else '✗' logger.info(f" 组 '{group.name}': returncode={r.returncode}, {status}") + # ── 3d-2. abend 记录单独执行(带命令行参数,触发 ABEND/异常返回)── + for ab_idx, (ab_rec, ab_args) in enumerate(abend_pairs): + ab_group = GroupInfo( + name=f"{program_name}_{scene_id}_abend_{ab_idx + 1}", + records=[ab_rec], expected_outputs=[], + expected_returncode=1, + fd_field_dicts=fd_field_dicts, + open_dir=open_dir, + select_info=select_info, + multi_write_fds=multi_write_fds, + command_args=ab_args, + ) + r2 = run_group(ab_group, exe_path, str(work_dir), log_dir=log_dir) + results.append(r2) + status2 = '✓' if r2.passed else '✗' + logger.info(f" 组 '{ab_group.name}': returncode={r2.returncode}, {status2}") + # ── 3e. 出力保存 ── dst_out_dir.mkdir(parents=True, exist_ok=True) for fd_name in fd_field_dicts: @@ -579,6 +607,7 @@ def run_and_compare(program_name: str, outdir: str, expected_returncode=1, fd_field_dicts=fd_field_dicts, open_dir=open_dir, select_info=select_info, + command_args=list((rec.get('__CLI_ARGS__') or {}).values()), ) r = run_group(group, exe_path, temp_dir, log_dir=log_dir) if r.returncode != 0: diff --git a/cobol_testgen/to_sql.py b/cobol_testgen/to_sql.py index e1d21ff..7a44e02 100644 --- a/cobol_testgen/to_sql.py +++ b/cobol_testgen/to_sql.py @@ -1,4 +1,4 @@ -"""SQL层:WHERE约束解析 + DB输入行生成""" +"""SQL层:WHERE约束解析 + DB输入行生成""" import re import logging @@ -28,8 +28,97 @@ def _restore_strings(text: str, replacements: list) -> str: # ── Bracket-aware AND splitting ── -def _split_on_AND(text: str) -> list[str]: - """Split WHERE clause on AND, respecting parentheses.""" +_RE_BETWEEN_SPAN = re.compile(r'\bBETWEEN\b', re.IGNORECASE) + + +def _scan_between_spans(text: str) -> (str, list): + """Replace `X BETWEEN lo AND hi` spans with `__BTW{i}__` placeholders. + + The inner AND that belongs to a BETWEEN clause must not be treated as a + top-level AND separator. Returns (protected_text, spans) where each span is: + {subject, lo, hi, neg} — each operand is the raw token string. + Subject may be a host variable (`:NAME`), a column, or a literal; lo/hi + likewise (host var, column, or literal). The full span text (subject + through hi) is replaced by a single placeholder token. + """ + spans = [] + out = [] + pos = 0 + i = 0 + while i < len(text): + m = _RE_BETWEEN_SPAN.search(text, i) + if not m: + break + # subject: token (optionally ':'-prefixed) immediately before an + # optional NOT that precedes BETWEEN + j = m.start() - 1 + while j >= 0 and text[j].isspace(): + j -= 1 + before = text[max(0, j - 5):m.start()] + mnot = re.search(r'NOT\s*$', before, re.IGNORECASE) + if mnot: + j = m.start() - len(mnot.group(0)) - 1 + while j >= 0 and text[j].isspace(): + j -= 1 + tok_end = j + while j >= 0 and (text[j].isalnum() or text[j] in '_-'): + j -= 1 + subject_start = j if j >= 0 and text[j] == ':' else j + 1 + subject = text[subject_start:tok_end + 1] + out.append(text[pos:subject_start]) + # lo: from after BETWEEN to the first top-level AND + j = m.end() + depth = 0 + and_pos = None + k = j + while k < len(text): + ch = text[k] + if ch == '(': + depth += 1 + elif ch == ')': + depth -= 1 + elif depth == 0 and re.match(r'\bAND\b', text[k:], re.IGNORECASE): + and_pos = k + break + k += 1 + if and_pos is None: + out.append(text[subject_start:m.start()]) + out.append(text[m.start():]) + pos = len(text) + break + lo = text[j:and_pos].strip() + # hi: from after AND until the next top-level AND/OR or end + k = and_pos + 3 + hi_end = len(text) + depth = 0 + while k < len(text): + ch = text[k] + if ch == '(': + depth += 1 + elif ch == ')': + depth -= 1 + elif depth == 0 and re.match(r'\b(?:AND|OR)\b', text[k:], re.IGNORECASE): + hi_end = k + break + k += 1 + hi = text[and_pos + 3:hi_end].strip() + spans.append({'subject': subject, 'lo': lo, 'hi': hi, 'neg': bool(mnot)}) + out.append(f"__BTW{len(spans) - 1}__ ") + pos = hi_end + i = hi_end + out.append(text[pos:]) + return ''.join(out), spans + + +def _split_on_AND(text: str, spans: list = None, keep_placeholders: bool = False) -> list[str]: + """Split WHERE clause on AND, respecting parentheses. + + BETWEEN ... AND ... spans are protected first so their inner AND is not + treated as a separator. When `keep_placeholders` is True (internal use), + BETWEEN spans stay as `__BTW{i}__` tokens so the caller can re-parse them. + """ + if spans is None: + text, spans = _scan_between_spans(text) parts = [] current = [] depth = 0 @@ -52,13 +141,51 @@ def _split_on_AND(text: str) -> list[str]: current.append(token) if current: parts.append(' '.join(current).strip()) + if not keep_placeholders: + parts = [_restore_between_placeholders(p, spans) for p in parts] return parts +def _restore_between_placeholders(part: str, spans: list) -> str: + """Replace `__BTW{i}__` tokens back with their original BETWEEN text.""" + def _repl(m): + idx = int(m.group(1)) + if idx >= len(spans): + return m.group(0) + s = spans[idx] + neg = 'NOT ' if s['neg'] else '' + return f"{s['subject']} {neg}BETWEEN {s['lo']} AND {s['hi']}" + return re.sub(r'__BTW(\d+)__', _repl, part) + + +_BETWEEN_OP = re.compile(r'(__STR\d+__|:\w[\w-]*|[\w.-]+)') + + +def _parse_between_operand(raw: str, replacements: list) -> dict: + """Parse a BETWEEN subject/lo/hi operand into a kinded dict. + + Returns one of: + {'kind': 'host_var', 'name': 'X'} + {'kind': 'literal', 'value': '...'} + {'kind': 'column', 'name': 'COL'} + """ + raw = raw.strip().strip('()') + if raw.startswith(':'): + return {'kind': 'host_var', 'name': raw[1:].upper()} + m = re.match(r'__STR(\d+)__$', raw) + if m and int(m.group(1)) < len(replacements): + return {'kind': 'literal', 'value': replacements[int(m.group(1))].strip("'\"")} + if raw.startswith('__STR') and raw.endswith('__'): + idx = int(raw[5:-2]) + if idx < len(replacements): + return {'kind': 'literal', 'value': replacements[idx].strip("'\"")} + return {'kind': 'column', 'name': raw.upper()} + + # ── WHERE condition parsing ── _COL_OP_PAT = re.compile( - r'(\w[\w.-]*)\s*' # column name (with optional alias prefix) + r'(:?\w[\w.-]*)\s*' # column name or host variable (with optional alias prefix) r'(=|>|<|>=|<=|<>|!=|NOT\s*=)\s*' r'(:\w[\w-]*(?::\w[\w-]*)?|__STR\d+__|[\w\d.-]+)', re.IGNORECASE @@ -70,7 +197,7 @@ _RE_IN_CLAUSE = re.compile( ) _RE_BETWEEN = re.compile( - r'(\w[\w.-]*)\s+(NOT\s+)?BETWEEN\s+(.+?)\s+AND\s+(.+)', + r'(:?\w[\w.-]*)\s+(NOT\s+)?BETWEEN\s+(.+?)\s+AND\s+(.+)', re.IGNORECASE ) @@ -124,14 +251,15 @@ def _parse_where_condition(part: str, replacements: list) -> dict | None: # BETWEEN m = _RE_BETWEEN.match(part) if m: - col = m.group(1).upper() + subj = m.group(1).upper() neg = bool(m.group(2)) lo = m.group(3).strip() hi = m.group(4).strip() return { - 'col': col, 'type': 'between', 'neg': neg, - 'op': 'BETWEEN', - 'lo': lo.strip("'\""), 'hi': hi.strip("'\""), + 'type': 'between', 'neg': neg, 'op': 'BETWEEN', + 'subject': _parse_between_operand(subj, replacements), + 'lo': _parse_between_operand(lo, replacements), + 'hi': _parse_between_operand(hi, replacements), } # LIKE @@ -216,19 +344,28 @@ def sql_extract_constraints(where_clause: str, table: str, # Protect string literals cleaned, replacements = _protect_strings(where_clause) - # Split on AND - and_parts = _split_on_AND(cleaned) + # Protect BETWEEN spans, then split on AND + protected, spans = _scan_between_spans(cleaned) + and_parts = _split_on_AND(protected, spans=spans, keep_placeholders=True) constraints = [] for part in and_parts: part = part.strip() if not part: continue - cond = _parse_where_condition(part, replacements) + btw = _parse_between_placeholder(part, spans, replacements) + if btw is not None: + cond = btw + else: + cond = _parse_where_condition(part, replacements) if cond: # Map column to COBOL field - cobol_field = guess_cobol_field(cond['col'], table, declared_columns, column_map) - cond['cobol_field'] = cobol_field + col_name = cond.get('col') or (cond.get('subject', {}) or {}).get('name') + if col_name and cond['type'] == 'between': + cond['cobol_field'] = guess_cobol_field(col_name, table, declared_columns, column_map) + elif cond.get('col'): + cobol_field = guess_cobol_field(cond['col'], table, declared_columns, column_map) + cond['cobol_field'] = cobol_field constraints.append(cond) else: logger.warning(f"Unparseable WHERE condition: {_restore_strings(part, replacements)}") @@ -236,6 +373,29 @@ def sql_extract_constraints(where_clause: str, table: str, return constraints +def _parse_between_placeholder(part: str, spans: list, replacements: list) -> dict | None: + """Parse a BETWEEN span from its `__BTW{i}__` placeholder. + + The part may contain trailing content after the placeholder (e.g. wrapped + in parentheses); only the first placeholder token is interpreted. Returns + a between constraint dict, or None when the part has no placeholder. + """ + m = re.search(r'__BTW(\d+)__', part) + if not m: + return None + idx = int(m.group(1)) + if idx >= len(spans): + return None + s = spans[idx] + subject = _parse_between_operand(s['subject'], replacements) + return { + 'type': 'between', 'neg': s['neg'], 'op': 'BETWEEN', + 'subject': subject, + 'lo': _parse_between_operand(s['lo'], replacements), + 'hi': _parse_between_operand(s['hi'], replacements), + } + + # ── DB input row generation ── _COLUMN_DEFAULTS = { @@ -251,8 +411,13 @@ _COLUMN_DEFAULTS = { def _format_db_value(col_info: dict, raw_val: str) -> str: db_type = col_info.get('db_type', 'CHAR') - formatter = _COLUMN_DEFAULTS.get(db_type, lambda _: str(raw_val)[:10]) - default = formatter(0) + if db_type in ('CHAR', 'VARCHAR'): + size = col_info.get('size', 1) + default = ' ' * size + elif db_type in ('INTEGER', 'SMALLINT', 'DECIMAL'): + default = _COLUMN_DEFAULTS.get(db_type, lambda _: '?')(0) + else: + default = _COLUMN_DEFAULTS.get(db_type, lambda _: str(raw_val)[:10])(0) if raw_val is None: return default if db_type in ('INTEGER', 'SMALLINT', 'DECIMAL'): @@ -261,10 +426,11 @@ def _format_db_value(col_info: dict, raw_val: str) -> str: except ValueError: return default return str(raw_val).ljust(len(default))[:len(default)] - - def _make_key_unique(key_val: str, path_index: int, seen_keys: set) -> str: - unique = f"{path_index:03d}{key_val[:5]}" + stripped = key_val.strip() + if len(stripped) >= 5: + return key_val + unique = f"{path_index:03d}{stripped[:5]}" while unique in seen_keys: unique = f"{path_index:03d}{hash(key_val) % 100000:05d}" seen_keys.add(unique) @@ -291,12 +457,18 @@ def collect_sql_meta(assignments: dict, declared_columns: dict, seen.add(key) where = asgn.get('where', '') table = asgn.get('table', '') + table = _norm_table(table) where_constraints = sql_extract_constraints( where, table, {}, column_map or {}, declared_columns ) meta = dict(asgn) + meta['table'] = table meta['where_constraints'] = where_constraints sql_meta.append(meta) + + # Order by source position so downstream SELECTs come last (reliable + # runtime query order). Entries without pos keep their insertion order. + sql_meta.sort(key=lambda m: m.get('pos') or 10 ** 9) return sql_meta @@ -311,6 +483,9 @@ def _path_has_sql_ok(path_cons: list) -> bool: sql_ok = False if pc[1] == '>' and pc[3]: sql_ok = False + # SQLCODE = (want True) means SQL must fail + if pc[1] == '=' and pc[3] and str(pc[2]).strip() not in ('0', "'0'"): + sql_ok = False break return sql_ok @@ -345,6 +520,367 @@ def _parse_select_columns(select_list: str) -> list[str]: return cols +def _rec_get(rec: dict, key: str, default=''): + """rec が flat / {working_storage: ...} 両方の形式をサポートする。""" + if key in rec: + return str(rec[key]) + ws = rec.get('working_storage', {}) + if isinstance(ws, dict) and key in ws: + return str(ws[key]) + inp = rec.get('input', {}) + if isinstance(inp, dict) and key in inp: + return str(inp[key]) + return default + +def _rec_has(rec: dict, key: str) -> bool: + return key in rec or key in rec.get('working_storage', {}) or key in rec.get('input', {}) + +def _norm_col(name: str) -> str: + """Normalize a SQL column name for comparisons ('-' and '_' are equivalent).""" + return str(name).upper().replace('-', '_') + + +def _norm_table(table: str) -> str: + """Normalize a SQL table name: strip DB2 schema qualifier (SCHEMA.TABLE → TABLE). + + core.py captures qualified names faithfully (e.g. 'SALARYDB.EMP-MASTER') so + the schema part is not lost; here we reduce to the last segment so seeds + target the YAML schema table (EMP-MASTER / EMP_MASTER). No-op for plain names. + """ + return str(table).rsplit('.', 1)[-1] + + +def _declared_cols_for(declared_columns: dict, table: str) -> list[dict]: + """Look up declared columns for a SQL table, tolerating '-'/'_' naming. + + YAML schema registers tables with underscores (EMP_MASTER) while SQL + references may use hyphens (EMP-MASTER); missing this fallback makes + build_db_input fall back to 10-char inferred sizes, padding stored keys + with trailing spaces so runtime '=' lookups with unpadded host vars fail. + """ + cols = declared_columns.get(table, []) + if not cols and '-' in table: + cols = declared_columns.get(table.replace('-', '_'), []) + elif not cols and '_' in table: + cols = declared_columns.get(table.replace('_', '-'), []) + return cols + + +def _hostvar_root(host_var: str, assignments: dict) -> str: + """Trace a SQL WHERE host var through MOVE assignments to its source root. + + e.g. assignments['DBV-EMP-ID'] = [{'type': 'move', 'source_vars': ['R02EMP-ID']}] + → root for DBV-EMP-ID is R02EMP-ID. Handles multi-hop MOVE chains. + Returns the original host_var when no MOVE chain applies. + """ + seen = set() + cur = host_var + while cur in assignments and cur not in seen: + seen.add(cur) + al = assignments[cur] + if isinstance(al, dict): + al = [al] + if not al: + break + last = al[-1] + if last.get('type') == 'move' and last.get('source_vars'): + cur = str(last['source_vars'][0]).upper() + else: + break + return cur + + +def _rec_get_ci(rec: dict, name: str): + """Case-insensitive _rec_get. Returns the value string or None if not found.""" + for scope in (rec, rec.get('working_storage', {}), rec.get('input', {})): + for k, v in (scope or {}).items(): + if str(k).upper() == name.upper(): + return str(v) + return None + + +def _resolve_runtime_hostvar(rec: dict, host_var: str, assignments: dict) -> str: + """Resolve a host var to the value the program actually uses at runtime. + + Returns only runtime-deterministic values: + 1. The literal default in the root field's assignment chain when the root + is fed by `ACCEPT ... FROM COMMAND-LINE` (e.g. `IF x = SPACES + MOVE '202605' TO x`). The pipeline runs programs without command-line + args, so that SPACES default is what the program actually uses at + runtime (DBV-YEAR-MONTH -> ... -> WRK-YEAR-MONTH = '202605'). + 2. The input-record field value at the end of a MOVE chain + (e.g. `MOVE R02EMP-ID TO DBV-EMP-ID` -> R02EMP-ID's record value). + + Returns '' when no deterministic runtime value is derivable; the caller + falls back to record heuristics. + """ + if not assignments: + return '' + root = _hostvar_root(host_var, assignments) + al = assignments.get(root) + if isinstance(al, dict): + al = [al] + if al and any(a.get('type') == 'accept' for a in al): + for a in al: + if a.get('type') == 'move_literal' and a.get('literal') is not None: + return str(a['literal']) + if root != host_var: + rv = _rec_get_ci(rec, root) + if rv is not None and str(rv).strip(): + return str(rv) + return '' + + +def _resolve_where_hostvar(rec: dict, host_var: str, assignments: dict): + """Resolve a WHERE host var to the value the program actually uses. + + If the host var is set by MOVE assignments (e.g. `MOVE R02EMP-ID TO + DBV-EMP-ID`) before the SQL statement, trace to the input-record root and + prefer its value, so the DB pre-seed matches the runtime query key. + Falls back to the host var's own record value when no chain resolves. + """ + root = _hostvar_root(host_var, assignments) + if root != host_var: + rv = _rec_get_ci(rec, root) + if rv is not None and str(rv).strip(): + return str(rv) + return _rec_get(rec, host_var, '') + + +def _runtime_or_where_hostvar(rec: dict, host_var: str, assignments: dict) -> str: + """Resolve a WHERE host var preferring the deterministic runtime value. + + `_resolve_runtime_hostvar` handles host vars fed by `ACCEPT ... FROM + COMMAND-LINE` whose SPACES default the program substitutes at runtime + (e.g. WRK-YEAR-MONTH = '202605'): the record carries a synthetic value + ('I00001') that does NOT match the runtime query, so without this the + seeded WHERE columns (EFFECTIVE-FROM/TO) fail the runtime predicate and + the rows never load. Falls back to the existing MOVE-chain / record + resolution when no deterministic runtime value exists. + """ + rv = _resolve_runtime_hostvar(rec, host_var, assignments) + if rv: + return rv + return _resolve_where_hostvar(rec, host_var, assignments) + + +def _resolve_between_operand(op: dict, rec: dict, assignments: dict): + """Resolve a BETWEEN subject/lo/hi operand to a concrete string value.""" + if not op: + return None + kind = op.get('kind') + if kind == 'host_var': + v = _resolve_where_hostvar(rec, op.get('name', ''), assignments) + return v + if kind == 'literal': + return op.get('value') + return None + + +def _derive_runtime_hostvar(rec: dict, host_var: str, assignments: dict, + fields: list) -> str | None: + """Derive the runtime value of a WORKING-STORAGE host var via propagation. + + Records generated for coverage carry synthetic WRK-* values (e.g. + WRK-TAXABLE-INCOME='000003701') that do NOT match what the program + computes at runtime from the input keys (e.g. max(0, gross - deduction)). + For a BETWEEN seed to match the runtime query key, we re-derive the value + on a copy of the record: seed WORKING-STORAGE constants from their field + VALUE clause, then run propagate_assignments. Returns None when the host + var is not derivable (e.g. it is an input key with a direct MOVE chain). + """ + if not assignments or not fields: + return None + try: + from .core import propagate_assignments + probe = dict(rec) + for f in fields: + if f.get('value') is not None and f.get('section') == 'WORKING-STORAGE': + probe.setdefault(f['name'], str(f['value'])) + propagate_assignments(probe, assignments, fields) + v = probe.get(host_var) + if v is not None and str(v).strip(): + return str(v) + except Exception: + pass + return None + + +def _between_seed_value(wc: dict, rec: dict, assignments: dict, col_name: str, + fields: list = None) -> str | None: + """Compute the DB column value for a BETWEEN where-constraint. + + Case A (`:hv BETWEEN LO-COL AND HI-COL`): the subject is a host var whose + runtime value must fall inside the band; seed LO-COL / HI-COL with it. + Case B (`SUBJ-COL BETWEEN :lo AND :hi`): the subject column is the one to + seed; use the lo operand value so `lo <= col <= hi` holds. + Returns None when this column is not governed by the BETWEEN constraint. + """ + if not wc or wc.get('type') != 'between' or wc.get('neg'): + return None + subject = wc.get('subject', {}) + lo = wc.get('lo', {}) + hi = wc.get('hi', {}) + if subject.get('kind') == 'host_var': + for side in (lo, hi): + if side.get('kind') == 'column' and _norm_col(side.get('name', '')) == _norm_col(col_name): + derived = _derive_runtime_hostvar(rec, subject.get('name', ''), + assignments, fields or []) + if derived: + return derived + v = _resolve_between_operand(subject, rec, assignments) + if v and str(v).strip(): + return str(v) + elif subject.get('kind') == 'column' and _norm_col(subject.get('name', '')) == _norm_col(col_name): + lo_val = _resolve_between_operand(lo, rec, assignments) + hi_val = _resolve_between_operand(hi, rec, assignments) + for v in (lo_val, hi_val): + if v is not None and str(v).strip(): + return str(v) + return None + + +def _input_pk_field(rec: dict, col_name: str) -> str | None: + """Find the input-record field (R##-prefixed) that feeds a PK column. + + Only returns a field whose name ends with the hyphen-normalized column + name. A YEAR-MONTH PK column therefore no longer falls back to the + EMP-ID input field (which produced a truncated, wrong collision value). + Returns None when no such input field exists (e.g. YEAR-MONTH comes from + a WORKING-STORAGE ACCEPT default instead). + """ + if not rec: + return None + base = col_name.upper().replace('EMP_ID', 'EMP-ID') + for key in rec: + k = str(key).upper() + if k.endswith(base) and k[:1] == 'R' and k[1:2].isdigit(): + return key + return None + + +def _insert_collision_row(sql: dict, rec: dict, pk_cols: list[str], + declared_columns: dict, assignments: dict = None) -> dict | None: + """Build a DB pre-seed row whose PK collides with the input record's PK. + + For an INSERT statement, this creates a row with the same primary-key value + as the value the program actually INSERTs at runtime, so the runtime INSERT + fails with a duplicate-key SQL error (DB2 -803). + Returns {} if no PK value could be derived from the record. + """ + if not rec or not pk_cols: + return None + columns = sql.get('columns', []) + host_vars = sql.get('host_vars', []) + if not columns or not host_vars: + return None + col_infos = declared_columns.get(sql.get('table', ''), []) + if not col_infos: + # Schema may declare the table with underscores while SQL uses hyphens. + col_infos = declared_columns.get(sql.get('table', '').replace('-', '_'), []) + if not col_infos: + # Fall back to CHAR types inferred from the INSERT column list. + col_infos = [{'name': c, 'db_type': 'CHAR', 'size': 20} for c in columns] + + col_to_hv = {} + for c, hv in zip(columns, host_vars): + col_to_hv[c.upper()] = hv + col_to_hv[c.upper().replace('-', '_')] = hv + row = {} + for ci in col_infos: + col_name = ci['name'].upper() + col_name_alt = col_name.replace('-', '_') if '-' in col_name else col_name.replace('_', '-') + if col_name not in pk_cols and col_name_alt not in pk_cols: + continue + pk_hit = col_name if col_name in pk_cols else col_name_alt + hv = col_to_hv.get(col_name) or col_to_hv.get(col_name_alt) + val = None + # Prefer the runtime value the program actually INSERTs: + # - EMP-ID <- MOVE chain to the input-record field (R02EMP-ID) + # - YEAR-MONTH <- ACCEPT + IF-SPACES default literal ('202605') + if hv: + val = _resolve_runtime_hostvar(rec, hv, assignments or {}) + if not val or not str(val).strip(): + inp_field = _input_pk_field(rec, pk_hit) + if inp_field: + v = _rec_get(rec, inp_field, '') + if v and str(v).strip(): + val = str(v) + if (not val or not str(val).strip()) and hv: + val = _rec_get(rec, hv, '') + if not val or not str(val).strip(): + return None + row[col_name] = _format_db_value(ci, str(val)) + return row if row else None + + +# ── 事务调度感知:按 88 级识别记录运行时执行的 SQL 种类 ── +# 目的:UPDATE/DELETE 种子只对实际执行 UPDATE/DELETE 的记录建行, +# 避免把 INSERT 记录的主键也预置进表(否则运行时 INSERT 全部 -803, +# INSERT 成功分支不可达)。通用实现(88 级名 INSERT/UPDATE/DELETE 语义标记、 +# 沿 MOVE 链追溯输入根字段),无程序名硬编码。 + + +def _dispatch_field_and_sets(fields_dict): + """返回 (调度字段名, {INSERT:[...], UPDATE:[...], DELETE:[...]}) 或 (None, {})。 + + 调度字段 = 带 INSERT/UPDATE/DELETE 88 级语义标记的父字段(如 WRK-TRAN-TYPE)。 + """ + groups = {} + for f in fields_dict or []: + if isinstance(f, dict) and f.get('is_88') and f.get('parent'): + groups.setdefault(f['parent'], []).append(f) + for parent, kids in groups.items(): + hi = any('INSERT' in str(k.get('name', '')).upper() for k in kids) + hu = any('UPDATE' in str(k.get('name', '')).upper() for k in kids) + hd = any('DELETE' in str(k.get('name', '')).upper() for k in kids) + if hi and hu and hd: + sets = {} + for k in kids: + nm = str(k.get('name', '')).upper() + vals = k.get('values') or ([k.get('value')] if k.get('value') else []) + group = next((g for g in ('INSERT', 'UPDATE', 'DELETE') if g in nm), None) + if not group: + continue + for v in vals: + sets.setdefault(group, set()).add(str(v).strip()) + return parent, sets + return None, {} + + +def _trace_input_root(field_name, assignments): + """沿 MOVE 链把调度字段追溯到输入记录根字段(WRK-TRAN-TYPE ← R01TRAN-TYPE)。""" + seen = set() + cur = field_name + while cur and cur not in seen: + seen.add(cur) + al = assignments.get(cur) or [] + if not al: + break + srcs = al[0].get('source_vars') or [] + if len(srcs) != 1: + break + cur = srcs[0] + return cur + + +def _classify_record_dispatches(records, fields_dict, assignments): + """返回 {path_idx: 'INSERT'|'UPDATE'|'DELETE'|'OTHER'}。 + + 用记录的实际调度值(输入根字段,非合成工作区值)对照 88 级集合分类。 + """ + field, sets = _dispatch_field_and_sets(fields_dict) + if not field: + return {} + root = _trace_input_root(field, assignments or {}) + out = {} + for i, r in enumerate(records or []): + v = str(r.get(root, r.get(field, ''))).strip() + out[i] = next((k for k in ('INSERT', 'UPDATE', 'DELETE') + if v in sets.get(k, set())), 'OTHER') + return out + + def build_db_input( branch_paths: list[tuple[list, dict]], fields_dict: list[dict], @@ -352,6 +888,7 @@ def build_db_input( sql_meta: list[dict], declared_columns: dict, records: list[dict] = None, + insert_pk: dict[str, list[str]] = None, ) -> dict: """Generate DB input rows per branch path. Returns {table: [{col: val, ...}, ...]}. @@ -363,14 +900,42 @@ def build_db_input( seen_keys = {} seq_counter = itertools.count(1) + # 事务调度感知:分类每条记录运行时执行的 SQL(INSERT/UPDATE/DELETE/OTHER)。 + # UPDATE/DELETE 种子只对实际执行 UPDATE/DELETE 的记录建行,避免把 INSERT + # 记录主键预置进表导致 INSERT 成功分支不可达。 + dispatch = _classify_record_dispatches(records, fields_dict, assignments) + + # Downstream no-data coverage: when a path seeds rows for multiple SELECT + # tables (e.g. EMP-MASTER then OVT-MONTHLY), drop the rows of the LAST + # (runtime-order) table for the LAST SQL-ok path. That record then reaches + # the downstream SELECT with no matching row (SQLCODE = 100), covering the + # downstream SELECT's "no data" branch (IF SQLCODE = 0 ELSE path). + sql_select_tables = [m.get('table') for m in sql_meta + if m.get('type') == 'exec_sql_select' and m.get('table')] + unique_select_tables = [] + for t in sql_select_tables: + if t not in unique_select_tables: + unique_select_tables.append(t) + drop_table = unique_select_tables[-1] if len(unique_select_tables) >= 2 else None + sql_ok_paths = [i for i, (pc, _pa) in enumerate(branch_paths) if _path_has_sql_ok(pc)] + last_sql_ok_path = sql_ok_paths[-1] if len(sql_ok_paths) >= 2 else -1 + # Collect all SQL meta per path for path_idx, (path_cons, path_assign) in enumerate(branch_paths): - # Skip paths where SQL fails (SQLCODE <> 0) - if not _path_has_sql_ok(path_cons): - continue + sql_ok = _path_has_sql_ok(path_cons) rec = records[path_idx] if records and path_idx < len(records) else {} + if path_idx == 0 and rec: + with open(r'C:\Users\marye\AppData\Local\Temp\opencode\build_db_input_debug.txt', 'w') as _f: + _f.write(f"rec keys count={len(rec)}\n") + _f.write(f"has_HV-ANNUAL-H={'HV-ANNUAL-H' in rec}\n") + _f.write(f"has_working_storage={'working_storage' in rec}\n") + _f.write(f"HV-ANNUAL-H via _rec_get={_rec_get(rec, 'HV-ANNUAL-H', 'NOT_FOUND')!r}\n") + if 'HV-ANNUAL-H' in rec: + _f.write(f"HV-ANNUAL-H value={rec['HV-ANNUAL-H']!r}\n") + _f.write(f"all keys sorted={sorted(rec.keys())}\n") + for sql in sql_meta: atype = sql.get('type', '') table = sql.get('table', '') @@ -383,12 +948,36 @@ def build_db_input( seen_keys[table] = set() if atype == 'exec_sql_insert': - # INSERT creates rows at runtime; no initial rows needed + # INSERT creates rows at runtime; no initial rows needed, + # EXCEPT a PK-collision row so the duplicate-key error path + # (SQLCODE <> 0 / -803) is reachable at runtime. + if insert_pk: + pk_cols = [c.upper() for c in insert_pk.get(table, [])] + if pk_cols and not _path_has_sql_ok(path_cons): + # 碰撞行必须命中真实执行 INSERT 的记录:若本路径记录并非 + # INSERT 类(如合成记录走了 WHEN OTHER),回退到首条 + # INSERT 类记录的主键,确保运行时 -803 分支可达。 + target = rec if dispatch.get(path_idx) == 'INSERT' else None + if target is None: + ins_idx = next((i for i in sorted(dispatch) + if dispatch[i] == 'INSERT'), None) + target = records[ins_idx] if ins_idx is not None else rec + row = _insert_collision_row(sql, target, pk_cols, + declared_columns, + assignments=assignments) + if row: + db_input[table].append(row) continue if atype in ('exec_sql_delete', 'exec_sql_update'): # DELETE/UPDATE needs existing rows to act on - col_infos = declared_columns.get(table, []) + if not sql_ok: + continue + # 仅对实际执行 UPDATE/DELETE 的记录建行(避免污染 INSERT/OTHER 主键)。 + # 无 88 级调度字段可识别时不做门控(回退旧行为)。 + if dispatch and dispatch.get(path_idx) not in ('UPDATE', 'DELETE'): + continue + col_infos = _declared_cols_for(declared_columns, table) if not col_infos: col_infos = _infer_columns_from_where(where_cons) row = {} @@ -397,17 +986,17 @@ def build_db_input( val = None for wc in where_cons: wc_col = wc.get('col', '').upper().split('.')[-1] - if wc_col != col_name: + if _norm_col(wc_col) != _norm_col(col_name): continue if wc['type'] == 'literal': val = wc.get('literal', '') break elif wc['type'] == 'host_var': hv = wc.get('host_var', '').upper() - val = str(rec.get(hv, '')) + val = _runtime_or_where_hostvar(rec, hv, assignments) break if val is None or not val.strip(): - val = str(rec.get(ci['name'], '')) + val = _rec_get(rec, ci['name'], '') if val and val.strip(): row[ci['name']] = _format_db_value(ci, val) if not row: @@ -416,8 +1005,10 @@ def build_db_input( continue # exec_sql_select (and any future read-only types) + if not sql_ok: + continue row = {} - col_infos = declared_columns.get(table, []) + col_infos = _declared_cols_for(declared_columns, table) if not col_infos: col_infos = _infer_columns_from_where(where_cons) into_vars = sql.get('into_vars', []) @@ -431,7 +1022,7 @@ def build_db_input( # Use SQL column names (not INTO var names) for row keys for sc in select_cols: - if sc not in [c['name'] for c in col_infos]: + if not any(_norm_col(sc) == _norm_col(c['name']) for c in col_infos): col_infos.append({'name': sc, 'db_type': 'CHAR', 'size': 20}) where_cols = set() @@ -439,35 +1030,48 @@ def build_db_input( col_name = col_info['name'] val = None for wc in where_cons: - if wc['type'] == 'literal' and wc.get('col', '').upper() == col_name: + if wc['type'] == 'between': + bv = _between_seed_value(wc, rec, assignments, col_name, + fields_dict) + if bv is not None: + val = bv + where_cols.add(col_name) + break + if wc['type'] == 'literal' and _norm_col(wc.get('col', '')) == _norm_col(col_name): val = wc.get('literal', '') where_cols.add(col_name) break if wc['type'] == 'host_var': + wc_col = wc.get('col', '').upper().split('.')[-1] + if _norm_col(wc_col) != _norm_col(col_name): + continue hv = wc.get('host_var', '').upper() for pc_field, pc_op, pc_val, pc_want in path_cons: if pc_field == hv: val = pc_val if pc_want else '' + where_cols.add(col_name) break - if val is None and hv in rec: - val = str(rec[hv]) + if val is None and _rec_has(rec, hv): + val = _runtime_or_where_hostvar(rec, hv, assignments) + where_cols.add(col_name) # Try to find value from INTO variable in the record if val is None: for iv, scola in into_to_col.items(): - if scola == col_name and iv in rec: - val = str(rec[iv]) + if _norm_col(scola) == _norm_col(col_name) and _rec_has(rec, iv): + val = _rec_get(rec, iv, '') break # Try COBOL field name mapping if val is None: cobol_field = guess_cobol_field(col_name, table, declared_columns) - if cobol_field in rec: - val = str(rec[cobol_field]) + if _rec_has(rec, cobol_field): + val = _rec_get(rec, cobol_field, '') if val is not None: row[col_name] = _format_db_value(col_info, val) else: + row[col_name] = _format_db_value(col_info, str(next(seq_counter))) if not row: @@ -478,6 +1082,8 @@ def build_db_input( if first_col in row and (not where_cols or first_col not in where_cols): row[first_col] = _make_key_unique(row[first_col], path_idx, seen_keys[table]) + if drop_table is not None and table == drop_table and path_idx == last_sql_ok_path: + continue db_input[table].append(row) return db_input diff --git a/config/program_schema.py b/config/program_schema.py index 96d7a63..649fdad 100644 --- a/config/program_schema.py +++ b/config/program_schema.py @@ -32,6 +32,7 @@ class SysinDef: period: str | None = "202607" include_invalid_period: bool = False modes: list[str] = field(default_factory=lambda: ["NORMAL"]) + final_mode: str = "RESET" @dataclass @@ -42,6 +43,9 @@ class ScenarioDef: inject_duplicate_pk: bool = False row_overrides: dict[str, dict[str, str]] = field(default_factory=dict) delete_all_rows: bool = False + drop_tables: list[str] = field(default_factory=list) + command_line: str | None = None + seed_extra_rows: dict[str, int] = field(default_factory=dict) @dataclass @@ -53,6 +57,7 @@ class ProgramSchema: db_name: str = "OVERTIME.DB" runs: list[ScenarioDef] = field(default_factory=list) coverage_dates: dict[str, list[dict[str, str]]] = field(default_factory=dict) + command_line: str | None = None @classmethod def from_yaml(cls, path: str | Path) -> ProgramSchema: @@ -76,6 +81,9 @@ class ProgramSchema: inject_duplicate_pk=r.get("inject_duplicate_pk", False), row_overrides=r.get("row_overrides", {}), delete_all_rows=r.get("delete_all_rows", False), + drop_tables=r.get("drop_tables", []), + command_line=r.get("command_line"), + seed_extra_rows=r.get("seed_extra_rows", {}), )) return cls( program_id=raw["program_id"], @@ -85,6 +93,7 @@ class ProgramSchema: db_name=raw.get("db_name", "OVERTIME.DB"), runs=runs, coverage_dates=raw.get("coverage_dates", {}), + command_line=raw.get("command_line"), ) diff --git a/config/programs/JIN05UPD.yaml b/config/programs/JIN05UPD.yaml new file mode 100644 index 0000000..6f803cb --- /dev/null +++ b/config/programs/JIN05UPD.yaml @@ -0,0 +1,34 @@ +program_id: JIN05UPD +db_type: SQLite +db_name: JIN.db + +db_tables: + - name: EMPLOYEE + columns: + - name: EMP_ID + type: CHAR(8) + primary_key: true + - name: KANA_SEI + type: VARCHAR(20) + - name: KANA_MEI + type: VARCHAR(20) + - name: KANJI_NAME + type: VARCHAR(40) + - name: SUB_CODE + type: CHAR(4) + - name: BIRTH_DATE + type: CHAR(8) + - name: DEPT_CODE + type: CHAR(4) + - name: ENTRY_DATE + type: CHAR(8) + - name: STATUS + type: CHAR(1) + +runs: + - id: normal + +subprograms: + - SUB01DAT + - SUB02MSG + - SUB03END diff --git a/config/programs/KIN06CLD.yaml b/config/programs/KIN06CLD.yaml index 69852f6..9c36c18 100644 --- a/config/programs/KIN06CLD.yaml +++ b/config/programs/KIN06CLD.yaml @@ -23,6 +23,22 @@ db_tables: - name: STATUS type: CHAR(1) +command_line: YEARMONTH=202607 + +runs: + - id: normal + - id: no_parm + command_line: "" + - id: feb + command_line: YEARMONTH=202602 + - id: feb_leap + command_line: YEARMONTH=202402 + - id: invalid_month + command_line: YEARMONTH=202613 + - id: empty_emp + command_line: YEARMONTH=202607 + delete_all_rows: true + subprograms: - SUB02MSG - SUB03END diff --git a/config/programs/KIN08DBU.yaml b/config/programs/KIN08DBU.yaml index 9929298..b1cc961 100644 --- a/config/programs/KIN08DBU.yaml +++ b/config/programs/KIN08DBU.yaml @@ -77,3 +77,22 @@ runs: include_invalid_period: true modes: ["NORMAL", "RESET"] inject_duplicate_pk: true + + - id: sql_delete_error + # RESET 時 DELETE MONTHLY_ABSENCE の SQL エラー(テーブル削除)→ 9100DBERRSOR + sysin: + period: "202607" + include_invalid_period: true + modes: ["RESET"] + inject_duplicate_pk: false + drop_tables: [MONTHLY_ABSENCE] + + - id: sql_select_error + # UPSERT 前 SELECT COUNT 的 SQL エラー(テーブル削除)→ SELECT COUNT failed 分岐 + sysin: + period: "202607" + include_invalid_period: true + modes: ["NORMAL"] + final_mode: "NORMAL" + inject_duplicate_pk: false + drop_tables: [MONTHLY_ABSENCE] diff --git a/config/programs/KIN09CSV.yaml b/config/programs/KIN09CSV.yaml index 18f5be3..d2bd6e9 100644 --- a/config/programs/KIN09CSV.yaml +++ b/config/programs/KIN09CSV.yaml @@ -49,6 +49,30 @@ db_tables: - name: UPDATED_AT type: TIMESTAMP +runs: + - id: FULL + command_line: "YEARMONTH=202607,MODE=FULL" + seed_extra_rows: + DAILY_RECORDS: 100 + - id: SHORT + command_line: "YEARMONTH=202607,MODE=SHORT" + + # Fix A: PARM 组合 + 空结果集场景 + - id: missing_mode + # WS-COMMA-CNT < 1 → 'WARNING: Missing MODE'(MODE 默认 FULL) + command_line: "YEARMONTH=202607" + - id: reordered_parm + # KEY1=MODE(VALUE1→WS-MODE)+ KEY2=YEARMONTH(VALUE2→WS-YEARMONTH) + command_line: "MODE=FULL,YEARMONTH=202607" + - id: missing_ym + # YEARMONTH 未指定 → SPACE → ABEND(SUB03END) + command_line: "MODE=FULL" + - id: empty_daily + # 首 FETCH EOF:删除 DAILY_RECORDS 表 → 光标 OPEN/FETCH 失败 → SQLCODE≠0 → WS-DAILY-EOF + # (delete_all_rows 空表时 gixsql 首次 FETCH 返回 SQLCODE=0,无法触发 EOF) + command_line: "YEARMONTH=202607,MODE=FULL" + drop_tables: [DAILY_RECORDS] + subprograms: - SUB02MSG - SUB03END diff --git a/config/programs/KYU02REG.yaml b/config/programs/KYU02REG.yaml new file mode 100644 index 0000000..a86ba25 --- /dev/null +++ b/config/programs/KYU02REG.yaml @@ -0,0 +1,32 @@ +program_id: KYU02REG +db_type: SQLite +db_name: SALARY.db + +db_tables: + - name: EMP_MASTER + columns: + - name: EMP_ID + type: CHAR(8) + primary_key: true + - name: EMP_NAME + type: VARCHAR(40) + - name: DEPT_CODE + type: CHAR(2) + - name: REGION_CODE + type: CHAR(2) + - name: CATEGORY_CODE + type: CHAR(3) + - name: BASE_SALARY + type: DECIMAL(9,0) + - name: HOURLY_RATE + type: DECIMAL(7,0) + - name: DEPENDENT_COUNT + type: SMALLINT + - name: STATUS + type: CHAR(1) + - name: UPDATED_AT + type: TIMESTAMP + +subprograms: + - SUB02MSG + - SUB03END diff --git a/config/programs/KYU04CAL.yaml b/config/programs/KYU04CAL.yaml new file mode 100644 index 0000000..a224dd4 --- /dev/null +++ b/config/programs/KYU04CAL.yaml @@ -0,0 +1,50 @@ +program_id: KYU04CAL +db_type: SQLite +db_name: SALARY.db + +db_tables: + - name: EMP_MASTER + columns: + - name: EMP_ID + type: CHAR(8) + primary_key: true + - name: EMP_NAME + type: VARCHAR(40) + - name: DEPT_CODE + type: CHAR(2) + - name: REGION_CODE + type: CHAR(2) + - name: CATEGORY_CODE + type: CHAR(3) + - name: BASE_SALARY + type: DECIMAL(9,0) + - name: HOURLY_RATE + type: DECIMAL(7,0) + - name: DEPENDENT_COUNT + type: SMALLINT + - name: STATUS + type: CHAR(1) + - name: UPDATED_AT + type: TIMESTAMP + + - name: OVT_MONTHLY + columns: + - name: EMP_ID + type: CHAR(8) + primary_key: true + - name: YEAR_MONTH + type: CHAR(6) + primary_key: true + - name: OVT_TYPE + type: CHAR(1) + - name: OVT_HOURS + type: DECIMAL(4,1) + - name: OVT_COUNT + type: DECIMAL(9) + - name: UPDATED_AT + type: TIMESTAMP + +subprograms: + - SUB01DAT + - SUB02MSG + - SUB03END diff --git a/config/programs/KYU05DED.yaml b/config/programs/KYU05DED.yaml new file mode 100644 index 0000000..7c29d5b --- /dev/null +++ b/config/programs/KYU05DED.yaml @@ -0,0 +1,36 @@ +program_id: KYU05DED +db_type: SQLite +db_name: SALARY.db + +db_tables: + - name: TAX_TABLE + columns: + - name: TAX_FROM + type: DECIMAL(9,0) + primary_key: true + - name: TAX_TO + type: DECIMAL(9,0) + primary_key: true + - name: TAX_RATE + type: DECIMAL(5,4) + - name: DEDUCTION + type: DECIMAL(9,0) + + - name: INSURANCE_TABLE + columns: + - name: INS_INCOME_FROM + type: DECIMAL(9,0) + primary_key: true + - name: INS_INCOME_TO + type: DECIMAL(9,0) + primary_key: true + - name: INS_HEALTH_RATE + type: DECIMAL(7,6) + - name: INS_PENSION_RATE + type: DECIMAL(7,6) + - name: UPDATED_AT + type: TIMESTAMP + +subprograms: + - SUB02MSG + - SUB03END diff --git a/config/programs/KYU06UPD.yaml b/config/programs/KYU06UPD.yaml new file mode 100644 index 0000000..20fa9ef --- /dev/null +++ b/config/programs/KYU06UPD.yaml @@ -0,0 +1,29 @@ +program_id: KYU06UPD +db_type: SQLite +db_name: SALARY.db + +db_tables: + - name: SALARY_RESULTS + columns: + - name: EMP_ID + type: CHAR(8) + primary_key: true + - name: YEAR_MONTH + type: CHAR(6) + primary_key: true + - name: GROSS_PAYMENT + type: DECIMAL(9,0) + - name: INCOME_TAX + type: DECIMAL(9,0) + - name: INSURANCE + type: DECIMAL(9,0) + - name: RESIDENT_TAX + type: DECIMAL(9,0) + - name: NET_PAYMENT + type: DECIMAL(9,0) + - name: UPDATED_AT + type: TIMESTAMP + +subprograms: + - SUB02MSG + - SUB03END diff --git a/config/programs/SHA02MNC.yaml b/config/programs/SHA02MNC.yaml new file mode 100644 index 0000000..4553457 --- /dev/null +++ b/config/programs/SHA02MNC.yaml @@ -0,0 +1,45 @@ +program_id: SHA02MNC +db_type: SQLite +db_name: INSURANCEDB.db + +db_tables: + - name: INSURANCE_RATES + columns: + - name: GRADE_CODE + type: CHAR(2) + primary_key: true + - name: MONTHLY_FROM + type: DECIMAL(9,0) + - name: MONTHLY_TO + type: DECIMAL(9,0) + - name: HEALTH_RATE + type: DECIMAL(7,6) + - name: PENSION_RATE + type: DECIMAL(7,6) + - name: EFFECTIVE_FROM + type: CHAR(6) + - name: EFFECTIVE_TO + type: CHAR(6) + + - name: EMP_MASTER + columns: + - name: EMP_ID + type: CHAR(8) + primary_key: true + - name: EMP_NAME + type: VARCHAR(40) + - name: DEPT_CODE + type: CHAR(2) + - name: BASE_SALARY + type: DECIMAL(9,0) + +runs: + - id: normal + - id: emp_open_fail + drop_tables: [EMP_MASTER] + inject_duplicate_pk: false + +subprograms: + - SUB01DAT + - SUB02MSG + - SUB04CHK \ No newline at end of file diff --git a/config/programs/SHA03MNP.yaml b/config/programs/SHA03MNP.yaml new file mode 100644 index 0000000..8281b84 --- /dev/null +++ b/config/programs/SHA03MNP.yaml @@ -0,0 +1,32 @@ +program_id: SHA03MNP +db_type: SQLite +db_name: INSURANCEDB.db + +db_tables: + - name: INSURANCE_RATES + columns: + - name: GRADE_CODE + type: CHAR(2) + primary_key: true + - name: MONTHLY_FROM + type: DECIMAL(9,0) + - name: MONTHLY_TO + type: DECIMAL(9,0) + - name: HEALTH_RATE + type: DECIMAL(7,6) + - name: PENSION_RATE + type: DECIMAL(7,6) + - name: EFFECTIVE_FROM + type: CHAR(6) + - name: EFFECTIVE_TO + type: CHAR(6) + +runs: + - id: normal + - id: rates_open_fail + drop_tables: [INSURANCE_RATES] + inject_duplicate_pk: false + +subprograms: + - SUB01DAT + - SUB02MSG \ No newline at end of file diff --git a/config/programs/SHA04TWO.yaml b/config/programs/SHA04TWO.yaml new file mode 100644 index 0000000..35c953a --- /dev/null +++ b/config/programs/SHA04TWO.yaml @@ -0,0 +1,19 @@ +program_id: SHA04TWO +db_type: SQLite +db_name: INSURANCEDB.db + +db_tables: + - name: INSURED_MASTER + columns: + - name: EMP_ID + type: CHAR(8) + primary_key: true + - name: INSURED_NO + type: CHAR(10) + primary_key: true + - name: STATUS + type: CHAR(1) + +subprograms: + - SUB01DAT + - SUB02MSG \ No newline at end of file diff --git a/config/programs/SHA06TWM.yaml b/config/programs/SHA06TWM.yaml new file mode 100644 index 0000000..6bb7405 --- /dev/null +++ b/config/programs/SHA06TWM.yaml @@ -0,0 +1,30 @@ +program_id: SHA06TWM +db_type: SQLite +db_name: INSURANCEDB.db + +db_tables: + - name: INSURANCE_RATES + columns: + - name: GRADE_CODE + type: CHAR(2) + primary_key: true + - name: HEALTH_RATE + type: DECIMAL(7,0) + - name: PENSION_RATE + type: DECIMAL(7,0) + + - name: EMP_MASTER + columns: + - name: EMP_ID + type: CHAR(8) + primary_key: true + - name: BIRTH_DATE + type: CHAR(8) + - name: DEPENDENT_COUNT + type: SMALLINT + - name: REGION_CODE + type: CHAR(2) + +subprograms: + - SUB01DAT + - SUB02MSG \ No newline at end of file diff --git a/config/programs/SHA07KBR.yaml b/config/programs/SHA07KBR.yaml new file mode 100644 index 0000000..3ae4532 --- /dev/null +++ b/config/programs/SHA07KBR.yaml @@ -0,0 +1,34 @@ +program_id: SHA07KBR +db_type: SQLite +db_name: INSURANCEDB.db + +db_tables: + - name: QUALIFICATION_CHANGES + columns: + - name: CHG_ID + type: DECIMAL(9,0) + primary_key: true + - name: EMP_ID + type: CHAR(8) + - name: CHG_DATE + type: CHAR(8) + - name: CHG_TYPE + type: CHAR(2) + - name: INSURER_CODE + type: CHAR(4) + - name: PREV_INSURER + type: CHAR(4) + - name: REASON + type: VARCHAR(100) + + - name: EMP_MASTER + columns: + - name: EMP_ID + type: CHAR(8) + primary_key: true + - name: EMP_NAME + type: VARCHAR(40) + +subprograms: + - SUB01DAT + - SUB02MSG \ No newline at end of file diff --git a/orchestrator_db.py b/orchestrator_db.py index 7708ed2..ff0f9a1 100644 --- a/orchestrator_db.py +++ b/orchestrator_db.py @@ -34,6 +34,33 @@ from runners.gixsql_runner import GixsqlCobolRunner, GixsqlTableData logger = logging.getLogger(__name__) +def _calc_birth_date(age: int, as_of: str = '20260802') -> str: + """年龄 → 出生日期 YYYYMMDD(通用)。as_of 为运行时运用日。""" + from datetime import datetime, timedelta + base = datetime.strptime(as_of, '%Y%m%d') + d = base - timedelta(days=max(age, 0) * 365) + return d.strftime('%Y%m%d') + + +def _merge_run_dirs_gcov(gcov_dir: str | Path, program: str, + gcov_func=run_gcov) -> dict[int, int]: + """Merge gcov line counts for ONE program across multi-run scenario dirs. + + Returns {line: max_count}. Subprogram gcov MUST be collected separately + (per subprogram name), never merged into the main program's dict: both use + plain integer line numbers, so SUB*.cbl line 167 would collide with and + overwrite the main program's line 167 (e.g. SUB04CHK 167=0 wiping the + main loop's 167=25). See _sub_gcov_data. + """ + merged: dict[int, int] = {} + for sd in sorted(Path(gcov_dir).glob("run_*")): + data = gcov_func(program, str(sd)) + if data: + for line, cnt in data.items(): + merged[line] = max(merged.get(line, 0), cnt) + return merged + + @dataclass class DbPipelineResult: """DB 管线単体実行結果""" @@ -86,6 +113,7 @@ class GixsqlOrchestrator: 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._sub_gcov_data: dict[str, dict[int, int]] = {} # per-subprogram gcov (kept separate from main) self.java_output_path: Optional[Path] = None self.generated_records: list[dict] = [] self.generated_structure: dict | None = None @@ -148,6 +176,20 @@ class GixsqlOrchestrator: copybook_dirs=[ascii_dir]) self.pp_path = Path(pp) + # Patch gixpp's broken CONNECT string + # gixpp converts CONNECT TO 'data/kin.db' -> 'sqlite://localhost/kin' + # Fix: use absolute path that gixsql runtime can resolve + if self.pp_path and self.pp_path.exists(): + pp_text = self.pp_path.read_text(encoding='utf-8') + old_conn = 'sqlite://localhost/kin' + new_conn = f'sqlite:///{self.db_path}' + if old_conn in pp_text: + pp_text = pp_text.replace(old_conn, new_conn) + self.pp_path.write_text(pp_text, encoding='utf-8') + logger.info(f" Patched CONNECT: {old_conn} -> {new_conn}") + else: + logger.info(f" CONNECT string not found (already patched?)") + exe = self.work_dir / "bin" / f"{self.program_id}.exe" extra_srcs = [] for sub in self.schema.subprograms: @@ -235,10 +277,27 @@ class GixsqlOrchestrator: # DB 初期行投入(DELETE/UPDATE が作用する行、SELECT が返す行) self._populate_database(db_path, src_text, recs, scenario=scenario) + # seed_extra_rows: 大结果集注入(SELECT 型プログラムの表头重出等の分支) + if scenario: + self._inject_extra_seed_rows(db_path, scenario) # P5: inject duplicate-PK rows (scenario で制御) if scenario is None or scenario.inject_duplicate_pk: self._inject_sql_error_rows(db_path, recs) + # First record: empty EMP-ID to trigger R01EMP-ID = SPACE path (DP#12). + # Independent of R01LINE (which may not exist for this program's FD layout). + if len(recs) > 0: + recs[0]['R01EMP-ID'] = ' ' * 8 + + # 全ゼロ EMP-ID レコードを SPACE にクレンジング(汎用)。 + # プログラムの空社員チェック(R01EMP-ID = SPACE / LOW-VALUES)は + # '00000000' を捕捉しないため、そのまま INSERT され DAILY_RECORDS の + # (EMP_ID, TARGET_DATE) 主キー衝突 → 早期 ABEND を引き起こす。 + for rec in recs: + _eid = str(rec.get('R01EMP-ID', '')).strip() + if _eid == '00000000': + rec['R01EMP-ID'] = ' ' * 8 + # Patch R01LINE records with EMP-IDs matching the record's own EMP-ID for i, rec in enumerate(recs): line = rec.get('R01LINE', '') @@ -253,10 +312,8 @@ class GixsqlOrchestrator: 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 @@ -286,20 +343,25 @@ class GixsqlOrchestrator: src_date = r.get('R01DATE', '') break dup_date = src_date + # Track used days to avoid PK conflict with src record's date + src_day = dup_date[6:8] if dup_date and len(dup_date) >= 8 else '' + used_days = set() + if src_day: + used_days.add(src_day) 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. + # FIXED format: keep same YEAR_MONTH but uniquely different day + # to avoid PK conflict in DAILY_RECORDS INSERT (EMP_ID + TARGET_DATE). 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' + orig_day = orig_date[6:8] if orig_date and len(orig_date) >= 8 else '' + day = orig_day if (orig_day and orig_day not in used_days) else f"{len(used_days)+1:02d}" + used_days.add(day) + rec['R01DATE'] = dup_ym + day # LINE SEQUENTIAL format: patch R01LINE line = rec.get('R01LINE', '') if line: @@ -307,6 +369,9 @@ class GixsqlOrchestrator: if len(parts) == 2: rec['R01LINE'] = f"{dup_eid.ljust(8)},{parts[1]}" + # 聚合边界数据(overflow / agg table full),通用注入,作用于共享 records + self._inject_aggregation_boundaries(recs) + # ── Coverage-driven data modifications (per-scenario) ── # Normal scenario or legacy single-run: no modifications needed. # Collision scenario: INSERT duplicate, OVT-MONTHLY match, COMMIT threshold. @@ -367,6 +432,9 @@ class GixsqlOrchestrator: r02_recs[-1]['R02APPL-ID'] = 'ZZZZZZZZ' logger.info(f" Coverage #14T: set last R02 APPL-ID='ZZZZZZZZ' for orphan cancel") + # 全レコードの(EMP_ID, DATE)重複チェック(PK衝突→ABEND防止) + self._deduplicate_r01_pk(recs) + # 出力先ディレクトリ(シナリオ毎に分離) run_label = f"run_{scenario.id}" if scenario else "" output_root = self.work_dir / run_label if scenario else self.work_dir @@ -384,6 +452,7 @@ class GixsqlOrchestrator: "period": scenario.sysin.period, "include_invalid_period": scenario.sysin.include_invalid_period, "modes": scenario.sysin.modes, + "final_mode": scenario.sysin.final_mode, } sysin_path = write_sysin_file(recs, src_text, input_dir, copybook_dirs=[str(d) for d in self.copybook_dirs], @@ -406,7 +475,7 @@ class GixsqlOrchestrator: data_fields = parse_data_division(data_div) if data_div else [] fdict = [] for f in data_fields: - fdict.append({ + _entry = { 'name': f.name, 'level': f.level, 'pic': f.pic, 'pic_info': { 'type': f.pic_info.type if f.pic_info else 'unknown', @@ -419,7 +488,11 @@ class GixsqlOrchestrator: 'occurs_depending': f.occurs_depending, 'value': f.value, 'values': f.values, 'redefines': f.redefines, 'usage': f.usage, - }) + } + if f.is_88: + _entry['is_88'] = True + _entry['parent'] = f.parent + fdict.append(_entry) fdict = expand_occurs(fdict) proc_div = extract_procedure_division(pp) branch_tree, assignments = build_branch_tree_fallback(proc_div, fdict) @@ -460,12 +533,14 @@ class GixsqlOrchestrator: # DB input for JSON data_div2, declared_columns = strip_exec_sql_from_data_div(data_div) + declared_columns = self._merge_schema_columns(declared_columns) 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) + sql_meta, declared_columns, records=recs, + insert_pk=self._insert_pk_map()) # Write main JSON(シナリオ毎に分離) json_outdir = output_root / "main" / "json" @@ -551,14 +626,19 @@ class GixsqlOrchestrator: self.db_path.unlink() shutil.copy2(str(db_path), str(self.db_path)) db_path = self.db_path - # CONNECT TO 'data/kin.db' のパス解釈に備え CWD にもコピー - if scenario is not None: - cwd_data = cwd / "data" - cwd_data.mkdir(parents=True, exist_ok=True) - cwd_db = cwd_data / "kin.db" - if cwd_db.exists(): - cwd_db.unlink() - shutil.copy2(str(db_path), str(cwd_db)) + # CONNECT TO 'data/kin.db' のパス解釈に備え CWD にもコピー(単輪/多輪共通) + cwd_data = cwd / "data" + cwd_data.mkdir(parents=True, exist_ok=True) + cwd_db = cwd_data / "kin.db" + if cwd_db.exists(): + cwd_db.unlink() + shutil.copy2(str(db_path), str(cwd_db)) + # gixsql regex requires sqlite://host/path (single segment, no dots). + # Copy to CWD/kin (no extension) for sqlite://localhost/kin. + cwd_kin = cwd / "kin" + if cwd_kin.exists(): + cwd_kin.unlink() + shutil.copy2(str(db_path), str(cwd_kin)) # .gcda は CWD(= run_dir)に書き出されるので、実行後に gcov/run_{id}/ に移動する # 各シナリオ実行前に前回の .gcda を削除(GnuCOBOL は累積書込みを行うため) @@ -569,15 +649,27 @@ class GixsqlOrchestrator: except PermissionError: pass + # Create parent directories for all ASSIGN TO files (COBOL needs them to exist) + for fname, direction in assign_map.items(): + if os.sep in fname or '/' in fname: + parent = cwd / os.path.dirname(fname) + parent.mkdir(parents=True, exist_ok=True) + # Subprogram DLLs cobol_bin = Path(self.cobol_src_dir).parent / "bin" + # command_line: scenario-level (if set) overrides program-level default + cmd_line = self.schema.command_line + if scenario and scenario.command_line is not None: + cmd_line = scenario.command_line + command_args = cmd_line.split() if cmd_line else None result = self.runner.run( 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, + command_args=command_args, ) log_dir = self.runtime_dir / "logs" @@ -642,12 +734,7 @@ class GixsqlOrchestrator: 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) + merged_data = _merge_run_dirs_gcov(gcov_dir, f"{self.program_id}_pp") logger.info(f" Merged gcov from {len(run_dirs)} runs ({len(merged_data)} lines)") return merged_data @@ -674,18 +761,16 @@ class GixsqlOrchestrator: # 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 + # Subprogram gcov is kept separate: SUB*.cbl line numbers are + # plain integers that collide with the main program's (e.g. + # SUB04CHK line 167=0 would overwrite main line 167=25 and + # wipe real coverage). Stored per-subprogram for reference. gcov_dir = self.runtime_dir / "gcov" + self._sub_gcov_data = {} 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) + sub_merged = _merge_run_dirs_gcov(gcov_dir, sub) if sub_merged: - gcov_data.update(sub_merged) + self._sub_gcov_data[sub] = sub_merged else: # Single-run: collect .gcno/.gcda and run gcov gcov_dir = self.runtime_dir / "gcov" @@ -726,10 +811,12 @@ class GixsqlOrchestrator: 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)) + # Subprogram gcov kept separate (line numbers collide with main). + self._sub_gcov_data = {} for sub in self.schema.subprograms: sd = run_gcov(sub, str(gcov_dir)) if sd: - gcov_data.update(sd) + self._sub_gcov_data[sub] = sd # 4. Static branch tree from step2 st = self.generated_structure @@ -758,6 +845,7 @@ class GixsqlOrchestrator: }, 'section': f.section, 'occurs': f.occurs_count, 'occurs_depending': f.occurs_depending, + 'value': f.value, 'values': f.values, 'redefines': f.redefines, 'usage': f.usage, } if f.is_88: @@ -1011,7 +1099,7 @@ class GixsqlOrchestrator: # 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.]+)', + r'SELECT\s+(\w+)\s+ASSIGN\s+TO\s+(?:EXTERNAL\s+)?"?([^"\s.]+)', src_text, re.IGNORECASE ): sel_name = m.group(1) @@ -1078,6 +1166,58 @@ class GixsqlOrchestrator: conn.close() logger.info(f" DB initialized: {db_path}") + def _merge_schema_columns(self, declared_columns: dict) -> dict: + """YAML スキーマのカラム型を declared_columns にマージする。 + EXEC SQL DECLARE TABLE がないプログラムでも正しい型が使われるようにする。""" + import re + for t in self.schema.db_tables: + name = t.name.upper() + if name not in declared_columns: + declared_columns[name] = [] + existing = {c['name'].upper() for c in declared_columns[name]} + for c in t.columns: + if c.name.upper() in existing: + continue + raw = c.type.upper() + if raw.startswith('CHAR('): + m = re.search(r'\((\d+)\)', raw) + col = {'name': c.name, 'db_type': 'CHAR', + 'size': int(m.group(1)) if m else 1} + elif raw.startswith('VARCHAR('): + m = re.search(r'\((\d+)\)', raw) + col = {'name': c.name, 'db_type': 'VARCHAR', + 'size': int(m.group(1)) if m else 50} + elif raw in ('INTEGER',): + col = {'name': c.name, 'db_type': 'INTEGER'} + elif raw in ('SMALLINT',): + col = {'name': c.name, 'db_type': 'SMALLINT'} + elif raw.startswith('DECIMAL(') or raw.startswith('NUMERIC('): + m = re.search(r'\((\d+)\s*,?\s*(\d+)?\)', raw) + col = {'name': c.name, 'db_type': 'DECIMAL', + 'precision': int(m.group(1)) if m else 6, + 'scale': int(m.group(2)) if m and m.group(2) else 0} + elif raw in ('DATE', 'TIMESTAMP'): + col = {'name': c.name, 'db_type': 'DATE'} + else: + col = {'name': c.name, 'db_type': 'CHAR', 'size': 20} + declared_columns[name].append(col) + return declared_columns + + def _insert_pk_map(self) -> dict[str, list[str]]: + """Map SQL table name → primary-key column names from the YAML schema. + + Used to generate PK-collision pre-seed rows for INSERT statements so the + duplicate-key error path (SQLCODE = -803) is reachable at runtime. + """ + pk_map = {} + for t in self.schema.db_tables: + cols = [c.name for c in t.columns if c.primary_key] + if cols: + for name in {t.name, t.name.replace('_', '-'), t.sql_name}: + if name: + pk_map[name] = cols + return pk_map + def _populate_database(self, db_path: Path, src_text: str, records: list[dict], scenario: ScenarioDef | None = None): """テストデータから DB 初期行を生成し挿入する。""" @@ -1094,7 +1234,7 @@ class GixsqlOrchestrator: data_fields = parse_data_division(data_div) if data_div else [] fields_dict = [] for f in data_fields: - fields_dict.append({ + _entry = { 'name': f.name, 'level': f.level, 'pic': f.pic, 'pic_info': { 'type': f.pic_info.type if f.pic_info else 'unknown', @@ -1107,7 +1247,11 @@ class GixsqlOrchestrator: 'occurs_depending': f.occurs_depending, 'value': f.value, 'values': f.values, 'redefines': f.redefines, 'usage': f.usage, - }) + } + if f.is_88: + _entry['is_88'] = True + _entry['parent'] = f.parent + fields_dict.append(_entry) fields_dict = expand_occurs(fields_dict) proc_div = extract_procedure_division(preprocessed) @@ -1127,10 +1271,12 @@ class GixsqlOrchestrator: logger.info(" No SQL metadata found, skipping DB population") return + declared_columns = self._merge_schema_columns(declared_columns) db_input = build_db_input( branch_paths, fields_dict, assignments, sql_meta, declared_columns, records=records, + insert_pk=self._insert_pk_map(), ) if not db_input: logger.info(" No DB input rows generated") @@ -1165,6 +1311,50 @@ class GixsqlOrchestrator: if i < len(holiday_overrides): row['HOLIDAY_DATE'] = holiday_overrides[i] + # -- DAILY_RECORDS date enrichment: replace counter dates with valid YYYYMMDD -- + if 'DAILY_RECORDS' in db_input: + dr_rows = db_input['DAILY_RECORDS'] + for i, row in enumerate(dr_rows): + day = (i % 31) + 1 + row['TARGET_DATE'] = f'202607{day:02d}' + + # -- MONTHLY_ABSENCE YEAR_MONTH enrichment: match command-line YEARMONTH -- + if 'MONTHLY_ABSENCE' in db_input: + ym = '202607' + for row in db_input['MONTHLY_ABSENCE']: + row['YEAR_MONTH'] = ym + + # -- INSURANCE-RATES ↔ EMP-MASTER SEARCH/EVALUATE coordination -- + # Programs load all rate rows effective for the runtime YEAR-MONTH + # (WHERE EFFECTIVE-FROM <= :ym AND EFFECTIVE-TO >= :ym), SEARCH the + # internal WRK-RATE-ENTRY table against each employee's BASE-SALARY, + # then EVALUATE DEPT-CODE. For the SEARCH to find a match (→ EVALUATE), + # some EMP BASE_SALARY must fall inside a loaded rate's + # MONTHLY_FROM..TO, and DEPT-CODE must span the EVALUATE ranges. + # Gated on the EFFECTIVE window pattern (SHA02MNC-style) so programs + # querying rates by other keys (e.g. SHA06TWM GRADE-CODE lookup) are + # untouched. Table-name driven, not program-ID hardcoded. + if ('INSURANCE-RATES' in db_input and 'EMP-MASTER' in db_input + and any('EFFECTIVE-FROM' in str(m.get('where', '')).upper() + for m in sql_meta if m.get('table') == 'INSURANCE-RATES')): + rate_rows = db_input.get('INSURANCE-RATES', []) + emp_rows = db_input.get('EMP-MASTER', []) + if rate_rows and emp_rows: + # First loaded rate (lowest GRADE_CODE, ORDER BY GRADE_CODE) + # gets a MONTHLY band covering the target salaries. Other + # employees' salaries stay OUTSIDE the band → SEARCH AT END + # (W02 error log) so both SEARCH branches are runtime-covered. + band_lo = 40000 + band_hi = 40500 + rate_rows[0]['MONTHLY_FROM'] = str(band_lo) + rate_rows[0]['MONTHLY_TO'] = str(band_hi) + # EMP: first rows inside the band, DEPT-CODE spanning the + # EVALUATE ranges (1-10 / 11-20 / 21-30 / OTHER). + dept_vals = ['1', '11', '21', '99'] + for i, row in enumerate(emp_rows[:4]): + row['DEPT_CODE'] = dept_vals[i] + row['BASE_SALARY'] = str(band_lo + i * 100) + # -- Per-scenario row overrides (from YAML runs[].row_overrides) -- if scenario and scenario.row_overrides: for table_name, overrides in scenario.row_overrides.items(): @@ -1173,12 +1363,26 @@ class GixsqlOrchestrator: for col, val in overrides.items(): row[col.upper()] = val + # -- DB 属性区间对齐(通用)-- + # 补全 DB 种子键(INSURANCE-RATES 的 GRADE / EMP-MASTER 的 EMP-ID), + # 并将部分 EMP-MASTER 属性(BIRTH-DATE / DEPENDENT-COUNT / REGION-CODE) + # 对齐到 flat R02 RULE-TBL 的 AGE/DEPENDENTS/REGION 区间,使 + # 第 2 段階ルールマッチング(2020RULESCOL)命中経路到達可能。 + self._coordinate_db_rule_matching(db_input, records, fields_dict) + # DB 种子值数字化:DB SELECT 种子列若对应 COBOL 输出 FD 的 PIC 9 + # (数字)字段,但值形如 'G0000001'(字母+数字),剥离字母转纯数字, + # 使 MOVE 到 PIC 9 输出合法(W01/W02 EMP-ID/CHG-DATE 正确显示)。 + self._coordinate_seed_numeric_types(db_input, fields_dict) + conn = sqlite3.connect(str(db_path)) for table_name, rows in db_input.items(): if not rows: logger.info(f" Table {table_name}: 0 initial rows (will be created at runtime)") continue + # Normalize DB2 hyphenated identifiers -> underscores (schema uses underscores) + db_table = table_name.replace('-', '_') + # Debug logger.info(f" Table {table_name}: {len(rows)} rows, cols={list(rows[0].keys()) if rows else []}") @@ -1186,7 +1390,7 @@ class GixsqlOrchestrator: col_types = {} try: pragma_cols = conn.execute( - f"PRAGMA table_info([{table_name}])" + f"PRAGMA table_info([{db_table}])" ).fetchall() valid_cols = {r[1].upper() for r in pragma_cols} col_types = {r[1].upper(): r[2].upper() for r in pragma_cols} @@ -1197,8 +1401,9 @@ class GixsqlOrchestrator: for row in rows: new_row = {} for k, v in row.items(): - if k.upper() in valid_cols: - new_row[k] = v + k_norm = k.replace('-', '_') + if k_norm.upper() in valid_cols: + new_row[k_norm] = v if new_row: remapped_rows.append(new_row) rows = remapped_rows @@ -1225,20 +1430,219 @@ class GixsqlOrchestrator: col_names = list(rows[0].keys()) placeholders = ", ".join("?" for _ in col_names) quoted_cols = ", ".join(f"[{c}]" for c in col_names) - sql = f"INSERT OR IGNORE INTO [{table_name}] ({quoted_cols}) VALUES ({placeholders})" + sql = f"INSERT OR IGNORE INTO [{db_table}] ({quoted_cols}) VALUES ({placeholders})" conn.executemany(sql, [tuple(r.get(c, "") for c in col_names) for r in rows]) logger.info(f" Table {table_name}: {len(rows)} initial rows inserted") # -- Per-scenario row deletion (e.g. empty cursor scenario) -- if scenario and scenario.delete_all_rows: for table_name in db_input.keys(): - conn.execute(f"DELETE FROM [{table_name}]") + conn.execute(f"DELETE FROM [{table_name.replace('-', '_')}]") logger.info(f" Table {table_name}: all rows deleted (scenario={scenario.id})") + + # -- Per-scenario table drop (e.g. OPEN CURSOR failure scenario) -- + # Drops the table so a subsequent SQL OPEN/query fails (SQLCODE != 0), + # covering the SQL-error branch. Generic: any program may declare + # drop_tables to exercise its table-not-found error paths. + if scenario and scenario.drop_tables: + for table_name in scenario.drop_tables: + conn.execute(f"DROP TABLE IF EXISTS [{table_name.replace('-', '_')}]") + logger.info(f" Table {table_name}: dropped (scenario={scenario.id})") conn.commit() conn.close() logger.info(f" DB populated: {db_path}") + def _coordinate_db_rule_matching(self, db_input, records, data_fields): + """DB 属性区间对齐(通用,无程序硬编码)。 + + 适用:DB 从 EMP-MASTER 取 属性(BIRTH-DATE / DEPENDENT-COUNT / + REGION-CODE),再与 flat R02 RULE-TBL 的 AGE-FROM/TO、 + DEPENDENTS-FROM/TO、REGION-CODE 区间做 M:N 照合するプログラム + (SHA06TWM 等)。生成データでは DB 属性と R02 区间が独立合成され + 数量级/値域がずれ、照合命中が発生しない。 + + 本関数: + 1) 補全 INSURANCE-RATES 种子鍵:R01 の GRADE-CODE と DB GRADE_CODE + の差を埋める(DB-ERR → 主経路)。 + 2) 補全 EMP-MASTER 种子:R01 の EMP-ID と DB EMP_ID の差を埋める。 + 3) 属性区间对齐:DB EMP-MASTER の一部行の属性を R02 RULE-TBL の + 区间内値に設定し(AGE≈70 / DEP≈85 / REGION=G1 等)、照合命中を + 発生させる。他行は区间外を維持し no-data/不照合分支を保持。 + + 検出はテーブル名 + R02 区间フィールド名パターン(AGE-FROM / + DEPENDENTS-FROM / REGION-CODE)で行う。プログラム名ハードコードなし。 + """ + if not db_input or not records: + return + + # 1) 从 records 提取 R02 RULE-TBL 区间(AGE/DEPENDENTS/REGION + 调整率) + rule_age_from = rule_age_to = None + rule_dep_from = rule_dep_to = None + rule_region = None + for rec in records: + v_af = str(rec.get('R02AGE-FROM', '')).strip() + v_at = str(rec.get('R02AGE-TO', '')).strip() + v_df = str(rec.get('R02DEPENDENTS-FROM', '')).strip() + v_dt = str(rec.get('R02DEPENDENTS-TO', '')).strip() + v_rg = str(rec.get('R02REGION-CODE', '')).strip() + if v_af.isdigit() and v_at.isdigit() and v_df.isdigit() and v_dt.isdigit() and v_rg: + rule_age_from, rule_age_to = int(v_af), int(v_at) + rule_dep_from, rule_dep_to = int(v_df), int(v_dt) + rule_region = v_rg + break + # R02 区间模式未检测到 → 不做对齐(避免误伤其他程序) + if rule_age_from is None or not rule_region: + return + + # 2) 属性区间对齐:将 EMP-MASTER 已有行的属性设为 RULE 区间内值。 + # 仅对齐部分行(保留反例 → no-data/不照合分支维持),不补全 DB 键 + # (缺失 GRADE/EMP-ID 记录继续走 DB-ERR → SQLCODE≠0 分支覆盖)。 + # AGE≈(from+to)/2 → BIRTH-DATE ≈ 運営日付(20260802) - age*365 + # DEPENDENTS≈(from+to)/2, REGION = RULE-TBL REGION + if 'EMP-MASTER' in db_input: + emp_rows = db_input['EMP-MASTER'] + mid_age = (rule_age_from + rule_age_to) // 2 + mid_dep = (rule_dep_from + rule_dep_to) // 2 + birth_date = _calc_birth_date(mid_age) + aligned = 0 + for row in emp_rows: + # 仅对齐部分行(保留反例) + if aligned >= 4: + break + if 'EMP_ID' not in row or 'BIRTH_DATE' not in row: + continue + row['BIRTH_DATE'] = birth_date + row['DEPENDENT_COUNT'] = str(mid_dep) + row['REGION_CODE'] = rule_region + aligned += 1 + if aligned: + logger.info( + f" DB 属性区间对齐: {aligned} 条 EMP-MASTER 属性→" + f"BIRTH={birth_date}(AGE~{mid_age}) DEP={mid_dep} REG={rule_region}" + ) + + def _coordinate_seed_numeric_types(self, db_input, data_fields): + """DB 种子值数字化(通用,无程序硬编码)。 + + 适用:DB SELECT 种子列的值形如 'G0000001'(字母+数字,来自 alpha 合 + 成序列),但对应 COBOL 输出 FD 字段是 PIC 9(数字,如 SHA07REC 的 + EMP-ID PIC 9(008))。运行时 MOVE 字母值到 PIC 9 非法 → 输出为空/0。 + + 本関数:输出 FD(W01/W02 等)中 PIC 9 类型字段的 base 名(EMP-ID、 + CHG-DATE、CHG-ID),对 DB 种子表中列名匹配的列,若值含非数字字符 + 则剥离非数字、左补零对齐 PIC 长度,转纯数字。字符字段(INSURER / + PREV / REASON / CHG-TYPE)不触碰。 + + 検出はフィールド名パターン(PIC 9 + 出力 FD)+ 値パターン([A-Z]\\d+) + で行う。プログラム名ハードコードなし。 + """ + if not db_input or not data_fields: + return + + # 1) 输出 FD 前缀集合(W01/W02 等 OUTPUT FD)中 PIC 9 字段的 base 名 + output_pref = set() + pic9_bases = {} # base 名(大写,去连字符)→ 数字位数 + for f in data_fields: + if not isinstance(f, dict) or not f.get('pic') or f.get('is_88'): + continue + name = f['name'] + m = re.match(r'^(W\d{2})(.*)$', name) + if not m: + continue + pref, rest = m.group(1), m.group(2) + pic = str(f.get('pic', '')) + if re.match(r'^9\((\d+)\)$', pic): + base = rest.lstrip('-').upper().replace('-', '_') + digits = int(re.match(r'^9\((\d+)\)$', pic).group(1)) + output_pref.add(pref) + pic9_bases.setdefault(base, digits) + + if not pic9_bases: + return + + # 2) 对每个 SELECT 种子表,数字化匹配的列 + for table, rows in db_input.items(): + if not rows: + continue + for col in list(rows[0].keys()): + col_base = col.upper().replace('-', '_') + if col_base not in pic9_bases: + continue + digits = pic9_bases[col_base] + fixed = 0 + for row in rows: + if col not in row: + continue + v = str(row[col]).strip() + if not v or v.isdigit(): + continue + # 形如 'G0000001' → 剥离非数字 → '0000001' → 左补零到 digits + num = ''.join(ch for ch in v if ch.isdigit()) + if not num: + continue + new_val = num.zfill(digits)[:digits] + if new_val != v: + row[col] = new_val + fixed += 1 + if fixed: + logger.info( + f" DB 种子值数字化: {table}.{col} {fixed} 条→纯数字" + f"(PIC 9({digits}) 输出对齐)" + ) + + def _deduplicate_r01_pk(self, recs: list[dict]) -> int: + """Ensure all R01 records have unique (EMP_ID, DATE) pairs. + + After all patching, some records may share the same (EMP_ID, DATE), + causing PK violation in DAILY_RECORDS INSERT -> ABEND -> 3000STPSOR + not reached. Adjusts the day field for colliding records. + """ + groups = {} + for i, rec in enumerate(recs): + eid = rec.get('R01EMP-ID', '') + dt = rec.get('R01DATE', '') + if not eid or not eid.strip() or eid == '00000000': + continue + if not dt or len(dt) < 8: + continue + ym = dt[:6] + groups.setdefault((eid, ym), []).append((i, dt[6:8])) + + fixed = 0 + for (eid, ym), entries in groups.items(): + if len(entries) <= 1: + continue + used_days = set(d for _, d in entries) + if len(used_days) == len(entries): + continue + for idx, day in entries: + rec = recs[idx] + if sum(1 for _, d in entries if d == day) == 1: + continue + for dd in range(1, 32): + nd = f"{dd:02d}" + if nd not in used_days: + used_days.add(nd) + rec['R01DATE'] = ym + nd + line = rec.get('R01LINE', '') + if line: + parts = line.split(',') + if len(parts) >= 2: + parts[1] = nd.ljust(8) + rec['R01LINE'] = ','.join(parts) + fixed += 1 + logger.info(f" Dedup PK: rec[{idx}] (eid={eid} ym={ym}) day {day}->{nd}") + break + if fixed: + logger.info(f" Dedup PK: {fixed} record(s) adjusted") + return fixed + 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.""" + """Insert duplicate-PK rows to trigger SQL error handling paths in COBOL. + + PK 冲突行的 PK 必须与"运行时实际会被 INSERT"的记录一致(如 R01 记录), + 否则程序 INSERT 时不会冲突。优先用测试记录的合成行(跳过会被清空 EMP-ID + 的 records[0] 等特殊记录);表已有数据时逐行注入,而非固定取 rows[0]。 + """ 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] @@ -1246,18 +1650,18 @@ class GixsqlOrchestrator: 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: + synthetic = self._make_synthetic_error_rows(table, records) + if synthetic: + rows = synthetic + else: + # fallback: 表已有行 + rows = conn.execute(f"SELECT * FROM [{table.name}] LIMIT 2").fetchall() + if not rows: 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)) + vals = tuple(str(row[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: @@ -1265,6 +1669,163 @@ class GixsqlOrchestrator: conn.commit() conn.close() + def _inject_extra_seed_rows(self, db_path: Path, scenario): + """seed_extra_rows: 为 SELECT 型程序注入额外行(大结果集覆盖表头重出等分支)。 + + config: {table_name: count}。从该表已有 seed 行推导月份(date 列前 6 位, + 如 DAILY_RECORDS 的 TARGET_DATE=202607xx),用唯一 EMP_ID + 当月日期 + 注入 count 行。通用实现:按表名注入,无程序硬编码。 + """ + extra = getattr(scenario, 'seed_extra_rows', None) + if not extra: + return + conn = sqlite3.connect(str(db_path)) + try: + for table_name, count in extra.items(): + table = next((t for t in self.schema.db_tables + if t.name == table_name), None) + if not table or not count or count <= 0: + continue + # 从现有 seed 行推导月份(PK 列中形如 YYYYMMDD 的值前 6 位) + sample = conn.execute(f"SELECT * FROM [{table_name}] LIMIT 1").fetchall() + month = None + for row in sample: + for i, c in enumerate(table.columns): + if c.primary_key: + v = str(row[i]) + if len(v) >= 6 and v[:4].isdigit() and v[4:6].isdigit(): + month = v[:6] + break + if month: + break + if not month: + logger.warning(f" seed_extra_rows: {table_name} 无月份可推导, 跳过") + continue + col_names = [c.name for c in table.columns] + quoted = ", ".join(f"[{c}]" for c in col_names) + ph = ", ".join("?" for _ in col_names) + inserted = 0 + for i in range(count): + emp = f"SEED{i + 1:04d}" + vals = [] + for c in table.columns: + if c.name == 'EMP_ID': + vals.append(emp) + elif c.name == 'TARGET_DATE': + vals.append(month + '01') + elif c.name == 'YEAR_MONTH': + vals.append(month) + else: + vals.append('0') + try: + conn.execute( + f"INSERT OR IGNORE INTO [{table_name}] ({quoted}) " + f"VALUES ({ph})", vals) + inserted += 1 + except Exception as e: + logger.debug(f" seed_extra_rows inject skipped: {e}") + conn.commit() + logger.info( + f" seed_extra_rows: {table_name} 注入 {inserted} 条(月 {month})" + ) + finally: + conn.close() + + def _inject_aggregation_boundaries(self, recs: list[dict]): + """聚合边界数据注入(通用,无程序硬编码)。 + + 目标分支(R01 集計型 DB 程序): + - AGG-ANNUAL-H ON SIZE ERROR:同 (EMP, 年月) 的 2+ 条记录设 *ANNUAL-H + 为 PIC 最大值 → 累加溢出。 + - AGG-COUNT < 100 的 ELSE:注入使不同 (EMP, 年月) 组合 >= 101 → 表满警告。 + R01 记录字段按名称模式(R01*EMP-ID / R01*DATE / R01*ANNUAL-H)自动识别, + 未命中即 no-op,不影响其他程序。 + """ + if not recs or len(recs) < 5: + return + first = recs[0] + emp_f = date_f = hours_f = None + for k in first: + u = k.upper() + if u.startswith('R01'): + if u.endswith('EMP-ID') and not emp_f: + emp_f = k + elif 'ANNUAL' in u and ('-H' in u or 'HOURS' in u) and not hours_f: + hours_f = k + elif u.endswith('DATE') and 'WORK' not in u and 'APPL' not in u and not date_f: + date_f = k + if not (emp_f and date_f and hours_f): + return + + # dup_eid = TARGET 最后批次(每批 8)的 EMP,保证被 T 卡片命中。 + # 只考虑数字型 EMP(9(008) 字段的合法值);字母型 EMP(如 'U0000031') + # 对数字字段非法,写文件时会被转成 SPACE 而跳过。 + all_ids = sorted({str(r.get(emp_f, '')).strip() + for r in recs + if str(r.get(emp_f, '')).strip().isdigit() + and str(r.get(emp_f, '')).strip() != '00000000'}) + n = len(all_ids) + if n < 2: + return + dup_eid = all_ids[n - (n % 8 or 8)] + + # 1) overflow:同 (EMP, 月) 的 2+ 条记录设 *ANNUAL-H 为 PIC 最大值 + max_h = '9' * len(str(first.get(hours_f, ''))) + src_idx = next((i for i, r in enumerate(recs) + if str(r.get(emp_f, '')).strip() == dup_eid), None) + if src_idx is not None and max_h: + dup_date = str(recs[src_idx].get(date_f, '')) + dup_ym = dup_date[:6] + recs[src_idx][hours_f] = max_h + used_days = {dup_date[6:8]} if len(dup_date) >= 8 else set() + changed = 0 + for j in range(max(1, len(recs) - 3), len(recs)): + if j == src_idx: + continue + rec = recs[j] + rec[emp_f] = dup_eid + orig = str(rec.get(date_f, '')) + day = (orig[6:8] if orig and orig[6:8] not in used_days + else f"{len(used_days) + 1:02d}") + used_days.add(day) + rec[date_f] = dup_ym + day + rec[hours_f] = max_h + changed += 1 + if changed >= 1: + logger.info(f" Agg overflow: {changed + 1} 条 {dup_eid} 同月 max={max_h}") + + # 2) agg-full:保证 dup_eid 有 >=110 个不同月(其被 T 卡片命中聚合), + # 使 AGG-COUNT 超过 100 → 触发 AGG-COUNT < 100 的 ELSE(表满警告) + distinct = set() + for r in recs: + e = str(r.get(emp_f, '')).strip() + d = str(r.get(date_f, '')) + if e and e != '00000000' and len(d) >= 6: + distinct.add((e, d[:6])) + if dup_eid: + used_ym = {d[:6] for (e, d) in distinct if e == dup_eid} + template = dict(recs[1] if len(recs) > 1 else recs[0]) + target = 110 + added = 0 + ym = 200001 + while len(used_ym) < target: + ys = f"{ym:06d}" + if ys not in used_ym: + nr = dict(template) + nr[emp_f] = dup_eid + nr[date_f] = ys + '15' + recs.append(nr) + used_ym.add(ys) + distinct.add((dup_eid, ys)) + added += 1 + ym += 1 + if ym > 209912: + break + if added: + logger.info( + f" Agg table full: 追加 {added} 条 {dup_eid} 不同月({dup_eid} 月数 {len(used_ym)})" + ) + def _seed_matching_monthly_rows(self, db_path: Path, records: list[dict] | None, max_seed: int = 1, r01_dir: Path | None = None): @@ -1353,42 +1914,60 @@ class GixsqlOrchestrator: 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.""" + """Build synthetic error rows from test record data. + + 冲突行的 PK 必须与运行时 INSERT 的实际值一致。运行时主机变量由输入记录 + 赋值(MOVE R01EMP-ID TO HV-EMP-ID 等),故优先取输入记录字段 + (R01EMP-ID / R01DATE),YEAR_MONTH 由 R01DATE[:6] 推导,而非取值 + 尚未赋值的 WS 合成值(HV-* 在运行前是垃圾值,如 'A0000001')。 + """ 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', ''), + 'EMP_ID': ('R01EMP-ID', 'HV-EMP-ID', ''), + 'TARGET_DATE': ('R01DATE', 'HV-TARGET-DATE', ''), + 'YEAR_MONTH': ('R01DATE', 'HV-YEAR-MONTH', ''), + 'TIME_IN': ('R01TIME-IN', 'HV-TIME-IN', ''), + 'TIME_OUT': ('R01TIME-OUT', 'HV-TIME-OUT', ''), + 'ANNUAL_LEAVE_H': ('R01ANNUAL-H', 'HV-ANNUAL-H', ''), + 'PERSONAL_LEAVE_H': ('R01PERSONAL-H', 'HV-PERSONAL-H', ''), + 'OFFICIAL_LEAVE_H': ('R01OFFICIAL-H', 'HV-OFFICIAL-H', ''), + 'SICK_LEAVE_H': ('R01SICK-H', 'HV-SICK-H', ''), + 'UNAPPROVED_ABSENT_H': ('R01ABSENT-H', 'HV-ABSENT-H', ''), } + def _first(rec, keys): + for k in keys: + if k and k in rec: + return str(rec[k]).strip() + return '' + result = [] - for idx in range(min(2, len(records))): - rec = records[idx] + picked = 0 + for rec in records: + # 跳过会被清空 EMP-ID / 无效键的特殊记录(records[0] 等), + # 只选运行时确实会被 INSERT 的记录作为冲突 PK。 + emp = _first(rec, hv_map.get('EMP_ID', ())) + if not emp or emp == '00000000': + continue + r01date = _first(rec, ('R01DATE', 'HV-TARGET-DATE')) 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 col.name == 'YEAR_MONTH': + val = r01date[:6] if len(r01date) >= 6 else '' + elif col.name in hv_map: + val = _first(rec, hv_map[col.name]) or None if val is None: val = ' ' if col.name in pk_cols else '' - vals.append(str(val) if val is not None else '') + vals.append(str(val)) result.append(tuple(vals)) + picked += 1 + if picked >= 2: + break return result if result else None diff --git a/runners/gixsql_runner.py b/runners/gixsql_runner.py index d3aa5e4..de38a98 100644 --- a/runners/gixsql_runner.py +++ b/runners/gixsql_runner.py @@ -13,6 +13,57 @@ from typing import Optional logger = logging.getLogger(__name__) +def _strip_schema_qualifiers(text: str) -> str: + """DB2 schema-qualified table refs (SCHEMA.TABLE) → TABLE. + + SQLite has no schema objects, so gixsql-generated SQL such as + `FROM SALARYDB.EMP_MASTER` fails at OPEN time ('no such table: + SALARYDB.EMP_MASTER', SQLCODE != 0) and the program ABENDs before its + main loop. Strip the qualifier from table positions only + (after FROM/INTO/UPDATE/JOIN). Column aliases (E.EMP-ID, B.DEPT-CODE) + and host-var INTO (:DBV-X) are untouched. Program-agnostic: handles + ANY ., incl. multi-part (DB2INST1.PAYROLL.TIMESHEET). + """ + def _fix(m: re.Match) -> str: + kw = m.group(1) + table = m.group(2).rsplit('.', 1)[-1] + return f"{kw} {table}" + return re.sub( + r'\b(FROM|INTO|UPDATE|JOIN)\s+([\w-]+(?:\.[\w-]+)+)', + _fix, text, flags=re.IGNORECASE, + ) + + +def _normalize_schema_qualifiers(text: str) -> str: + """Strip schema qualifiers inside EXEC SQL blocks (before gixpp). + + Applied to the source BEFORE gixpp so the generated SQL string literals + reference plain table names. Only EXEC SQL blocks are touched; other + text (data literals, comments) is left unchanged. Mirrors + `_normalize_current_timestamp`. + """ + def _fix_block(m: re.Match) -> str: + return _strip_schema_qualifiers(m.group(0)) + return re.sub(r'(?is)EXEC SQL\b.*?\bEND-EXEC', _fix_block, text) + + +def _normalize_current_timestamp(text: str) -> str: + """SQLite backend: DB2 `CURRENT TIMESTAMP` → `CURRENT_TIMESTAMP` inside EXEC SQL. + + Applied to the source BEFORE gixpp. gixpp wraps long SQL across continuation + lines and can split the token (`CURRENT TIMES` / `TAMP`), which defeats a + post-gixpp same-line regex; normalizing the source first makes the generated + SQL string literals concatenate to the SQLite-valid `CURRENT_TIMESTAMP`. + Only EXEC SQL blocks are touched; other text (data literals, comments) is + left unchanged. + """ + def _fix_sql(m: re.Match) -> str: + block = m.group(0) + return re.sub(r'\bCURRENT\s+TIMESTAMP\b', 'CURRENT_TIMESTAMP', + block, flags=re.IGNORECASE) + return re.sub(r'(?is)EXEC SQL\b.*?\bEND-EXEC', _fix_sql, text) + + @dataclass class GixsqlBuildResult: success: bool @@ -103,13 +154,13 @@ class GixsqlCobolRunner: return ( " 01 SQLCA.\n" " 05 SQLCAID PIC X(8).\n" - " 05 SQLCABC PIC S9(9) COMP.\n" - " 05 SQLCODE PIC S9(9) COMP.\n" + " 05 SQLCABC PIC S9(9) COMP-5.\n" + " 05 SQLCODE PIC S9(9) COMP-5.\n" " 05 SQLERRM.\n" - " 49 SQLERRML PIC S9(4) COMP.\n" + " 49 SQLERRML PIC S9(4) COMP-5.\n" " 49 SQLERRMC PIC X(256).\n" " 05 SQLERRP PIC X(8).\n" - " 05 SQLERRD PIC S9(9) COMP OCCURS 6.\n" + " 05 SQLERRD PIC S9(9) COMP-5 OCCURS 6.\n" " 05 SQLWARN.\n" " 10 SQLWARN0 PIC X(1).\n" " 10 SQLWARN1 PIC X(1).\n" @@ -140,6 +191,10 @@ class GixsqlCobolRunner: """ text = src_path.read_text(encoding="utf-8-sig") + # 0. Strip ALL comment lines BEFORE any EXEC SQL transforms (regex may match + # 'EXEC SQL' inside Japanese comments, pulling comment text into SQL strings) + text = re.sub(r'^[ \t]{0,10}\*.*\n?', '', text, flags=re.MULTILINE) + # 1. Replace EXEC SQL INCLUDE SQLCA → COPY SQLCA text = re.sub(r'EXEC SQL INCLUDE SQLCA END-EXEC\.', ' COPY SQLCA.', text, flags=re.IGNORECASE) # Transform EXEC SQL CONNECT TO 'literal' → use WS variables (gixpp requires :variable not literal) @@ -154,17 +209,10 @@ class GixsqlCobolRunner: r"CONNECT\s+TO\s+'[^']*'", f"CONNECT TO :{conn_var} USER :{usr_var}", inner, - flags=re.IGNORECASE + flags=re.IGNORECASE | re.DOTALL ) - # Short absolute path under C:\Temp\gix\ (no Chinese chars, fits col 72). - # The orchestrator creates the DB at the same path so they match. - from pathlib import Path as _Path - pid = _Path(src_path).stem - gix_root = _Path("C:/Temp/gix") - gix_root.mkdir(parents=True, exist_ok=True) - db_path = gix_root / f"{pid}.db" - conn_val = "sqlite:///" + str(db_path).replace("\\", "/") - return (f"MOVE '{conn_val}' TO {conn_var}\n" + # Orchestrator copies DB to CWD/kin. + return (f"MOVE 'sqlite://kin' TO {conn_var}\n" f" MOVE 'gix' TO {usr_var}\n" f" EXEC SQL\n" f" {new_inner.strip()}\n" @@ -185,9 +233,6 @@ class GixsqlCobolRunner: if hasattr(self, '_copybook_dirs') and self._copybook_dirs: text = self._expand_all_copies(text, self._copybook_dirs) - # 3. Strip ALL comment lines (* in any column 7-11) - text = re.sub(r'^[ \t]{0,10}\*.*\n?', '', text, flags=re.MULTILINE) - # 4. Collapse multiple spaces between keywords lines = [] for line in text.splitlines(keepends=True): @@ -246,8 +291,29 @@ class GixsqlCobolRunner: text, count=1, flags=re.MULTILINE | re.IGNORECASE ) + # 8. SQLite backend: DB2 `CURRENT TIMESTAMP` → `CURRENT_TIMESTAMP` inside + # EXEC SQL. Must run before gixpp (gixpp can split the token across + # continuation lines, defeating the post-gixpp same-line patch). + text = _normalize_current_timestamp(text) + + # 9. SQLite backend: DB2 schema-qualified table refs (SCHEMA.TABLE) → + # TABLE inside EXEC SQL. gixsql would otherwise emit + # `FROM SALARYDB.EMP_MASTER`, which SQLite cannot resolve. + text = _normalize_schema_qualifiers(text) + norm_path = src_path.parent / f"{src_path.stem}_norm.cbl" norm_path.write_text(text, encoding="utf-8") + # Save a copy in runtime for diagnosis + try: + debug_dir = Path(__file__).parent.parent / "runtime" / src_path.stem / "pre_src" + debug_dir.mkdir(parents=True, exist_ok=True) + (debug_dir / f"{src_path.stem}_norm.cbl").write_text(text, encoding="utf-8") + (debug_dir / f"{src_path.stem}_pre.cbl").write_text( + (src_path.parent / f"{src_path.stem}_pre.cbl").read_text(encoding="utf-8"), + encoding="utf-8" + ) + except Exception: + pass return pre_path, norm_path def preprocess(self, src_path: str | Path, out_dir: str | Path, @@ -273,6 +339,80 @@ class GixsqlCobolRunner: raise RuntimeError(f"gixpp failed (rc={r.returncode}): {err}") return str(out_path) + def _patch_sql_identifiers(self, pp_path: Path) -> None: + """Translate DB2 hyphenated identifiers to underscores inside GIXSQL SQL strings. + + gixsql emits the SQL text verbatim from the COBOL source (e.g. + "INSERT INTO EMP-MASTER (EMP-ID, ...)"), but SQLite cannot parse bare + hyphenated identifiers. Convert '-' -> '_' only inside the SQL string + literals (VALUE "..." lines and & "..." continuation lines), leaving + COBOL-level identifiers (cursor names, host variables, work items) untouched. + """ + text = pp_path.read_text(encoding="utf-8") + + def _fix_line(m: re.Match) -> str: + return m.group(1) + m.group(2).replace('-', '_') + m.group(3) + + # SQL start lines: GIXSQL ... VALUE "SQL TEXT" + text = re.sub( + r'^(GIXSQL.*?VALUE\s+")([^"]*)(")', + _fix_line, text, flags=re.MULTILINE + ) + # SQL continuation lines: GIXSQL & "SQL TEXT" + text = re.sub( + r'^(GIXSQL\s*&\s*")([^"]*)(")', + _fix_line, text, flags=re.MULTILINE + ) + # SQLite accepts CURRENT_TIMESTAMP (no space); gixsql emits CURRENT TIMESTAMP + text = re.sub(r'\bCURRENT\s+TIMESTAMP\b', 'CURRENT_TIMESTAMP', text, flags=re.IGNORECASE) + pp_path.write_text(text, encoding="utf-8") + + def _patch_sqlcode_normalize(self, pp_path: Path) -> None: + """Inject SQLITE-constraint → -803 mapping after each SQL execution. + + gixsql+SQLite reports duplicate-key (PRIMARY KEY / UNIQUE) violations as + SQLCODE=-1555 / -2067 / -19, whereas the COBOL programs follow DB2 + semantics (SQLCODE = -803). Without this mapping, `IF SQLCODE = -803` + branches are unreachable under gixsql+SQLite. The injected code is + program-agnostic: it only rewrites the constraint-violation codes. + """ + text = pp_path.read_text(encoding="utf-8") + mapping = ( + ' IF SQLCODE = -1555\n' + ' MOVE -803 TO SQLCODE\n' + ' END-IF\n' + ' IF SQLCODE = -2067\n' + ' MOVE -803 TO SQLCODE\n' + ' END-IF\n' + ' IF SQLCODE = -19\n' + ' MOVE -803 TO SQLCODE\n' + ' END-IF\n' + ) + text = re.sub( + r'(GIXSQLEndSQL\s*\n?.*?END-CALL\.?)\s*\n(\s*)(IF SQLCODE\s+)', + lambda m: m.group(1) + '\n' + m.group(2) + mapping + m.group(2) + m.group(3), + text, + flags=re.IGNORECASE | re.DOTALL + ) + pp_path.write_text(text, encoding="utf-8") + + def _patch_sqlcode_override(self, pp_path: Path) -> None: + """Inject MOVE 0 TO SQLCODE after each GIXSQLEndSQL call. + + Workaround for gixsql DLL bug: GIXSQLExecParams/GIXSQLExec fail with + 'Can't find a connection' even though the connection is valid. + Setting SQLCODE=0 lets the program flow through success paths for + coverage measurement. The DB state is not validated in coverage runs. + """ + text = pp_path.read_text(encoding="utf-8") + text = re.sub( + r'(GIXSQLEndSQL\s*\n?.*?END-CALL\.?)\s*\n(\s*)(IF SQLCODE\s+(?:NOT\s+)?=\s+0)', + r'\1\n\2 MOVE 0 TO SQLCODE\n\2\3', + text, + flags=re.IGNORECASE | re.DOTALL + ) + pp_path.write_text(text, encoding="utf-8") + def compile(self, pp_path: str | Path, exe_path: str | Path, copybook_dirs: list[str | Path] | None = None, extra_srcs: list[str | Path] | None = None) -> GixsqlBuildResult: @@ -282,6 +422,19 @@ class GixsqlCobolRunner: exe_dir = exe_path.parent exe_dir.mkdir(parents=True, exist_ok=True) + # SQL is now executed correctly (hyphen->underscore identifiers), so the + # SQLCODE=0 override workaround is no longer needed. It masked real SQL + # errors and caused infinite FETCH loops (SQLCODE forced to 0 prevents + # "PERFORM UNTIL SQLCODE NOT = 0" from terminating at EOF=100). + # self._patch_sqlcode_override(pp_path) + + # Translate DB2 hyphenated identifiers -> underscores in SQL strings + self._patch_sql_identifiers(pp_path) + + # Map gixsql SQLite constraint codes (-1555/-2067/-19) -> DB2 -803 so + # `IF SQLCODE = -803` branches are reachable under gixsql+SQLite. + self._patch_sqlcode_normalize(pp_path) + # Functions that the preprocessed COBOL actually CALLs gixsql_k = [ "-K", "GIXSQLStartSQL", @@ -331,7 +484,8 @@ class GixsqlCobolRunner: input_dir: str | Path | None = None, timeout: int = 30, cobol_lib_path: str | Path | None = None, - env_overrides: dict[str, str] | None = None) -> GixsqlRunResult: + env_overrides: dict[str, str] | None = None, + command_args: list[str] | None = None) -> GixsqlRunResult: """Step 3: COBOL DB プログラム実行""" exe_path = Path(exe_path) work_dir = Path(work_dir) @@ -388,6 +542,8 @@ class GixsqlCobolRunner: dst.write_bytes(f.read_bytes()) cmd = [str(exe_path)] + if command_args: + cmd.extend(command_args) logger.info(f" run: {' '.join(cmd)} (cwd={work_dir}, db={db_path})") try: r = subprocess.run(cmd, capture_output=True, timeout=timeout,