#!/usr/bin/env python3
"""VCS 内置 uvm-1.2 -> uvm-ieee-2020-2.0 迁移代码扫描器

扫描源码与构建文本，命中 docs/01-diff-uvm12-to-2.0.md 中的迁移差异条目。
仅支持上述迁移路径；YAML migration 声明不用于切换规则集。
规则对应迁移清单：C=编译断点 S=行为风险 D=deprecated/扩展清理。

用法:
    uv run tools/uvm_migration_scan.py
    uv run tools/uvm_migration_scan.py env/
    uv run tools/uvm_migration_scan.py env/ --output-dir ./reports

自动读取脚本同目录的同名 YAML；固定输出 JSON、CSV 及扫描范围摘要。
省略路径扫描 check_path；相对路径基于各 check_path，绝对路径直接使用。
未配置 check_path 时，显式相对路径基于当前目录。
退出码: 0=无未过滤命中; 1=有未过滤命中; 2=配置、输入、读取或写入失败。
"""

import argparse
import ast
import bisect
import csv
import json
import os
import re
import stat
import sys
from dataclasses import dataclass, field, replace
from pathlib import Path

try:
    import yaml
except ImportError:  # pragma: no cover - reported clearly by load_config
    yaml = None

MIGRATION = {"from": "uvm-1.2", "to": "uvm-ieee-2020-2.0"}

# ---------------------------------------------------------------------------
# 模式集：与 docs/01-diff-uvm12-to-2.0.md 条目对应
# kind: 命中即报；heuristic: 启发式，需人工确认；file: 文件级（每文件只报一次）
# ---------------------------------------------------------------------------

@dataclass
class Rule:
    id: str
    category: str          # 编译断点 / 静默失效 / 日志/调试变化 / 废弃与扩展 / 提示
    title: str
    pattern: str | None    # None 表示由专用检查函数处理
    doc: str               # 01 文档章节引用
    fix: str
    kind: str = "hit"      # hit / heuristic / file


