"""Non-exploding path enumeration — per-decision-point coverage, O(N) paths. Strategy: 1. Walk the tree once to collect ALL decision points and their "access paths" 2. For each decision point D, generate 2 paths: - D=True with ancestor and descendant access constraints - D=False with ancestor and descendant access constraints 3. Total: 2 * N paths, where N = number of decision points This guarantees every branch is exercised at least once, without O(2^N) explosion. """ import re import logging 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 logger = logging.getLogger(__name__) _STOP = ('__STOP__', '', None, True) def _parse_condition(condition_text, fields): """Parse an IF condition into (field, op, value) or None.""" parsed = parse_single_condition(condition_text, fields) if parsed and is_field(parsed[0], fields): return parsed if parsed: return parsed return None def _invert_condition(parsed): """Invert a parsed condition (True ↔ False).""" if parsed is None: return None field, op, val = parsed inv_op = {'=': '<>', '<>': '=', '>': '<=', '<': '>=', '>=': '<', '<=': '>'}.get(op, op) return (field, inv_op, val) # ── Collect all decision points with access paths ── def _collect_all_dps(node, fields, path_cons=None, path_assign=None, depth=0): """Walk tree, collect list of (decision_point, access_path) tuples. Returns list of dicts: { "node": decision_point_node, "kind": "IF"|"EVALUATE"|"PERFORM"|"SEARCH"|"AT_END", "access_constraints": [constraints to reach this point], "branches": list of (branch_label, body_node_children) "true_idx": index of "True" branch in branches, "false_idx": index of "False" branch (or None), } """ path_cons = list(path_cons or []) path_assign = dict(path_assign or {}) result = [] if isinstance(node, BrIf): parsed = _parse_condition(node.condition, fields) dp = { "node": node, "kind": "IF", "condition": node.condition, "parsed": parsed, "access_constraints": list(path_cons), "true_idx": 0, "false_idx": 1 if parsed else None, } result.append(dp) # Recurse into both branches 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)) result.extend(_collect_all_dps(node.true_seq, fields, t_cons, path_assign, depth + 1)) result.extend(_collect_all_dps(node.false_seq, fields, f_cons, path_assign, depth + 1)) elif isinstance(node, BrEval): dp = { "node": node, "kind": "EVALUATE", "subject": node.subject, "access_constraints": list(path_cons), } result.append(dp) for value, seq in node.when_list: w_cons = list(path_cons) if is_field(node.subject, fields): w_cons.append((node.subject, '=', value, True)) result.extend(_collect_all_dps(seq, fields, w_cons, path_assign, depth + 1)) if node.has_other: result.extend(_collect_all_dps(node.other_seq, fields, list(path_cons), path_assign, depth + 1)) elif isinstance(node, BrPerform): if node.perf_type in ('until', 'para_until', 'varying', 'para_varying'): parsed = _parse_condition(node.condition, fields) dp = { "node": node, "kind": "PERFORM", "condition": node.condition, "parsed": parsed, "access_constraints": list(path_cons), } result.append(dp) if parsed: field, op, val = parsed body_cons = list(path_cons) + [(field, op, val, False)] else: body_cons = list(path_cons) result.extend(_collect_all_dps(node.body_seq, fields, body_cons, path_assign, depth + 1)) else: result.extend(_collect_all_dps(node.body_seq, fields, list(path_cons), path_assign, depth + 1)) elif isinstance(node, BrSeq): for child in node.children: result.extend(_collect_all_dps(child, fields, path_cons, path_assign, depth)) elif isinstance(node, BrSearch): dp = { "node": node, "kind": "SEARCH", "access_constraints": list(path_cons), } result.append(dp) result.extend(_collect_all_dps(node.at_end_seq, fields, list(path_cons), path_assign, depth + 1)) for _, seq in node.when_list: result.extend(_collect_all_dps(seq, fields, list(path_cons), path_assign, depth + 1)) return result def _make_path_for_branch(dp, branch_idx, fields): """Create a single path (constraints, assignments) for one branch of a decision point.""" constraints = list(dp.get("access_constraints", [])) kind = dp["kind"] if kind == "IF": parsed = dp.get("parsed") if parsed is None: return ([], {}) field, op, val = parsed want_true = (branch_idx == dp.get("true_idx", 0)) if not want_true: field2, op2, val2 = _invert_condition(parsed) field, op, val = field2, op2, val2 constraints.append((field, op, val, True)) # Pick body, just take first assignment node = dp["node"] body_seq = node.true_seq if branch_idx == 0 else node.false_seq return (constraints, {}) if kind == "EVALUATE": node = dp["node"] n_when = len(node.when_list) if branch_idx < n_when: value, seq = node.when_list[branch_idx] if is_field(node.subject, fields): constraints.append((node.subject, '=', value, True)) prior_cases = [v for v, _ in node.when_list[:branch_idx]] for prior in prior_cases: constraints.append((node.subject, '<>', prior, True)) return (constraints, {}) if kind == "PERFORM": parsed = dp.get("parsed") if parsed is None: return ([], {}) field, op, val = parsed if branch_idx == 0: constraints.append((field, op, val, False)) else: constraints.append((field, op, val, True)) return (constraints, {}) return ([], {}) # ── Public API ── def enum_paths(node, fields): """Linear path enumeration: one True + one False per decision point. Returns list of (constraints, assignments) tuples. Total paths = 2 * number_of_decision_points (capped at 1000). """ all_dps = _collect_all_dps(node, fields) MAX_PATH = 1000 paths = [] # Start with one neutral path (no constraints) paths.append(([], {})) for dp in all_dps: 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 dp.get("true_idx", 0), fields) if true_path: paths.append(true_path) if false_path: paths.append(false_path) elif kind == "EVALUATE": node = dp["node"] for i in range(len(node.when_list)): bp = _make_path_for_branch(dp, i, fields) if bp: paths.append(bp) if node.has_other: other_cons = list(dp.get("access_constraints", [])) for v, _ in node.when_list: if is_field(node.subject, fields): other_cons.append((node.subject, '<>', v, True)) paths.append((other_cons, {})) elif kind == "PERFORM": enter_path = _make_path_for_branch(dp, 0, fields) skip_path = _make_path_for_branch(dp, 1, fields) if enter_path: paths.append(enter_path) if skip_path: paths.append(skip_path) if len(paths) >= MAX_PATH: paths = paths[:MAX_PATH] break return paths def _filter_stop(cons): return [c for c in cons if c is not _STOP]