RULES: list[Rule] = [
    # ---- C 系列：编译断点 ----
    Rule("C1", "编译断点", "printer knobs 宽度字段 / uvm_printer_knobs 类型名删除",
         r"\buvm_printer_knobs\b|\.\s*knobs\s*\.\s*(?:name_width|type_width|size_width|value_width|max_width|truncation)\b",
         "第2章", "删除宽度字段引用（2.0 列宽自动计算）；类型引用改 accessor"),
    Rule("C2", "编译断点", "uvm_transaction::begin_event/end_event 字段删除",
         r"\b(?:begin_event|end_event)\b",
         "第1章", "改用 item.get_event_pool().get(\"begin\"/\"end\")", kind="heuristic"),
    Rule("C3", "编译断点", "kill/stop 机制删除（kill/do_kill_all/stop_phase/enable_stop_interrupt/stop_request/force_stop/stop_timeout）",
         r"\b(?:do_kill_all|stop_phase|enable_stop_interrupt|kill)\s*\(|\b(?:stop_request|force_stop|stop_timeout)\b",
         "第1、3章", "确认旧 component 接口后用完成握手、检查器排空和成对 objection 收尾；sequence.kill/process.kill 另按原语义核对", kind="heuristic"),
    Rule("C4", "编译断点", "uvm_event::add_callback/delete_callback 删除",
         r"\b(?:add_callback|delete_callback)\s*\(",
         "第3章", "确认 event 接收者后改用 uvm_callbacks#(uvm_event#(T), uvm_event_callback#(T))::add/delete；保留 event_callback 派生类型，append=0 改 UVM_PREPEND", kind="heuristic"),
    Rule("C5", "编译断点", "uvm_do_* 宏移入 deprecated（需 +define+UVM_ENABLE_DEPRECATED_API 或改写）",
         r"`uvm_(?:do_with|do_pri|do_pri_with|do_on|do_on_pri|do_on_with|do_on_pri_with|create_on|create_seq|do_seq|do_seq_with|send_pri|rand_send_pri|rand_send_with|rand_send_pri_with)\b",
         "第6、9章", "按 M09-004 完整映射直接改写；需保留旧宏时临时加 +define+UVM_ENABLE_DEPRECATED_API，两种方式均验证原约束、sequencer 和优先级"),
    Rule("C6", "编译断点", "无参构造函数：核对旧版 constructor 兼容宏及 factory 注册",
         r"\bfunction\s+(?:(?:\w+\s*::\s*)+)?new\s*(?:\(\s*\))?\s*;",
         "第9章", "确认 factory/create 依赖及旧 constructor 开关后，为对象构造补 string name 并转发 super.new(name)；未知依赖先核对", kind="heuristic"),
    Rule("C7", "编译断点", "uvm_resource#(T) 自管理接口删除（set/set_override/get_by_name/get_by_type/UVM_RESOURCE_GET_FCNS）",
         r"`UVM_RESOURCE_GET_FCNS\b",  # 静态查询另用括号配对解析
         "第5章", "对象 set/query 改走 pool/DB；显式构造 scope 仍有效，两版都需入池，特殊 precedence 在入池后设置", kind="heuristic"),
    Rule("C8", "编译断点", "uvm_sequence_base 新增抽象限制；uvm_sequence 在 1.2 已抽象",
         None,  # 静态 factory 调用与直接构造分别解析
         "第6章", "改用 uvm_compat_pkg::uvm_compat_proxy_sequence#(REQ,RSP) 或具体 sequence 类"),
    Rule("C9", "编译断点", "map 查询删除 caller 形参，核对显式实参与 override",
         r"\b(?:Xcheck_accessX|get_local_map|get_default_map)\s*\(",
         "第7章 M07-002", "删除实际传入的 caller 及 override 形参；未带 caller 的普通调用保留", kind="heuristic"),
    Rule("C10a", "编译断点", "全局仿真控制函数删除（uvm_test_done/global_stop_request/set_global_timeout/set_global_stop_timeout）",
         r"\b(?:uvm_test_done|global_stop_request|set_global_timeout|set_global_stop_timeout)\b",
         "第4章", "结束控制改为实际 phase 的成对 raise/drop 与完成条件；set_global_timeout 改 root.set_timeout，stop 收尾超时单独设计"),
    Rule("C10b", "编译断点", "reporting deprecated 方法删除（get_report_server/process_report/compose_message）",
         r"\b(?:get_report_server|process_report|compose_message)\s*\(",
         "第4章", "get_report_server → uvm_report_server::get_server()；自定义 server 重写 execute_report_message/compose_report_message"),
    Rule("C10c", "编译断点", "全局 set_config_int/object/string 删除（注意：组件方法仍可用）",
         r"\bset_config_(?:int|object|string)\s*\(",
         "第4章", "全局配置可转 root 同名组件方法；改 config_db 时同步类型、scope 与克隆契约；未限定的组件成员调用需辨别", kind="heuristic"),
    Rule("C11", "编译断点", "field automation 旧钩子不再被调用，status container/scope stack 删除",
         r"__m_uvm_field_automation|__m_uvm_status_container|\buvm_status_container\b|\buvm_scope_stack\b",
         "第1、9章", "改写为 override do_execute_op(uvm_field_op op)"),
    Rule("C12", "编译断点", "自定义 uvm_factory 子类须补 4 个 pure virtual",
         r"extends\s+(?:uvm_pkg\s*::\s*)?uvm_factory\b",
         "第1章", "补 is_type_registered/is_type_name_registered/set_type_alias/set_inst_alias 实现"),
    Rule("C13", "编译断点", "uvm_print_* 宏数字后缀变体删除、签名重排",
         r"`uvm_print_(?:int3|int4|object2|string2|string4|queue3|qda3|qda4|aa_int_object|aa_string_object)\b",
         "第2、9章", "按新原型改写（VALUE,SIZE,RADIX,VALUE_TYPE,PRINTER），建议直接用 printer.print_* API"),

    Rule("C14", "编译断点", "自定义 user_priority_arbitration 旧 integer 签名在 VCS 2.0 不匹配",
         r"\bfunction\s+integer\s+user_priority_arbitration\b|\buser_priority_arbitration\s*\(\s*integer\b",
         "第6章 M06-007", "返回值与队列形参改 int，直接调用实参队列同步类型"),
    Rule("C15", "编译断点", "port 连接查询的 ref 容器类型变化",
         r"\b(?:get_connected_to|get_provided_to)\s*\(",
         "第8章 M08-011", "直接 port 查询改用 uvm_port_base#(IF) 关联数组；proxy 上查询仍用旧容器", kind="heuristic"),
    Rule("C16", "编译断点", "资源特化类的静态查询删除",
         None,  # 静态查询另用括号配对解析
         "第5章 M05-011", "使用 resource_db#(T) 查询并检查 null/cast；子类型不再有静态查询"),

    # ---- S 系列：静默失效（编译通过但行为变化） ----
    Rule("S1", "静默失效", "旧 default_sequence 机制待核对（标准 phase wrapper/instance 无需迁移）",
         r"\"default_sequence\"|\badd_sequence\s*\(|\bstart_default_sequence\b|\bnum_sequences\s*\(|\bget_seq_kind\s*\(|\bmax_random_count\b|\bmax_random_depth\b",
         "第6章", "字符串式改为 phase wrapper/instance 配置；其他命中核对类型与作用域", kind="heuristic"),
    Rule("S2", "静默失效", "uvm_test_done_objection 孤儿化（raise/drop 不再门控 run phase）",
         r"\buvm_test_done_objection\b",
         "第3章", "改用 phase.raise_objection(this)/drop_objection"),
    Rule("S3", "静默失效", "field FLAG 的启用操作发生变化，或表达式需要展开",
         None,  # 由 scan_file 半解析实现
         "第9章", "按报告列出的缺失操作保留原禁用意图并补正向位；未知 FLAG 展开后复扫，过渡开关及改写结果按 M09-002 验证", kind="heuristic"),
    Rule("S4", "静默失效", "UVM_VERSION_1_2 等版本宏删除，`ifdef 静默走 else 分支",
         r"\bUVM_VERSION_1_2\b|\bUVM_MAJOR_VERSION_1_2\b|\bUVM_MAJOR_REV_1\b|\bUVM_MINOR_REV_2\b",
         "第4、9章 M09-008", "核对精确版本与能力分支意图；勿机械替换成 POST 宏，逐库验证预处理结果"),
    Rule("S5", "提示", "default_precedence 兼容字段核对（原静默失效结论已撤销）",
         r"\bdefault_precedence\b",
         "第5章", "默认 coreservice 下仍有效；新代码推荐 set_default_precedence()，自定义 coreservice 需核对", kind="heuristic"),
    Rule("S6", "静默失效", "packer big_endian/use_metadata 删除，位流布局不兼容",
         r"\bbig_endian\b|\buse_metadata\b",
         "第2章 M02-001", "compat packer 仅部分恢复旧流；嵌套对象/GP 须显式契约并与 1.2 golden 逐位比对"),
    Rule("S7", "静默失效", "消息宏对 UVM_NO_ACTION 短路：catcher 不再能捕获被抑制消息",
         None,  # 文件级检查：同文件出现 uvm_report_catcher 与 UVM_NO_ACTION
         "第4章", "审计 demote 类 catcher 对 NO_ACTION 消息的依赖", kind="file"),
    Rule("S8", "静默失效", "uvm_comparer show_max==0 语义反转（0=不限量）",
         r"\bshow_max\s*=\s*0\b|set_show_max\s*\(\s*0\s*\)",
         "第2章", "0 不再静音，按 verbosity/action 控制输出并检查返回值；另按 M02-005 核对类型名与 wrapper，本规则不覆盖类型判据变化"),
    Rule("S9", "静默失效", "uvm_tlm_fifo::flush() 广播变化，VCS 两项 FIFO 实测残留一项",
         r"\bflush\s*\(",
         "第8章", "确认 TLM FIFO 后，在无并发清场边界使用 while(fifo.try_get(item)) 并断言 used()==0；保留 get_ap 广播", kind="heuristic"),
    Rule("S10", "日志/调试变化", "+UVM_STACKTRACE 不再输出报告调用栈",
         r"\bUVM_STACKTRACE\b",
         "第4章 M04-019", "保留可复现问题的旧调试构建，提交最小触发测试和工具/库版本确认目标调用栈流程；目前无已验证的通用替代命令"),
    Rule("S11", "静默失效", "resource_db 按名查询可能选择较低优先级资源",
         None,  # 静态查询另用括号配对解析
         "第5章 M05-010", "同名同类型多个资源时核对优先级；可 lookup_name 后 get_highest_precedence 并检查类型", kind="heuristic"),

    Rule("S12", "静默失效", "TLM generic payload 比较可能漏检 extension",
         r"\buvm_tlm_generic_payload\b",
         "第8章 M08-002", "显式核对两侧 extension 键集合并逐项比较，不能只做双向 GP compare", kind="heuristic"),
    Rule("S13", "静默失效", "大端 reg map 可能丢失数据，单拍也受影响",
         r"\bUVM_BIG_ENDIAN\b",
         "第7章 M07-020", "核对总线宽度与 uvm_reg_data_t 整宽，包含32位寄存器/32位总线单拍；检查实际数据，补偿须独立验证", kind="heuristic"),
    Rule("S14", "静默失效", "字段独立访问可能退化为整寄存器访问",
         r"\bsupports_byte_enable\b|\bis_indv_accessible\b",
         "第7章 M07-021", "核对 byte strobe、相邻字段与镜像；必要时用显式 bus sequence/frontdoor", kind="heuristic"),
    Rule("S15", "静默失效", "resource 单侧 override 掩码与入池前 precedence 需核对",
         r"\b(?:NAME_OVERRIDE|TYPE_OVERRIDE)\b|\.\s*precedence\s*=",
         "第5章 M05-004/M05-005", "使用显式 set_name_override/set_type_override；特殊 precedence 在入池后设置", kind="heuristic"),
    Rule("S16", "静默失效", "旧两参 uvm_print_int 的 radix 可能被当成位宽",
         r"`uvm_print_int\s*\(",
         "第2章 M02-004", "核对第二参是 SIZE，目标写法为 `uvm_print_int(value, $bits(value), UVM_HEX)", kind="heuristic"),
    Rule("S17", "静默失效", "sequence 响应溢出默认关闭报错",
         r"\bextends\s+(?:uvm_pkg\s*::\s*)?uvm_sequence\b|\bset_response_queue_depth\s*\(",
         "第6章 M06-008", "显式 set_response_queue_error_report_disabled(0)，并核对响应消费及深度", kind="heuristic"),
    Rule("S18", "静默失效", "命令行 set_config_int 超过 32 位可能截断",
         r"\b(?:uvm_set_config_int|UVM_SET_CONFIG_INT)\b",
         "第4章 M04-018", "目标宽值改 set_config_bitstream，并断言 config_db 读回值", kind="heuristic"),
    Rule("S19", "静默失效", "push sequencer 第二笔请求可能报错或重复发送",
         r"\buvm_push_sequencer\b",
         "第6章 M06-019", "核对请求 FIFO 消费；评估 pull 流程或经验证的派生修复，不能只屏蔽 SQRSNDREQGNI", kind="heuristic"),
    Rule("S20", "静默失效", "sequence library 动态缩减 max 可能先返回越界索引",
         r"\bselect_sequence\s*\(|\bUVM_SEQ_LIB_USER\b",
         "第6章 M06-017", "动态候选集在选择前验证索引，必要时派生带边界检查的选择器", kind="heuristic"),
    Rule("S21", "静默失效", "正则缓存键未区分 regex 与 glob 模式",
         r"\bUVM_ENABLE_RE_MATCH_CACHE\b",
         "第10章 M10-006", "混合 uvm_re_match/uvm_is_match 时保留默认关闭缓存，核对匹配顺序", kind="heuristic"),
    Rule("S22", "静默失效", "backdoor encode/decode 数组修改未写回",
         r"\b(?:encode|decode)\s*\(",
         "第7章 M07-023", "确认是 reg callback；单 codec 可直接在 pre_write/post_read 改 rw.value，多 codec 保持逆序解码", kind="heuristic"),
    Rule("S23", "静默失效", "全局和实例 catcher 混用可能重复调用",
         r"\buvm_report_cb\s*::\s*add\s*\(|\buvm_report_catcher\b",
         "第1章 M01-016", "固定注册集合可先实例后全局；动态注册须实测实际调用次数", kind="heuristic"),
    Rule("S24", "静默失效", "多拍 predictor 未聚合后续拍失败状态",
         r"\buvm_reg_predictor\b",
         "第7章 M07-024", "monitor 聚合整次事务状态；每拍成功且事务完整后才预测", kind="heuristic"),

    # ---- D 系列：deprecated/扩展清理 ----
    Rule("D1", "废弃与扩展", "uvm_deprecated_defines.svh 整文件删除（uvm_sequence_utils/uvm_package 等）",
         r"`uvm_(?:(?:sequence|sequencer)_utils(?:_begin|_end)?|package|end_package|declare_sequence_lib|update_sequence_lib|update_sequence_lib_and_item)\b",
         "第9章", "uvm_sequence_utils → `uvm_object_utils + `uvm_add_to_seq_lib；uvm_package → 显式 package"),
    Rule("D2", "废弃与扩展", "UVM_NO_DEPRECATED 开关失效（极性翻转为 opt-in）",
         r"\bUVM_NO_DEPRECATED\b",
         "第9章", "按是否需要保留 deprecated API 选择新开关；旧 opt-out 意图不能机械改为 opt-in"),
    Rule("D3", "废弃与扩展", "UVM_SPARSE_ARRAY / uvm_reg_array（VCS 私有扩展，2.0 悬空引用编译失败）",
         r"\bUVM_SPARSE_ARRAY\b|\buvm_reg_array\b",
         "第7章", "移除该 define，改用普通 uvm_reg 数组建模"),
    Rule("D4", "废弃与扩展", "UVM_REG_ENABLE_ADDRESS_EXCLUSION / uvm_reg_address_config（VCS 私有扩展删除）",
         r"\bUVM_REG_ENABLE_ADDRESS_EXCLUSION\b|\buvm_reg_address_config\b",
         "第7章", "改用 NO_REG_TESTS 等标准 resource 排除机制"),
    Rule("D5", "废弃与扩展", "disable_apply_cfg_settings / +UVM_DISABLE_APPLY_CFG_SETTINGS（VCS 1.2 私有开关删除）",
         r"\bdisable_apply_cfg_settings\b|\bUVM_DISABLE_APPLY_CFG_SETTINGS\b",
         "第1章", "改为在基类 override use_automatic_config() 返回 0"),
    Rule("D6", "废弃与扩展", "snps_uvm_reg_bank（Synopsys 私有 reg-bank 扩展，API 有变）",
         r"\bsnps_uvm_reg_bank(?:_group|_set|ed)?\b|\bsnps_uvm_reg_predictor\b|\bcreate_snps_bank_map\b",
         "第7章 M07-025", "add_coverage 改为构造时声明能力，set_coverage 只控制启用；私有 predictor 不是静态 type_name() 替换，path 别名可保留"),
    Rule("D7", "废弃与扩展", "uvm_vector_to_string / uvm_dump_re_cache 删除",
         r"\buvm_vector_to_string\b|\buvm_dump_re_cache\b",
         "第4、10章", "uvm_vector_to_string → uvm_bitstream_to_string；dump_re_cache 直接删除"),
    Rule("D8", "废弃与扩展", "VCS FGP / native_dumping 相关（uvm_fgp_*/vcs_uvm_alt/native_dumping）",
         r"\buvm_fgp_\w+|\bvcs_uvm_alt\b|native_dumping",
         "第10章", "FGP 替代待 Synopsys 确认；引用 native_dumping 的脚本迁至标准 recorder，验证 DPI、事务字段与父子关系"),
    Rule("D9", "废弃与扩展", "直接 include 旧文件路径（uvm_tlm2_time.svh 等已更名/删除）",
         r"`include\s+\"[^\"]*uvm_tlm2_time\.svh\"",
         "第8章", "改 include uvm_tlm_time.svh（类名 uvm_tlm_time 有 typedef 兜底）"),
    Rule("D10", "废弃与扩展", "uvm_resource_converter / UVM_USE_RESOURCE_CONVERTER 删除",
         r"\buvm_resource_converter\b|\bUVM_USE_RESOURCE_CONVERTER\b",
         "第5、9章", "删除该 define；转换需求改用派生资源类重写 m_value_as_string()"),
    Rule("D11", "废弃与扩展", "内置 sequence 库删除（uvm_random_sequence/uvm_exhaustive_sequence/uvm_simple_sequence）",
         r"\buvm_(?:random|exhaustive|simple)_sequence\b",
         "第6章", "改用 uvm_sequence_library 或显式 sequence"),
    Rule("D12", "废弃与扩展", "uvm_random_stimulus 降级为 @uvm-compat（非标准 API）",
         r"\buvm_random_stimulus\b",
         "第10章", "可继续用；建议排期替换为 sequence 方式"),

    # 明确的旧名称、调用参数及扩展入口。通用方法名仍标为启发式。
    Rule("C17", "编译断点", "m_get_tr_database 改名",
         r"\bm_get_tr_database\s*\(", "第1章 M01-015",
         "调用及 override 改为 get_tr_database，保留数据库选择逻辑"),
    Rule("C18", "编译断点", "packer 旧状态 API 与无参 get_packed_bits",
         r"\b(?:get_bits|get_bytes|get_ints|put_bits|put_bytes|put_ints|set_packed_size|unpack_object_ext)\s*\(|\bget_packed_bits\s*\(\s*\)",
         "第2章 M02-002", "确认 packer 后按条目改 get_packed_*/set_packed_*；状态流不能直接当跨版本载荷", kind="heuristic"),
    Rule("C19", "编译断点", "tree/table printer 的换行字段或列宽方法删除",
         r"\.\s*newline\b|\bcalculate_max_widths\s*\(", "第2章 M02-008",
         "单行输出使用 uvm_line_printer；删除 calculate_max_widths 后核对自动列宽", kind="heuristic"),
    Rule("C20", "编译断点", "phase 公共状态改为访问器",
         r"\b(?:max_ready_to_end_iter|phase_done)\b|\bget_ready_to_end_count\s*\(",
         "第3章 M03-005/M03-006", "max_ready_to_end_iter 改 get/set_max_ready_to_end_iterations；phase_done 改 get_objection 并判空；count 无等价替代", kind="heuristic"),
    Rule("C21", "编译断点", "sequence starting_phase 与旧仲裁类型",
         r"\bstarting_phase\b|\bUVM_SEQ_ARB_TYPE\b", "第6章 M06-009/M06-014",
         "starting_phase 改 get/set_starting_phase，并在锁定前设置；类型改 uvm_sequencer_arb_mode", kind="heuristic"),
    Rule("C22", "编译断点", "sequence_base 旧库管理方法",
         r"\b(?:get_sequence|get_sequence_by_name|do_sequence_kind|create_and_start_sequence_by_name|num_sequences|get_seq_kind)\s*\(",
         "第6章 M06-003", "固定 sequence 直接 create/start；候选库移到具体 uvm_sequence_library", kind="heuristic"),
    Rule("C23", "编译断点", "自定义 coreservice 的虚方法与安装入口",
         r"\bextends\s+(?:uvm_pkg\s*::\s*)?uvm_(?:default_coreservice_t|coreservice_t)\b|\buvm_coreservice_t\s*::\s*set\s*\(",
         "第1章 M01-014", "补齐目标虚方法；整体替换须核对库初始化与 Verdi 集成时机", kind="heuristic"),
    Rule("C24", "编译断点", "RAL 按完整名称查询去掉 m_ 前缀",
         r"\bm_get_(?:reg|field)_by_full_name\s*\(", "第7章 M07-014",
         "改 get_reg_by_full_name/get_field_by_full_name，并检查返回值非空"),
    Rule("C25", "编译断点", "直接构造目标抽象 backdoor、callback 或 socket 基类",
         None, "第7、8章 M07-003/M07-013/M08-006",
         "句柄声明可保留；new 改具体 backdoor/callback 或对应具体 socket，按条目实现实际访问与连接", kind="heuristic"),
    Rule("C26", "编译断点", "录制虚方法的 integer 签名或已删 m_get_handle",
         r"\bm_get_handle\s*\(|\bfunction\s+(?:automatic\s+)?integer\s+(?:\w+::)*(?:get_handle|begin_tr|begin_child_tr)\s*\(|\b(?:do_begin_tr|do_end_tr|do_link_tr|do_free_tr)\s*\([^;]*?\binteger\b",
         "第1、2章 M01-012/M02-012", "override 按目标 int 签名改写；删除无调用入口的 m_get_handle，普通句柄变量可保留", kind="heuristic"),
    Rule("C27", "编译断点", "type_name 被当作可写变量",
         r"\btype_name\s*(?:\[[^\]\n]*\]\s*)?(?:=(?!=)|\+=)", "第7、9、10章",
         "需要可写或 ref 字符串时复制到自有变量；普通 type_name 读取可保留", kind="heuristic"),
    Rule("C28", "编译断点", "comparer 旧初始化与摘要接口",
         r"\buvm_comparer\s*::\s*init\s*\(|\bprint_rollup\s*\(",
         "第2章 M02-005", "init 改 get_default；独立操作先 flush，自定义摘要使用返回值和 get_result", kind="heuristic"),
    Rule("D13", "废弃与扩展", "VCS 数组录制上限接口与运行开关删除",
         r"\b(?:UVM_ARRAY_NUM_LIMIT|verdi_get_array_limit|max_array_limit_check|max_array_num_limit)\b",
         "第2章 M02-016", "按字段关闭自动录制并在 do_record 中显式限制项数，核对实际后端输出"),
    Rule("D14", "废弃与扩展", "VCS 旧 recorder 取时开关删除",
         r"\bUVM_VERDI_BEGIN_TR_REALTIME\b", "第2章 M02-017",
         "核对时间契约；默认取时可删除旧 plusarg，显式时间需按单位转换后传给 open_recorder"),
    Rule("D15", "废弃与扩展", "旧 object 构造兼容开关删除",
         r"\bUVM_OBJECT_DO_NOT_NEED_CONSTRUCTOR\b", "第9章 M09-003",
         "为需要 factory/create 的对象类补 name 参数并转发 super；结合 C6 定位无参构造"),
    Rule("D16", "废弃与扩展", "直接包含 policy 宏的旧宿主文件",
         r'`include\s+"[^"\n]*uvm_object_defines\.svh"', "第9章 M09-010",
         "改用 uvm_macros.svh 统一入口；默认 umbrella include 不受此项影响", kind="heuristic"),
    Rule("D17", "废弃与扩展", "直接依赖 UVM 内部表、执行入口或状态字段",
         r"\b(?:m_run_phases|m_rh|sequence_item_requested|get_next_item_called|rtab|ttab)\b|\.\s*access\s*\[",
         "第1、3、4、5、6章", "按关联内部实现条目迁移；先确认属于 UVM 对象，常规公开调用无需重审内部表", kind="heuristic"),
    Rule("S25", "日志/调试变化", "copy 显式传入 null",
         r"\bcopy\s*\(\s*(?:\.rhs\s*\(\s*)?null\b", "第1章 M01-005",
         "仅当空源表示不更新时判空；源对象应存在时修复创建/传递流程", kind="heuristic"),
    Rule("S26", "日志/调试变化", "按名称设置 factory override 的解析时机",
         r"\bset_(?:type|inst)_override_by_name\s*\(", "第1章 M01-010",
         "设置后实际 create 并检查类型；报告只定位入口，不能静态证明名称何时注册", kind="heuristic"),
    Rule("S27", "日志/调试变化", "手工 printer 输出或自定义 recorder 递归",
         r"\bemit\s*\(|\bdo_record_object\s*\(|\bextends\s+(?:uvm_pkg\s*::\s*)?uvm_(?:(?:table|tree|line)_printer|printer|comparer|recorder|text_recorder)\b",
         "第1、2章", "独立 emit 前 flush；do_record_object 只保留一次递归；派生策略按条目核对状态与输出", kind="heuristic"),
    Rule("S28", "静默失效", "phase 跳转显式传入 null",
         r"\b(?:jump|set_jump_phase)\s*\(\s*(?:\.phase\s*\(\s*)?null\b", "第3章 M03-009",
         "结束流程用实际 phase objection；跳转传合法 phase，未激活节点清除挂起状态另按条目处理", kind="heuristic"),
    Rule("S29", "静默失效", "event 触发数据读取与 reset 生命周期",
         r"\bget_trigger_data\s*\(", "第3章 M03-011",
         "脚本定位数据读取；跨 reset 继续使用旧数据时，在 reset 前保存到自有变量", kind="heuristic"),
    Rule("S30", "日志/调试变化", "warning/error/fatal 报告的 verbosity 过滤变化",
         r"\buvm_report(?:_(?:warning|error|fatal))?\s*\(", "第4章 M04-002/M04-003",
         "核对原过滤意图；非 INFO 不再由 verbosity 隐藏，需用明确 action 或已验证 catcher", kind="heuristic"),
    Rule("S31", "日志/调试变化", "report summary 与输出句柄路由",
         r"\breport_summarize\s*\(|\bUVM_STDOUT\b", "第4章 M04-007/M04-008",
         "stdout 用默认参数或 UVM_STDOUT；显式文件摘要后按需恢复原 ID 文件配置，旧常量数值不可复用", kind="heuristic"),
    Rule("S32", "静默失效", "自研复位检查与旧字段排除资源",
         r"\bNO_REG_HW_RESET_TEST\b|\bextends\s+(?:uvm_pkg\s*::\s*)?uvm_reg_hw_reset_seq\b",
         "第7章 M07-004", "按 has_reset 决定比较并恢复原 compare 设置，核对层级访问次数", kind="heuristic"),
    Rule("S33", "静默失效", "按字段访问权限字符串选择策略",
         r"\bget_access\s*\(", "第7章 M07-007", "确认 reg field 后按目标 WO map 的折算结果更新分支及预测", kind="heuristic"),
    Rule("S34", "静默失效", "sequence lock/kill 与等待请求流程",
         r"\b(?:lock|grab|kill)\s*\(",
         "第6章 M06-011/M06-012/M06-013", "定位并发入口；核对实际授权、kill 后下一笔请求及响应清理，不能仅凭调用确定并发关系", kind="heuristic"),
    Rule("S35", "静默失效", "派生 TLM FIFO 的字段自动配置",
         r"\bextends\s+(?:uvm_pkg\s*::\s*)?uvm_tlm_(?:fifo_base|fifo|analysis_fifo)\b",
         "第8章 M08-004", "依赖派生字段自动配置时 override use_automatic_config 返回 1，并验证非默认字段值", kind="heuristic"),
    Rule("S36", "日志/调试变化", "旧消息标识、类型字符串和日志开关",
         r"\b(?:TEST_DONE|PH_ADD_PHASE|UVM_OBJECTION_TRACE|UVM_USE_REALTIME_IN_MSGS)\b|[\"']uvm_event[\"']|[\"']uvm_transport_channel[^\"'\n]*[\"']",
         "第3、4、8章", "按对应条目更新日志解析；完成判据用业务观察量，类型判断优先使用 cast", kind="heuristic"),
    Rule("S37", "日志/调试变化", "pre_abort 与自定义 phase 图",
         r"\bpre_abort\s*\(|\bextends\s+(?:uvm_pkg\s*::\s*)?uvm_(?:task|function|topdown|bottomup)_phase\b|\buvm_set_(?:verbosity|action|severity)\b",
         "第1、3章 M01-008/M03-008", "核对 abort/phase 实际执行顺序及命令行配置读回值", kind="heuristic"),
    Rule("S38", "静默失效", "RAL Backdoor 伪 map、内置 sequence 访问次数与随机激励",
         r"\buvm_(?:reg_access|reg_single_access|reg_bit_bash|reg_single_bit_bash|mem_access|reg_mem_shared_access)_seq\b|\buvm_reg_map\s*::\s*backdoor\s*\(",
         "第7章 M07-005/M07-006", "Backdoor map 告警按 M07-005 区分 reg/field/mirror；field 后门入口可能不命中本规则，须另查默认 path；保留内置 sequence，复制实现时核对遍历，同 seed 不保证跨库逐笔数据一致", kind="heuristic"),
    Rule("S39", "静默失效", "memory 地址查询的越界路径",
         r"\bget_addresses\s*\(", "第7章 M07-011", "确认是 memory 后验证 offset < get_size；普通寄存器同名调用不受此项影响", kind="heuristic"),
    Rule("S40", "提示", "自定义 field 操作位宽或自动配置查询模式",
         r"\b(?:UVM_MACRO_NUMFLAGS|UVM_FIELD_FLAG_SIZE|UVM_COMPONENT_CONFIG_MODE_DEFAULT)\b",
         "第9章 M09-015", "核对自定义操作保留位和目标查询模式，默认配置不要求为此重写", kind="heuristic"),
    Rule("S41", "提示", "无 DPI 构建与 Resource DB 隐式追踪依赖",
         r"\b(?:UVM_CMDLINE_NO_DPI|UVM_NO_DPI|UVM_REGEX_NO_DPI|UVM_VERDI_NO_VERDI_TRACE|is_verdi_trace_aware_used)\b",
         "第4、5章 M04-011/M05-012", "无 DPI 单独验证匹配能力；需要资源访问日志时显式开启 UVM_RESOURCE_DB_TRACE", kind="heuristic"),
    Rule("S42", "静默失效", "config_db 非空 context 的正则作用域",
         None, "第5章 M05-003",  # 与 S1 共用静态 config_db 参数解析
         "常规 glob 调用可保留；核对非空 context 与 /regex/ 拼接后的实际匹配对象", kind="heuristic"),
    Rule("S43", "日志/调试变化", "日志脚本包含旧 TLM 未实现接口文案",
         r"(?<!UVM )\bTLM(?:-2)? interface (?:task|function) not implemented\b",
         "第8章 M08-009", "精确全文匹配按目标消息更新，优先按 ID 判断；不锚定全文的原匹配可能可保留", kind="heuristic"),
    Rule("S44", "静默失效", "pack/unpack 位流边界与 GP 布局",
         r"\b(?:pack|pack_bytes|pack_ints|unpack|unpack_bytes|unpack_ints)\s*\(|`uvm_(?:pack|unpack)_\w+\s*\(",
         "第2、8章 M02-001/M08-003", "定位位流产生/消费入口；确认外部接收端、历史数据及 GP 格式契约后逐位比对", kind="heuristic"),
]

# Stable documentation IDs are shared by the CLI report and the generated site.
RULE_ENTRIES = {
    "C1": "M02-003", "C2": "M01-001", "C3": "M01-002 M03-002", "C4": "M03-001",
    "C5": "M09-004", "C6": "M09-003", "C7": "M05-001 M05-002", "C8": "M06-004",
    "C9": "M07-002", "C10a": "M04-004", "C10b": "M04-005", "C10c": "M04-004",
    "C11": "M01-003 M04-006", "C12": "M01-004", "C13": "M02-004",
    "C14": "M06-007", "C15": "M08-011", "C16": "M05-011",
    "S1": "M06-001 M06-002", "S2": "M03-003", "S3": "M09-002 M04-013", "S4": "M09-008",
    "S5": "M05-004", "S6": "M02-001 M08-003", "S7": "M04-001",
    "S8": "M02-005", "S9": "M08-001", "S10": "M04-019", "S11": "M05-010",
    "S12": "M08-002", "S13": "M07-020", "S14": "M07-021", "S15": "M05-004 M05-005",
    "S16": "M02-004", "S17": "M06-008", "S18": "M04-018", "S19": "M06-019",
    "S20": "M06-017", "S21": "M10-006", "S22": "M07-023", "S23": "M01-016", "S24": "M07-024",
    "D1": "M09-005", "D2": "M09-007", "D3": "M07-018", "D4": "M07-019",
    "D5": "M01-007", "D6": "M07-025", "D7": "M04-016 M10-006", "D8": "M10-007 M10-008", "D9": "M08-007",
    "D10": "M05-008", "D11": "M06-001", "D12": "M10-005",
    "C17": "M01-015", "C18": "M02-002", "C19": "M02-008", "C20": "M03-005 M03-006",
    "C21": "M06-009 M06-014", "C22": "M06-003", "C23": "M01-014", "C24": "M07-014",
    "C25": "M07-003 M07-013 M08-006", "C26": "M01-012 M02-012",
    "C27": "M07-012 M09-006 M10-003", "C28": "M02-005",
    "D13": "M02-016", "D14": "M02-017", "D15": "M09-003", "D16": "M09-010",
    "D17": "M03-007 M04-010 M05-007 M05-009 M06-006",
    "S25": "M01-005", "S26": "M01-010", "S27": "M01-009 M02-006 M02-007",
    "S28": "M03-009", "S29": "M03-011", "S30": "M04-002 M04-003",
    "S31": "M04-007 M04-008", "S32": "M07-004", "S33": "M07-007",
    "S34": "M06-011 M06-012 M06-013", "S35": "M08-004",
    "S36": "M03-004 M03-008 M03-012 M03-014 M04-009 M08-008",
    "S37": "M01-008 M03-008", "S38": "M07-005 M07-006", "S39": "M07-011",
    "S40": "M09-015", "S41": "M04-011 M05-012", "S42": "M05-003", "S43": "M08-009",
    "S44": "M02-001 M08-003",
}
RULE_MODULES = {}

# S3（field FLAG 操作变化）需要半解析，单独实现。
FIELD_MACRO = re.compile(r"`uvm_field_\w+\s*\(")

FILE_RULE_TOKENS = {
    "S7": ("uvm_report_catcher", "UVM_NO_ACTION"),
}


@dataclass
class Hit:
    rule: Rule
    path: Path
    line_no: int
    line: str
    reason: str = ""
    entry_ids: tuple[str, ...] = ()
    # Group unresolved FLAG expressions without discarding their use locations.
    related_lines: list[int] = field(default_factory=list)
    compatibility: str = ""  # Only the particular finding is covered, not its entire rule.


SV_STRING = r'"(?:\\[\s\S]|[^"\\])*"'


def strip_comments(text: str, *, slash: bool = False, hash: bool = False) -> str:
    """Remove only explicitly selected syntax, preserving offsets and newlines."""
    if not slash and not hash:
        return text
    # Hash-style languages commonly use single and triple quotes. Do not
    # interpret SV apostrophes (8'hff, '0, casts) as quotes in slash-only mode.
    quoted = SV_STRING
    if hash:
        quoted = r'''"""[\s\S]*?"""|\x27\x27\x27[\s\S]*?\x27\x27\x27|''' + SV_STRING + r"|'(?:\\[\s\S]|[^'\\])*'"
    # Recognize both slash comment delimiters when slash is enabled so a #
    # inside a block cannot hide its closing marker when both are enabled.
    parts = [quoted, r"\\[^\r\n]"]
    if slash:
        parts.extend([r"//[^\r\n]*", r"/\*[\s\S]*?(?:\*/|\Z)"])
    if hash:
        parts.append(r"\#[^\r\n]*")
    return re.sub("|".join(parts), lambda match:
                  re.sub(r"[^\r\n]", " ", match[0])
                  if (slash and match[0].startswith(("//", "/*"))) or (hash and match[0].startswith("#"))
                  else match[0], text)


def comment_modes(path: Path, config: dict) -> dict[str, bool]:
    modes = {}
    for mode, settings in config["remove_comments"].items():
        modes[mode] = ((not settings["include_suffix"] or path.suffix.lower() in settings["include_suffix"])
                       and (not settings["include_files"] or any(re.search(pattern, path.name)
                                                                for pattern in settings["include_files"])))
    return modes


# Config keys, build switches and log signatures are meaningful inside strings.
# These literal alternatives supplement the full rules for every input file.
LITERAL_PATTERNS = {
    "D2": r"\bUVM_NO_DEPRECATED\b", "D3": r"\bUVM_SPARSE_ARRAY\b",
    "D4": r"\bUVM_REG_ENABLE_ADDRESS_EXCLUSION\b",
    "D5": r"\bUVM_DISABLE_APPLY_CFG_SETTINGS\b",
    "D8": r"native_dumping", "D10": r"\bUVM_USE_RESOURCE_CONVERTER\b",
    "D13": r"\bUVM_ARRAY_NUM_LIMIT\b", "D14": r"\bUVM_VERDI_BEGIN_TR_REALTIME\b",
    "D15": r"\bUVM_OBJECT_DO_NOT_NEED_CONSTRUCTOR\b",
    "S4": None, "S10": None, "S18": None, "S21": None,
    "S36": None, "S37": r"\buvm_set_(?:verbosity|action|severity)\b",
    "S40": None, "S41": r"\b(?:UVM_CMDLINE_NO_DPI|UVM_NO_DPI|UVM_REGEX_NO_DPI|UVM_VERDI_NO_VERDI_TRACE)\b",
    "S43": None,
    "S1": r'"default_sequence"', "S32": r"\bNO_REG_HW_RESET_TEST\b",
}


def call_arguments(text: str, opening: int) -> tuple[list[str], int]:
    parts = []
    start = opening + 1
    stack = [")"]
    quoted = False
    escaped = False
    for index in range(start, len(text)):
        char = text[index]
        if quoted:
            if escaped:
                escaped = False
            elif char == "\\":
                escaped = True
            elif char == '"':
                quoted = False
            continue
        if char == '"':
            quoted = True
        elif char in "([{":
            stack.append({"(": ")", "[": "]", "{": "}"}[char])
        elif char == stack[-1]:
            stack.pop()
            if not stack:
                if text[start:index].strip() or parts:
                    parts.append(text[start:index].strip())
                return parts, index + 1
        elif char == "," and len(stack) == 1:
            parts.append(text[start:index].strip())
            start = index + 1
    return [], opening


def code_without_strings(code: str) -> str:
    return re.sub(SV_STRING, lambda m: re.sub(r"[^\n]", " ", m[0]), code)


# Source constants: VCS 1.2 base/uvm_object_globals.svh:175-210;
# VCS 2.0 src/base/uvm_object_globals.svh:196-224. Only FLAG arithmetic,
# never eval user code, imports, calls or attribute expressions.
FLAG_BITS = {name: 1 << bit for bit, name in enumerate((
    "UVM_COPY", "UVM_NOCOPY", "UVM_COMPARE", "UVM_NOCOMPARE", "UVM_PRINT", "UVM_NOPRINT",
    "UVM_RECORD", "UVM_NORECORD", "UVM_PACK", "UVM_NOPACK"))}
FLAG_OLD = dict(FLAG_BITS, UVM_ALL_ON=0x155, UVM_FLAGS_ON=0x155, UVM_DEFAULT=0x555,
                UVM_FLAGS_OFF=0, UVM_READONLY=1 << 15, UVM_PHYSICAL=1 << 13,
                UVM_ABSTRACT=1 << 14, UVM_NODEFPRINT=1 << 16,
                UVM_DEEP=1 << 10, UVM_SHALLOW=1 << 11, UVM_REFERENCE=1 << 12)
FLAG_NEW = dict(FLAG_BITS, UVM_ALL_ON=0xd55, UVM_FLAGS_ON=0xd55, UVM_DEFAULT=0xd55,
                UVM_FLAGS_OFF=0, UVM_READONLY=1 << 12, UVM_NOSET=1 << 12,
                UVM_SET=1 << 11, UVM_UNPACK=1 << 10, UVM_NOUNPACK=1 << 9,
                UVM_PHYSICAL=1 << 13, UVM_ABSTRACT=1 << 14, UVM_NODEFPRINT=1 << 15,
                UVM_DEEP=1 << 16, UVM_SHALLOW=1 << 17, UVM_REFERENCE=1 << 18)
for _flags in (FLAG_OLD, FLAG_NEW):
    _flags.update({"UVM_" + name: number << 24 for number, name in enumerate((
        "NORADIX", "BIN", "DEC", "UNSIGNED", "UNFORMAT2", "UNFORMAT4", "OCT", "HEX",
        "STRING", "TIME", "ENUM", "REAL", "REAL_DEC", "REAL_EXP"))})


def flag_value(expression: str, symbols: dict[str, int]) -> int | None:
    def literal(match):
        width, base, digits = match.groups()
        value = int(digits.replace("_", ""), {"b": 2, "o": 8, "d": 10, "h": 16}[base.lower()])
        if width:
            size = int(width)
            if not 1 <= size <= 4096:
                raise ValueError("unsupported literal width")
            value &= (1 << size) - 1
        return str(value)

    def evaluate(node):
        if isinstance(node, ast.Constant) and type(node.value) is int:
            return node.value
        if isinstance(node, ast.Name):
            return symbols[node.id]
        if isinstance(node, ast.UnaryOp):
            value = evaluate(node.operand)
            if isinstance(node.op, ast.Invert):
                return ~value
            if isinstance(node.op, ast.USub):
                return -value
            if isinstance(node.op, ast.UAdd):
                return value
        if isinstance(node, ast.BinOp):
            left, right = evaluate(node.left), evaluate(node.right)
            if isinstance(node.op, ast.BitOr):
                return left | right
            if isinstance(node.op, ast.BitAnd):
                return left & right
            if isinstance(node.op, ast.BitXor):
                return left ^ right
            if isinstance(node.op, ast.LShift) and 0 <= right <= 4096:
                return left << right
            if isinstance(node.op, ast.RShift) and 0 <= right <= 4096:
                return left >> right
        raise ValueError("unresolved FLAG")

    try:
        if len(expression) > 4096:
            return None
        # Recognize only known constants in the standard UVM package. Other
        # package/class names and user macros still require manual resolution.
        expr = re.sub(r"\buvm_pkg\s*::\s*(UVM_\w+)\b",
                      lambda m: m[1] if m[1] in symbols else m[0], expression)
        expr = re.sub(r"(?<![\w'])(\d*)'([bBoOdDhH])([0-9a-fA-F_]+)", literal, expr)
        expr = re.sub(r"(?<![\w'])'([01])(?!\w)", lambda m: "0" if m[1] == "0" else "(-1)", expr)
        return evaluate(ast.parse(expr.strip(), mode="eval").body)
    except (ValueError, SyntaxError, KeyError, RecursionError):
        return None


def field_flag_reason(flag: str) -> str:
    old, new = flag_value(flag, FLAG_OLD), flag_value(flag, FLAG_NEW)
    if old is None or new is None:
        return "FLAG 含自定义宏、符号或不支持的表达式；展开后复扫，未自动核销"
    changes = []
    for name, positive, negative in (("copy", 1, 2), ("compare", 4, 8), ("print", 16, 32),
                                    ("record", 64, 128), ("pack", 256, 512), ("unpack", 1024, 512)):
        before, after = not bool(old & negative), bool(new & positive) and not bool(new & negative)
        if before != after:
            changes.append(f"{name}: {'开' if before else '关'}→{'开' if after else '关'}")
    if changes:
        return "FLAG 层面的操作变化（按宏支持范围验证）：" + "、".join(changes)
    numbers = re.findall(r"(?<![\w'])(?:\d*'[bBoOdDhH][0-9a-fA-F_]+|'[01]|\d+)", flag)
    # Only discard neutral zero literals beside symbolic flags. Nonzero masks
    # can affect set/recursion even when these six operation enables agree.
    if numbers and (not re.search(r"\bUVM_\w+\b", flag)
                    or any(flag_value(number, {}) != 0 for number in numbers)):
        return "含数值 FLAG；已比对 copy/compare/print/record/pack/unpack，其他操作位及跨语言掩码须改用符号后核对"
    return ""


ABSTRACT_TYPES = (r"uvm_sequence(?:_base)?|uvm_sequencer_(?:param_)?base|uvm_reg_backdoor|uvm_vreg_cbs|uvm_vreg_field_cbs|"
                  r"uvm_tlm_(?:b|nb)_(?:passthrough_)?(?:initiator|target)_socket_base")

QUALIFIED_NAME = r"[A-Za-z_]\w*(?:\s*::\s*[A-Za-z_]\w*)*"


def canonical_type(name: str) -> str:
    # Do not discard arbitrary package prefixes: a project package can define
    # an unrelated type with the same final name.
    return re.sub(r"\s+", "", name).removeprefix("uvm_pkg::")


def type_uses(code: str):
    """Names and balanced specialization arguments with original offsets."""
    for match in re.finditer(r"\b" + QUALIFIED_NAME, code):
        if re.search(r"[.`:]\s*$", code[max(0, match.start() - 256):match.start()]):
            continue
        end = match.end()
        parameter = re.match(r"\s*#\s*\(", code[end:])
        parts = []
        if parameter:
            opening = end + parameter.end() - 1
            parts, end = call_arguments(code, opening)
            if end == opening:
                continue
        yield match, canonical_type(match[0]), parts, end


@dataclass
class StaticCall:
    offset: int
    typename: str
    parameters: list[str]
    method: str
    arguments: list[str]
    end: int


def static_calls(code: str, identifiers: str):
    for match, name, parameters, end in type_uses(identifiers):
        tail = re.match(r"\s*::\s*(" + QUALIFIED_NAME + r")\s*\(", identifiers[end:])
        if tail:
            method = re.sub(r"\s+", "", tail[1])
            opening = end + tail.end() - 1
        elif not parameters and "::" in name:
            # An unspecialized name consumes the entire qualified call.
            name, method = name.split("::", 1)
            tail = re.match(r"\s*\(", identifiers[end:])
            if not tail:
                continue
            opening = end + tail.end() - 1
        else:
            continue
        args, call_end = call_arguments(code, opening)
        if call_end != opening:
            yield StaticCall(match.start(), name, parameters, method, args, call_end)


def statement_end(code: str, start: int, depth: int = 0) -> int | None:
    """Bounded loop-body parsing; unknown syntax must not create a long scope."""
    if depth > 64:
        return None
    first = re.compile(r"\s*(\w+|\S)").match(code, start)
    if not first:
        return None
    word = first[1]
    cursor = first.end()
    if word in {"begin", "fork", "case", "casex", "casez"}:
        pairs = {"begin": "end", "fork": "join", "case": "endcase",
                 "casex": "endcase", "casez": "endcase"}
        stack = [pairs[word]]
        for token in re.finditer(r"\b(?:begin|end|fork|join|join_any|join_none|case|casex|casez|endcase)\b", code[cursor:]):
            current = token[0]
            prefix = code[cursor:cursor + token.start()]
            if current == "fork" and re.search(r"\b(?:disable|wait)\s*$", prefix):
                continue
            if current in pairs:
                stack.append(pairs[current])
            elif current == stack[-1] or (stack[-1] == "join" and current.startswith("join")):
                stack.pop()
                if not stack:
                    end = cursor + token.end()
                    label = re.match(r"\s*:\s*\w+", code[end:])
                    return end + label.end() if label else end
        return None
    if word in {"for", "foreach", "while", "repeat", "if"}:
        condition = re.match(r"\s*\(", code[cursor:])
        if not condition:
            return None
        opening = cursor + condition.end() - 1
        _, cursor = call_arguments(code, opening)
        if cursor == opening:
            return None
        end = statement_end(code, cursor, depth + 1)
        if end is not None and word == "if":
            alternative = re.match(r"\s*else\b", code[end:])
            if alternative:
                end = statement_end(code, end + alternative.end(), depth + 1)
        return end
    if word in {"do", "forever"} or word.startswith("end") or word == "`":
        return None
    # Simple statements: skip delimiters rather than stopping at a for-header
    # or call argument semicolon. Stop at a structural boundary on bad input.
    stack = []
    for token in re.finditer(r"[()\[\]{};]|\bend\w*\b", code[start:]):
        if token[0] in "([{":
            stack.append({"(": ")", "[": "]", "{": "}"}[token[0]])
        elif stack and token[0] == stack[-1]:
            stack.pop()
        elif token[0] == ";" and not stack:
            return start + token.end()
        elif token[0].startswith("end"):
            return None
    return None


@dataclass
class Scope:
    parent: int | None
    kind: str
    base: str = ""
    name: str = ""


class SourceContext:
    """Bounded lexical scopes and direct declarations, not an SV elaborator.

    Unknown receivers stay heuristic. A known declaration is never borrowed from
    a sibling class/function, and inner declarations shadow outer handles.
    """

    def __init__(self, code: str):
        self.code = code
        self.scopes = [Scope(None, "root")]
        self.events = [(0, 0)]
        self.bases: dict[str, set[str]] = {}
        stack = [0]
        prototypes = set()
        loop_ends = {}
        self.uncertain_loops = []
        openings = {"class", "module", "interface", "package", "function", "task", "begin", "fork", "for"}
        closings = {"endclass": "class", "endmodule": "module", "endinterface": "interface",
                    "endpackage": "package", "endfunction": "function", "endtask": "task",
                    "end": "begin", "join": "fork", "join_any": "fork", "join_none": "fork"}
        tokens = re.compile(r"\b(?:" + "|".join(sorted(openings | closings.keys())) + r")\b|;")
        for match in tokens.finditer(code):
            while any(scope in loop_ends and loop_ends[scope] <= match.start() for scope in stack):
                index = next(i for i, scope in enumerate(stack) if scope in loop_ends and loop_ends[scope] <= match.start())
                end = loop_ends[stack[index]]
                del stack[index:]
                self.events.append((end, stack[-1]))
            word = match[0]
            if word == ";":
                if stack[-1] in prototypes:
                    stack.pop()
                    self.events.append((match.start(), stack[-1]))
                continue
            if word in openings:
                prefix = code[code.rfind(";", 0, match.start()) + 1:match.start()]
                if word in {"class", "interface"} and re.search(r"\b(?:typedef|virtual)\s+$", prefix):
                    # virtual class is a scope; virtual interface is a type.
                    if word != "class" or "typedef" in prefix:
                        continue
                if word == "fork" and re.search(r"\b(?:disable|wait)\s+$", prefix):
                    continue
                base = ""
                class_name = ""
                if word == "for":
                    end = statement_end(code, match.start())
                    if end is None:
                        # Do not let an unparsed loop's declaration masquerade
                        # as a reliable function-local type after the loop.
                        header = re.match(r"\s*\(", code[match.end():])
                        if header:
                            opening = match.end() + header.end() - 1
                            _, header_end = call_arguments(code, opening)
                            if header_end != opening:
                                self.uncertain_loops.append((match.start(), header_end, stack[-1]))
                        continue
                if word == "class":
                    header = code[match.end():code.find(";", match.end())]
                    name = re.match(r"\s*(\w+)", header)
                    class_name = name[1] if name else ""
                    extends = re.search(r"\bextends\s+(" + QUALIFIED_NAME + ")", header)
                    if extends:
                        base = canonical_type(extends[1])
                        if name:
                            self.bases.setdefault(name[1], set()).add(base)
                self.scopes.append(Scope(stack[-1], word, base, class_name))
                stack.append(len(self.scopes) - 1)
                if word == "for":
                    loop_ends[stack[-1]] = end
                if word in {"function", "task"} and re.search(r"\b(?:extern|pure)\b[^;]*$", prefix):
                    prototypes.add(stack[-1])
            else:
                kind = closings[word]
                index = next((i for i in range(len(stack) - 1, 0, -1)
                              if self.scopes[stack[i]].kind == kind), None)
                if index is None:
                    continue
                del stack[index:]
            self.events.append((match.start(), stack[-1]))
        self.positions = [event[0] for event in self.events]
        self.named_registrations = {self.class_scope(m.start()) for m in re.finditer(
            r"`(?:uvm_(?:object|component)_utils(?:_begin)?|uvm_type_name_decl)\s*\(", code)}
        self.factory_scopes = {self.class_scope(m.start()) for m in re.finditer(
            r"`uvm_(?:(?:object|component)(?:_param)?|sequence|sequencer)_utils(?:_begin)?\s*\(", code)}
        self.factory_scopes.discard(None)
        for _, typename, params, _ in type_uses(code):
            if typename in {"uvm_object_registry", "uvm_component_registry"} and params:
                registered = canonical_type(params[0])
                candidates = [i for i, scope in enumerate(self.scopes) if scope.kind == "class" and scope.name == registered]
                if len(candidates) == 1:
                    self.factory_scopes.add(candidates[0])
        self.create_scopes = {self.class_scope(m.start()) for m in re.finditer(
            r"\bfunction\s+(?:automatic\s+)?(?:" + QUALIFIED_NAME + r")\s+create\s*\(", code)}
        self.create_scopes.discard(None)
        self.declarations: dict[tuple[int, str], list[tuple[int, str]]] = {}
        # Match types independently to handle qualifiers and parameterized types.
        non_types = openings | closings.keys() | set((
            "return new extends virtual local protected static automatic const rand randc "
            "input output inout ref parameter localparam typedef extern pure if else while "
            "for foreach repeat do case assign initial always import export void signed unsigned"
        ).split())
        for match, typename, _, end in type_uses(code):
            if typename in non_types:
                continue
            # Packed dimensions precede the variable name.
            dims = re.match(r"(?:\s*\[[^\]\n]*\])*", code[end:])
            end += dims.end()
            tail = re.match(r"\s+([A-Za-z_]\w*)\s*(?=[;=,)\[])", code[end:])
            if not tail:
                continue
            offset = end + tail.start(1)
            self.declarations.setdefault((self.scope_at(offset), tail[1]), []).append((offset, typename))
            # Comma-separated declarations retain their type. Stop when a new
            # typed formal begins; its own match will register that declaration.
            cursor = end + tail.end(1)
            stack_delimiters = []
            while cursor < len(code):
                char = code[cursor]
                if char in "([{":
                    stack_delimiters.append({"(": ")", "[": "]", "{": "}"}[char])
                elif stack_delimiters and char == stack_delimiters[-1]:
                    stack_delimiters.pop()
                elif not stack_delimiters:
                    if char in ";)":
                        break
                    if char == ",":
                        another = re.match(r"\s*(\w+)\s*(?=[;=,)\[])", code[cursor + 1:])
                        if not another:
                            break
                        offset = cursor + 1 + another.start(1)
                        self.declarations.setdefault((self.scope_at(offset), another[1]), []).append((offset, typename))
                        cursor += another.end(1)
                cursor += 1

    def scope_at(self, offset: int) -> int:
        return self.events[bisect.bisect_right(self.positions, offset) - 1][1]

    def class_scope(self, offset: int) -> int | None:
        scope = self.scope_at(offset)
        while scope is not None and self.scopes[scope].kind != "class":
            scope = self.scopes[scope].parent
        return scope

    def resolve(self, name: str, offset: int, member: bool = False) -> str | None:
        scope = self.class_scope(offset) if member else self.scope_at(offset)
        while scope is not None:
            declarations = self.declarations.get((scope, name), [])
            visible = declarations if self.scopes[scope].kind == "class" else [item for item in declarations if item[0] <= offset]
            if visible:
                declaration, typename = visible[-1]
                if any(start < declaration < end <= offset and scope == owner for start, end, owner in self.uncertain_loops):
                    return "<unresolved>"
                return typename
            scope = self.scopes[scope].parent
        return None

    def derives(self, typename: str | None, bases: set[str]) -> bool:
        pending = [canonical_type(typename)] if typename else []
        seen = set()
        while pending:
            current = pending.pop()
            if current in bases:
                return True
            if current in seen:
                continue
            seen.add(current)
            # Do not infer from conditional duplicate class definitions.
            parents = self.bases.get(current, set())
            if len(parents) == 1:
                pending.extend(parents)
        return False

    def receiver(self, offset: int) -> str | None:
        prefix = self.code[max(0, offset - 256):offset]
        match = re.search(r"\b((?:this\s*\.\s*)?[A-Za-z_]\w*)\s*\.\s*$", prefix)
        if match:
            if re.search(r"[.\]]\s*$", prefix[:match.start()]):
                return None  # A hierarchical path is not a local handle.
            name = re.sub(r"\s", "", match[1])
            if name in {"this", "super"}:
                scope = self.class_scope(offset)
                return self.scopes[scope].base if scope is not None else None
            return self.resolve(name.removeprefix("this."), offset, name.startswith("this."))
        scoped = re.search(r"\b(" + QUALIFIED_NAME + r")\s*::\s*$", prefix)
        if scoped:
            return canonical_type(scoped[1])
        if re.search(r"[.\]]\s*$", prefix):
            return None
        scope = self.class_scope(offset)
        return self.scopes[scope].base if scope is not None else None

    def assignment_type(self, offset: int, name: str) -> str | None:
        prefix = self.code[max(0, offset - 256):offset]
        if re.search(r"\bthis\s*\.\s*$", prefix):
            return self.resolve(name, offset, member=True)
        if re.search(r"[.:]\s*$", prefix):
            return None
        return self.resolve(name, offset)

    def own_type_name(self, offset: int) -> bool:
        if self.assignment_type(offset, "type_name") is None:
            return False
        member = re.search(r"\bthis\s*\.\s*$", self.code[max(0, offset - 256):offset])
        scope = self.class_scope(offset) if member else self.scope_at(offset)
        while scope is not None:
            if (scope, "type_name") in self.declarations:
                # A class declaration can collide with a registration macro;
                # a formal/local variable with the same name cannot.
                return scope not in self.named_registrations
            scope = self.scopes[scope].parent
        return True

    def constructor_registered(self, offset: int) -> bool:
        scope = self.class_scope(offset)
        if scope in self.factory_scopes:
            return True
        # A class-qualified extern definition lives outside the class scope.
        name = re.match(r"function\s+(\w+)\s*::", self.code[offset:])
        return bool(name and any(self.scopes[i].name == name[1] for i in self.factory_scopes if i is not None))

    def known_non_fifo(self, typename: str | None) -> bool:
        # Only exclude explicit unrelated families, never an unresolved project
        # base. A project subtype may inherit FIFO through another source file.
        return self.derives(typename, {"uvm_packer", "uvm_printer", "uvm_table_printer", "uvm_tree_printer",
                                      "uvm_line_printer", "uvm_comparer", "uvm_recorder", "process", "int", "string"})


def abstract_constructions(code: str, context: SourceContext):
    for match in re.finditer(r"\b(\w+)\s*=\s*new\s*(?=[;(])", code):
        declared_type = context.assignment_type(match.start(), match[1])
        if declared_type and re.fullmatch(ABSTRACT_TYPES, declared_type):
            entry = ("M06-004" if declared_type.startswith("uvm_sequence") else
                     "M07-003" if declared_type == "uvm_reg_backdoor" else
                     "M07-013" if declared_type.startswith("uvm_vreg") else "M08-006")
            yield match.start(), declared_type, entry


def argument_value(parts: list[str], name: str, position: int) -> str | None:
    for part in parts:
        named = re.fullmatch(r"\." + name + r"\s*\(([\s\S]*)\)", part)
        if named:
            return named[1].strip() or None
    if position < len(parts) and not parts[position].startswith("."):
        return parts[position] or None
    return None


def report_reason(name: str, parts: list[str]) -> str:
    general = name == "uvm_report"
    severity = (flag_value(argument_value(parts, "severity", 0) or "", {
        "UVM_INFO": 0, "UVM_WARNING": 1, "UVM_ERROR": 2, "UVM_FATAL": 3}) if general
        else {"uvm_report_warning": 1, "uvm_report_error": 2, "uvm_report_fatal": 3}[name])
    if severity == 0:
        return ""
    verbosity = argument_value(parts, "verbosity", 3 if general else 2)
    # Default fatal and explicit UVM_NONE/0 do not rely on verbosity filtering
    # at ordinary (nonnegative) report thresholds.
    if verbosity is None:
        if severity == 3:
            return ""
        return ("动态 severity 且省略 verbosity；核对非 INFO 分支的旧默认过滤" if severity is None else
                "省略 warning/error verbosity；核对旧默认等级过滤")
    if flag_value(verbosity, {"UVM_NONE": 0}) == 0:
        return ""
    checked = argument_value(parts, "report_enabled_checked", 7 if general else 6)
    if checked and flag_value(checked, {}) == 1:
        return ""
    return "显式非零或动态 verbosity；核对是否依赖旧的非 INFO 入口过滤"



def static_rule(call: StaticCall) -> str | None:
    if call.typename == "uvm_resource" and call.method in {"get_by_name", "get_by_type"}:
        return "C7"
    if call.typename == "uvm_resource_db" and call.method == "get_by_name":
        return "S11"
    if re.fullmatch(r"uvm_(?:int|string|obj|bit|byte)_rsrc", call.typename) and call.method in {"get_by_name", "get_by_type"}:
        return "C16"
    if call.typename in {"uvm_sequence", "uvm_sequence_base"} and call.method == "type_id::create":
        return "C8"
    return None


def stdout_symbol_risk(code: str, start: int, end: int) -> bool:
    # Ordinary symbolic reads/writes are the recommended migration spelling.
    # Flag numeric comparisons and descriptor/MCD bit arithmetic only.
    left, right = code[max(0, start - 100):start], code[end:end + 100]
    number = r"(?:\b\d+(?:'\w+)?|'[01])"
    bit_op = r"(?:(?<!&)&(?!&)|(?<!\|)\|(?!\|)|\^|<<|>>)"
    return bool(re.search(bit_op + r"\s*$", left)
                or re.match(r"\s*" + bit_op, right)
                or re.search(number + r"\s*(?:===?|!==?)\s*$", left)
                or re.match(r"\s*(?:===?|!==?)\s*" + number, right))


def matched_entries(rule_id: str, matched: str) -> tuple[str, ...]:
    """A combined rule must not send an unrelated hit to every linked entry."""
    choices = {
        "C20": [(r"phase_done", "M03-006"), (r".", "M03-005")],
        "C21": [(r"UVM_SEQ_ARB_TYPE", "M06-009"), (r".", "M06-014")],
        "D8": [(r"native_dumping", "M10-008"), (r".", "M10-007")],
        "D17": [(r"m_run_phases", "M03-007"), (r"m_rh", "M04-010"),
                (r"sequence_item_requested|get_next_item_called", "M06-006"),
                (r"rtab|ttab", "M05-009"), (r"access", "M05-007")],
        "S27": [(r"do_record_object", "M02-007"), (r"emit", "M02-006"), (r".", "M01-009")],
        "S31": [(r"report_summarize", "M04-007"), (r".", "M04-008")],
        "S34": [(r"lock|grab", "M06-011"), (r".", "M06-012 M06-013")],
        "S36": [(r"TEST_DONE", "M03-004"), (r"PH_ADD_PHASE", "M03-008"),
                (r"UVM_OBJECTION_TRACE", "M03-014"), (r"UVM_USE_REALTIME_IN_MSGS", "M04-009"),
                (r"uvm_event", "M03-012"), (r"uvm_transport_channel", "M08-008")],
        "S37": [(r"extends", "M03-008"), (r".", "M01-008")],
        "S38": [(r"uvm_(?:mem_access|reg_mem_shared_access)_seq", "M07-006"), (r".", "M07-005")],
        "S41": [(r"VERDI|verdi", "M05-012"), (r".", "M04-011")],
    }
    for pattern, entries in choices.get(rule_id, []):
        if re.search(pattern, matched):
            return tuple(entries.split())
    return tuple(RULE_ENTRIES.get(rule_id, "").split())


def scan_file(path: Path, rules: list[Rule], *, text: str | None = None,
              comments: dict[str, bool] | None = None) -> list[Hit]:
    text = path.read_text(encoding="utf-8", errors="replace") if text is None else text
    # Callers select comment modes (the CLI resolves configured/default scopes).
    # Comment preprocessing never changes which migration rules are selected.
    code = strip_comments(text, **(comments or {}))
    raw_lines = text.splitlines()
    newlines = [-1] + [m.start() for m in re.finditer("\n", code)]
    identifiers = code_without_strings(code)
    context = SourceContext(identifiers)
    hits: list[Hit] = []
    def add_hit(rule, offset, reason="", entry_ids=()):
        line_no = bisect.bisect_left(newlines, offset)
        hits.append(Hit(rule, path, line_no, raw_lines[line_no - 1].strip()[:160], reason, entry_ids))
        return hits[-1]

    compiled = [(r, re.compile(r.pattern)) for r in rules if r.pattern and r.kind != "file"]
    selected = {rule.id: rule for rule in rules}
    phase_calls = []
    for call in static_calls(code, identifiers):
        rule_id = static_rule(call)
        if rule_id in selected:
            add_hit(selected[rule_id], call.offset, "静态 UVM 调用；已配对嵌套类型参数", matched_entries(rule_id, call.method))
        if call.typename != "uvm_config_db" or call.method != "set":
            continue
        scope = argument_value(call.arguments, "inst_name", 1)
        name = argument_value(call.arguments, "field_name", 2)
        cntxt = argument_value(call.arguments, "cntxt", 0)
        parameter = canonical_type(argument_value(call.parameters, "T", 0) or "")
        if parameter in {"uvm_object_wrapper", "uvm_sequence_base"} and name == '"default_sequence"' and re.fullmatch(r'"(?:[^"\n]+\.)?\w+_phase"', scope or ""):
            phase_calls.append((call.offset, call.end))
        if "S42" in selected and cntxt != "null" and re.fullmatch(r'"/[^\n]*/"', scope or ""):
            add_hit(selected["S42"], call.offset, "set 使用非 null 或待确认的 context 与字面 /regex/ 作用域", ("M05-003",))
    for rule, cpat in compiled:
        matches = list(cpat.finditer(code if rule.id in {"D9", "D16"} else identifiers))
        if rule.id in LITERAL_PATTERNS:
            literal_pattern = LITERAL_PATTERNS[rule.id] or rule.pattern
            matches.extend(re.finditer(literal_pattern, code))
        matches = {(m.start(), m.end()): m for m in matches}
        for _, match in sorted(matches.items()):
            if rule.id in {"D9", "D16"} and identifiers[match.start()] != "`":
                continue
            if rule.id == "S1" and any(start <= match.start() < end for start, end in phase_calls):
                continue
            reason = ""
            hit_rule = rule
            if rule.id == "C6":
                if context.constructor_registered(match.start()):
                    reason = "已识别 factory 注册；核对旧 constructor 开关及 name 构造签名"
                else:
                    hit_rule = replace(rule, category="提示", title="无参构造的 factory/create 依赖待确认")
                    reason = "未确认 factory/create 依赖；直接 new 的无参类可保留，需排查外部 registry、项目宏及继承"
                    if context.class_scope(match.start()) in context.create_scopes:
                        reason = "已识别自定义 create；需核对其构造方式及外部 registry，不能仅凭方法名判定断点"
            if rule.id == "C10c":
                prefix = identifiers[max(0, match.start() - 256):match.start()]
                if re.search(r"\.\s*$", prefix) or context.derives(context.receiver(match.start()), {
                    "uvm_component", "uvm_root", "uvm_env", "uvm_agent", "uvm_monitor", "uvm_driver", "uvm_test", "uvm_subscriber"}):
                    continue
            if rule.id == "S9":
                receiver = context.receiver(match.start())
                if context.derives(receiver, {"uvm_tlm_fifo", "uvm_tlm_analysis_fifo"}):
                    reason = "当前可见接收者是 TLM FIFO；核对 flush 后 used() 与 get_ap 广播"
                elif context.known_non_fifo(receiver):
                    continue
                else:
                    hit_rule = replace(rule, category="提示", title="flush 接收者是否为 TLM FIFO 待确认")
                    reason = "接收者未解析为明确类型；核对跨文件继承和实际对象，仅 TLM FIFO 适用本条"
            if context and rule.id in {"C3", "C18", "C22", "S33", "S34", "S39"}:
                method = re.search(r"\b\w+\s*\(", match[0])
                method_offset = match.start() + method.start() if method else match.start()
                receiver = context.receiver(method_offset)
                exclusions = {
                    "C3": {"process", "uvm_sequence", "uvm_sequence_base"},
                    "C18": {"uvm_event", "uvm_event_base"},
                    "C22": {"uvm_sequence_library"},
                    "S33": {"uvm_mem", "uvm_vreg", "uvm_vreg_field"},
                    "S34": {"process", "uvm_component"},
                    "S39": {"uvm_reg", "uvm_vreg"},
                }
                excluded = context.derives(receiver, exclusions[rule.id])
                if rule.id == "C3" and not re.match(r"kill\s*\(", match[0]):
                    excluded = False
                if rule.id == "C22" and not re.match(r"get_sequence\s*\(", match[0]):
                    excluded = False  # Library only adds get_sequence, not the other deleted methods.
                if excluded:
                    continue
            if rule.id == "C27":
                if context.own_type_name(match.start()):
                    continue
            if rule.id == "S30":
                args, end = call_arguments(code, match.end() - 1)
                reason = report_reason(match[0].split("(")[0].strip(), args)
                if not reason:
                    continue
            if rule.id == "S31":
                if match[0] == "UVM_STDOUT":
                    if not stdout_symbol_risk(identifiers, match.start(), match.end()):
                        continue
                    reason = "UVM_STDOUT 与数值比较或参与位运算；核对文件描述符和 MCD"
                else:
                    args, _ = call_arguments(code, match.end() - 1)
                    value = argument_value(args, "file", 0)
                    if value is None or value == "UVM_STDOUT":
                        continue
                    reason = "显式摘要文件参数；核对句柄 0 的旧含义及后续文件路由"
            if rule.id in {"C9", "S16"}:
                opening = match.end() - 1
                parts, end = call_arguments(code, opening)
                if end > opening:
                    name = match[0].split("(")[0].strip()
                    limit = {"get_local_map": 1, "get_default_map": 0, "Xcheck_accessX": 2}.get(name)
                    if rule.id == "C9":
                        if not any(re.match(r"\.caller\s*\(", arg) for arg in parts) and len(parts) <= limit:
                            continue
                        reason = "显式 caller 参数或超过目标签名的实参数量"
                    else:
                        if len(parts) != 2:
                            continue
                        reason = "两参 uvm_print_int：核对第二参是否仍是旧 RADIX"
            add_hit(hit_rule, match.start(), reason, matched_entries(rule.id, match[0]))
    s3 = next((rule for rule in rules if rule.id == "S3"), None)
    if s3:
        unresolved = {}
        for match in FIELD_MACRO.finditer(identifiers):
            parts, end = call_arguments(code, match.end() - 1)
            expected = 3 if re.match(r"`uvm_field_(?:(?:(?:sarray|array|queue)_)?enum|aa_\w+_key)\b", match[0]) else 2
            if len(parts) < expected or not parts[-1]:
                continue
            flag = parts[-1]
            reason = field_flag_reason(flag)
            if reason:
                unknown = flag_value(flag, FLAG_OLD) is None or flag_value(flag, FLAG_NEW) is None
                if unknown:
                    key = re.sub(r"\s+", "", flag)
                    line_no = bisect.bisect_left(newlines, match.start())
                    if key in unresolved:
                        unresolved[key].related_lines.append(line_no)
                    else:
                        advisory = replace(s3, category="提示", title="field FLAG 表达式待展开确认",
                                           fix="按实际编译宏展开此 FLAG，核对列出的全部使用位置；未解析不代表已发现操作失效")
                        hit = add_hit(advisory, match.start(), reason, ("M09-002",))
                        hit.related_lines = [line_no]
                        unresolved[key] = hit
                    continue
                ids = ("M09-002", "M04-013") if re.search(r"(?<!\w)\d|'[01bBoOdDhH]", flag) else ("M09-002",)
                rule = replace(s3, category="提示") if reason.startswith("含数值 FLAG") else s3
                hit = add_hit(rule, match.start(), reason, ids)
                # Only symbolic operation flags with known legacy coverage. Numeric
                # masks and recursion/set/format flags still need separate review.
                symbols = set(re.findall(r"\bUVM_\w+\b", flag))
                operation_flags = set(FLAG_BITS) | {"UVM_ALL_ON", "UVM_DEFAULT", "UVM_FLAGS_ON", "UVM_FLAGS_OFF"}
                if ids == ("M09-002",) and symbols <= operation_flags:
                    hit.compatibility = "UVM_LEGACY_FIELD_MACRO_SEMANTICS"
    c25 = next((rule for rule in rules if rule.id == "C25"), None)
    c8 = next((rule for rule in rules if rule.id == "C8"), None)
    if c25 or c8:
        for offset, declared_type, entry in abstract_constructions(identifiers, context):
            rule = c8 if entry == 'M06-004' else c25
            if rule:
                add_hit(rule, offset, f"当前作用域可见的 {declared_type} 句柄直接 new；未解析完整预处理", (entry,))
    # 文件级规则
    for rule in rules:
        if rule.kind != "file":
            continue
        a, b = FILE_RULE_TOKENS[rule.id]
        matched = a in identifiers and b in identifiers
        if matched:
            hits.append(Hit(rule, path, 0, f"<文件级> 同现 {a!r} 与 {b!r}", "同文件线索；未证明接收者或跨文件依赖关系"))
    # Only use the receiver declaration visible at the call site.
    c7 = next((rule for rule in rules if rule.id == "C7"), None)
    if c7:
        for use in re.finditer(r"\b(?:set|set_override)\s*\(", identifiers):
            if context.derives(context.receiver(use.start()), {"uvm_resource"}):
                add_hit(c7, use.start(), "当前作用域的 resource 句柄调用旧入池方法", ("M05-001", "M05-002"))
    c18 = next((rule for rule in rules if rule.id == "C18"), None)
    if c18:
        for use in re.finditer(r"\b(?:reset|get_bit|get_byte|get_int)\s*\(", identifiers):
            receiver = context.receiver(use.start())
            default = re.search(r"\buvm_default_packer\s*\.\s*$", identifiers[:use.start()])
            if context.derives(receiver, {"uvm_packer"}) or (default and receiver is None):
                add_hit(c18, use.start(), "当前作用域可见的 packer 或 uvm_default_packer 旧状态方法", ("M02-002",))
    unique = {}
    for hit in hits:
        key = (hit.rule.id, hit.rule.category, hit.line_no)
        if key in unique:
            previous = unique[key]
            if previous.compatibility != hit.compatibility:
                previous.compatibility = ""
            previous.entry_ids = tuple(dict.fromkeys((*previous.entry_ids, *hit.entry_ids)))
            previous.related_lines = sorted(set(previous.related_lines + hit.related_lines))
            if hit.reason and hit.reason not in previous.reason:
                previous.reason = '；'.join(filter(None, [previous.reason, hit.reason]))
        else:
            unique[key] = hit
    return list(unique.values())



COMPAT_OPTIONS = ("UVM_ENABLE_DEPRECATED_API", "UVM_LEGACY_FIELD_MACRO_SEMANTICS",
                  "uvm_compat_pkg_compiled")
REPORT_FIELDS = ["id", "entry_ids", "reference", "category", "file", "line", "text", "kind",
                 "reason", "fix", "related_lines", "status", "compatibility", "suppressed", "waivers"]


def mapping(value, allowed, label):
    if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
        raise ValueError(f"{label} 必须是字符串键的 mapping")
    unknown = set(value) - set(allowed)
    if unknown:
        raise ValueError(f"{label} 未知配置键: {', '.join(sorted(unknown))}")
    return value


def string_list(value, label):
    if not isinstance(value, list) or any(not isinstance(item, str) or not item.strip() for item in value):
        raise ValueError(f"{label} 必须是非空字符串组成的列表")
    return value


def suffix_list(value, label):
    values = string_list(value, label)
    if any('/' in item or '\\' in item or '*' in item or item == '.' for item in values):
        raise ValueError(f"{label} 需要字面后缀，例如 .sv；不支持正则或 glob")
    return [item.lower() if item.startswith('.') else '.' + item.lower() for item in values]


def regex(pattern, label):
    if not isinstance(pattern, str) or not pattern:
        raise ValueError(f"{label} 必须是非空正则字符串")
    try:
        return re.compile(pattern)
    except re.error as error:
        raise ValueError(f"{label} 正则无效: {error}") from error


def load_config(path: Path) -> dict:
    if yaml is None:
        raise ValueError("读取 YAML 需要 PyYAML；可用 uv run --with pyyaml==6.0.3 执行脚本")

    class UniqueLoader(yaml.SafeLoader):
        pass

    def unique_mapping(loader, node):
        result = {}
        for key_node, value_node in node.value:
            key = loader.construct_object(key_node)
            if not isinstance(key, str) or key in result:
                raise ValueError(f"YAML 键必须是唯一字符串: {key!r}")
            result[key] = loader.construct_object(value_node)
        return result

    UniqueLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, unique_mapping)
    try:
        value = yaml.load(path.read_text(encoding="utf-8-sig"), Loader=UniqueLoader)
    except yaml.YAMLError as error:
        raise ValueError(f"YAML 解析失败: {error}") from error
    return {} if value is None else value


def validate_config(raw: dict) -> dict:
    allowed = {"version", "migration", "check_path", "output_dir", "exclude_dirs", "exclude_files", "exclude_suffix",
               "include_suffix", "rules", "compatibility", "hide_compat_covered", "remove_comments", "waivers"}
    config = dict(mapping(raw, allowed, "配置"))
    if type(config.get("version", 1)) is not int or config.get("version", 1) != 1:
        raise ValueError("version 只支持整数 1")
    migration = mapping(config.setdefault("migration", dict(MIGRATION)), set(MIGRATION), "migration")
    if migration != MIGRATION:
        raise ValueError("migration 只支持 from: uvm-1.2、to: uvm-ieee-2020-2.0；须完整填写两项")
    for name in ("check_path", "exclude_dirs", "exclude_files", "rules"):
        config[name] = string_list(config.get(name, []), name)
    for name in ("exclude_suffix", "include_suffix"):
        config[name] = suffix_list(config.get(name, []), name)
    for name in ("exclude_dirs", "exclude_files"):
        for pattern in config[name]:
            regex(pattern, name)
    output = config.setdefault("output_dir", ".")
    if not isinstance(output, str) or not output.strip():
        raise ValueError("output_dir 必须是非空路径字符串")
    compat = mapping(config.setdefault("compatibility", {}), COMPAT_OPTIONS, "compatibility")
    for name, enabled in compat.items():
        if enabled is not None and type(enabled) is not bool:
            raise ValueError(f"compatibility.{name} 必须是 true、false 或 null")
    config["compatibility"] = {name: compat.get(name) for name in COMPAT_OPTIONS}
    if type(config.setdefault("hide_compat_covered", False)) is not bool:
        raise ValueError("hide_compat_covered 必须是 true 或 false")
    comments = mapping(config.setdefault("remove_comments", {}), {"slash", "hash"}, "remove_comments")
    default_suffixes = {"slash": [".sv", ".svh", ".v", ".vh"],
                        "hash": [".sh", ".py", ".yaml", ".yml"]}
    for mode in ("slash", "hash"):
        label = f"remove_comments.{mode}"
        settings = mapping(comments.setdefault(mode, {}), {"include_suffix", "include_files"}, label)
        settings["include_suffix"] = suffix_list(settings.get("include_suffix", default_suffixes[mode]), f"{label}.include_suffix")
        settings["include_files"] = string_list(settings.get("include_files", []), f"{label}.include_files")
        for pattern in settings["include_files"]:
            regex(pattern, f"{label}.include_files")
    waivers = config.setdefault("waivers", [])
    if not isinstance(waivers, list):
        raise ValueError("waivers 必须是列表；空列表表示无豁免")
    identities = set()
    for waiver in waivers:
        mapping(waiver, {"id", "rule", "path", "lines", "reason"}, "waivers 项")
        for name in ("id", "rule", "path", "reason"):
            if not isinstance(waiver.get(name), str) or not waiver[name].strip():
                raise ValueError(f"waivers.{name} 必须是非空字符串")
        if waiver["id"] in identities:
            raise ValueError(f"重复 waiver ID: {waiver['id']}")
        identities.add(waiver["id"])
        if waiver["rule"] not in {rule.id for rule in RULES}:
            raise ValueError(f"waiver 未知规则 ID: {waiver['rule']}")
        regex(waiver["path"], "waivers.path")
        if "lines" in waiver:
            lines = waiver["lines"]
            if not isinstance(lines, list) or any(type(line) is not int or line < 0 for line in lines):
                raise ValueError("waivers.lines 必须是非负整数列表（0 表示文件级命中）")
    return config


def matching_waivers(hit: Hit, waivers: list[dict], base: Path) -> list[dict]:
    # Relative to the one shared YAML, so the waiver is portable across checkouts.
    relative = Path(os.path.relpath(hit.path, base)).as_posix()
    locations = set(hit.related_lines or [hit.line_no])
    matches = []
    for waiver in waivers:
        if waiver["rule"] != hit.rule.id or not re.search(waiver["path"], relative):
            continue
        lines = locations & set(waiver["lines"]) if waiver.get("lines") else locations
        if lines:
            matches.append({"id": waiver["id"], "reason": waiver["reason"], "lines": sorted(lines)})
    return matches


def collect_files(paths, config: dict, skipped: list[dict], reserved=()) -> list[Path]:
    dirs = [re.compile(pattern) for pattern in config["exclude_dirs"]]
    names = [re.compile(pattern) for pattern in config["exclude_files"]]
    files, seen, visited, considered = [], set(), set(), set()

    def skip(path, reason):
        skipped.append({"path": str(path), "reason": reason})

    def directory_excluded(path):
        for parent in (path, *path.parents):
            if any(pattern.search(parent.as_posix()) for pattern in dirs):
                skip(path, "exclude_dirs")
                return True
        return False

    def add(path):
        if path in considered:
            return
        considered.add(path)
        resolved = path.resolve()
        if resolved in seen:
            return
        if resolved in reserved:
            skip(path, "配置或本次报告文件")
        elif directory_excluded(path.parent) or (resolved != path and directory_excluded(resolved.parent)):
            return
        elif not stat.S_ISREG(path.stat().st_mode):
            skip(path, "非普通文件")
        elif any(pattern.search(path.name) or pattern.search(resolved.name) for pattern in names):
            skip(path, "exclude_files")
        elif path.suffix.lower() in config["exclude_suffix"]:
            skip(path, "exclude_suffix")
        elif config["include_suffix"] and path.suffix.lower() not in config["include_suffix"]:
            skip(path, "include_suffix/--ext")
        else:
            seen.add(resolved)
            files.append(path)

    def walk_error(error):
        raise error

    for input_path in paths:
        path = Path(os.path.abspath(input_path))
        mode = path.stat().st_mode  # Missing/inaccessible inputs are errors, not clean scans.
        if stat.S_ISDIR(mode):
            if directory_excluded(path) or (path.resolve() != path and directory_excluded(path.resolve())):
                continue
            for current, children, entries in os.walk(path, followlinks=False, onerror=walk_error):
                base = Path(current)
                if base.resolve() in visited:
                    children[:] = []
                    continue
                visited.add(base.resolve())
                kept = []
                for name in sorted(children):
                    child = base / name
                    if child.is_symlink():
                        skip(child, "目录符号链接（不递归）")
                    elif not directory_excluded(child):
                        kept.append(name)
                children[:] = kept
                for name in sorted(entries):
                    add(base / name)
        else:
            add(path)
    return files


def read_text_file(path: Path) -> str | None:
    # Content detection instead of an extension denylist. Decode failures are
    # errors; silently replacing bytes could hide names or change locations.
    with path.open("rb") as stream:
        sample = stream.read(8192)
        controls = sum(byte < 32 and byte not in (9, 10, 12, 13, 27) for byte in sample)
        if b'\x00' in sample or (sample and controls / len(sample) > 0.3):
            return None
        data = sample + stream.read()
    return data.decode("utf-8-sig")


def report_row(hit: Hit, config: dict, base: Path) -> dict:
    compat = "UVM_ENABLE_DEPRECATED_API" if hit.rule.id == "C5" else hit.compatibility
    covered = bool(compat and config["compatibility"].get(compat) is True)
    waivers = matching_waivers(hit, config["waivers"], base)
    waived_lines = {line for waiver in waivers for line in waiver["lines"]}
    waived = set(hit.related_lines or [hit.line_no]) <= waived_lines
    return {"id": hit.rule.id, "entry_ids": list(hit.entry_ids) or RULE_ENTRIES.get(hit.rule.id, "").split(),
            "reference": RULE_MODULES.get(hit.rule.id, ""), "category": hit.rule.category, "file": str(hit.path),
            "line": hit.line_no, "text": hit.line, "kind": hit.rule.kind,
            "reason": hit.reason or "匹配规则所列名称或调用形态；按条目确认类型及生效分支",
            "fix": hit.rule.fix, "related_lines": hit.related_lines,
            "status": "waived" if waived else "compat_covered" if covered else "active",
            "compatibility": compat if covered else "",
            "suppressed": waived or (covered and config["hide_compat_covered"]), "waivers": waivers}


def write_reports(data, summary, json_path, csv_path, summary_path):
    json_path.parent.mkdir(parents=True, exist_ok=True)
    json_path.write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
    with csv_path.open("w", encoding="utf-8-sig", newline="") as stream:
        writer = csv.DictWriter(stream, fieldnames=REPORT_FIELDS)
        writer.writeheader()
        for item in data:
            row = dict(item)
            row["entry_ids"] = " ".join(row["entry_ids"])
            row["related_lines"] = " ".join(map(str, row["related_lines"]))
            row["waivers"] = json.dumps(row["waivers"], ensure_ascii=False)
            writer.writerow(row)
    summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    for label, path in (("JSON", json_path), ("CSV", csv_path), ("扫描摘要", summary_path)):
        print(f"{label} 报告已写入 {path}")


def check_report_destination(path: Path, kind: str):
    # Repeated scans can replace their reports, but must not silently overwrite
    # an existing source/config file accidentally selected as an output.
    if not path.exists():
        return
    try:
        if kind == "csv":
            with path.open(encoding="utf-8-sig", newline="") as stream:
                valid = next(csv.reader(stream), None) in (REPORT_FIELDS, REPORT_FIELDS[:-1])
        else:
            data = json.loads(path.read_text(encoding="utf-8"))
            if kind == "summary":
                valid = isinstance(data, dict) and data.get("schema_version") == 1 and "effective_config" in data
            else:
                valid = isinstance(data, list) and all(isinstance(row, dict) and
                            {"id", "file", "line", "category", "entry_ids"} <= row.keys() for row in data)
    except (UnicodeError, ValueError):
        valid = False
    if not valid:
        raise ValueError(f"报告路径已有非扫描报告文件，拒绝覆盖: {path}")


def main() -> int:
    ap = argparse.ArgumentParser(description=f"VCS 内置 {MIGRATION['from']} -> {MIGRATION['to']} 迁移扫描器；自动读取同目录同名 YAML", allow_abbrev=False)
    ap.add_argument("paths", nargs="*", type=Path,
                    help="省略时扫描 check_path；相对路径基于各 check_path（未配置则基于当前目录），绝对路径直接使用")
    ap.add_argument("--ext", help="限定后缀，逗号分隔；覆盖 include_suffix，默认全部")
    ap.add_argument("--output-dir", type=Path, help="统一输出目录（JSON、CSV、摘要）；覆盖 YAML 的 output_dir")
    ap.add_argument("--quiet", action="store_true", help="只输出汇总表")
    ap.add_argument("--rules", help="指定团队维护的内置规则，逗号分隔；默认全部")
    args = ap.parse_args()
    try:
        config_path = Path(__file__).resolve().with_suffix(".yaml")
        if not config_path.is_file():
            raise ValueError(f"缺少团队配置，请将同名 YAML 与脚本放在同一目录: {config_path}")
        config = validate_config(load_config(config_path))
        base = config_path.parent
        roots = [Path(os.path.abspath(base / path)) for path in config["check_path"]]
        paths = []
        for path in args.paths:
            paths.extend([path] if path.is_absolute() else [root / path for root in roots or [Path.cwd()]])
        paths = list(dict.fromkeys(Path(os.path.abspath(path)) for path in (paths or roots)))
        if not paths:
            raise ValueError("请提供路径，或在 YAML 中配置 check_path")
        known = {rule.id for rule in RULES}
        selected = (set(args.rules.split(",")) if args.rules is not None
                    else set(config["rules"]) if config["rules"] else known)
        selected = {name.strip() for name in selected}
        if selected - known:
            raise ValueError("未知规则 ID: " + ", ".join(sorted(selected - known)))
        rules = [rule for rule in RULES if rule.id in selected]
        config["rules"] = [rule.id for rule in rules]
        if args.ext is not None:
            config["include_suffix"] = suffix_list([part.strip() for part in args.ext.split(",")], "--ext")
        config["check_path"] = [str(root) for root in roots]
        output = (args.output_dir if args.output_dir is not None else base / config["output_dir"]).absolute()
        config["output_dir"] = str(output)
        json_path = output / "migration-report.json"
        csv_path = output / "migration-report.csv"
        summary_path = output / "migration-report.summary.json"
        outputs = [json_path, csv_path, summary_path]
        reserved = {path.resolve() for path in outputs}
        if len(reserved) != len(outputs):
            raise ValueError("JSON、CSV 与扫描摘要不能指向同一个文件")
        if config_path in reserved:
            raise ValueError("报告路径不能覆盖配置文件")
        reserved.add(config_path)
        reserved.add(Path(__file__).resolve())
        if any(path.resolve() in reserved for path in paths if not path.is_dir()):
            raise ValueError("扫描输入不能与配置或报告文件重合")
        for path, kind in ((json_path, "json"), (csv_path, "csv"), (summary_path, "summary")):
            check_report_destination(path, kind)
        skipped = []
        files = collect_files(paths, config, skipped, reserved)
    except (OSError, ValueError, UnicodeError) as error:
        ap.error(str(error))

    hits, scanned, errors = [], [], []
    for path in files:
        try:
            source = read_text_file(path)
            if source is None:
                skipped.append({"path": str(path), "reason": "二进制内容"})
                continue
            if rules:
                hits.extend(scan_file(path, rules, text=source, comments=comment_modes(path, config)))
            scanned.append(str(path))
        except (OSError, UnicodeError) as error:
            errors.append({"path": str(path), "reason": str(error)})
    if not scanned:
        errors.append({"path": "", "reason": "没有可扫描的文本文件，请核对路径、排除配置和编码"})
    data = [report_row(hit, config, base) for hit in hits]
    visible = [(hit, row) for hit, row in zip(hits, data) if not row["suppressed"]]
    exit_code = 2 if errors else 1 if visible else 0
    summary = {"schema_version": 1, "complete": not errors, "exit_code": exit_code,
               "config_file": str(config_path),
               "effective_config": config, "input_paths": [str(path) for path in paths],
               "scanned_files": scanned, "skipped": skipped, "errors": errors,
               "findings": len(data), "visible_findings": len(visible),
               "compat_covered": sum(bool(row["compatibility"]) for row in data),
               "waived": sum(row["status"] == "waived" for row in data),
               "waiver_matches": {waiver["id"]: sum(any(match["id"] == waiver["id"] for match in row["waivers"])
                                                   for row in data) for waiver in config["waivers"]},
               "suppressed": sum(row["suppressed"] for row in data)}
    print(f"# VCS 内置 {MIGRATION['from']} -> {MIGRATION['to']} 迁移扫描报告")
    print("# 扫描规则: " + ", ".join(config["rules"]))
    print(f"# 扫描文件 {len(scanned)} 个，命中 {len(data)} 项，显示 {len(visible)} 项，"
          f"过滤 {summary['suppressed']} 项（豁免 {summary['waived']} 项），跳过文件/目录 {len(skipped)} 项，错误 {len(errors)} 项")
    by_rule = {}
    for hit, row in visible:
        by_rule.setdefault((hit.rule.id, hit.rule.category), []).append((hit, row))
    print(f"{'规则':<12}{'类别':<10}{'命中数':>5}  说明")
    for category in ["编译断点", "静默失效", "日志/调试变化", "废弃与扩展", "提示"]:
        for rule in rules:
            group = by_rule.get((rule.id, category), [])
            if not group:
                continue
            print(f"{rule.id:<12}{category:<10}{len(group):>5}  {group[0][0].rule.title}")
            if args.quiet:
                continue
            print(f"\n## [{rule.id}] {group[0][0].rule.title}")
            print(f"   文档: {rule.doc} ｜ 修法: {group[0][0].rule.fix}")
            if rule.kind != "hit":
                print(f"   （{'启发式' if rule.kind == 'heuristic' else '文件级'}规则，命中需人工确认）")
            for hit, row in group:
                print(f"   {hit.path}:{hit.line_no}  {hit.line}")
                if row["entry_ids"]:
                    print("      差异条目: " + " ".join(row["entry_ids"]))
                if hit.reason:
                    print(f"      原因: {hit.reason}")
                if row["compatibility"]:
                    print(f"      兼容依赖: {row['compatibility']}（按配置声明，仍须验证实际构建与行为）")
                if row["waivers"]:
                    for waiver in row["waivers"]:
                        print(f"      部分位置豁免: {waiver['id']} 行 {waiver['lines']}；{waiver['reason']}")
                if hit.related_lines:
                    print(f"      同表达式使用 {len(hit.related_lines)} 处，行号: " + ", ".join(map(str, hit.related_lines)))
    for error in errors:
        print(f"扫描失败: {error['path']}: {error['reason']}", file=sys.stderr)
    try:
        write_reports(data, summary, json_path, csv_path, summary_path)
    except OSError as error:
        ap.error(f"报告写入失败: {error}")
    return exit_code


if __name__ == "__main__":
    sys.exit(main())
