#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
泛微技能检测工具 · 静态检测脚本

检测上传的 skill 压缩包(.zip) 或 Markdown 文件(.md) 是否为「符合 WorkBuddy 规范、
且通过泛微专属安全合规质检」的 skill。

用法:
    python validate_skill.py <path_to_skill.zip|.md|directory> [--json] [--out report.md] [--html report.html]
                                     [--market-catalog catalog.json] [--market-live] [--no-html]
                                     [--platform {weaver,qwenwork}]
    # 默认仅在 WARN/FAIL 时自动输出 HTML 报告到「输入同目录/<输入名>-report.html」（如 a.zip → a-report.html）；
    # 纯 PASS 不落 HTML；显式传 --html <path> 可强制输出；--no-html 可关闭默认 HTML 输出（仅控制台/JSON）。
    # --platform 显式指定目标发布平台（默认 weaver）：
    #     weaver    - 泛微生态（WorkBuddy 平台 + 泛微专属品牌/契约约束，原默认行为）
    #     qwenwork  - 千问办公生态（按千问开发指南校验打包硬性、frontmatter 字段、连接器契约等）

检测维度:
    A. 基础合规 (WorkBuddy 通用): SKILL.md / frontmatter / name(必须weaver前缀) / display_name(必须泛微前缀)
       / display_name_en / description / description_zh / description_en / version / author(泛微强制) / 命名规范
       / 打包结构: A1a Windows反斜杠路径(BLOCK) + A1b 目录层级不超过两级(BLOCK: 仅支持 根/二级目录/文件)
       / A1c 文件总数≤300(BLOCK) + A1d 仅纯文本(禁止任意位置二进制/压缩/媒体, BLOCK)
       / A1e SKILL.md 文件名大小写精确(BLOCK: 必须精确大写 SKILL.md, 禁止 skill.md/Skill.md 等变体)
       / A2 结构化文件可解析性(BLOCK: JSON/YAML/TOML) / A3 依赖引导完整性(WARN)
    B. 专项质检 (泛微合规与安全): 默认登录地址、硬编码 IP、硬编码凭证、凭证来源、敏感词
       / API 请求凭证完整性(BLOCK: 发起 API 请求必须携带 Cookie 完整原始串+eteamsid+User-Agent(AgentType=,IsAgent=true))
    G. 扩展安全 (对齐官方 expert-reviewer): G02危险命令 / G03内网域名 / G04个人路径+IDE残留+CDN@latest
       / C08平台虚假承诺 / BOM+CRLF编码规范
    Q. 质量评估 (启发式): Q1路由触发清晰度 / Q2上下文效率 / Q3容错降级 / Q4字段字数控制
    C. 威胁模型 (8 类) 与 D. 行为图谱: 暂时停用（用户要求移除，函数保留可随时恢复）
    E. 技能能力清单: HTTP/API/文件读写/安装依赖/浏览器控制/环境变量/命令执行等

退出码:
    0  通过 (PASS) 或 仅警告通过 (WARN)
    1  不通过 (FAIL，存在阻断项)
    2  输入非法 (文件不存在 / 无法识别)

检测维度:
    A. 基础合规 (WorkBuddy 通用): SKILL.md / frontmatter / name(必须weaver前缀) / display_name(必须泛微前缀)
       / display_name_en / description / description_zh / description_en / version / author(泛微强制) / 命名规范
       / 打包结构: A1a Windows反斜杠路径(BLOCK) + A1b 目录层级不超过两级(BLOCK: 仅支持 根/二级目录/文件)
       / A1c 文件总数≤300(BLOCK) + A1d 仅纯文本(禁止任意位置二进制/压缩/媒体, BLOCK)
       / A1e SKILL.md 文件名大小写精确(BLOCK: 必须精确大写 SKILL.md, 禁止 skill.md/Skill.md 等变体)
       / A2 结构化文件可解析性(BLOCK: JSON/YAML/TOML) / A3 依赖引导完整性(WARN)
    B. 专项质检 (泛微合规与安全): 默认登录地址、硬编码 IP、硬编码凭证、凭证来源、敏感词
       / API 请求凭证完整性(BLOCK: 发起 API 请求必须携带 Cookie 完整原始串+eteamsid+User-Agent(AgentType=,IsAgent=true))
    G. 扩展安全 (对齐官方 expert-reviewer): G02危险命令 / G03内网域名 / G04个人路径+IDE残留+CDN@latest
       / C08平台虚假承诺 / BOM+CRLF编码规范
    Q. 质量评估 (启发式): Q1路由触发清晰度 / Q2上下文效率 / Q3容错降级 / Q4字段字数控制
    C. 威胁模型 (8 类) 与 D. 行为图谱: 暂时停用（用户要求移除，函数保留可随时恢复）
    E. 技能能力清单: HTTP/API/文件读写/安装依赖/浏览器控制/环境变量/命令执行等
"""

import sys
import os
import re
import json
import zipfile
import tempfile
import shutil
import hashlib
from datetime import datetime
from pathlib import Path
import urllib.request
import urllib.parse

# ---------------------------------------------------------------------------
# 检测规则常量
# ---------------------------------------------------------------------------

# skillhub.cn 公开检索接口（市场可达性动态核查用，零第三方依赖，仅用标准库）
SKILLHUB_API = "https://api.skillhub.cn/api/skills"

# 泛微自用系统地址（严禁作为默认地址写死在 skill 中）
WEAVER_OWNED_DOMAINS = [
    "e-cology.com.cn",
    "e-weaver.com",
    "weaver.com.cn",
    "eteams.cn",
    "weaver.com",
]

# 可接受的占位符 / 示例地址（不应判定为真实 IP）
SAFE_IP_PATTERNS = re.compile(
    r"^(127\.0\.0\.1|0\.0\.0\.0|255\.255\.255\.255|10\.0\.0\.0|192\.168\.0\.0|172\.16\.0\.0)$"
)

# 文本文件扩展名（仅扫描这些，避免二进制误读）
TEXT_EXTS = {
    ".md", ".markdown", ".txt", ".py", ".sh", ".bash", ".js", ".mjs", ".cjs", ".ts",
    ".jsx", ".tsx", ".json", ".yaml", ".yml", ".toml", ".cfg", ".ini", ".env",
    ".html", ".htm", ".css", ".scss", ".java", ".sql", ".xml", ".csv", ".rst",
    ".go", ".rb", ".php", ".ps1", ".bat", ".pl",
}

# 纯文本允许清单（ZIP 包内只允许包含以下纯文本格式文件，其余一律视为违规）
TEXT_EXT_ALLOWLIST = {
    # 文档 / 标记
    "md", "markdown", "txt", "text", "rst", "adoc", "asc", "org",
    # 源码 / 脚本
    "py", "pyw", "js", "mjs", "cjs", "ts", "tsx", "jsx", "json", "jsonl",
    "yaml", "yml", "toml", "ini", "cfg", "conf", "config", "sh", "bash", "zsh",
    "ps1", "bat", "cmd", "html", "htm", "xhtml", "css", "scss", "less", "svg",
    "xml", "csv", "tsv", "tab", "vue", "svelte", "ipynb",
    # 编译型 / 其它文本源码
    "c", "h", "cpp", "cc", "hpp", "java", "go", "rs", "rb", "php", "sql",
    "r", "lua", "pl", "scala", "kt", "swift", "dart",
    # 数据 / 配置 / 杂项文本
    "proto", "graphql", "gql", "drawio", "lock", "log", "env", "env.example",
    "gitignore", "gitattributes", "dockerignore", "editorconfig", "npmignore",
}

# 已知二进制扩展名黑名单（命中即视为二进制文件，禁止打包）
BINARY_EXT_BLACKLIST = {
    # 可执行 / 库
    "exe", "dll", "so", "dylib", "sys", "ocx", "com", "msi", "class", "jar",
    "pyc", "pyd", "pyo", "wasm", "node", "nupkg",
    # 图片 / 媒体
    "png", "jpg", "jpeg", "gif", "bmp", "tiff", "tif", "webp", "ico", "heic",
    "mp4", "mov", "avi", "mkv", "webm", "flv", "wmv", "mp3", "wav", "ogg",
    "flac", "m4a", "aac", "wma", "bin",
    # 压缩 / 归档
    "zip", "tar", "gz", "tgz", "bz2", "xz", "7z", "rar", "zst", "iso", "img",
    # 办公 / 文档二进制
    "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods", "odp",
    # 数据库 / 数据
    "db", "sqlite", "sqlite3", "mdb", "dat",
    # 字体
    "ttf", "otf", "woff", "woff2", "eot",
}

# 无扩展名但允许的常见纯文本文件名（避免误杀 LICENSE / Makefile 等）
KNOWN_TEXT_NOEXT = {
    "license", "licence", "makefile", "dockerfile", "copying", "authors",
    "changelog", "notice", "readme", "news", "todo",
}

# 二进制文件魔数（内容嗅探，防止「文本扩展名但实为二进制」漏网）
BINARY_MAGIC_PREFIXES = (
    b"\x89PNG", b"\xff\xd8\xff", b"GIF87a", b"GIF89a", b"%PDF",
    b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08", b"\x1f\x8b", b"Rar!",
    b"\x37\x7a\xbc\xaf", b"RIFF", b"OggS", b"\xca\xfe\xba\xbe", b"\xfe\xed\xfa\xce",
    b"\x4d\x5a",  # MZ (Windows 可执行)
)


def _sniff_binary(path: Path) -> bool:
    """读取前 512 字节做二进制嗅探：含空字节或命中魔数即判为二进制。"""
    try:
        with open(path, "rb") as f:
            head = f.read(512)
    except OSError:
        return False
    if not head:
        return False
    if b"\x00" in head:
        return True
    for m in BINARY_MAGIC_PREFIXES:
        if head.startswith(m):
            return True
    return False


# 硬编码凭证可疑模式（赋值型字面量）
# 负向前瞻 (?![\w_]+\s*\()：排除 RHS 为函数调用（如 token = _parent_token(key)）的误判；
# 真实硬编码字面量不会以「标识符(」开头，故该排除安全。
CREDENTIAL_PATTERNS = [
    re.compile(r"(?i)(password|passwd|pwd)\s*[:=]\s*(?![\w_]+\s*\()['\"]?[^\s'\"]{4,}"),
    re.compile(r"(?i)(api[_-]?key|apikey|secret|token|access[_-]?key)\s*[:=]\s*(?![\w_]+\s*\()['\"]?[A-Za-z0-9_\-]{8,}"),
    re.compile(r"(?i)authorization\s*[:=]\s*['\"]?bearer\s+[A-Za-z0-9._\-]{8,}"),
    re.compile(r"(?i)cookie\s*[:=]\s*['\"]?[A-Za-z0-9_=;.\-]{12,}"),
]

# 凭证应通过 E10 插件获取的迹象（用于正向确认）
PLUGIN_CREDENTIAL_HINTS = [
    "命名凭据", "e10 插件", "E10 插件", "ETEAMSID", "获取.*凭证", "凭据",
    "plugin", "credential",
]

# ---------------------------------------------------------------------------
# 运行时行为风险（B6/B7/B8）：子进程、命令行传凭证、硬编码密钥解密本地凭证
# ---------------------------------------------------------------------------
# 仅对脚本类扩展名做 AST 级检测，避免对文档正文误报
SCRIPT_EXTS = (".py", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx",
               ".sh", ".bash", ".bat", ".ps1", ".rb", ".pl")

# 敏感子进程调用（实际调用点；import child_process 不算调用，由 COMMAND_EXEC_RE 在威胁模型中识别）
RISKY_SUBPROCESS_RE = re.compile(
    r"\b(spawnSync|spawn|execSync|execFileSync|execFile|exec)\s*\("
    r"|\bsubprocess\.(Popen|run|call|check_output|check_call)\b",
    re.IGNORECASE,
)

# 调用了包外脚本文件（.py/.sh 等拼在子进程参数里）
EXTERNAL_SCRIPT_RE = re.compile(
    r"['\"][^'\"]*\.(py|sh|bash|bat|ps1|pl|rb|js|mjs|ts)['\"]", re.IGNORECASE)

# 子进程参数块内出现的凭证类字段（命令行明文传递）
CRED_IN_SUBPROC_RE = re.compile(
    r"\b(ETEAMSID|eteamsid|token|session|cookie|password|passwd|"
    r"secret|credential|auth)\b", re.IGNORECASE)

# AES / 解密调用
AES_DECRYPT_RE = re.compile(
    r"aes-?256|createDecipheriv|createDecipher|decipher|cipher\.update|"
    r"cipher\.final|AES\.(new|decrypt|createDecipheriv)|CryptoJS\.AES|"
    r"cryptography.*decrypt|fromEncryptedString|\.decrypt\(", re.IGNORECASE)

# 疑似硬编码密钥常量（较长随机字符串字面量，如 'cow_eteams_auth_v1_8a3f...'）
HARDCODED_SECRET_RE = re.compile(r"['\"][A-Za-z0-9_\-]{16,}['\"]")

# 本地敏感凭证路径（会话/凭证文件）
# 注意：第四分支要求路径上下文（引号/反引号/~/斜杠开头），避免把 x.key 这类
# 对象属性访问误判为本地凭证文件；统一用 [^\s'"] 排除空白/引号（含换行，不跨行）。
LOCAL_CRED_PATH_RE = re.compile(
    r"(~?/?\.?(?:e10-cli|e10|eteams|weaver)[^\s'\"]*profiles[^\s'\"]*auth(?:[\"'\s\`]|$))"
    r"|([^\s'\"]*profiles[^\s'\"]*/(?:auth|session|cookie|credential)(?:[\"'\s\`]|$))"
    r"|([^\s'\"]*/(?:session|cookie|credential)[^\s'\"]*file(?:[\"'\s\`]|$))"
    r"|([\"'`~][^\s'\"\`]*\.(?:auth|session|cookie|pem|key)(?:[\"'\s\`]|$))",
    re.IGNORECASE)

TODO_PATTERN = re.compile(r"\[?TODO\]?|待补充|待填写|placeholder\s*=|示例填写", re.IGNORECASE)


# ---------------------------------------------------------------------------
# 威胁模型 / 行为图谱 / 技能能力清单（新增）
# ---------------------------------------------------------------------------
# 说明：以下规则用于从 skill 源码/文档中识别运行时行为、威胁分类与能力清单，
#       辅助提交前做「威胁捕获模型视图」与「威胁行为图谱」式自检。
# 注意：C（威胁捕获模型视图）/ D（威胁行为图谱）已重新启用，详见 main() 内调用与 render_report。

# 安装依赖 / 供应链风险
DEPENDENCY_INSTALL_RE = re.compile(
    r"\b(pip\s+install|pip3\s+install|npm\s+install|npm\s+i\b|yarn\s+add|"
    r"pnpm\s+add|pnpm\s+install|composer\s+require|gem\s+install|"
    r"apt-get\s+install|yum\s+install|brew\s+install)\b",
    re.IGNORECASE)

# 命令执行风险（含 eval / system / os.system 等）
COMMAND_EXEC_RE = re.compile(
    r"\b(eval|exec|system|os\.system|subprocess\.call|subprocess\.run|"
    r"subprocess\.Popen|subprocess\.check_output|subprocess\.check_call|"
    r"spawnSync|spawn|execSync|execFileSync|execFile|exec\s*\(|"
    r"child_process|powershell|cmd\.exe|bash\s+-c|sh\s+-c)\b",
    re.IGNORECASE)

# 网络请求与数据外传（避免把 dict.get() 等误判为 HTTP GET）
NETWORK_RE = re.compile(
    r"\b(requests\.(get|post|put|patch|delete)|urllib\.request|http\.client|"
    r"httpx\b|aiohttp\b|axios\.(get|post|put|patch|delete)|fetch\(|"
    r"XMLHttpRequest|curl\b|wget\b|Invoke-WebRequest|Invoke-RestMethod|"
    r"\.(post|put|patch|delete)\s*\()")
EXTERNAL_API_RE = re.compile(
    r"\b(api\.|openapi|external\s+api|third.?party|第三方接口|调用.*接口|"
    r"https?://[^\s'\"]+/(api|v\d+|rest|graphql|webhook))",
    re.IGNORECASE)

# 文件操作（读 / 写 / 删）
FILE_IO_RE = re.compile(
    r"\b(open\s*\(|read_text\(|write_text\(|read_file\(|write_file\(|"
    r"readFileSync|writeFileSync|readFile\(|writeFile\(|fs\.read|fs\.write|"
    r"os\.path\.join|Path\(|with\s+open|\.open\s*\()\b",
    re.IGNORECASE)

# 敏感路径（凭证、系统、隐私数据）
# 注意：这里的 `[^\s'\"]*` 一律写成有界量词 `{0,N}`。
# 无界量词 + 末尾定界在超长单行（压缩 JS / 无换行数据）上会退化成逐起点回溯的 O(n²)，
# 11 MB 单行曾让扫描卡死数百秒；有界后单次尝试被限制在 N 个字符内，整体降为线性。
# 写法约定（性能关键）：每条分支都**先锚定字面量 token**，再向后做「有界尾部 + 定界」校验。
# 反例（切勿回退）：`[^\s'\"]{0,120}(?:\.ssh|\.aws|...)` 这种「可空前缀 + 有界量词」写法，
# 引擎会在每个起点试 120 种前缀长度 → 单次扫描退化为准 O(n·120)，1.6 MB 单行需 90+ 秒。
SENSITIVE_PATH_RE = re.compile(
    # ① 泛微 / eteams 客户端凭证目录（如 ~/.e10-cli/profiles/<x>/auth）
    r"((?:e10-cli|e10|eteams|weaver)[^\s'\"]{0,120}profiles[^\s'\"]{0,120}auth(?:[\"'\s`]|$))"
    # ② .ssh / .aws / .docker 等敏感目录本身，或其下的配置文件（如 .aws/credentials）
    r"|((?:\.ssh|\.aws|\.kube|\.docker|\.gitconfig|\.npmrc|"
    r"\.pypirc|id_rsa|id_dsa|known_hosts|passwd|shadow|sam)"
    r"(?:/[^\s'\"]{0,60})?(?:[\"'\s`]|$))"
    # ③ profiles/<x>/{auth,session,cookie,credential} 形态
    r"|(profiles[^\s'\"]{0,60}/(?:auth|session|cookie|credential)(?:[\"'\s`]|$))",
    re.IGNORECASE)

# 浏览器控制
BROWSER_CONTROL_RE = re.compile(
    r"\b(playwright|puppeteer|selenium|webdriver|chromium|chrome|edge|"
    r"browserType|launch\(|new_context\(|new_page\()\b",
    re.IGNORECASE)

# 沙箱绕过参数
SANDBOX_BYPASS_RE = re.compile(
    r"--no-sandbox|--disable-gpu|--disable-setuid-sandbox|--disable-dev-shm-usage|"
    r"--no-first-run|--single-process|--disable-web-security|--disable-features", re.IGNORECASE)

# dangerouslyDisableSandbox + run_in_background（跨行匹配用）
DISABLE_SANDBOX_FLAG_RE = re.compile(r"dangerouslyDisableSandbox", re.IGNORECASE)
RUN_BACKGROUND_FLAG_RE = re.compile(r"run_in_background", re.IGNORECASE)

# 环境变量读取
ENV_READ_RE = re.compile(
    r"\b(os\.environ|process\.env|env\.|getenv|ENV\[|os\.getenv|"
    r"process\.env\[|dotenv|load_dotenv)\b",
    re.IGNORECASE)

# Prompt 注入 / 诱导用户贴凭证（限定强诱导动词，避免“提供登录地址”等正常说明误报）
PROMPT_CRED_RE = re.compile(
    r"(ETEAMSID|token|session|cookie|password|passwd|secret|credential)\b"
    r"[^。，\n]{0,35}(粘贴|贴到|贴入|贴进|贴出来|发给我|发过来|发送给|"
    r"告诉我|输入.*对话|贴到对话|贴进对话)", re.IGNORECASE)
# 否定/禁止性安全声明（如「不要要求用户直接粘贴 ETEAMSID」），属合规正向约束，非诱导贴凭证
NEGATION_SAFE_RE = re.compile(
    r"(不要|禁止|严禁|不得|切勿|请勿|不可|避免|不允许|拒绝|无须|无需|不必)", re.IGNORECASE)
PROMPT_INJECTION_RE = re.compile(
    r"(忽略.*指令|ignore\s+previous\s+instructions|忽略.*提示|"
    r"\bDAN\b|\bjailbreak\b|越狱|绕过.*限制|你是.*现在.*需要)", re.IGNORECASE)

# 远程脚本下载执行
REMOTE_SCRIPT_EXEC_RE = re.compile(
    r"(curl\s+[^|]*\|\s*(bash|sh|python|python3|powershell)|"
    r"wget\s+.*\|\s*(bash|sh)|"
    r"powershell\s+-enc|iex\s+\(Invoke-WebRequest|"
    r"Invoke-Expression|"
    r"https?://[^\s'\"]+\.(py|sh|ps1|bat|js|mjs)\b)",
    re.IGNORECASE)

# 可疑编码 / 混淆（不再裸匹配长串 base64，避免 URL 路径误报）
OBFUSCATION_RE = re.compile(
    r"(eval\s*\(\s*(atob|btoa|Base64|decodeURIComponent|unescape)|"
    r"atob\s*\(|btoa\s*\(|base64\s+--decode|Base64\.decode|Base64\.encode|"
    r"\\\\x[0-9a-fA-F]{2}|\\\\u[0-9a-fA-F]{4}|"
    r"(\\x[0-9a-fA-F]{2}){8,})", re.IGNORECASE)

# 其他高风险：屏幕截图、键盘记录、剪贴板读取、摄像头/麦克风
OTHER_PRIVACY_RE = re.compile(
    r"\b(screenshot|keylogger|clipboard|camera|microphone|录音|录屏|截屏|"
    r"getUserMedia|navigator\.mediaDevices)\b",
    re.IGNORECASE)

# ---------------------------------------------------------------------------
# 官方 expert-reviewer 对齐补充检测规则（G 组安全 + C 组展示 + A 组结构 + Q 组质量）
# ---------------------------------------------------------------------------

# G02: 危险 shell 命令（rm -rf /、curl|sh、sudo、chmod 777、dd、fork bomb 等）
DANGEROUS_CMD_RE = re.compile(
    r"\brm\s+-rf\s+/(?!\w)|"              # rm -rf /
    r"\brm\s+-rf\s+~(?!\w)|"              # rm -rf ~
    r"\brm\s+-rf\s+\*(?!\w)|"            # rm -rf *
    r":\(\)\s*\{.*;\}\s*:|&?\s*\};:|"     # fork bomb :(){ :|:& };:
    r"\bcurl\s+[^|]*\|\s*(?:bash|sh|python|python3)\b|"
    r"\bwget\s+[^|]*\|\s*(?:bash|sh)\b|"
    r"\bsudo\s+(?:rm|chmod|chown|dd|mkfs|kill|reboot|shutdown)\b|"
    r"\bchmod\s+777\s+/(?!\w)|"          # chmod 777 /
    r"\bdd\s+if=.*of=/dev/|"
    r"\bmkfs\.\w+\s+/dev/|"
    r">\s*/dev/sda\b|"
    r"\bshutdown\s+-[rh]|"
    r"\breboot\b|"
    r"\bkill\s+-9\s+-?\d+\s*$",
    re.IGNORECASE)

# G04a: 开发者个人路径残留（/Users/xxx/、/home/xxx/、C:\Users\xxx\）
PERSONAL_PATH_RE = re.compile(
    r"/Users/[a-zA-Z0-9._-]+/"            # macOS: /Users/tangbin/
    r"|/home/[a-zA-Z0-9._-]+/"           # Linux: /home/user/
    r"|C:\\Users\\[a-zA-Z0-9._-]+\\\\"   # Windows: C:\Users\user\
    r"|C:/Users/[a-zA-Z0-9._-]+/",       # Windows forward slash
    re.IGNORECASE)

# G04b: 其他 IDE / 平台配置目录残留
IDE_DIR_RE = re.compile(
    r"\.cursor/|\.vscode/|\.idea/|\.codebuddy/|\.fleet/|"
    r"\.claude/|\.aider/|\.github/|\.gitlab/|"
    r"\.DS_Store|Thumbs\.db",
    re.IGNORECASE)

# G04c: CDN @latest 未锁定版本（jsdelivr/unpkg/cdnjs 等）
CDN_LATEST_RE = re.compile(
    r"https?://(?:cdn\.jsdelivr\.net|unpkg\.com|cdnjs\.cloudflare\.com|"
    r"cdn\.tailwindcss\.com|esm\.sh)/[^\s'\"]+@latest\b",
    re.IGNORECASE)

# G03: 扩展内网域名 / IP 段（除泛微自用域名外，补充通用内网域名）
# 注意：10.x / 172.16-31.x / 192.168.x 由 B2 IP 检查覆盖，此处仅补充域名
INTERNAL_DOMAIN_RE = re.compile(
    r"\b[\w.-]+\.("
    r"woa\.com|oa\.com|tencent\.com|"
    r"internal|local|localhost|intranet|"
    r"corp|privatesubnet|test\.local"
    r")\b",
    re.IGNORECASE)

# C08: 平台虚假技术承诺（本地AI/离线运行/不上云/数据不出本机 等）
FALSE_PROMISE_RE = re.compile(
    r"本地AI|本地运行|离线运行|不上云|完全离线|"
    r"无需联网|不联网|纯本地|数据不出本机|"
    r"100%.*私密|绝对.*安全|永不泄露|"
    r"完全私有化部署.*无需|零云端|no.*cloud|"
    r"fully\s+offline|local\s+only|runs?\s+locally\s+only",
    re.IGNORECASE)

# BOM 检测：UTF-8 BOM (EF BB BF)
BOM_RE = re.compile(b"\xef\xbb\xbf")
# CRLF 行尾
CRLF_RE = re.compile(b"\r\n")

# 结构化配置文件扩展名（可解析性检查）
STRUCTURED_EXTS = (".json", ".yaml", ".yml", ".toml")


# ---------------------------------------------------------------------------
# 工具函数
# ---------------------------------------------------------------------------

def log(msg):
    sys.stderr.write(msg + "\n")


def extract_frontmatter(content):
    """返回 (frontmatter_text, body_text, ok)。"""
    if not content.startswith("---"):
        return None, content, False
    m = re.match(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", content, re.DOTALL)
    if not m:
        return None, content, False
    return m.group(1), m.group(2), True


def parse_frontmatter_field(fm, key):
    """宽松解析 frontmatter **顶层**单行字段（容忍引号与不整齐缩进）。

    仅认可顶层写法（行首顶格 `key: ...`），不认可嵌套写法——例如
    `metadata:\\n  author: ...` 中的 author 不会被读取（视为缺失）。
    合作方规范：所有字段（name/version/author/description 等）均为顶层，
    不存在 metadata 块；嵌套写法由 check_frontmatter_flat_structure 单独阻断。
    """
    m = re.search(r"^" + re.escape(key) + r"\s*:\s*(.*)$", fm, re.MULTILINE)
    if not m:
        return None
    val = m.group(1).strip()
    # 去掉首尾引号
    if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")):
        val = val[1:-1]
    return val


def get_field(fm, *keys):
    """按顺序读取多个候选字段名，返回首个非空值。

    用于兼容不同平台/规范的字段命名差异，例如：
        get_field(fm, "display_name", "displayName")
        # 优先取 display_name，缺失时回退取 displayName
    全部候选都缺失则返回 None。
    """
    for k in keys:
        if not k:
            continue
        val = parse_frontmatter_field(fm, k)
        if val:
            return val, k  # 同时返回命中字段名用于提示
    return None, (keys[0] if keys else "")


def check_frontmatter_flat_structure(fm, checks):
    """A2m frontmatter 顶层结构（禁止 metadata 嵌套）。

    合作方规范：所有字段均为顶层，frontmatter 中不应出现 `metadata:` 块。
    若出现嵌套且其中包含关键字段（author/version/name/description 等），
    检测器按顶层读取会判缺失，因此嵌套写法必须 BLOCK 纠正；metadata 块内
    无关键字段则 WARN 提示提升到顶层。
    """
    meta_idx = None
    for i, ln in enumerate(fm.splitlines()):
        if re.match(r"^metadata\s*:", ln):
            meta_idx = i
            break
    if meta_idx is None:
        return
    lines = fm.splitlines()
    inner = []
    for ln in lines[meta_idx + 1:]:
        if ln.strip() and not (ln.startswith(" ") or ln.startswith("\t")):
            break
        if ln.strip():
            inner.append(ln.strip())
    keys_in_meta = re.findall(r"^([\w-]+)\s*:", "\n".join(inner), re.MULTILINE)
    CRITICAL_KEYS = ("author", "version", "name", "description", "description_zh",
                     "description_en", "display_name", "display_name_en")
    blocked = [k for k in CRITICAL_KEYS if k in keys_in_meta]
    if blocked:
        checks.append(Check(
            "基础合规", "frontmatter 顶层结构(禁止metadata嵌套)", "FAIL", "BLOCK",
            f"frontmatter 不允许 `metadata:` 嵌套块，字段 {blocked} 必须写在顶层。"
            "嵌套写法不会被识别，将视为字段缺失。",
            suggestion="删除 `metadata:` 层级，改为顶层字段，例如：\n"
                       "author: 泛微网络科技股份有限公司\nversion: 1.0.0"))
    else:
        checks.append(Check(
            "基础合规", "frontmatter 顶层结构(禁止metadata嵌套)", "WARN", "MEDIUM",
            "frontmatter 中不应包含 `metadata:` 嵌套块；所有字段（name/version/author 等）均为顶层字段。",
            suggestion="删除 `metadata:` 层级，把其中的字段提升到顶层。"))


def check_frontmatter_yaml_syntax(fm, checks):
    """A2n frontmatter YAML 语法真解析（BLOCK）。

    平台上传时用真 YAML 解析器（Go gopkg.in/yaml）解析 SKILL.md frontmatter，
    典型报错「yaml: line N: mapping values are not allowed in this context」，
    常见触发：① 一行挤多个键值对（`name: xxx display_name: yyy`，粘贴丢换行）；
    ② 值中含未加引号的 `: `（如 `description: 使用 xxx: yyy`）；③ Tab 缩进。
    本检查双层实现：PyYAML 可用时真解析并精确报行号；否则降级为启发式扫描，
    覆盖上述三类高频错误（零第三方依赖，保证可移植）。
    """
    lines = fm.splitlines()
    errors = []

    try:
        import yaml  # noqa: F401
        has_yaml = True
    except ImportError:
        has_yaml = False

    if has_yaml:
        try:
            data = yaml.safe_load(fm)
            if data is None:
                errors.append("frontmatter 解析结果为空（仅含注释或空白）")
            elif not isinstance(data, dict):
                errors.append(f"frontmatter 解析结果不是键值映射（得到 {type(data).__name__}），"
                              "顶层必须是一组 `key: value`")
        except yaml.YAMLError as e:
            mark = getattr(e, "problem_mark", None)
            if mark is not None and mark.line < len(lines):
                ctx = lines[mark.line].strip()[:60]
                errors.append(f"YAML 解析失败（第 {mark.line + 1} 行，near: `{ctx}`）："
                              f"{getattr(e, 'problem', '') or str(e)}")
            else:
                errors.append(f"YAML 解析失败：{e}")
    else:
        # 降级启发式：覆盖 Go yaml 报 mapping values are not allowed 的高频场景
        for i, line in enumerate(lines, start=1):
            stripped = line.strip()
            if not stripped or stripped.startswith("#"):
                continue
            indent = line[:len(line) - len(line.lstrip())]
            if "\t" in indent:
                errors.append(f"第 {i} 行: 行首使用 Tab 缩进（YAML 仅允许空格缩进）: `{stripped[:60]}`")
                continue
            # 仅检查顶层键行（行首顶格 key: value）
            m = re.match(r"^([A-Za-z_][\w-]*)\s*:\s*(.*)$", line)
            if not m:
                continue
            key, val = m.group(1), m.group(2).strip()
            if not val:
                continue
            quoted = (val.startswith('"') and val.endswith('"')) or \
                     (val.startswith("'") and val.endswith("'"))
            if quoted:
                # 整体引号包裹的值内部 `: ` 合法
                continue
            # ① 一行多键：值中再出现 ` word: `（键名后冒号+空格）
            mm = re.search(r"(?:^|\s)([A-Za-z_][\w-]*)\s*:\s", val)
            if mm:
                errors.append(
                    f"第 {i} 行: 一行内出现多个键值对（`{key}: … {mm.group(1)}: …`）。"
                    "YAML 不允许在值中继续写键——这正是平台报 "
                    "`mapping values are not allowed in this context` 的最常见原因"
                    "（多为粘贴时丢失换行）。"
                )
                continue
            # ② 值中含裸冒号+空格（如 `description: 支持 xx: yy 模式`）
            if re.search(r":\s", val) and not re.match(r"https?://", val):
                errors.append(
                    f"第 {i} 行: `{key}` 的值中含未加引号的冒号+空格（`{stripped[:60]}`），"
                    "会被 YAML 解析器误判为新键。请用引号包裹整个值，或改用中文全角冒号「：」。"
                )

    if errors:
        checks.append(Check(
            "基础合规", "frontmatter YAML 语法", "FAIL", "BLOCK",
            "SKILL.md frontmatter 存在 YAML 语法错误，平台上传时会直接报 "
            "「frontmatter 无法解析 / mapping values are not allowed in this context」。",
            locations=errors[:10],
            suggestion=(
                "常见修复：① 每个字段独占一行（`key: value`），不要把多个字段挤在一行；"
                "② 值中含英文冒号+空格时用引号包裹整个值（`description: '支持 xx: yy'`）；"
                "③ 缩进只用空格，不用 Tab；④ 冒号后必须有一个空格。"
            )
        ))
    else:
        checks.append(Check(
            "基础合规", "frontmatter YAML 语法", "PASS", "INFO",
            "frontmatter YAML 语法校验通过"
            + ("（PyYAML 真解析）" if has_yaml else "（启发式扫描，未安装 PyYAML）") + "。"
        ))


# 单文件参与正则扫描的最大字节数（超出部分截断）。
# 背景：超大文本（如 11 MB 单行数据文件）会让扫描耗时从毫秒级膨胀到分钟级。
MAX_SCAN_FILE_BYTES = 4 * 1024 * 1024


def iter_text_files(root: Path):
    """遍历 root 下所有文本文件，yield (rel_path, text)。

    超大文件按 MAX_SCAN_FILE_BYTES 截断后再参与扫描：既避免病态耗时，
    又不影响检测结论（威胁特征几乎总在前若干 KB 内出现；扫描顺序覆盖全部文件）。
    """
    for p in root.rglob("*"):
        if not p.is_file():
            continue
        if p.suffix.lower() not in TEXT_EXTS:
            continue
        try:
            text = p.read_text(encoding="utf-8")
        except (UnicodeDecodeError, OSError):
            # 二进制或无法解码，跳过
            continue
        except MemoryError:
            # 超大/异常文件：直接跳过，避免拖垮整体扫描
            continue
        if len(text.encode("utf-8", errors="ignore")) > MAX_SCAN_FILE_BYTES:
            text = _truncate_chars(text, MAX_SCAN_FILE_BYTES)
        yield p.relative_to(root), text


def _truncate_chars(text: str, max_bytes: int) -> str:
    """按字节预算安全截断文本（不切断多字节字符的语义，直接按字符切片后解码回退）。"""
    encoded = text.encode("utf-8", errors="ignore")
    if len(encoded) <= max_bytes:
        return text
    return encoded[:max_bytes].decode("utf-8", errors="ignore")


# 单行参与正则扫描的最大字符数（超出部分截断，行号保持不变）。
# 背景：部分检测正则含 `[^\\s'\"]*` 这类可回溯量词，在超长单行（压缩 JS、
# 无换行数据文件）上会退化成逐起点回溯的 O(n²)，11 MB 单行曾导致扫描卡死数百秒。
# 截断后行号一一对应，正常代码行远小于阈值，检测结论不受影响。
SCAN_LINE_MAX_CHARS = 4000


def scan_lines(text: str, max_chars: int = SCAN_LINE_MAX_CHARS):
    """返回与 splitlines() 行号一一对应的截断行列表，供所有逐行正则扫描使用。"""
    return [ln if len(ln) <= max_chars else ln[:max_chars]
            for ln in text.splitlines()]


def is_safe_ip(ip):
    """判断是否为明显占位/回环地址，避免误报。"""
    if SAFE_IP_PATTERNS.match(ip):
        return True
    # 数字段中带 x / * / 0 占据多段 视为占位
    if re.search(r"\bx\b|\*|example", ip):
        return True
    return False


# ---------------------------------------------------------------------------
# 检测主流程
# ---------------------------------------------------------------------------

class Check:
    def __init__(self, category, name, status, severity, detail, locations=None, suggestion=None, extra=None):
        self.category = category
        self.name = name
        self.status = status          # PASS / FAIL / WARN / INFO
        self.severity = severity      # BLOCK / HIGH / MEDIUM / LOW / INFO
        self.detail = detail
        self.locations = locations or []
        self.suggestion = suggestion
        self.extra = extra            # 可选：结构化附加数据（如依赖市场可达性）

    def to_dict(self):
        return {
            "category": self.category,
            "name": self.name,
            "status": self.status,
            "severity": self.severity,
            "detail": self.detail,
            "locations": self.locations,
            "suggestion": self.suggestion,
            "extra": self.extra,
        }


def prepare_skill_source(target: Path):
    """
    根据输入类型准备 skill 源码目录。
    返回 (skill_root: Path, source_type: str, cleanup: bool, error: str, zip_raw_names: list)
    source_type ∈ {'directory','zip','markdown'}
    zip_raw_names: 对 zip 输入返回原始 namelist（保留 zip 条目中的反斜杠等字面字符），
                   用于 A1a 跨平台准确判定；其他类型为 None。
    """
    if not target.exists():
        return None, None, False, f"文件/目录不存在: {target}", None

    if target.is_dir():
        return target, "directory", False, None, None

    suffix = target.suffix.lower()
    if suffix == ".zip":
        tmp = tempfile.mkdtemp(prefix="weaver_skill_")
        zip_raw_names = []
        try:
            # 文件名编码鲁棒处理：优先 gbk（兼容 Windows 打包的中文名），
            # 失败则依次回退 utf-8 / 自动（Python 依据 UTF-8 标志位选 utf-8，否则 cp437），
            # 避免 gbk 无法解码某些字节（如 UTF-8 文件名）时直接崩溃。
            zf = None
            last_err = None
            for enc in ("gbk", "utf-8", None):
                try:
                    zf = zipfile.ZipFile(target, "r", metadata_encoding=enc)
                    break
                except UnicodeDecodeError as e:
                    last_err = e
                    continue
            if zf is None:
                shutil.rmtree(tmp, ignore_errors=True)
                return None, None, False, f"压缩包文件名解码失败: {last_err}", None
            with zf:
                # 防止 zip 路径穿越
                zip_raw_names = list(zf.namelist())
                for name in zip_raw_names:
                    if name.startswith("/") or ".." in name.split("/"):
                        return None, None, False, f"压缩包含不安全路径: {name}", None
                zf.extractall(tmp)
        except zipfile.BadZipFile:
            shutil.rmtree(tmp, ignore_errors=True)
            return None, None, False, "压缩包损坏或不是合法的 zip 文件", None
        return Path(tmp), "zip", True, None, zip_raw_names

    if suffix in (".md", ".markdown"):
        # 单 Markdown 文件：视作 SKILL.md 候选，临时目录承载
        tmp = tempfile.mkdtemp(prefix="weaver_skill_")
        shutil.copy(target, Path(tmp) / "SKILL.md")
        return Path(tmp), "markdown", True, None, None

    return None, None, False, f"不支持的输入类型: {suffix}（仅支持 .zip / .md / 目录）", None


def find_skill_md(root: Path):
    """在 root 下查找 SKILL.md，返回其所在目录与路径。
    兼容 Windows 反斜杠路径：反斜杠在 Unix 上是文件名里的字面字符，
    需对路径字符串归一化（\\ -> /）后判断末段是否为 SKILL.md。"""
    def _is_skill_md(p: Path) -> bool:
        return str(p).replace("\\", "/").split("/")[-1] == "SKILL.md"
    candidates = [p for p in root.rglob("*") if p.is_file() and _is_skill_md(p)]
    if not candidates:
        return None, None
    if len(candidates) == 1:
        p = candidates[0]
        return p, p.parent
    # 多个：优先选取层级最浅的（最接近 root 的）
    candidates.sort(key=lambda p: len(p.parts))
    p = candidates[0]
    return p, p.parent


def _duplicate_skill_md_at_same_level(root: Path):
    """返回同一目录下出现多个 SKILL.md 的重复路径列表（平台 450019 的真正拒收情形）。"""
    def _is_skill_md(p: Path) -> bool:
        return str(p).replace("\\", "/").split("/")[-1] == "SKILL.md"
    by_parent = {}
    for p in root.rglob("*"):
        if p.is_file() and _is_skill_md(p):
            by_parent.setdefault(p.parent, []).append(p)
    dups = []
    for paths in by_parent.values():
        if len(paths) > 1:
            dups.extend(str(x) for x in paths)
    return dups


# ---------------------------------------------------------------------------
# 各维度检测
# ---------------------------------------------------------------------------

def check_package_constraints(root: Path, platform: str = "weaver", target: Path = None):
    """A. 打包约束红线：① 文件总数（按 platform 取阈值，weaver ≤ 300 / qwenwork < 1000）；
    ③ 禁止任意位置含二进制文件；
    ④ ZIP 包内只允许纯文本格式文件；
    ⑤ ZIP 包整体大小（仅 qwenwork，硬性 ≤ 10 MB）。

    target 为原始输入路径：zip/md 输入时直接 stat 真实文件体积（权威值）；
    目录输入时无 archive 可测，降级为「解压后文件合计」并在结论中标注为近似值。

    实现稳健性：
    - 允许清单法 + 二进制魔数嗅探双保险：扩展名不在纯文本清单内的文件，
      再做内容嗅探（空字节 / 文件头魔数），确保「文本扩展名却实为二进制」也漏不掉。
    - 无扩展名的纯文本文件（LICENSE / Makefile 等）通过已知名单或嗅探放行，
      避免因命名习惯误杀。
    - macOS 打包垃圾（.DS_Store / ._xxx / __MACOSX/）一律判为违规二进制。
    """
    checks = []
    spec = PLATFORMS.get(platform, PLATFORMS["weaver"])
    files = [p for p in root.rglob("*") if p.is_file()]
    total = len(files)
    max_entries = spec["max_entries"]
    entry_rule_name = (f"文件总数 < {max_entries + 1}" if max_entries == 999
                       else f"文件总数 ≤ {max_entries}")

    # --- ① 文件总数（按 platform 阈值） ---
    if total > max_entries:
        checks.append(Check(
            "基础合规", entry_rule_name, "FAIL", "BLOCK",
            f"包内文件总数为 {total}，超过 {spec['label']} 平台上限 {max_entries} 个，"
            f"上传将被拒收。",
            suggestion="清理冗余文件（如 __pycache__、.DS_Store、临时产物），"
                       "或将大文件外链到 SKILL.md 而非塞进包里。",
        ))
    else:
        checks.append(Check(
            "基础合规", entry_rule_name, "PASS", "INFO",
            f"包内文件总数 {total}（≤ {max_entries}）。",
        ))

    # --- ⑤ ZIP 包大小（仅 qwenwork，硬性 ≤ 10 MB） ---
    if spec.get("max_zip_mb"):
        max_bytes = spec["max_zip_mb"] * 1024 * 1024
        # 优先使用真实归档文件体积（zip/md 输入）；目录输入降级为「文件合计」近似值
        zip_note = ""
        if target is not None and target.is_file():
            try:
                total_bytes = target.stat().st_size
                zip_note = f"（实测归档文件 {target.name}）"
            except OSError:
                total_bytes = None
        else:
            total_bytes = None
        if total_bytes is None:
            total_bytes = 0
            for p in files:
                try:
                    total_bytes += p.stat().st_size
                except OSError:
                    pass
            zip_note = "（目录输入，无归档文件，取包内文件合计近似）"
        if total_bytes > max_bytes:
            checks.append(Check(
                "基础合规", f"zip 包大小 ≤ {spec['max_zip_mb']} MB", "FAIL", "BLOCK",
                f"包体积 {total_bytes / 1024 / 1024:.2f} MB {zip_note}，"
                f"超过 {spec['label']} 平台硬性上限 {spec['max_zip_mb']} MB，上传将被拒收。",
                suggestion=f"瘦身包体积：图片/示例文档外链到 SKILL.md，"
                          f"剔除冗余文件，控制在 {spec['max_zip_mb']} MB 以内。",
            ))
        else:
            checks.append(Check(
                "基础合规", f"zip 包大小 ≤ {spec['max_zip_mb']} MB", "PASS", "INFO",
                f"包体积 {total_bytes / 1024 / 1024:.2f} MB {zip_note}"
                f"（≤ {spec['max_zip_mb']} MB）。",
            ))

    # --- ②/③ 禁止二进制 / 仅纯文本 ---
    violations = []
    for p in files:
        rel = str(p.relative_to(root))
        parts = Path(rel).parts
        name = p.name
        # macOS 打包垃圾（二进制/无意义）
        if name == ".DS_Store" or name.startswith("._") or "__MACOSX" in parts:
            violations.append((rel, "macOS 垃圾/二进制文件（禁止打包）"))
            continue
        ext = p.suffix.lower().lstrip(".")
        if ext in TEXT_EXT_ALLOWLIST:
            if _sniff_binary(p):
                violations.append((rel, "扩展名虽为纯文本，但内容为二进制（疑似伪装）"))
            continue
        if ext == "":
            if name.lower() in KNOWN_TEXT_NOEXT:
                continue
            if _sniff_binary(p):
                violations.append((rel, "无扩展名且内容为二进制"))
            # 无扩展名但内容为文本 → 放行（避免误杀 LICENSE 等）
            continue
        # 扩展名不在纯文本允许清单
        if ext in BINARY_EXT_BLACKLIST or _sniff_binary(p):
            violations.append((rel, "二进制文件（禁止）"))
        else:
            violations.append((rel, "非纯文本文件（不在允许清单）"))

    if violations:
        locs = [f"{rel} —— {reason}" for rel, reason in violations[:30]]
        tail = f"（共 {len(violations)} 个，仅展示前 30）" if len(violations) > 30 else ""
        checks.append(Check(
            "基础合规", "仅含纯文本文件（禁止二进制）", "FAIL", "BLOCK",
            f"包内存在 {len(violations)} 个非纯文本/二进制文件，违反"
            f"「ZIP 包内只允许纯文本文件」规定{tail}：",
            locations=locs,
            suggestion="移除所有二进制/压缩/媒体文件，仅保留 .md/.py/.js/.txt/.json/.yaml 等纯文本。",
        ))
    else:
        checks.append(Check(
            "基础合规", "仅含纯文本文件（禁止二进制）", "PASS", "INFO",
            "包内所有文件均为允许清单内的纯文本格式。",
        ))

    return checks


def check_skill_md_naming(root: Path):
    """A. SKILL.md 文件名大小写精确性：必须是精确大写 SKILL.md，
    不允许 skill.md / Skill.md / Skill.MD 等任何大小写变体。

    平台/工具以区分大小写方式匹配入口文件名，变体不会被识别为 skill 入口，
    等于「无 SKILL.md」。本检查给出明确、可操作的判定与重命名建议，
    优于笼统的「未找到 SKILL.md」。
    """
    checks = []
    md_variants = [p for p in root.rglob("*") if p.is_file() and p.name.lower() == "skill.md"]
    exact = [p for p in md_variants if p.name == "SKILL.md"]
    wrong_case = [str(p.relative_to(root)) for p in md_variants if p.name != "SKILL.md"]

    if exact and not wrong_case:
        checks.append(Check(
            "基础合规", "SKILL.md 文件名大小写精确", "PASS", "INFO",
            "已找到精确大写的 SKILL.md，且无大小写变体。",
        ))
    elif not exact and wrong_case:
        checks.append(Check(
            "基础合规", "SKILL.md 文件名大小写精确", "FAIL", "BLOCK",
            f"未找到精确大写的 SKILL.md，但发现大小写变体：{', '.join(wrong_case)}。"
            "平台要求入口文件名必须精确为 SKILL.md（区分大小写），变体将不被识别为 skill 入口。",
            locations=wrong_case,
            suggestion="将变体文件重命名为 SKILL.md（首字母大写、后缀全大写 MD）。",
        ))
    elif exact and wrong_case:
        checks.append(Check(
            "基础合规", "SKILL.md 文件名大小写精确", "FAIL", "BLOCK",
            f"同时存在精确 SKILL.md 与大小写变体 {', '.join(wrong_case)}，"
            "可能导致平台/工具加载歧义或重复打包。",
            locations=wrong_case,
            suggestion="删除多余的大小写变体，仅保留 SKILL.md。",
        ))
    # 两者皆无：交给 check_structure 的「SKILL.md 存在」检查处理，不重复添加。
    return checks


def check_structure(root: Path, skill_md: Path, skill_dir: Path, source_type: str, zip_raw_names=None, platform: str = "weaver"):
    """A. 基础合规检测。
    默认 platform="weaver"（泛微生态，保持向后兼容）；platform="qwenwork" 时启用千问专属规则。
    """
    checks = []
    spec = PLATFORMS.get(platform, PLATFORMS["weaver"])

    # A1 文件/SKILL.md 存在
    if skill_md is None:
        # 若已存在大小写变体（skill.md/Skill.md 等），精确大小写检查已给出 BLOCK，
        # 此处不再重复「未找到 SKILL.md」，避免提示歧义。
        has_case_variant = any(
            p.is_file() and p.name.lower() == "skill.md" and p.name != "SKILL.md"
            for p in root.rglob("*")
        )
        if not has_case_variant:
            checks.append(Check(
                "基础合规", "SKILL.md 存在", "FAIL", "BLOCK",
                "未找到 SKILL.md，这不是一个标准 WorkBuddy skill 包。"
                + ("（单 Markdown 文件未包含 frontmatter，或压缩包内无 SKILL.md）" if source_type in ("markdown", "zip") else ""),
                suggestion="在 skill 根目录放置 SKILL.md（含 YAML frontmatter 与正文说明）。"
            ))
        return checks, None

    content = skill_md.read_text(encoding="utf-8")
    checks.append(Check("基础合规", "SKILL.md 存在", "PASS", "INFO", f"已找到: {skill_md.name}"))

    # A1a Windows 反斜杠路径（打包安全，阻断性）
    # skillhub 上传校验运行在 Unix 环境，反斜杠在 zip 条目路径里会被视为文件名中的
    # 非法/不安全字符，整包被判「文件路径不安全」而拒收。常见于 Windows 资源管理器
    # 直接拖拽打包（中文 Windows 易写出反斜杠路径）。请用 `zip -r xxx.zip xxx/`
    # 或 7-Zip「标准 zip」模式重新打包。
    #
    # 实现稳健性：必须基于 zip 原始条目名判断，不能检查解压后的 OS 路径——在 Windows
    # 上 OS 路径天然使用 \，会 100% 误报；在 Unix 上目录输入的文件名若含 \ 也会误报。
    # prepare_skill_source 已把 zip.namelist() 原始字符串传入 zip_raw_names。
    if source_type == "zip" and zip_raw_names is not None:
        _bs = [n for n in zip_raw_names if "\\" in n]
        if _bs:
            _sample = _bs[:5]
            checks.append(Check(
                "基础合规", "Windows 反斜杠路径分隔符", "FAIL", "BLOCK",
                f"检测到 {len(_bs)} 个 zip 条目路径使用 Windows 反斜杠 `\\` 作为分隔符"
                f"（示例：{'; '.join(_sample)}" + (" …" if len(_bs) > 5 else "") + "）。"
                "skillhub 的上传校验运行在 Unix 环境，会把反斜杠判定为文件名中的非法/不安全字符，"
                "整包将被标记为「文件路径不安全」而无法上传。",
                suggestion=(
                    "在 Windows 上重新打包：用 `zip -r weaver-e10-doc.zip weaver-e10-doc/` "
                    "或 7-Zip 的「标准 zip」模式；不要使用资源管理器直接拖拽"
                    "（中文 Windows 易写出反斜杠路径）。"
                )
            ))
        else:
            checks.append(Check("基础合规", "Windows 反斜杠路径分隔符", "PASS", "INFO",
                                "未检测到 Windows 反斜杠路径分隔符，所有 zip 条目均为 Unix 正斜杠路径。"))
    elif source_type == "directory":
        # 目录输入不是 zip，无法从 OS 路径推断打包后的 zip 路径分隔符，跳过本检查。
        checks.append(Check(
            "基础合规", "Windows 反斜杠路径分隔符", "PASS", "INFO",
            "目录输入：跳过 zip 路径分隔符检查（打包时请使用 Unix 正斜杠 `/`）。"
        ))

    # A1b 目录层级限制（打包结构合规，阻断性）
    # Skill 包仅支持两级目录结构：根目录/二级目录/文件。以 SKILL.md 所在目录为 skill 根，
    # 任何条目相对深度超过 2（即出现三级及以上子目录）即判 FAIL——平台解析会异常。
    # 构建产物（__pycache__/.pyc）与隐藏项（.git、.DS_Store 等）不计入层级判定。
    if source_type in ("zip", "directory"):
        _deep = []
        for _dp, _dirs, _files in os.walk(skill_dir):
            _dirs[:] = [d for d in _dirs if d != "__pycache__" and not d.startswith(".")]
            for _f in sorted(_files):
                if _f.endswith(".pyc") or _f.startswith("."):
                    continue
                _rel = Path(_dp).relative_to(skill_dir) / _f
                if len(_rel.parts) > 2:
                    _deep.append(str(_rel))
        if _deep:
            _sample = _deep[:5]
            checks.append(Check(
                "基础合规", "目录层级不超过两级", "FAIL", "BLOCK",
                f"检测到 {len(_deep)} 个条目超过两级目录结构（根目录/二级目录/文件）"
                f"（示例：{'; '.join(_sample)}" + (" …" if len(_deep) > 5 else "") + "）。"
                "Skill 包仅支持两级目录结构，三级及以上子目录会导致平台解析异常。",
                suggestion=(
                    "将深层文件上移到二级目录，仅保留「根目录/二级目录/文件」结构，"
                    "例如 scripts/ 或 references/ 下直接放文件，不要再嵌套子目录。"
                )
            ))
        else:
            checks.append(Check("基础合规", "目录层级不超过两级", "PASS", "INFO",
                                "所有条目均符合两级目录结构（根目录/二级目录/文件）。"))

    # A1b SKILL.md 唯一性（平台 450019 的真正拒收情形：同一层级出现多个 SKILL.md）
    # 说明：外层包裹目录（如 weaver-e10-xxx/ 单一包裹层）是允许的，平台取最浅层 SKILL.md 作为根。
    #       仅在「同一目录下存在多个 SKILL.md」时才无法确定根目录，会被拒（450019）。
    if source_type in ("zip", "directory"):
        dup = _duplicate_skill_md_at_same_level(root)
        if dup:
            checks.append(Check(
                "基础合规", "SKILL.md 唯一性(450019)", "FAIL", "BLOCK",
                f"在同一目录下发现多个 SKILL.md，平台无法确定根目录，会拒绝（450019）。"
                f"重复路径：{'; '.join(dup[:6])}" + (" …" if len(dup) > 6 else ""),
                suggestion="每个 skill 包只保留一个 SKILL.md（移除多余的副本或合并）。"
            ))
        else:
            checks.append(Check("基础合规", "SKILL.md 唯一性(450019)", "PASS", "INFO",
                                "SKILL.md 唯一，无同级重复。"))

    # A2 frontmatter
    fm, body, fm_ok = extract_frontmatter(content)
    if not fm_ok:
        checks.append(Check(
            "基础合规", "YAML frontmatter 完整", "FAIL", "BLOCK",
            "SKILL.md 缺少以 `---` 包裹的 YAML frontmatter。",
            suggestion="在文件开头使用:\n---\nname: xxx\ndescription: xxx\n---"
        ))
        return checks, (None, body)
    checks.append(Check("基础合规", "YAML frontmatter 完整", "PASS", "INFO", "frontmatter 解析成功"))

    # A2n frontmatter YAML 语法真解析（平台用真 YAML 解析器，语法错误直接拒收）
    check_frontmatter_yaml_syntax(fm, checks)

    # A2m frontmatter 顶层结构（禁止 metadata 嵌套；author/version 等必须为顶层字段）
    check_frontmatter_flat_structure(fm, checks)

    # A3 name
    name = parse_frontmatter_field(fm, "name")
    if not name:
        checks.append(Check("基础合规", "name 字段", "FAIL", "BLOCK",
                            "frontmatter 缺少 `name` 字段。",
                            suggestion="补充 name: your-skill-name（hyphen-case）。"))
    else:
        if not re.match(r"^[a-z0-9-]+$", name):
            checks.append(Check("基础合规", "name 命名规范", "FAIL", "BLOCK",
                                f"name='{name}' 必须为 hyphen-case（仅小写字母、数字、连字符）。",
                                suggestion="改为小写连字符命名，如 weaver-skill-detector。"))
        elif name.startswith("-") or name.endswith("-") or "--" in name:
            checks.append(Check("基础合规", "name 命名规范", "FAIL", "BLOCK",
                                f"name='{name}' 不能以连字符开头/结尾，也不能含连续连字符。",
                                suggestion="修正命名。"))            
        elif len(name) < 3 or len(name) > 64:
            checks.append(Check("基础合规", "name 命名规范", "FAIL", "BLOCK",
                                f"name='{name}' 长度 {len(name)}，必须在 3-64 个字符之间。",
                                suggestion="调整 name 长度至 3-64 个字符。"))
        else:
            checks.append(Check("基础合规", "name 命名规范", "PASS", "INFO",
                                f"name='{name}' 合法（字符集/首尾/连字符/长度 3-64 均通过）"))

    # A3b name 前缀（按 platform 区分：weaver 必须 weaver- 开头；qwenwork 无品牌前缀要求）
    if name:
        if platform == "weaver":
            if not name.startswith("weaver"):
                checks.append(Check(
                    "基础合规", "name 前缀(泛微强制)", "FAIL", "BLOCK",
                    f"name='{name}' 必须以 `weaver` 开头。泛微相关 skill 的唯一标识必须以 weaver 作为前缀。",
                    suggestion="将 name 改为以 weaver 开头，如 weaver-e10-xxx。"))
            else:
                checks.append(Check("基础合规", "name 前缀(泛微强制)", "PASS", "INFO",
                                    f"name='{name}' 以 weaver 开头"))
        elif platform == "qwenwork":
            # 千问硬性：仅小写字母、数字、连字符，首尾不能是连字符，≤ 64 字符（沿用 A3 命名规范）
            # 千问另要求 name 与目录名完全一致（精确匹配在 A4b 单独触发）
            if len(name) > 64:
                checks.append(Check(
                    "基础合规", "name 长度(千问硬性 ≤ 64)", "FAIL", "BLOCK",
                    f"name='{name}' 长度 {len(name)}，超过千问硬性上限 64 字符。",
                    suggestion="精简 name 至 64 字符以内。"))

    # A4 千问规范：name 与目录名完全一致（精确路径匹配 <name>/SKILL.md，硬性）
    # 三种输入形态都能判定：
    #   zip       → 用原始 namelist 精确比对 "<name>/SKILL.md"
    #   directory → 用 SKILL.md 所在目录名比对 name（与解压后必须保留同名目录等价）
    #   markdown  → 单文件输入无目录结构，不做该项判定
    if name and platform == "qwenwork":
        if source_type == "zip" and zip_raw_names:
            # 千问硬性：必须存在精确路径 <name>/SKILL.md（区分大小写、区分路径分隔符）
            target_path = f"{name}/SKILL.md"
            has_exact = any(
                n == target_path or n == f"./{target_path}"
                for n in zip_raw_names
            )
            if not has_exact:
                checks.append(Check(
                    "基础合规", "千问硬性 <name>/SKILL.md 精确路径", "FAIL", "BLOCK",
                    f"千问硬性要求压缩包内必须存在精确路径 `<name>/SKILL.md`（即 `{target_path}`），"
                    f"且目录名与填写的名称完全一致。这是千问平台最高频的失败原因——"
                    f"macOS Finder 右键压缩文件会得到 `SKILL.md` 而不是 `<name>/SKILL.md`，会被直接拒收。",
                    suggestion=(
                        f"在命令行执行：`zip -r my-skill.zip {name}/`（带上外层目录一起压缩），\\"
                        f"不要使用 Finder 的「压缩 N 项」只压缩内部文件。"
                    )
                ))
            else:
                checks.append(Check(
                    "基础合规", "千问硬性 <name>/SKILL.md 精确路径", "PASS", "INFO",
                    f"精确路径 `{target_path}` 命中。"
                ))
        elif source_type == "directory" and skill_dir is not None:
            # 目录输入：无 zip namelist 可比对，改为校验「SKILL.md 所在目录名 == name」，
            # 与 zip 场景的 <name>/SKILL.md 等价（解压后同样必须保留外层同名目录）。
            dir_name = skill_dir.name
            if dir_name != name:
                checks.append(Check(
                    "基础合规", "千问硬性 <name>/SKILL.md 精确路径", "FAIL", "BLOCK",
                    f"SKILL.md 所在目录名为 `{dir_name}`，与 frontmatter 的 name=`{name}` 不一致。"
                    f"千问硬性要求入口路径为 `<name>/SKILL.md`，解压后同样必须保留这层同名目录，"
                    f"否则会被直接拒收。",
                    suggestion=f"把 SKILL.md 放进以 `{name}` 命名的目录后再打包："
                               f"`mkdir -p {name} && mv SKILL.md {name}/ && zip -r {name}.zip {name}/`"
                ))
            else:
                checks.append(Check(
                    "基础合规", "千问硬性 <name>/SKILL.md 精确路径", "PASS", "INFO",
                    f"SKILL.md 位于 `<name>/` 目录（`{name}/SKILL.md`）命中。"
                ))

    # A4 description
    desc = parse_frontmatter_field(fm, "description")
    if not desc:
        checks.append(Check("基础合规", "description 字段", "FAIL", "BLOCK",
                            "frontmatter 缺少 `description` 字段。",
                            suggestion="补充 description，写明技能做什么、何时触发。"))
    else:
        if len(desc) > 1024:
            checks.append(Check("基础合规", "description 长度(1-1024)", "FAIL", "BLOCK",
                                f"description 长度 {len(desc)} 字符，超过 1024 字符上限。",
                                suggestion="精简 description 至 1024 字符以内；详细内容放入 description_zh/description_en 或正文。"))
        elif "<" in desc or ">" in desc:
            checks.append(Check("基础合规", "description 无尖括号", "WARN", "LOW",
                                "description 包含尖括号 `<` 或 `>`。平台规范未禁止，但建议改为全角〈〉或直接移除占位符，"
                                "避免个别 YAML 解析器对未加引号的尖括号标量产生误判。",
                                suggestion="将 <> 占位符改为中文全角〈〉，或移除；确保 YAML 中 description 用单引号包裹。"))
        elif len(desc) < 20:
            checks.append(Check("基础合规", "description 充分性", "WARN", "MEDIUM",
                                f"description 仅 {len(desc)} 字，过于简略，不利于触发匹配。",
                                suggestion="补全为 1-3 句：技能能力 + 触发场景。"))
        else:
            checks.append(Check("基础合规", "description 充分性", "PASS", "INFO",
                                f"description 长度 {len(desc)} 字，内容充分。"))

    # A5 agent_created 标志
    if parse_frontmatter_field(fm, "agent_created") != "true":
        checks.append(Check("基础合规", "agent_created 标志", "WARN", "LOW",
                            "缺少 `agent_created: true`（由 WorkBuddy 生成的技能建议带此标志，便于后续管理）。",
                            suggestion="在 frontmatter 增加 agent_created: true"))
    else:
        checks.append(Check("基础合规", "agent_created 标志", "PASS", "INFO", "已包含 agent_created: true"))

    # A5b 中文展示名（按 platform 区分字段名 + 前缀要求）
    cn_name_field = spec["name_cn_field"]
    cn_aliases = tuple(filter(None, [cn_name_field, "display_name", "displayName"]))
    cn_name, cn_hit_field = get_field(fm, *cn_aliases)
    cn_prefix_required = spec.get("name_cn_prefix_required")
    if not cn_name:
        checks.append(Check(
            "基础合规", f"{cn_name_field} 字段", "FAIL", "BLOCK",
            f"frontmatter 缺少 `{cn_name_field}` 字段（{spec['label']}规范必填）。",
            suggestion=f"补充 {cn_name_field}: 中文展示名称"))
    elif cn_prefix_required and not cn_name.startswith(cn_prefix_required):
        checks.append(Check(
            "基础合规", f"{cn_name_field} 前缀({cn_prefix_required}强制)", "FAIL", "BLOCK",
            f"{cn_name_field}='{cn_name}' 必须以「{cn_prefix_required}」开头。",
            suggestion=f"将 {cn_name_field} 改为以「{cn_prefix_required}」开头，"
                       f"如「{cn_prefix_required} E10 日程管理」。"))
    else:
        checks.append(Check("基础合规", f"{cn_name_field} 字段", "PASS", "INFO",
                            f"{cn_name_field}='{cn_name}' 符合要求"))

    # A5c 英文展示名（按 platform 区分字段名）
    en_name_field = spec["name_en_field"]
    en_aliases = tuple(filter(None, [en_name_field, "display_name_en", "name_en"]))
    en_name, _ = get_field(fm, *en_aliases)
    if not en_name:
        checks.append(Check(
            "基础合规", f"{en_name_field} 字段", "FAIL", "BLOCK",
            f"frontmatter 缺少 `{en_name_field}` 字段（{spec['label']}规范必填）。",
            suggestion=f"补充 {en_name_field}: English Display Name"))
    else:
        checks.append(Check("基础合规", f"{en_name_field} 字段", "PASS", "INFO",
                            f"{en_name_field}='{en_name}'"))

    # A5d 中文描述（按 platform 区分字段名）
    # 千问：description 直接是中文（已在 A4 校验）；description_zh 不强制，但仍可识别存在
    desc_cn_field = spec["desc_cn_field"]
    desc_cn_aliases = tuple(filter(None, [desc_cn_field, "description_zh", "description"]))
    desc_cn, desc_cn_hit = get_field(fm, *desc_cn_aliases)
    if not desc_cn:
        # 千问模式下，description 在前面已经校验过必填，所以这里基本不会触发
        checks.append(Check(
            "基础合规", f"{desc_cn_field} 字段", "FAIL", "BLOCK",
            f"frontmatter 缺少 `{desc_cn_field}` 字段（{spec['label']}规范必填）。",
            suggestion=f"补充 {desc_cn_field}: 简短中文介绍"))
    else:
        checks.append(Check("基础合规", f"{desc_cn_field} 字段", "PASS", "INFO",
                            f"{desc_cn_hit or desc_cn_field} 长度 {len(desc_cn)} 字"))

    # A5e 英文描述（按 platform 区分字段名）
    desc_en_field = spec["desc_en_field"]
    desc_en_aliases = tuple(filter(None, [desc_en_field, "description_en"]))
    desc_en, _ = get_field(fm, *desc_en_aliases)
    if not desc_en:
        checks.append(Check(
            "基础合规", f"{desc_en_field} 字段", "FAIL", "BLOCK",
            f"frontmatter 缺少 `{desc_en_field}` 字段（{spec['label']}规范必填）。",
            suggestion=f"补充 {desc_en_field}: A Brief English Introduction"))
    else:
        checks.append(Check("基础合规", f"{desc_en_field} 字段", "PASS", "INFO",
                            f"{desc_en_field} 长度 {len(desc_en)} 字符"))

    # A5f version 字段（泛微强制）
    version = parse_frontmatter_field(fm, "version")
    if not version:
        checks.append(Check(
            "基础合规", "version 字段", "FAIL", "BLOCK",
            "frontmatter 缺少顶层 `version` 字段（不允许 metadata 嵌套，嵌套写法判为缺失）。",
            suggestion="补充顶层 version: 1.0.0"))
    elif not re.fullmatch(r"\d+\.\d+\.\d+", version):
        checks.append(Check(
            "基础合规", "version 格式(SemVer)", "FAIL", "BLOCK",
            f"version='{version}' 不符合 SemVer 格式。必须是「主版本.次版本.修订号」三段纯数字（如 1.0.0），"
            "不支持 v1.0、1.0、latest 等非标准格式。",
            suggestion="改为严格的 x.y.z 三段数字格式，如 1.0.0。"))
    else:
        checks.append(Check("基础合规", "version 字段", "PASS", "INFO",
                            f"version='{version}' 符合 SemVer 格式"))

    # A9 author 作者字段（按 platform 区分）
    # - weaver：硬性要求顶层 author = 「泛微网络科技股份有限公司」
    # - qwenwork：可选字段（author 用于展示给用户的发布方名称，255 字符内）
    author_locked = spec.get("author_locked")
    author = parse_frontmatter_field(fm, "author")
    if author_locked:
        if not author:
            checks.append(Check(
                "基础合规", "author 作者字段(泛微强制)", "FAIL", "BLOCK",
                "frontmatter 缺少顶层 `author` 字段。"
                "提交至泛微相关市场的 skill 必须声明作者为「泛微网络科技股份有限公司」。",
                suggestion=f"在 frontmatter 顶层增加：\nauthor: {author_locked}"
            ))
        elif author != author_locked:
            checks.append(Check(
                "基础合规", "author 作者字段(泛微强制)", "FAIL", "BLOCK",
                f"作者字段值为「{author}」，不符合要求。泛微相关 skill 的作者必须声明为「{author_locked}」。",
                suggestion=f"将 author 改为：{author_locked}"
            ))
        else:
            checks.append(Check("基础合规", "author 作者字段(泛微强制)", "PASS", "INFO",
                                f"author='{author}' 符合泛微作者要求"))
    else:
        # 千问模式：author 是「推荐」字段，不强制；缺失只给 INFO 提示
        if author:
            if len(author) > 255:
                checks.append(Check(
                    "基础合规", "author 长度(≤ 255)", "WARN", "LOW",
                    f"author 长度 {len(author)}，超过推荐上限 255 字符。",
                    suggestion="精简至 255 字符以内。"
                ))
            else:
                checks.append(Check("基础合规", "author 字段(推荐)", "PASS", "INFO",
                                    f"author='{author}'"))
        else:
            checks.append(Check("基础合规", "author 字段(推荐)", "INFO", "INFO",
                                "frontmatter 未声明 author（千问推荐字段，可选）："
                                "展示给用户的发布方名称，便于用户识别。"))

    # A5f 千问特有：author_name 字段（必填，255 字符内，新建必填）
    #     与 author 的区别：author_name 是表单字段（市场列表展示），author 是 frontmatter 字段
    extra_field = spec.get("extra_name_field")
    if extra_field:
        extra_val = parse_frontmatter_field(fm, extra_field)
        if not extra_val:
            checks.append(Check(
                "基础合规", f"{extra_field} 字段(千问硬性)", "FAIL", "BLOCK",
                f"frontmatter 缺少 `{extra_field}` 字段（千问硬性必填）。"
                f"这是「创建表单必填字段」对应的 frontmatter 字段，新建技能时必须填写，"
                f"后续编辑不强求重填。",
                suggestion=f"补充 {extra_field}: 发布方名称（≤ 255 字符）"))
        elif len(extra_val) > 255:
            checks.append(Check(
                "基础合规", f"{extra_field} 长度(≤ 255)", "FAIL", "BLOCK",
                f"{extra_field}='{extra_val}' 长度 {len(extra_val)}，超过硬性上限 255 字符。",
                suggestion=f"精简 {extra_field} 至 255 字符以内。"))
        else:
            checks.append(Check("基础合规", f"{extra_field} 字段", "PASS", "INFO",
                                f"{extra_field}='{extra_val}'"))

    # A5h 千问特有：argument-hint 字段（推荐，可缺不阻）
    #     用户点开该技能时预填在输入框里的引导语；缺则没预填，用户自由输入。
    if platform == "qwenwork":
        arg_hint = parse_frontmatter_field(fm, "argument-hint")
        if arg_hint:
            checks.append(Check("基础合规", "argument-hint 字段", "PASS", "INFO",
                                f"argument-hint='{arg_hint}'（预填引导语已设置）"))
        else:
            checks.append(Check("基础合规", "argument-hint 字段", "INFO", "INFO",
                                "未设置 argument-hint（千问推荐，可缺不阻）："
                                "用于在用户输入框预填引导语，如「合同文件或条款文本」。"))

    # A6 目录名一致性（仅真实目录输入有意义；zip 解包临时目录名为随机值，跳过）
    if source_type == "directory" and name:
        if skill_dir.name != name:
            checks.append(Check("基础合规", "目录名与 name 一致", "WARN", "MEDIUM",
                                f"目录名 '{skill_dir.name}' 与 frontmatter name '{name}' 不一致。",
                                suggestion="确保 skill 目录名与 name 完全一致（打包时外层目录即 name）。"))
        else:
            checks.append(Check("基础合规", "目录名与 name 一致", "PASS", "INFO", "目录名与 name 一致"))
    elif source_type in ("zip", "markdown") and name:
        checks.append(Check("基础合规", "目录名与 name 一致", "INFO", "INFO",
                            "zip / 单 md 输入无法校验目录名，跳过（平台以 name 字段为准）。"))

    # A7 TODO 占位符残留
    todo_hits = []
    for m in TODO_PATTERN.finditer(body):
        todo_hits.append(m.group(0))
    if todo_hits:
        checks.append(Check("基础合规", "无 TODO 占位残留", "WARN", "MEDIUM",
                            f"正文仍含占位标记: {', '.join(sorted(set(todo_hits))[:5])}",
                            suggestion="删除模板占位符，填充真实内容。"))
    else:
        checks.append(Check("基础合规", "无 TODO 占位残留", "PASS", "INFO", "正文无模板占位符"))

    # A8 正文体量
    if len(body.strip()) < 100:
        checks.append(Check("基础合规", "正文内容充实", "WARN", "MEDIUM",
                            f"SKILL.md 正文仅 {len(body.strip())} 字，偏空。",
                            suggestion="补充技能说明、流程、示例。"))
    else:
        checks.append(Check("基础合规", "正文内容充实", "PASS", "INFO", f"正文 {len(body.strip())} 字"))

    # A8b 正文档行数（建议 ≤500 行，内容过多时拆分到 references/ 独立文件）
    body_lines = len(body.splitlines())
    if body_lines > 500:
        checks.append(Check("基础合规", "正文档行数≤500", "WARN", "MEDIUM",
                            f"SKILL.md 正文 {body_lines} 行，超过建议上限 500 行。",
                            suggestion="将详细内容拆分为 references/ 目录下的独立文件，正文只保留路由触发与核心说明。"))
    else:
        checks.append(Check("基础合规", "正文档行数≤500", "PASS", "INFO", f"正文 {body_lines} 行"))

    # A11d 千问硬性：SKILL.md 正文 ≤ 64 KB（超出部分会被市场详情页截断）
    if spec.get("max_body_kb"):
        max_kb = spec["max_body_kb"]
        body_bytes = len(body.encode("utf-8"))
        if body_bytes > max_kb * 1024:
            checks.append(Check(
                "基础合规", f"SKILL.md 正文 ≤ {max_kb} KB", "FAIL", "BLOCK",
                f"SKILL.md 正文 UTF-8 字节 {body_bytes / 1024:.2f} KB，"
                f"超过 {spec['label']}硬性上限 {max_kb} KB，"
                f"超出部分会被市场详情页说明文档展示时截断（不影响上传成功，但首屏体验劣化）。",
                suggestion=f"将长知识拆到 references/ 目录，正文只保留路由触发与核心说明，控制在 {max_kb} KB 以内。"
            ))
        else:
            checks.append(Check(
                "基础合规", f"SKILL.md 正文 ≤ {max_kb} KB", "PASS", "INFO",
                f"SKILL.md 正文 UTF-8 字节 {body_bytes / 1024:.2f} KB（≤ {max_kb} KB）。"
            ))

    # A6c 千问特有：category_code 字段（推荐，14 个固定分类枚举）
    category_enum = spec.get("category_enum")
    if category_enum:
        cat_code = parse_frontmatter_field(fm, "category_code")
        if cat_code:
            if cat_code not in category_enum:
                checks.append(Check(
                    "基础合规", "category_code 分类(千问硬性枚举)", "FAIL", "BLOCK",
                    f"category_code='{cat_code}' 不在千问固定 14 个分类清单内。",
                    suggestion=f"从下列分类中选一个：{', '.join(category_enum)}。",
                    locations=[f"category_code={cat_code}"]))
            else:
                checks.append(Check(
                    "基础合规", "category_code 分类(千问硬性枚举)", "PASS", "INFO",
                    f"category_code='{cat_code}' 属于千问固定分类。"))
        else:
            checks.append(Check(
                "基础合规", "category_code 分类(推荐)", "INFO", "INFO",
                "未设置 category_code（千问推荐字段）："
                f"从 14 个分类中选一个，便于市场筛选归类：{', '.join(category_enum)}。"))

    # A6e 千问特有：icon 字段（必需，公网可访问的 icon_url）
    if platform == "qwenwork":
        icon = parse_frontmatter_field(fm, "icon") or parse_frontmatter_field(fm, "icon_url")
        if not icon:
            checks.append(Check(
                "基础合规", "icon 字段(千问硬性)", "FAIL", "BLOCK",
                "frontmatter 缺少 `icon` 字段（千问硬性必填）。"
                "用于在市场列表展示技能图标，必须是公网可访问的 URL（推荐 HTTPS）。",
                suggestion="补充 icon: https://example.com/icon-240.png  "
                          "（规格 240*240 PNG，≤ 2 MB；SVG 免尺寸校验）"))
        else:
            if not re.match(r"^https?://", icon):
                checks.append(Check(
                    "基础合规", "icon 协议(千问)", "WARN", "LOW",
                    f"icon='{icon}' 不是 http(s) URL；千问要求公网可访问。",
                    suggestion="icon 改为公网 URL，如 https://example.com/icon-240.png。"))
            else:
                checks.append(Check(
                    "基础合规", "icon 字段(千问硬性)", "PASS", "INFO",
                    f"icon='{icon}'（公网 URL）"))

    # A6f 千问特有：tags 字段（推荐，回车或逗号分隔）
    if platform == "qwenwork":
        tags_raw = parse_frontmatter_field(fm, "tags")
        if tags_raw:
            # 仅做基本结构校验：含中英文/数字/下划线/逗号分隔即可；详细过滤是平台职责
            checks.append(Check(
                "基础合规", "tags 字段(推荐)", "PASS", "INFO",
                f"tags='{tags_raw}'（当前不在市场界面展示，仅用于平台内部筛选归类）"))
        else:
            checks.append(Check(
                "基础合规", "tags 字段(推荐)", "INFO", "INFO",
                "未设置 tags（推荐，可选）：回车或逗号分隔录入，自动去重。"
                "当前不在市场界面展示，仅用于平台内部筛选归类，按需填写。"))

    # A6g 千问特有：初始提示词（可选，frontmatter 中以 description 替代——已在 A4 校验）

    # A8c 引用相对路径规范：引用其他文件须使用相对 Skills 根目录的路径
    #    （如 references/api-docs.md、scripts/main.py），且被引用文件须真实存在于包内。
    bad_refs, missing_refs = [], []
    ref_candidates = set()
    # ① Markdown 链接目标：[文本](path)
    for m in re.finditer(r"\[[^\]]*\]\(([^)\s]+)\)", body):
        p = m.group(1).strip()
        if p and not p.startswith(("http://", "https://", "#", "mailto:", "//")):
            ref_candidates.add(p)
    # ② 反引号包裹的路径（须带目录段 + 文件扩展名，如 references/api-docs.md；兼容反斜杠与前导 / 以捕获违规）
    for m in re.finditer(r"`(/?(?:[\w.-]+[/\\])+[\w.-]+\.[A-Za-z0-9]+)`", body):
        ref_candidates.add(m.group(1).strip())
    # 占位符/示例路径豁免：<xxx>、{var}、xxx.md、your-xxx、示例/模板字样等，避免文档举例误报
    _PLACEHOLDER_RE = re.compile(r"[<>{}*]|xxx|yyy|zzz|your[-_]|示例|模板|占位")
    # 运行时系统路径豁免：/tmp/、/Users/、~/、C:\ 等是文档描述的运行时数据目录，不是「引用包内文件」
    _RUNTIME_PATH_RE = re.compile(
        r"^(?:/(?:tmp|var|usr|etc|home|Users|opt|private|Library|System|data|srv|mnt)/|~/|[A-Za-z]:[\\/])",
        re.IGNORECASE)
    # 存在性校验仅针对已知资源目录前缀：其余自定义相对路径可能是运行时产物描述，不校验以免误报
    _RESOURCE_DIR_RE = re.compile(
        r"^(?:references|scripts|assets|docs|examples|templates|config|prompts|data|output|outputs)/",
        re.IGNORECASE)
    for p in sorted(ref_candidates):
        if _PLACEHOLDER_RE.search(p):
            continue
        if _RUNTIME_PATH_RE.match(p):
            continue  # 运行时系统路径（非包内文件引用），跳过
        if "\\" in p:
            bad_refs.append(f"{p}（含反斜杠，须用 / 分隔）")
            continue
        if p.startswith("/"):
            bad_refs.append(f"{p}（绝对路径，须改为相对 skill 根目录的路径）")
            continue
        norm = p[2:] if p.startswith("./") else p
        norm = norm.split("#")[0].strip()
        if norm and _RESOURCE_DIR_RE.match(norm) and not (skill_dir / norm).exists():
            missing_refs.append(norm)
    if bad_refs or missing_refs:
        _parts = []
        if bad_refs:
            _parts.append("路径格式问题: " + "; ".join(bad_refs[:5]))
        if missing_refs:
            _parts.append("引用文件不存在: " + "; ".join(missing_refs[:5]))
        checks.append(Check("基础合规", "引用相对路径规范", "WARN", "MEDIUM",
                            "。".join(_parts) + "。引用其他文件时必须使用相对 Skills 根目录的路径。",
                            suggestion="改为如 references/api-docs.md、scripts/main.py 的相对路径，并确保被引用文件已随包分发。"))
    else:
        checks.append(Check("基础合规", "引用相对路径规范", "PASS", "INFO",
                            "正文引用路径均为相对路径且文件存在"))

    return checks, (name, body)


def _is_detector_script(rel: str, text: str) -> bool:
    """判断是否为检测工具自身源码（避免规则定义字符串自匹配误报）。"""
    return rel == "scripts/validate_skill.py" and ("泛微技能检测工具" in text or "weaver-skill-detector" in text)


def _is_detector_doc(rel: str, text: str) -> bool:
    """判断是否为检测工具自身说明文档（避免规则说明文字被当成风险命中）。"""
    if rel not in ("SKILL.md", "references/checklist.md"):
        return False
    return "泛微技能检测工具" in text or "weaver-skill-detector" in text


def check_weaver_compliance(skill_dir: Path, body: str, source_type: str):
    """B. 泛微专项质检（安全合规）。"""
    checks = []

    # 汇总所有文本文件内容用于扫描（排除检测工具自身源码，避免正则常量自匹配）
    file_texts = {}
    for rel, text in iter_text_files(skill_dir):
        if _is_detector_script(str(rel), text):
            continue
        file_texts[str(rel)] = text
    # 也确保 SKILL.md 正文在列（单 md 情形）
    all_text = "\n".join(file_texts.values())

    # B1 默认登录地址指向泛微自用系统（核心红线）
    # 仅在「作为 URL」或「赋值给配置型变量」时判定为默认地址，
    # 避免 SKILL.md / references 正文为说明规则而提及域名时造成误报。
    CONFIG_VAR_RE = re.compile(
        r"^\s*[A-Za-z_\u4e00-\u9fff]*(?:url|host|base|login|endpoint|address|addr|"
        r"server|domain|site|target|api|default|link)[A-Za-z_\u4e00-\u9fff]*\s*[:=]",
        re.IGNORECASE,
    )
    URL_RE = re.compile(r"https?://", re.IGNORECASE)
    default_addr_hits = []
    for rel, text in file_texts.items():
        for li, line in enumerate(scan_lines(text), start=1):
            lower = line.lower()
            for domain in WEAVER_OWNED_DOMAINS:
                if domain.lower() not in lower:
                    continue
                is_url = bool(URL_RE.search(line))
                is_assign = bool(CONFIG_VAR_RE.match(line))
                if is_url or is_assign:
                    default_addr_hits.append(f"{rel}:{li} → {line.strip()[:120]}")
                break
    if default_addr_hits:
        checks.append(Check(
            "专项质检", "默认登录地址未指向泛微自用系统", "FAIL", "BLOCK",
            "检测到 skill 内写死了泛微自用系统域名（如 e-cology.com.cn）。"
            "若作为默认值，客户下载后会访问到泛微自用系统，存在越权访问与隐私合规风险。",
            locations=default_addr_hits[:20],
            suggestion="移除写死的默认地址；改为运行时由用户显式提供自身系统地址，"
                      "或在说明中要求用户填写自有域名，绝不给默认系统地址。"
        ))
    else:
        checks.append(Check("专项质检", "默认登录地址未指向泛微自用系统", "PASS", "INFO",
                            "未发现泛微自用域名写死为默认地址。"))

    # B2 硬编码真实 IP
    ip_hits = []
    ip_re = re.compile(r"\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b")
    for rel, text in file_texts.items():
        text_lines = scan_lines(text)  # 每个文件只截断一次，避免逐命中重复构造
        for m in ip_re.finditer(text):
            ip = m.group(1)
            parts = ip.split(".")
            if not all(0 <= int(p) <= 255 for p in parts):
                continue
            if is_safe_ip(ip):
                continue
            line_no = text.count("\n", 0, m.start()) + 1
            snippet = text_lines[line_no - 1].strip() if line_no <= len(text_lines) else ""
            ip_hits.append(f"{rel}:{line_no} → {ip}  ({snippet[:80]})")
    if ip_hits:
        checks.append(Check(
            "专项质检", "无硬编码真实 IP", "WARN", "HIGH",
            "检测到疑似硬编码 IP 地址（可能是真实服务器地址）。对外分发前必须脱敏。",
            locations=ip_hits[:20],
            suggestion="将真实 IP 替换为占位符（如 <服务器IP> 或 192.168.x.x 示例），"
                      "并改为配置/参数传入。"
        ))
    else:
        checks.append(Check("专项质检", "无硬编码真实 IP", "PASS", "INFO", "未发现疑似硬编码真实 IP"))

    # B3 硬编码凭证
    cred_hits = []
    for rel, text in file_texts.items():
        text_lines = scan_lines(text)  # 每个文件只截断一次，避免逐命中重复构造
        for pat in CREDENTIAL_PATTERNS:
            for m in pat.finditer(text):
                line_no = text.count("\n", 0, m.start()) + 1
                snippet = text_lines[line_no - 1].strip() if line_no <= len(text_lines) else ""
                cred_hits.append(f"{rel}:{line_no} → {snippet[:120]}")
    if cred_hits:
        checks.append(Check(
            "专项质检", "无硬编码凭证", "WARN", "HIGH",
            "检测到疑似硬编码凭证（密码 / token / secret / api_key 等字面量）。",
            locations=cred_hits[:20],
            suggestion="移除硬编码凭证；统一通过 E10 命名凭据插件获取（如 key=ETEAMSID），"
                      "严禁将密钥写入脚本或聊天。"
        ))
    else:
        checks.append(Check("专项质检", "无硬编码凭证", "PASS", "INFO", "未发现疑似硬编码凭证"))

    # B4 凭证来源建议（若涉及 Weaver API 调用）
    mentions_api = bool(re.search(r"e10|eteams|e-cology|weaver|api|接口", all_text, re.IGNORECASE))
    mentions_plugin = any(re.search(h, all_text, re.IGNORECASE) for h in PLUGIN_CREDENTIAL_HINTS)
    if mentions_api and not mentions_plugin:
        checks.append(Check(
            "专项质检", "凭证应通过插件获取", "WARN", "MEDIUM",
            "skill 涉及 Weaver / API 调用，但未说明凭证通过 E10 插件（命名凭据）获取。",
            suggestion="补充：凭证须由 E10 命名凭据插件提供，禁止硬编码或 Playwright 登录兜底。"
        ))
    elif mentions_api and mentions_plugin:
        checks.append(Check("专项质检", "凭证应通过插件获取", "PASS", "INFO",
                            "已说明使用 E10 插件/命名凭据获取凭证。"))
    else:
        checks.append(Check("专项质检", "凭证应通过插件获取", "INFO", "INFO",
                            "未检测到 Weaver API 调用，跳过此项。"))

    # B5 敏感词占位规范（软性）
    uses_placeholder = "-替换敏感词占位符-" in all_text
    if uses_placeholder:
        checks.append(Check("专项质检", "敏感词占位规范", "PASS", "INFO",
                            "已采用 `-替换敏感词占位符-` 占位规范处理敏感内容。"))
    else:
        checks.append(Check("专项质检", "敏感词占位规范", "INFO", "INFO",
                            "未检测到敏感词占位处理；若 skill 会向 Weaver 发送内容，"
                            "建议复用 `-替换敏感词占位符-` 规范以避免敏感词拦截。"))

    # -----------------------------------------------------------------------
    # B6/B7/B8 运行时行为风险（仅扫描脚本类文件）
    # 子进程调用块上下文：命中行 + 后续 6 行（覆盖 spawn/exec 的 args / command 列表）
    # -----------------------------------------------------------------------
    ext_script_calls = []
    cred_in_subproc = []
    aes_decrypt_files = set()
    hardcoded_key_files = set()
    local_cred_path_hits = []

    for rel, text in file_texts.items():
        if not rel.lower().endswith(SCRIPT_EXTS):
            continue
        lines = scan_lines(text)
        for i, line in enumerate(lines, start=1):
            if RISKY_SUBPROCESS_RE.search(line):
                block = "\n".join(lines[i - 1:i + 6])
                if EXTERNAL_SCRIPT_RE.search(block):
                    ext_script_calls.append(f"{rel}:{i} → {line.strip()[:120]}")
                if CRED_IN_SUBPROC_RE.search(block):
                    cred_in_subproc.append(f"{rel}:{i} → {line.strip()[:120]}")
            if AES_DECRYPT_RE.search(line):
                aes_decrypt_files.add(rel)
        if HARDCODED_SECRET_RE.search(text):
            hardcoded_key_files.add(rel)
        for m in LOCAL_CRED_PATH_RE.finditer(text):
            local_cred_path_hits.append(f"{rel} → {m.group(0)[:100]}")

    # B6 包外脚本子进程调用
    if ext_script_calls:
        checks.append(Check(
            "专项质检", "无包外脚本子进程调用", "WARN", "MEDIUM",
            "检测到 skill 通过子进程（spawnSync/spawn/exec/subprocess 等）调用了包外脚本文件。"
            "这类行为可能在用户环境自动执行外部代码，需审查其来源、参数与权限范围。",
            locations=ext_script_calls[:20],
            suggestion="尽量用内置 SDK/库替代子进程调用；若必须调用，应明确脚本来源、"
                      "限制参数范围，并向用户说明所执行的外部脚本。"
        ))
    else:
        checks.append(Check("专项质检", "无包外脚本子进程调用", "PASS", "INFO",
                            "未发现调用包外脚本的子进程行为。"))

    # B7 凭证通过命令行参数明文传递
    if cred_in_subproc:
        checks.append(Check(
            "专项质检", "凭证未通过命令行明文传递", "WARN", "HIGH",
            "检测到子进程（spawnSync/spawn/exec 等）调用附近出现凭证类字段"
            "（ETEAMSID/token/session/cookie/password/secret 等），疑似将凭证以命令行参数明文传入。",
            locations=cred_in_subproc[:20],
            suggestion="严禁将凭证作为命令行参数或 env 明文传入子进程；"
                      "凭证须由 E10 命名凭据插件提供，通过标准输入或安全配置文件传递。"
        ))
    else:
        checks.append(Check("专项质检", "凭证未通过命令行明文传递", "PASS", "INFO",
                            "未发现子进程命令行中明文传递凭证。"))

    # B8 硬编码加密密钥解密本地凭证
    aes_and_key = sorted(aes_decrypt_files & hardcoded_key_files)
    if aes_and_key:
        checks.append(Check(
            "专项质检", "无硬编码加密密钥解密本地凭证", "WARN", "HIGH",
            "检测到使用 AES 解密且同文件存在硬编码密钥常量，疑似用于解密本地存储的登录会话凭证。",
            locations=[f"{r}（AES 解密 + 硬编码密钥常量）" for r in aes_and_key][:20],
            suggestion="不得硬编码加密密钥；密钥与凭证应由 E10 插件安全管理，"
                      "本地凭证文件须脱敏并限定在用户授权范围内读取。"
        ))
    else:
        checks.append(Check("专项质检", "无硬编码加密密钥解密本地凭证", "PASS", "INFO",
                            "未发现硬编码密钥 + AES 解密组合。"))

    if local_cred_path_hits:
        checks.append(Check(
            "专项质检", "无本地敏感凭证路径访问", "WARN", "MEDIUM",
            "检测到访问本地敏感凭证路径（如 ~/.e10-cli/profiles/*/auth）。"
            "对外分发前须确认已脱敏，且仅在用户授权范围内读取。",
            locations=local_cred_path_hits[:20],
            suggestion="移除对本地凭证文件的硬编码路径引用；如需读取，须明确授权并脱敏后处理。"
        ))
    else:
        checks.append(Check("专项质检", "无本地敏感凭证路径访问", "PASS", "INFO",
                            "未发现访问本地敏感凭证路径。"))

    return checks


# ---------------------------------------------------------------------------
# 扩展安全检测（对齐官方 expert-reviewer G 组）
# ---------------------------------------------------------------------------

def check_extended_security(skill_dir: Path):
    """G 组扩展安全检测：危险命令、个人路径、IDE残留、CDN未锁定、内网域名、虚假承诺、BOM/CRLF。"""
    checks = []

    file_texts = {}
    for rel, text in iter_text_files(skill_dir):
        if _is_detector_script(str(rel), text) or _is_detector_doc(str(rel), text):
            continue
        file_texts[str(rel)] = text

    # G02: 危险 shell 命令模式
    dangerous_hits = []
    for rel, text in file_texts.items():
        for i, line in enumerate(scan_lines(text), start=1):
            if DANGEROUS_CMD_RE.search(line):
                # 豁免否定语境（如「禁止使用 rm -rf /」）
                if NEGATION_SAFE_RE.search(line):
                    continue
                dangerous_hits.append(f"{rel}:{i} → {line.strip()[:120]}")
    if dangerous_hits:
        checks.append(Check(
            "扩展安全", "无危险 shell 命令", "WARN", "HIGH",
            "检测到危险 shell 命令模式（如 rm -rf /、curl|sh、sudo、chmod 777、dd、fork bomb 等），"
            "可能在用户环境造成不可逆破坏。",
            locations=dangerous_hits[:20],
            suggestion="移除危险命令；如需清理操作应限制范围并加确认逻辑；禁止 curl|sh 远程管道执行。"
        ))
    else:
        checks.append(Check("扩展安全", "无危险 shell 命令", "PASS", "INFO",
                            "未检测到危险 shell 命令模式。"))

    # G04a: 开发者个人路径残留
    personal_hits = []
    for rel, text in file_texts.items():
        for i, line in enumerate(scan_lines(text), start=1):
            if PERSONAL_PATH_RE.search(line):
                personal_hits.append(f"{rel}:{i} → {line.strip()[:120]}")
    if personal_hits:
        checks.append(Check(
            "扩展安全", "无开发者个人路径残留", "WARN", "MEDIUM",
            "检测到开发者个人路径残留（如 /Users/xxx/、C:\\Users\\xxx\\）。"
            "对外分发后其他用户环境路径不一致，会导致脚本/配置失效。",
            locations=personal_hits[:20],
            suggestion="将个人路径替换为相对路径或环境变量（如 $HOME、~/、os.path.expanduser）。"
        ))
    else:
        checks.append(Check("扩展安全", "无开发者个人路径残留", "PASS", "INFO",
                            "未检测到个人路径残留。"))

    # G04b: 其他 IDE / 平台配置目录残留
    ide_hits = []
    for rel, text in file_texts.items():
        for i, line in enumerate(scan_lines(text), start=1):
            if IDE_DIR_RE.search(line):
                ide_hits.append(f"{rel}:{i} → {line.strip()[:120]}")
    if ide_hits:
        checks.append(Check(
            "扩展安全", "无 IDE/平台配置目录残留", "WARN", "LOW",
            "检测到其他 IDE/平台配置目录残留（如 .vscode/、.idea/、.cursor/、.DS_Store）。"
            "这些文件不应包含在发布包中。",
            locations=ide_hits[:20],
            suggestion="从发布包中移除 IDE 配置目录与系统生成文件；添加 .gitignore 排除规则。"
        ))
    else:
        checks.append(Check("扩展安全", "无 IDE/平台配置目录残留", "PASS", "INFO",
                            "未检测到 IDE 配置目录残留。"))

    # G04c: CDN @latest 未锁定版本
    cdn_hits = []
    for rel, text in file_texts.items():
        for i, line in enumerate(scan_lines(text), start=1):
            if CDN_LATEST_RE.search(line):
                cdn_hits.append(f"{rel}:{i} → {line.strip()[:120]}")
    if cdn_hits:
        checks.append(Check(
            "扩展安全", "CDN 依赖版本锁定", "WARN", "MEDIUM",
            "检测到 CDN 引用使用 @latest 未锁定版本（如 cdn.jsdelivr.net/.../@latest）。"
            "上游更新可能导致引入不兼容或恶意代码。",
            locations=cdn_hits[:20],
            suggestion="将 @latest 替换为具体版本号（如 @1.2.3），锁定依赖版本。"
        ))
    else:
        checks.append(Check("扩展安全", "CDN 依赖版本锁定", "PASS", "INFO",
                            "未检测到未锁定版本的 CDN 引用。"))

    # G03: 扩展内网域名检测（泛微自用域名已由 B1 覆盖，此处补充通用内网域名）
    internal_hits = []
    for rel, text in file_texts.items():
        for i, line in enumerate(scan_lines(text), start=1):
            if INTERNAL_DOMAIN_RE.search(line):
                # 豁免否定语境
                if NEGATION_SAFE_RE.search(line):
                    continue
                internal_hits.append(f"{rel}:{i} → {line.strip()[:120]}")
    if internal_hits:
        checks.append(Check(
            "扩展安全", "无内网域名残留", "WARN", "MEDIUM",
            "检测到通用内网域名（如 *.woa.com、*.tencent.com、*.internal、*.local）。"
            "对外分发后这些域名不可达，且可能泄露内部网络拓扑。",
            locations=internal_hits[:20],
            suggestion="移除内网域名引用；改为用户提供的外部地址或占位符。"
        ))
    else:
        checks.append(Check("扩展安全", "无内网域名残留", "PASS", "INFO",
                            "未检测到通用内网域名残留。"))

    # C08: 平台虚假技术承诺
    promise_hits = []
    for rel, text in file_texts.items():
        if not str(rel).lower().endswith((".md", ".markdown")):
            continue
        for i, line in enumerate(scan_lines(text), start=1):
            if FALSE_PROMISE_RE.search(line):
                # 豁免否定语境（如「不应声称完全离线」）
                if NEGATION_SAFE_RE.search(line):
                    continue
                promise_hits.append(f"{rel}:{i} → {line.strip()[:120]}")
    if promise_hits:
        checks.append(Check(
            "扩展安全", "无平台虚假技术承诺", "WARN", "MEDIUM",
            "检测到可能的技术承诺（如「本地AI」「离线运行」「不上云」「数据不出本机」）。"
            "技能实际运行在云端 Agent 中，这些承诺与平台运行方式不符，可能误导用户。",
            locations=promise_hits[:20],
            suggestion="移除或修正不实技术承诺；如实描述技能运行环境（云端执行、需联网等）。"
        ))
    else:
        checks.append(Check("扩展安全", "无平台虚假技术承诺", "PASS", "INFO",
                            "未检测到虚假技术承诺。"))

    # B11: BOM / CRLF / 编码规范性
    bom_files = []
    crlf_files = []
    for p in skill_dir.rglob("*"):
        if not p.is_file():
            continue
        if p.suffix.lower() not in TEXT_EXTS:
            continue
        if _is_detector_script(str(p.relative_to(skill_dir)), ""):
            continue
        try:
            raw = p.read_bytes()[:4096]  # 只读前 4KB 判断 BOM/CRLF
        except (OSError, IOError):
            continue
        if BOM_RE.match(raw):
            bom_files.append(str(p.relative_to(skill_dir)))
        if CRLF_RE.search(raw):
            crlf_files.append(str(p.relative_to(skill_dir)))

    if bom_files:
        checks.append(Check(
            "扩展安全", "无 BOM 头残留", "WARN", "LOW",
            f"检测到 {len(bom_files)} 个文件含 UTF-8 BOM 头（EF BB BF）。"
            "BOM 在某些解析器中会被当作正文内容，可能导致 YAML frontmatter 解析失败。",
            locations=bom_files[:10],
            suggestion="使用 `sed -i '1s/^\\xef\\xbb\\xbf//' <file>` 或编辑器设置「保存时不带 BOM」移除。"
        ))
    else:
        checks.append(Check("扩展安全", "无 BOM 头残留", "PASS", "INFO",
                            "未检测到 BOM 头残留。"))

    if crlf_files:
        checks.append(Check(
            "扩展安全", "无 CRLF 行尾", "WARN", "LOW",
            f"检测到 {len(crlf_files)} 个文件使用 CRLF (\\r\\n) 行尾。"
            "跨平台分发时 CRLF 可能导致 shell 脚本执行异常或 YAML 解析问题。",
            locations=crlf_files[:10],
            suggestion="使用 `dos2unix` 或 `sed -i 's/\\r$//' <file>` 统一为 LF 行尾。"
        ))
    else:
        checks.append(Check("扩展安全", "无 CRLF 行尾", "PASS", "INFO",
                            "所有文本文件均为 LF 行尾。"))

    return checks


# ---------------------------------------------------------------------------
# API 请求凭证完整性检测（泛微强制红线）
#   被检测 skill 在发起 API 请求时，必须同时携带：
#     ① Cookie 完整原始串（禁止裁剪/去重/改写）
#     ② eteamsid
#     ③ User-Agent（AgentType=<agentType>,IsAgent=true）
#   三条缺一不可，作为阻断性规则（BLOCK）。仅当源码确实存在发起 HTTP/API 请求的代码时生效。
# ---------------------------------------------------------------------------

# 源码中出现「发起 HTTP/API 请求」的触发模式（仅在脚本文件中判定，避免文档描述误触发）
API_CALL_RE = re.compile(
    r"\b(requests\.(?:get|post|put|delete|patch|request)|httpx\.(?:get|post|request)|"
    r"http\.client|urllib\.request|aiohttp\.|session\.(?:get|post|request)|"
    r"fetch\s*\(|axios(?:\.[a-z]+)?\s*\(|got\s*\(|\bcurl\b|\bwget\b|\.request\s*\()",
    re.IGNORECASE,
)

# API 触发行的「否定/断言/列举」语境豁免：这些行只是在检查「不应出现某调用」，
# 而非真正发起请求（如 `for marker in ("fetch(", ...)` / `assert "fetch(" not in html` /
# `token not in html`）。命中即不视为发起 API 请求，避免误阻断。
API_CALL_NEGATION_RE = re.compile(
    r"\b(not in|assert|marker|token|forbidden|blocked|noNetwork|"
    r"does not contain|不包含|禁止出现|不得包含)\b", re.IGNORECASE)

# 弱信号动词（fetch/got/axios）若以引号包裹，说明是字符串字面量（被列举/检查的词），非真实调用。
API_CALL_QUOTED_LITERAL_RE = re.compile(r"""["'](?:fetch|got|axios)\b""", re.IGNORECASE)


def _line_has_real_api_call(line: str) -> bool:
    """判定一行是否「真正发起 API 请求」，排除否定/断言/字面量列举等误触发语境。"""
    if API_CALL_NEGATION_RE.search(line):
        return False
    if not API_CALL_RE.search(line):
        return False
    # fetch/got/axios 出现在引号内（如 ("fetch(", ...)）视为字面量，不算调用
    if API_CALL_QUOTED_LITERAL_RE.search(line):
        return False
    return True


# Cookie 头/参数出现的标记（大小写不敏感）
COOKIE_HEADER_RE = re.compile(r"['\"]?Cookie['\"]?\s*[:=]|cookie\s*=", re.IGNORECASE)

# eteamsid 出现的标记（覆盖 eteamsid / eteams_id / ETEAMSID / eteamsId）
ETEAMSID_RE = re.compile(r"eteams[_-]?id", re.IGNORECASE)

# User-Agent 必须携带 AgentType=<agentType>,IsAgent=true
UA_RE = re.compile(r"User-Agent", re.IGNORECASE)
AGENT_TYPE_RE = re.compile(r"AgentType\s*=", re.IGNORECASE)
IS_AGENT_RE = re.compile(r"IsAgent\s*[:=]\s*true", re.IGNORECASE)

# Cookie 裁剪/去重/改写的可疑模式（违反「禁止裁剪/去重/改写」）
COOKIE_TAMPER_RES = [
    re.compile(r"cookie\b[^;]{0,50}?\.(?:split|replace)\s*\(", re.IGNORECASE),
    re.compile(r"dict\s*\([^)]{0,80}?cookie", re.IGNORECASE),
    re.compile(r"cookie\b[^;]{0,50}?\[[0-9\s:]+[\]:]", re.IGNORECASE),
]


def check_api_request_credentials(skill_dir: Path):
    """
    B 红线补充：被检测 skill 在发起 API 请求时，必须同时携带
      ① Cookie 完整原始串（禁止裁剪/去重/改写）
      ② eteamsid
      ③ User-Agent（AgentType=<agentType>,IsAgent=true）
    三条缺一不可，作为阻断性规则（BLOCK）。仅当源码中确实存在发起 HTTP/API 请求的代码时才生效；
    纯文档/纯提示词类 skill 不触发。
    """
    # 收集文件：排除检测工具自身（避免规则说明文字 / urllib 自调用误触发）
    script_texts = {}   # 脚本文件（用于判断是否发起 API 请求）
    all_texts = {}      # 全量文本（用于查找凭证标记）
    for rel, text in iter_text_files(skill_dir):
        if _is_detector_script(str(rel), text) or _is_detector_doc(str(rel), text):
            continue
        all_texts[str(rel)] = text
        if Path(str(rel)).suffix.lower() in SCRIPT_EXTS:
            script_texts[str(rel)] = text

    # 1) 是否发起 API 请求（仅在脚本文件中判定触发，避免文档描述误触发）
    api_hits = []
    for rel, text in script_texts.items():
        for i, line in enumerate(scan_lines(text), start=1):
            if _line_has_real_api_call(line):
                api_hits.append(f"{rel}:{i} → {line.strip()[:120]}")

    if not api_hits:
        return [Check(
            "API 凭证完整性", "发起 API 请求时携带完整凭证", "PASS", "INFO",
            "未检测到发起 HTTP/API 请求的源码（requests/urllib/httpx/fetch/axios/curl 等），"
            "本规则不适用（仅约束实际对外发起请求的 skill）。"
        )]

    # 2) 三项凭证标记是否齐全（在包内全量文本中查找；模块化 skill 凭证可能在独立模块）
    joined = "\n".join(all_texts.values())
    has_cookie = bool(COOKIE_HEADER_RE.search(joined))
    has_eteamsid = bool(ETEAMSID_RE.search(joined))
    has_ua = bool(UA_RE.search(joined) and AGENT_TYPE_RE.search(joined) and IS_AGENT_RE.search(joined))

    missing = []
    if not has_cookie:
        missing.append("① Cookie 完整原始串（headers 中未携带 'Cookie' 头/参数）")
    if not has_eteamsid:
        missing.append("② eteamsid（源码中未出现 eteamsid 相关字段）")
    if not has_ua:
        missing.append("③ User-Agent（AgentType=<agentType>,IsAgent=true）")

    checks = []
    if missing:
        checks.append(Check(
            "API 凭证完整性", "API 请求携带 Cookie+eteamsid+User-Agent", "FAIL", "BLOCK",
            "被检测 skill 在发起 API 请求时，必须同时携带 ① Cookie 完整原始串 "
            "（禁止裁剪/去重/改写）② eteamsid ③ User-Agent（AgentType=<agentType>,IsAgent=true），"
            "三条缺一不可。当前缺失以下项，阻断上架：\n" + "\n".join(f"  · {m}" for m in missing),
            locations=api_hits[:15],
            suggestion="在请求 headers 中补充：'Cookie': <完整原始 Cookie 串>、"
                      "'eteamsid': <从 E10 插件获取的凭证>、"
                      "'User-Agent': 'AgentType=<agentType>,IsAgent=true'。"
                      "Cookie 必须原样透传，禁止 split / 去重 / 切片 / 改写。"
        ))
    else:
        checks.append(Check(
            "API 凭证完整性", "API 请求携带 Cookie+eteamsid+User-Agent", "PASS", "INFO",
            "检测到对外 API 请求，且三项强制凭证（Cookie 完整原始串 / eteamsid / "
            "User-Agent:AgentType=...,IsAgent=true）均已在包内源码中出现。"
        ))

    # 3) Cookie 裁剪/去重/改写嫌疑（违反「禁止裁剪/去重/改写」），作为独立 WARN 提示
    tamper_hits = []
    for rel, text in all_texts.items():
        for i, line in enumerate(scan_lines(text), start=1):
            for rx in COOKIE_TAMPER_RES:
                if rx.search(line):
                    tamper_hits.append(f"{rel}:{i} → {line.strip()[:120]}")
                    break
    if tamper_hits:
        checks.append(Check(
            "API 凭证完整性", "Cookie 未裁剪/去重/改写", "WARN", "MEDIUM",
            "检测到 Cookie 变量可能存在裁剪/去重/改写操作（如 cookie.split() / dict(cookie) / cookie[..]），"
            "违反「Cookie 必须完整原始串、禁止裁剪/去重/改写」的要求。",
            locations=tamper_hits[:15],
            suggestion="将服务端下发的原始 Cookie 串原样放入请求头（headers['Cookie'] = raw_cookie），"
                      "不要拆分、去重或重新拼装。"
        ))
    else:
        checks.append(Check(
            "API 凭证完整性", "Cookie 未裁剪/去重/改写", "PASS", "INFO",
            "未检测到 Cookie 裁剪/去重/改写的可疑操作。"
        ))

    return checks


# ---------------------------------------------------------------------------
# E10 接口型技能「共享登录 + 凭证」契约检测（泛微强制红线，仅对 weaver-e10- 前缀技能生效）
#   规则 1（B10a，BLOCK）：登录必须走共享规则 —— 必须声明/引用 weaver-e10-shared（连接器版为 weaver-e10-shared-connector）
#                          或明确通过 weaver-work-cli auth 体系处理，
#                          且不得自建登录（Playwright/Selenium 自动登录、账号密码登录、自建 OAuth 等）。
#   规则 2（B10c，BLOCK）：API 请求必须由 weaver-work-cli 托管并携带三项用户信息参数 ——
#                          ① Cookie 完整原始串（禁止裁剪/去重/改写）
#                          ② eteamsid
#                          ③ User-Agent（AgentType=<agentType>,IsAgent=true）
#   触发范围：name 以 weaver-e10- 开头（weaver-e10-shared 自身豁免），且确实存在 API 表面
#             （脚本内真实 HTTP 调用，或文档中描述了接口请求/curl/请求头）。
# ---------------------------------------------------------------------------

SHARED_SKILL_NAMES = {"weaver-e10-shared", "weaver-e10-shared-connector"}

# 「登录走共享规则」的证据模式（任一命中即认为已接入共享登录约束）
SHARED_AUTH_DEP_RE = re.compile(
    r"weaver-e10-shared(?:-connector)?"       # 共享技能名（frontmatter 声明或正文引用）
    r"|weaver-work-cli\s+auth\b"              # CLI 认证命令体系
    r"|共享规则"                              # 中文表述：共享规则
    r"|共享\s*(?:s?kill|技能)",               # 中文表述：共享 skill / 共享技能
    re.IGNORECASE,
)

# 自建登录（绕过共享规则）的可疑模式：命中即 BLOCK
SELF_LOGIN_RES = [
    re.compile(r"\b(playwright|selenium|puppeteer|pyppeteer)\b", re.IGNORECASE),
    re.compile(r"扫码\s*登录|二维码\s*登录", re.IGNORECASE),
    re.compile(r"(账号|用户名|username)[^\n]{0,10}(密码|password)[^\n]{0,10}登录", re.IGNORECASE),
    re.compile(r"login\s*\(\s*[^)]{0,60}(password|passwd)\s*=", re.IGNORECASE),
    re.compile(r"oauth2?\s*/\s*(authorize|token)[^\n]{0,40}(client_secret|grant_type)", re.IGNORECASE),
]

# 文档型（无脚本）E10 技能的「API 表面」证据：接口请求示例 / 请求头说明 / 接口地址
DOC_API_SURFACE_RES = [
    re.compile(r"\bcurl\s+-|\bcurl\s+['\"]", re.IGNORECASE),
    re.compile(r"\b(GET|POST|PUT|DELETE|PATCH)\s+/\S+", re.IGNORECASE),
    re.compile(r"请求头|请求头部|headers?\s*[:：]", re.IGNORECASE),
    re.compile(r"接口\s*地址|API\s*地址|请求\s*URL|base\s*url", re.IGNORECASE),
]

# E10 生态标识（用于确认该技能确实面向 E10 接口，避免误伤纯 UI/规范类技能）
E10_API_CONTEXT_RE = re.compile(
    r"\bE10\b|eteams|ebuilder|esb\b|/api/|泛微|weaver",
    re.IGNORECASE,
)


def collect_api_surface(skill_dir: Path):
    """
    收集 skill 的 API 表面证据。
    返回 (script_hits, doc_hits, all_texts)
      script_hits: 脚本文件中「真实发起 HTTP/API 请求」的行（复用 B9 的判定逻辑）
      doc_hits:    Markdown 文档中描述接口请求/请求头/curl 的行
      all_texts:   {相对路径: 全文}（已排除检测器自身文件）
    """
    script_hits, doc_hits = [], []
    all_texts = {}
    for rel, text in iter_text_files(skill_dir):
        rel_s = str(rel)
        if _is_detector_script(rel_s, text) or _is_detector_doc(rel_s, text):
            continue
        all_texts[rel_s] = text
        is_script = Path(rel_s).suffix.lower() in SCRIPT_EXTS
        if is_script:
            for i, line in enumerate(scan_lines(text), start=1):
                if _line_has_real_api_call(line):
                    script_hits.append(f"{rel_s}:{i} → {line.strip()[:120]}")
        elif rel_s.lower().endswith((".md", ".markdown")):
            for i, line in enumerate(scan_lines(text), start=1):
                if any(rx.search(line) for rx in DOC_API_SURFACE_RES):
                    doc_hits.append(f"{rel_s}:{i} → {line.strip()[:120]}")
    return script_hits, doc_hits, all_texts


def check_weaver_e10_auth_contract(skill_dir: Path, name: str):
    """
    E10 接口型技能专属红线（BLOCK）：
      B10a 登录必须走共享规则（依赖 weaver-e10-shared / weaver-e10-shared-connector，且不得自建登录）
      B10c 已于 2026-09-20 停用（请求头三件套收敛到 weaver-e10-shared 唯一权威声明，逐目录校验会误判引用写法）
    仅对 name 以 weaver-e10- 开头的技能生效；weaver-e10-shared 自身豁免。
    """
    checks = []
    nm = (name or "").strip().strip('"\'`')
    if not nm.lower().startswith("weaver-e10-"):
        return [Check("E10 登录与凭证契约", "适用范围内（weaver-e10- 前缀技能）", "PASS", "INFO",
                      f"当前技能 name='{nm}' 非 weaver-e10- 前缀，本组规则不适用。")]
    if nm.lower() in SHARED_SKILL_NAMES:
        return [Check("E10 登录与凭证契约", "共享规则自身豁免", "PASS", "INFO",
                      f"当前技能即 {nm}，登录契约规则豁免。")]

    script_hits, doc_hits, all_texts = collect_api_surface(skill_dir)
    if not script_hits and not doc_hits:
        return [Check("E10 登录与凭证契约", "接口型技能判定", "PASS", "INFO",
                      "未检测到 API 表面（无脚本 HTTP 调用，文档中也无接口请求/请求头描述），"
                      "判定为非接口型技能，登录与凭证契约规则不适用。")]

    surface_desc = (
        f"脚本内 HTTP 调用 {len(script_hits)} 处" if script_hits else ""
    ) + ("；" if script_hits and doc_hits else "") + (
        f"文档接口描述 {len(doc_hits)} 处" if doc_hits else ""
    )
    surface_locs = (script_hits or doc_hits)[:15]

    # ---- B10a 登录必须走共享规则 ----
    joined = "\n".join(all_texts.values())
    shared_evidence = []
    for rel, text in all_texts.items():
        for i, line in enumerate(scan_lines(text), start=1):
            if SHARED_AUTH_DEP_RE.search(line):
                shared_evidence.append(f"{rel}:{i} → {line.strip()[:120]}")
    if shared_evidence:
        checks.append(Check(
            "E10 登录与凭证契约", "登录走共享规则（依赖 weaver-e10-shared）", "PASS", "INFO",
            f"检测到共享规则/CLI auth 依赖证据 {len(shared_evidence)} 处"
            f"（示例：{shared_evidence[0]}），符合「登录必须走 weaver-e10-shared」要求。",
            locations=shared_evidence[:8],
        ))
    else:
        checks.append(Check(
            "E10 登录与凭证契约", "登录走共享规则（依赖 weaver-e10-shared）", "FAIL", "BLOCK",
            f"当前技能为 E10 接口型技能（{surface_desc}），但未发现任何共享登录规则依赖证据："
            "既未在 frontmatter `dependencies` 中声明 `weaver-e10-shared`（连接器版为 `weaver-e10-shared-connector`），"
            "也未在正文/脚本中引用共享规则或 `weaver-work-cli auth`。"
            "「登录必须走共享规则」为泛微强制红线，未声明即阻断上架。",
            locations=surface_locs,
            suggestion="在 SKILL.md frontmatter 增加：\n"
                       "dependencies:\n"
                       "  - weaver-e10-shared\n"
                       "连接器版使用：\n"
                       "  - weaver-e10-shared-connector\n"
                       "或在正文/脚本中明确：认证与会话由 weaver-e10-shared 共享规则和 `weaver-work-cli auth ...` 托管。"
        ))

    # ---- B10b 禁止自建登录（绕过共享规则）----
    self_login_hits = []
    for rel, text in all_texts.items():
        for i, line in enumerate(scan_lines(text), start=1):
            for rx in SELF_LOGIN_RES:
                if rx.search(line):
                    self_login_hits.append(f"{rel}:{i} → {line.strip()[:120]}")
                    break
    if self_login_hits:
        checks.append(Check(
            "E10 登录与凭证契约", "未自建登录（禁止绕过共享规则）", "FAIL", "BLOCK",
            f"检测到自建登录/自动登录实现 {len(self_login_hits)} 处"
            "（Playwright/Selenium 自动登录、扫码登录、账号密码登录、自建 OAuth 等）。"
            "E10 技能的登录与会话必须统一由 weaver-e10-shared 共享规则和 weaver-work-cli auth 体系托管，禁止自行实现登录流程。",
            locations=self_login_hits[:15],
            suggestion="删除自建登录逻辑，改为依赖 weaver-e10-shared 共享规则，并通过 `weaver-work-cli auth ...` 处理会话。"
        ))
    else:
        checks.append(Check(
            "E10 登录与凭证契约", "未自建登录（禁止绕过共享规则）", "PASS", "INFO",
            "未检测到自建登录实现（Playwright/Selenium/扫码/账号密码/自建 OAuth）。"
        ))

    # ---- B10c 三项用户信息参数完整（2026-09-20 停用）----
    # 停用原因：请求头三件套（Cookie 完整原始串 / eteamsid / User-Agent: AgentType=<agentType>,IsAgent=true）
    # 已收敛为「唯一权威声明」，只写在 weaver-e10-shared（连接器版 weaver-e10-shared-connector）入口 SKILL.md
    # 的「请求头契约」章节，业务模块一律改为指针引用，不再各自复制代码块。
    # 本规则按「单个技能目录」独立扫描，无法跨目录看到 shared 的正文，继续启用会把正确的引用写法误判为 FAIL，
    # 因此整段停用；脚本型技能仍由 B9「API 请求凭证完整性」覆盖，不重复判定。
    #
    # if not script_hits and doc_hits:
    #     has_cookie = bool(COOKIE_HEADER_RE.search(joined))
    #     has_eteamsid = bool(ETEAMSID_RE.search(joined))
    #     has_ua = bool(UA_RE.search(joined) and AGENT_TYPE_RE.search(joined) and IS_AGENT_RE.search(joined))
    #     missing = []
    #     if not has_cookie:
    #         missing.append("① Cookie 完整原始串（文档中未说明请求头携带 Cookie）")
    #     if not has_eteamsid:
    #         missing.append("② eteamsid（文档中未出现 eteamsid 字段）")
    #     if not has_ua:
    #         missing.append("③ User-Agent（AgentType=<agentType>,IsAgent=true）")
    #     if missing:
    #         checks.append(Check(
    #             "E10 登录与凭证契约", "接口文档声明完整凭证（Cookie+eteamsid+User-Agent）", "FAIL", "BLOCK",
    #             "当前技能以接口文档形式发起/描述 API 请求，必须明确要求由 weaver-work-cli 托管并携带三项用户信息参数，"
    #             "缺一不可。当前缺失：\n" + "\n".join(f"  · {m}" for m in missing),
    #             locations=surface_locs,
    #             suggestion="在接口文档的「请求头/认证」章节补充：\n"
    #                        "Cookie: <weaver-work-cli 托管登录态的完整原始 Cookie 串>（禁止裁剪/去重/改写）\n"
    #                        "eteamsid: <weaver-work-cli 托管登录态的 ETEAMSID>\n"
    #                        "User-Agent: AgentType=<agentType>,IsAgent=true"
    #         ))
    #     else:
    #         checks.append(Check(
    #             "E10 登录与凭证契约", "接口文档声明完整凭证（Cookie+eteamsid+User-Agent）", "PASS", "INFO",
    #             "接口文档已声明三项强制凭证（Cookie 完整原始串 / eteamsid / User-Agent:AgentType=...,IsAgent=true）。"
    #         ))
    # elif script_hits:
    #     checks.append(Check(
    #         "E10 登录与凭证契约", "接口文档声明完整凭证（Cookie+eteamsid+User-Agent）", "PASS", "INFO",
    #         "脚本型技能的三项凭证校验由「API 凭证完整性」规则覆盖，此处不重复判定。"
    #     ))

    return checks


# ---------------------------------------------------------------------------
# B11 千问连接器就绪性契约（仅 qwenwork 模式触发，BLOCK）
# ---------------------------------------------------------------------------
# 千问「连接器型 skill」硬性要求：执行前必须做 qw_query 连接器就绪性检查，
# 并在 401/403/token 失效时回到 Step 0 重新认证。本检测检查：
# ① frontmatter 是否声明连接器依赖；
# ② 若是，正文/脚本是否包含 qw_query 调用 + 401/403/token 失效处理分支。
QW_CONNECTOR_RE = re.compile(
    r"qwenwork\.settings\.connector\."   # 标准连接器 key 格式（前端处理 key）
    r"|前置\s*依赖.{0,30}连接器"
    r"|依赖.{0,5}\*\*?\s*连接器"
    r"|connector_id|connector_key",
    re.IGNORECASE,
)
QW_QUERY_RE = re.compile(r"\bqw_query\b|\bqw_action\b|\bqw_config\b", re.IGNORECASE)
QW_REFRESH_RE = re.compile(
    r"401|403|token\s*(?:失效|过期|invalid)|回到\s*Step\s*0|重新认证",
    re.IGNORECASE,
)


def check_qwenwork_connector_readiness(skill_dir: Path, name: str, platform: str = "weaver"):
    """B11: 千问连接器就绪性契约（仅在 platform="qwenwork" 触发，weaver 模式直接返回空）。

    千问规范强制要求：依赖连接器的 skill，执行前必须调用 `qw_query` 查询连接器状态，
    并在 401/403/token 失效时回到 Step 0 重新走认证分支。本检测通过以下信号判定：
    ① SKILL.md 含连接器依赖声明（`qwenwork.settings.connector.<类别>.<id>` 或「依赖连接器」表述）
    ② 缺 ① 中任一证据 → 不触发本规则（该 skill 非连接器型，无需校验）
    ③ 缺 `qw_query` / `qw_action` / `qw_config` 关键词 → BLOCK，提示补 Step 0
    ④ 缺 401/403/token 失效处理关键词 → WARN，提示补重新认证分支
    """
    # 本检查在 main() 中已用 platform=="qwenwork" 守护，但函数体内仍做一次防御
    checks = []
    if platform != "qwenwork":
        return checks

    skill_md_path = skill_dir / "SKILL.md"
    if not skill_md_path.exists():
        return checks

    content = skill_md_path.read_text(encoding="utf-8")
    fm_match = re.search(r"^---\n(.*?)\n---", content, re.DOTALL | re.MULTILINE)
    fm = fm_match.group(1) if fm_match else ""
    body = content[fm_match.end():] if fm_match else content

    # ① 检测是否为连接器型 skill
    full_text = fm + "\n" + body
    is_connector_skill = bool(QW_CONNECTOR_RE.search(full_text))

    if not is_connector_skill:
        checks.append(Check(
            "千问契约", "B11 连接器就绪性契约", "PASS", "INFO",
            "未检测到连接器依赖声明（无 `qwenwork.settings.connector.*` / 「依赖连接器」表述），"
            "本 skill 非连接器型，跳过本检查。"
        ))
        return checks

    # ② 检查 qw_query / qw_action 调用
    has_query = bool(QW_QUERY_RE.search(full_text))
    if not has_query:
        checks.append(Check(
            "千问契约", "B11a 连接器就绪性检查(qw_query)", "FAIL", "BLOCK",
            "frontmatter 含连接器依赖声明（连接器型），但全文未提及 `qw_query` / `qw_action` / `qw_config` 调用。"
            "千问硬性要求：执行前必须调用 `qw_query` 查询连接器状态（installed/enabled/agentCanEnable）并按结果分支。",
            suggestion=(
                "在 SKILL.md 开头「前置依赖 / Step 0」段落增加：\n"
                "### Step 0 - 连接器就绪性检查（必做，每次执行前）\n"
                "1. 调用 `qw_query` 查询连接器状态：`key = \"qwenwork.settings.connector.<类别>.<id>\"`\n"
                "2. 根据返回字段分支处理：\n"
                "   - `installed=false` → `qw_action execute install`\n"
                "   - `enabled=false` & `agentCanEnable=true` → `qw_action enable`\n"
                "   - `enabled=true` & `status!=connected` → `qw_action connect`\n"
                "   - `enabled=true` & `status==connected` → 直接进入 Step 1"
            )
        ))
    else:
        checks.append(Check(
            "千问契约", "B11a 连接器就绪性检查(qw_query)", "PASS", "INFO",
            "已检测到 `qw_query` / `qw_action` 调用（Step 0 已实现）。"
        ))

    # ③ 检查 401/403/token 失效处理
    has_refresh = bool(QW_REFRESH_RE.search(full_text))
    if not has_refresh:
        checks.append(Check(
            "千问契约", "B11b 401/403/token 失效回到 Step 0", "WARN", "MEDIUM",
            "frontmatter 含连接器依赖声明，但全文未提及 401/403/token 失效处理。"
            "千问硬性要求：遇到 401/403/token 失效时，必须回到 Step 0 重新走认证分支，不要硬失败。",
            suggestion=(
                "在「常见错误与恢复」段落增加：\n"
                "- **401/403/token 失效** → 回到 Step 0 重新认证分支"
            )
        ))
    else:
        checks.append(Check(
            "千问契约", "B11b 401/403/token 失效回到 Step 0", "PASS", "INFO",
            "已检测到 401/403/token 失效处理分支。"
        ))

    return checks


# ---------------------------------------------------------------------------
# 结构化文件可解析性检测（对齐官方 A17）
# ---------------------------------------------------------------------------

def check_structured_files(skill_dir: Path):
    """A2: 扫描包内所有 .json/.yaml/.yml/.toml 文件，验证可解析性。"""
    checks = []

    broken_files = []
    for p in skill_dir.rglob("*"):
        if not p.is_file():
            continue
        if p.suffix.lower() not in STRUCTURED_EXTS:
            continue
        rel = str(p.relative_to(skill_dir))
        if _is_detector_script(rel, "") or _is_detector_doc(rel, ""):
            continue
        try:
            raw = p.read_text(encoding="utf-8")
        except (UnicodeDecodeError, OSError) as e:
            broken_files.append(f"{rel} → 编码错误: {e}")
            continue
        ext = p.suffix.lower()
        try:
            if ext == ".json":
                json.loads(raw)
            elif ext in (".yaml", ".yml"):
                # 无 PyYAML 时做基本语法检查（冒号/缩进明显错误）
                _basic_yaml_check(raw, rel, broken_files)
            elif ext == ".toml":
                # 无 tomllib 时做基本语法检查
                _basic_toml_check(raw, rel, broken_files)
        except json.JSONDecodeError as e:
            broken_files.append(f"{rel} → JSON 解析失败: {e}")
        except Exception as e:
            broken_files.append(f"{rel} → 解析失败: {e}")

    if broken_files:
        checks.append(Check(
            "基础合规", "结构化文件可解析性", "FAIL", "BLOCK",
            f"检测到 {len(broken_files)} 个 JSON/YAML/TOML 文件解析失败，"
            "格式错误会导致平台加载 skill 时直接报错。",
            locations=broken_files[:20],
            suggestion="修复格式错误：检查引号配对、逗号尾随、缩进一致性、YAML 冒号后空格等。"
        ))
    else:
        # 检查是否有结构化文件
        has_structured = any(
            p.suffix.lower() in STRUCTURED_EXTS
            for p in skill_dir.rglob("*")
            if p.is_file() and not _is_detector_script(str(p.relative_to(skill_dir)), "")
        )
        if has_structured:
            checks.append(Check("基础合规", "结构化文件可解析性", "PASS", "INFO",
                                "包内所有 JSON/YAML/TOML 文件均可正常解析。"))
        else:
            checks.append(Check("基础合规", "结构化文件可解析性", "INFO", "INFO",
                                "包内无 JSON/YAML/TOML 文件，跳过此项。"))

    return checks


def _basic_yaml_check(raw, rel, broken_files):
    """无 PyYAML 依赖时的 YAML 基本语法检查。"""
    lines = raw.splitlines()
    for i, line in enumerate(lines, start=1):
        stripped = line.strip()
        if not stripped or stripped.startswith("#"):
            continue
        # 检查 key: value 格式（冒号后须有空格或为空）
        if ":" in stripped and not stripped.startswith("-"):
            colon_pos = stripped.index(":")
            after = stripped[colon_pos + 1:]
            if after and not after.startswith(" ") and not after.startswith('"') and not after.startswith("'"):
                # 可能是 URL 中的冒号（http:），跳过
                if not re.match(r"https?://", stripped[colon_pos - 4:colon_pos + 1] if colon_pos >= 4 else ""):
                    # 只在明确是 key:value（无空格）时报错
                    key_part = stripped[:colon_pos]
                    if re.match(r"^[A-Za-z_\u4e00-\u9fff][\w\u4e00-\u9fff.-]*$", key_part):
                        broken_files.append(f"{rel}:{i} → YAML 语法警告: 冒号后应有空格")


def _basic_toml_check(raw, rel, broken_files):
    """无 tomllib 时的 TOML 基本语法检查。"""
    lines = raw.splitlines()
    for i, line in enumerate(lines, start=1):
        stripped = line.strip()
        if not stripped or stripped.startswith("#"):
            continue
        # 基本 key = value 检查
        if "=" in stripped and not stripped.startswith("["):
            if not re.match(r"^[A-Za-z_][\w-]*\s*=", stripped):
                # 可能是表内键值对，跳过
                pass


# ---------------------------------------------------------------------------
# 依赖引导完整性检测（对齐官方 G05）
# ---------------------------------------------------------------------------

def check_dependency_guidance(skill_dir: Path):
    """A3: 若包内含 scripts/ 或 bin/ 目录，检查是否有依赖安装/使用说明。"""
    checks = []

    has_scripts = (skill_dir / "scripts").is_dir()
    has_bin = (skill_dir / "bin").is_dir()
    has_package_json = (skill_dir / "package.json").exists()
    has_requirements = any(
        p.name in ("requirements.txt", "Pipfile", "pyproject.toml")
        for p in skill_dir.rglob("*") if p.is_file()
    )

    if not (has_scripts or has_bin or has_package_json or has_requirements):
        checks.append(Check("基础合规", "依赖引导完整性", "INFO", "INFO",
                            "包内无 scripts/bin/依赖声明文件，跳过此项。"))
        return checks

    # 检查 SKILL.md 或 references/ 下是否有安装/使用说明
    guidance_keywords = [
        r"安装", r"install", r"pip\s+install", r"npm\s+install",
        r"依赖", r"requirements", r"环境准备", r"前置条件",
        r"usage", r"用法", r"运行方式", r"快速开始",
    ]
    guidance_found = False
    for rel, text in iter_text_files(skill_dir):
        if _is_detector_script(str(rel), text):
            continue
        for kw in guidance_keywords:
            if re.search(kw, text, re.IGNORECASE):
                guidance_found = True
                break
        if guidance_found:
            break

    if guidance_found:
        checks.append(Check("基础合规", "依赖引导完整性", "PASS", "INFO",
                            "包内含脚本/依赖声明，且已提供安装/使用说明。"))
    else:
        missing = []
        if has_scripts:
            missing.append("scripts/")
        if has_bin:
            missing.append("bin/")
        if has_package_json:
            missing.append("package.json")
        if has_requirements:
            missing.append("Python 依赖声明")
        checks.append(Check(
            "基础合规", "依赖引导完整性", "WARN", "MEDIUM",
            f"包内含 {'、'.join(missing)} 但未找到安装/使用说明。"
            "用户下载后不知道如何安装依赖、配置环境，可能导致技能无法运行。",
            suggestion="在 SKILL.md 或 references/ 中补充「环境准备」「安装步骤」「用法说明」章节，"
                      "列明依赖安装命令、版本要求与配置方法。"
        ))

    return checks


# ---------------------------------------------------------------------------
# 质量启发式检测（对齐官方 Q 组 + C02）
# ---------------------------------------------------------------------------

def check_quality_heuristics(skill_dir: Path, skill_md: Path, body: str):
    """Q 组质量启发式检测：路由触发清晰度、上下文效率、容错降级、字段字数。"""
    checks = []

    file_texts = {}
    for rel, text in iter_text_files(skill_dir):
        if _is_detector_script(str(rel), text) or _is_detector_doc(str(rel), text):
            continue
        file_texts[str(rel)] = text

    # Q1: 路由触发清晰度——SKILL.md 是否有明确的触发场景/关键词描述
    trigger_keywords = [
        r"当用户", r"触发", r"适用场景", r"使用场景", r"何时使用",
        r"trigger", r"when to use", r"适用范围", r"使用时机",
        r"关键词", r"触发词", r"识别条件",
    ]
    has_trigger = False
    if body:
        for kw in trigger_keywords:
            if re.search(kw, body, re.IGNORECASE):
                has_trigger = True
                break
    if has_trigger:
        checks.append(Check("质量评估", "路由触发清晰度", "PASS", "INFO",
                            "SKILL.md 含明确的触发场景/关键词描述，有利于平台路由匹配。"))
    else:
        checks.append(Check(
            "质量评估", "路由触发清晰度", "WARN", "LOW",
            "SKILL.md 未检测到明确的触发场景/关键词描述。"
            "平台依赖描述文本来匹配用户意图，缺少触发词可能导致技能无法被正确路由。",
            suggestion="在 SKILL.md 中补充「适用场景」「触发条件」章节，列明何时应使用本技能。"
        ))

    # Q2: 上下文效率——检查是否有超大文件（>100KB 的单个 .md 文件）
    oversized = []
    for rel, text in file_texts.items():
        if rel.suffix.lower() in (".md", ".markdown") if hasattr(rel, 'suffix') else str(rel).lower().endswith((".md", ".markdown")):
            size = len(text.encode("utf-8"))
            if size > 100 * 1024:  # 100KB
                oversized.append(f"{rel} → {_human_size(size)}")
    if oversized:
        checks.append(Check(
            "质量评估", "上下文效率", "WARN", "LOW",
            f"检测到 {len(oversized)} 个超大 Markdown 文件（>100KB）。"
            "过大的参考文件会占用大量上下文窗口，影响 Agent 处理效率。",
            locations=oversized[:10],
            suggestion="拆分大文件为多个模块化小文件，或使用渐进披露策略按需加载。"
        ))
    else:
        checks.append(Check("质量评估", "上下文效率", "PASS", "INFO",
                            "未检测到超大文件，上下文负载合理。"))

    # Q3: 容错降级——脚本中是否有 try/catch 或 error handling
    script_files = {rel: text for rel, text in file_texts.items()
                    if str(rel).lower().endswith(SCRIPT_EXTS)}
    if script_files:
        has_error_handling = False
        error_handling_patterns = re.compile(
            r"\btry\s*[:({]|\bcatch\s*\(|\bexcept\b|\bfinally\b|"
            r"\.catch\s*\(|\bthrow\b|\braise\b|"
            r"onError|on_error|error_handler|"
            r"if\s+__name__|if\s+err|if\s+error",
            re.IGNORECASE)
        for rel, text in script_files.items():
            if error_handling_patterns.search(text):
                has_error_handling = True
                break
        if has_error_handling:
            checks.append(Check("质量评估", "容错降级", "PASS", "INFO",
                                "脚本中检测到错误处理逻辑（try/catch/except 等）。"))
        else:
            checks.append(Check(
                "质量评估", "容错降级", "WARN", "LOW",
                "包内含脚本但未检测到错误处理逻辑（try/catch/except 等）。"
                "缺少容错降级会导致异常时直接崩溃，用户体验不佳。",
                suggestion="在关键逻辑处添加 try/except（Python）或 try/catch（JS），"
                          "提供友好的错误提示与降级方案。"
            ))
    else:
        checks.append(Check("质量评估", "容错降级", "INFO", "INFO",
                            "包内无脚本文件，跳过此项。"))

    # Q4 / C02: display_name 字数检查 + description 字数上限
    if skill_md and skill_md.exists():
        content = skill_md.read_text(encoding="utf-8")
        fm, _, _ = extract_frontmatter(content)
        if fm:
            dn = parse_frontmatter_field(fm, "display_name") or ""
            if len(dn) > 20:
                checks.append(Check(
                    "质量评估", "display_name 简洁性", "WARN", "LOW",
                    f"display_name 长度 {len(dn)} 字，建议控制在 20 字以内以便展示。",
                    suggestion="精简 display_name，保留核心信息。"
                ))
            else:
                checks.append(Check("质量评估", "display_name 简洁性", "PASS", "INFO",
                                    f"display_name 长度 {len(dn)} 字，简洁。"))

            desc = parse_frontmatter_field(fm, "description") or ""
            if len(desc) > 200:
                checks.append(Check(
                    "质量评估", "description 字数控制", "WARN", "LOW",
                    f"description 长度 {len(desc)} 字，建议控制在 200 字以内（一句话概述），"
                    "详细内容放在 description_zh/description_en。",
                    suggestion="精简 description 为 1-2 句摘要。"
                ))
            else:
                checks.append(Check("质量评估", "description 字数控制", "PASS", "INFO",
                                    f"description 长度 {len(desc)} 字。"))
        else:
            checks.append(Check("质量评估", "字段字数检查", "INFO", "INFO",
                                "无 frontmatter，跳过字数检查。"))
    else:
        checks.append(Check("质量评估", "字段字数检查", "INFO", "INFO",
                            "无 SKILL.md，跳过字数检查。"))

    return checks


def check_threat_model(skill_dir: Path):
    """
    威胁捕获模型视图：将 skill 行为映射到 8 类风险卡片。
    返回 8 个 Check（PASS/INFO/WARN），分别对应一张风险卡片。
    """
    checks = []
    file_texts = {}
    for rel, text in iter_text_files(skill_dir):
        if _is_detector_script(str(rel), text) or _is_detector_doc(str(rel), text):
            continue
        file_texts[str(rel)] = text

    def _hits(pattern, script_only=False):
        out = []
        for rel, text in file_texts.items():
            if script_only and not rel.lower().endswith(SCRIPT_EXTS):
                continue
            for i, line in enumerate(scan_lines(text), start=1):
                if pattern.search(line):
                    out.append(f"{rel}:{i} → {line.strip()[:120]}")
        return out

    def _any(pattern, script_only=False):
        return bool(_hits(pattern, script_only))

    # 1) 供应链风险
    supply_hits = _hits(DEPENDENCY_INSTALL_RE)
    if supply_hits:
        checks.append(Check(
            "威胁模型", "供应链风险", "WARN", "MEDIUM",
            "检测到 skill 执行依赖包安装或远程脚本拉取，存在供应链投毒 / 恶意依赖风险。",
            locations=supply_hits[:10],
            suggestion="固定依赖版本并校验来源；避免安装来源不明的包或执行远程脚本。"
        ))
    else:
        checks.append(Check("威胁模型", "供应链风险", "PASS", "INFO", "未检测到依赖安装 / 远程脚本拉取行为。"))

    # 2) 命令执行风险
    cmd_hits = _hits(COMMAND_EXEC_RE, script_only=True)
    if cmd_hits:
        checks.append(Check(
            "威胁模型", "命令执行风险", "WARN", "HIGH",
            "检测到 eval/exec/spawn/subprocess/system 等命令执行点。",
            locations=cmd_hits[:10],
            suggestion="避免动态执行外部命令；必须使用时严格限制命令白名单与参数范围。"
        ))
    else:
        checks.append(Check("威胁模型", "命令执行风险", "PASS", "INFO", "未检测到动态命令执行点。"))

    # 3) 网络请求与数据外传
    net_hits = _hits(NETWORK_RE, script_only=True)
    if net_hits:
        checks.append(Check(
            "威胁模型", "网络请求与数据外传", "WARN", "MEDIUM",
            "检测到 HTTP / requests / curl / fetch 等网络请求能力，可能将用户数据外传到第三方。",
            locations=net_hits[:10],
            suggestion="明确请求目标域名与用途；禁止将用户隐私、会话凭证回传到非授权地址。"
        ))
    else:
        checks.append(Check("威胁模型", "网络请求与数据外传", "PASS", "INFO", "未检测到网络请求能力。"))

    # 4) 文件操作与敏感路径
    file_hits = _hits(FILE_IO_RE, script_only=True)
    path_hits = _hits(SENSITIVE_PATH_RE, script_only=False)
    if file_hits or path_hits:
        checks.append(Check(
            "威胁模型", "文件操作与敏感路径", "WARN", "MEDIUM",
            "检测到文件读写操作或敏感路径引用。",
            locations=(file_hits + path_hits)[:10],
            suggestion="限制文件操作范围；禁止读取系统凭证、SSH 私钥、浏览器 Cookie 等敏感路径。"
        ))
    else:
        checks.append(Check("威胁模型", "文件操作与敏感路径", "PASS", "INFO", "未检测到文件操作或敏感路径引用。"))

    # 5) Prompt 注入风险
    # 否定性安全声明（如「不要要求用户直接粘贴 ETEAMSID」）属合规正向约束，不计入诱导贴凭证
    cred_hits = [h for h in _hits(PROMPT_CRED_RE) if not NEGATION_SAFE_RE.search(h)]
    prompt_hits = cred_hits + _hits(PROMPT_INJECTION_RE)
    if prompt_hits:
        checks.append(Check(
            "威胁模型", "Prompt 注入风险", "WARN", "HIGH",
            "检测到提示词模板诱导用户粘贴会话凭证，或存在典型 prompt 注入话术。",
            locations=prompt_hits[:10],
            suggestion="禁止要求用户将 ETEAMSID/token/password 等凭证明文贴入对话；避免使用忽略指令类话术。"
        ))
    else:
        checks.append(Check("威胁模型", "Prompt 注入风险", "PASS", "INFO", "未检测到 prompt 注入或诱导贴凭证行为。"))

    # 6) 远程脚本下载执行
    remote_hits = _hits(REMOTE_SCRIPT_EXEC_RE)
    if remote_hits:
        checks.append(Check(
            "威胁模型", "远程脚本下载执行", "WARN", "HIGH",
            "检测到 curl | bash、powershell -enc、远程 .py/.sh 脚本执行等远程代码执行模式。",
            locations=remote_hits[:10],
            suggestion="禁止下载并直接执行远程脚本；如需外部脚本应提供内容校验与来源说明。"
        ))
    else:
        checks.append(Check("威胁模型", "远程脚本下载执行", "PASS", "INFO", "未检测到远程脚本下载执行。"))

    # 7) 可疑编码/混淆
    obf_hits = _hits(OBFUSCATION_RE, script_only=True)
    if obf_hits:
        checks.append(Check(
            "威胁模型", "可疑编码/混淆", "WARN", "MEDIUM",
            r"检测到 eval(atob/base64)、长串 base64、\x/\u 转义序列等可疑混淆特征。",
            locations=obf_hits[:10],
            suggestion="避免使用 eval 执行动态字符串；对外分发前解码并审计混淆内容。"
        ))
    else:
        checks.append(Check("威胁模型", "可疑编码/混淆", "PASS", "INFO", "未检测到可疑编码/混淆特征。"))

    # 8) 其他安全风险（沙箱绕过、隐私采集、反调试等）
    sandbox_hits = _hits(SANDBOX_BYPASS_RE)
    privacy_hits = _hits(OTHER_PRIVACY_RE)
    if sandbox_hits or privacy_hits:
        checks.append(Check(
            "威胁模型", "其他安全风险", "WARN", "HIGH",
            "检测到沙箱绕过参数（--no-sandbox 等）或屏幕截图、录音、剪贴板读取等隐私采集能力。",
            locations=(sandbox_hits + privacy_hits)[:10],
            suggestion="禁止默认关闭浏览器沙箱；采集屏幕/剪贴板/录音等敏感能力必须明示用户并获得授权。"
        ))
    else:
        checks.append(Check("威胁模型", "其他安全风险", "PASS", "INFO", "未检测到沙箱绕过或隐私采集特征。"))

    return checks


def check_behavior_graph(skill_dir: Path):
    """
    威胁行为图谱解析：针对截图中展示的具体跨文件/跨行行为做联合检测。
    """
    checks = []
    file_texts = {}
    for rel, text in iter_text_files(skill_dir):
        if _is_detector_script(str(rel), text) or _is_detector_doc(str(rel), text):
            continue
        file_texts[str(rel)] = text

    all_text = "\n".join(file_texts.values())

    # 1) 沙箱绕过：dangerouslyDisableSandbox + run_in_background 同时出现
    has_disable = DISABLE_SANDBOX_FLAG_RE.search(all_text)
    has_bg = RUN_BACKGROUND_FLAG_RE.search(all_text)
    if has_disable and has_bg:
        locations = []
        for rel, text in file_texts.items():
            for i, line in enumerate(scan_lines(text), start=1):
                if DISABLE_SANDBOX_FLAG_RE.search(line) or RUN_BACKGROUND_FLAG_RE.search(line):
                    locations.append(f"{rel}:{i} → {line.strip()[:120]}")
        checks.append(Check(
            "行为图谱", "dangerouslyDisableSandbox + run_in_background 绕过权限确认", "WARN", "HIGH",
            "检测到 skill 同时出现 dangerouslyDisableSandbox: true 与 run_in_background: true，"
            "可能用于在跳过默认工具权限确认的 Bash 工具中启动浏览器并关闭沙箱。",
            locations=locations[:10],
            suggestion="禁止默认禁用沙箱；浏览器启动须经用户授权，保留沙箱保护。"
        ))
    else:
        checks.append(Check(
            "行为图谱", "dangerouslyDisableSandbox + run_in_background 绕过权限确认", "PASS", "INFO",
            "未检测到 dangerouslyDisableSandbox 与 run_in_background 组合。"))

    # 2) 浏览器启动参数关闭沙箱
    sandbox_arg_hits = []
    for rel, text in file_texts.items():
        if not rel.lower().endswith(SCRIPT_EXTS + (".md", ".markdown", ".txt")):
            continue
        for i, line in enumerate(scan_lines(text), start=1):
            if SANDBOX_BYPASS_RE.search(line):
                sandbox_arg_hits.append(f"{rel}:{i} → {line.strip()[:120]}")
    if sandbox_arg_hits:
        checks.append(Check(
            "行为图谱", "浏览器启动参数关闭沙箱", "WARN", "HIGH",
            "检测到 --no-sandbox、--disable-gpu、--disable-setuid-sandbox 等浏览器启动参数。",
            locations=sandbox_arg_hits[:10],
            suggestion="浏览器自动化应保留沙箱；确需关闭时须向用户明确说明风险并获得授权。"
        ))
    else:
        checks.append(Check(
            "行为图谱", "浏览器启动参数关闭沙箱", "PASS", "INFO",
            "未检测到关闭沙箱的浏览器启动参数。"))

    # 3) 提示词诱导用户明文粘贴 ETEAMSID
    prompt_cred_hits = []
    for rel, text in file_texts.items():
        if not rel.lower().endswith((".md", ".markdown", ".txt")):
            continue
        for i, line in enumerate(scan_lines(text), start=1):
            if PROMPT_CRED_RE.search(line):
                if NEGATION_SAFE_RE.search(line):
                    continue  # 否定性安全声明（如「不要要求用户直接粘贴」），非诱导贴凭证
                prompt_cred_hits.append(f"{rel}:{i} → {line.strip()[:120]}")
    if prompt_cred_hits:
        checks.append(Check(
            "行为图谱", "提示词诱导用户明文粘贴会话凭证", "WARN", "HIGH",
            "检测到提示词模板要求用户将 ETEAMSID / token / session 等会话凭证明文粘贴进对话，"
            "使可复用会话凭证以明文进入跨主体持久化的 Agent 上下文。",
            locations=prompt_cred_hits[:20],
            suggestion="严禁在提示词中要求用户贴出凭证；凭证须通过 E10 命名凭据插件安全获取。"
        ))
    else:
        checks.append(Check(
            "行为图谱", "提示词诱导用户明文粘贴会话凭证", "PASS", "INFO",
            "未检测到诱导用户粘贴会话凭证的提示词。"))

    return checks


def check_skill_capabilities(skill_dir: Path):
    """
    技能能力清单：识别 skill 具备的运行时能力，以 INFO 形式列出，便于上架前透明披露。
    """
    file_texts = {}
    for rel, text in iter_text_files(skill_dir):
        if _is_detector_script(str(rel), text) or _is_detector_doc(str(rel), text):
            continue
        file_texts[str(rel)] = text
    all_text = "\n".join(file_texts.values()).lower()

    caps = []
    if re.search(r"\b(requests\.|urllib|http\.client|httpx|aiohttp|axios|fetch\(|curl\b|wget\b)\b", all_text):
        caps.append("HTTP 请求")
    if EXTERNAL_API_RE.search(all_text):
        caps.append("调用外部 API")
    if re.search(r"\b(open\s*\(|read_text\(|read_file\(|readFileSync|readFile\(|fs\.read|Path\([^)]*\)\.read_text)", all_text):
        caps.append("读取文件")
    if re.search(r"\b(write_text\(|write_file\(|writeFileSync|writeFile\(|fs\.write|Path\([^)]*\)\.write_text)", all_text):
        caps.append("写入文件")
    if DEPENDENCY_INSTALL_RE.search(all_text):
        caps.append("安装依赖包")
    if BROWSER_CONTROL_RE.search(all_text):
        caps.append("控制浏览器")
    if ENV_READ_RE.search(all_text):
        caps.append("读取环境变量")
    if COMMAND_EXEC_RE.search(all_text):
        caps.append("执行系统命令")
    if re.search(r"\b(sqlite3|pymysql|psycopg2|sqlalchemy|\.query\(|SELECT\s+.*FROM)\b", all_text, re.IGNORECASE):
        caps.append("数据库访问")
    if re.search(r"\b(socket\.|listen\(|connect\(|bind\(|tcp://|udp://|websocket|ws://|wss://)\b", all_text):
        caps.append("网络监听 / Socket / WebSocket")

    if not caps:
        caps.append("未识别到特定运行时能力（可能为纯文档/提示词类 skill）")

    return [Check(
        "技能能力清单", "识别到的能力", "INFO", "INFO",
        f"本 skill 源码/文档中识别到以下运行时能力：{', '.join(caps)}。",
        suggestion="上架时建议在说明中如实披露这些能力，便于用户与平台评估权限范围。"
    )]


SKILL_DEP_FIELDS = ("dependencies", "requires", "depends_on", "depends", "skills", "uses")

# 中文引述场景下的非技能名词，避免把「使用本技能」「结合通用技能」等常见表述误判为依赖
DEP_STOPWORDS = {
    "本", "该", "此", "某", "一个", "一种", "多个", "相关", "其他", "其它", "对应",
    "以下", "上述", "如上", "我们", "你们", "他们", "平台", "工作", "检测", "分析",
    "通用", "标准", "规范", "基础", "核心", "高级", "专属", "内置", "外部", "已有",
    "当前", "整个", "所有", "这些", "那些", "这种", "此类", "一切", "全部",
    "自身", "自己", "本技能", "该技能", "此技能", "其他技能",
    # 常见路径段，避免把 /tmp、/usr、/data 等误判为技能依赖
    "tmp", "usr", "home", "var", "etc", "opt", "bin", "lib", "dev", "src", "root",
    "user", "app", "data", "docs", "doc", "test", "tests", "logs", "cache", "mnt",
    # 口语/疑问词与通用路径段，避免把「使用哪个技能」「/api/xxx」误判为依赖
    "哪个", "什么", "如何", "怎么", "哪些", "这个", "那个", "此处",
    "api", "count", "v1", "v2", "rest", "graphql", "oauth", "openapi", "swagger",
}


def looks_like_skill_name(name: str) -> bool:
    """
    稳健判定「看起来像个泛微生态技能名」。仅接受一种高置信形状：
      weaver-e10- 前缀 + 连字符：weaver-e10- 开头且必须含连字符
      （如 weaver-e10-shared、weaver-e10-hrm）。
    依赖检测**只关注 weaver-e10- 前缀**的泛微生态技能（合作方要求），weaver- 但非
    e10-/e10-/eteams- 等前缀视为平台模块名/内部引用，不作为技能依赖识别。
    这样可排除：weaver-ebuilder-*/weaver-openserver-* 等内部接口模块、单单词
    （send/list/api）、中文片段（以下外部/调用本）、URL 路径段（/references/、/send/）、
    文档内函数/参数名（hrm-employee-query、emp-by-ids、to-uid）、全大写常量/环境变量
    （ETEAMSID、WEAVER_AUTH_ENTRY，无连字符故不命中）。
    说明：frontmatter 中**显式声明**的依赖（dependencies/requires 字段）同样受
    weaver-e10- 前缀约束——非 weaver-e10- 前缀的一律不识别为技能依赖。
    """
    s = (name or "").strip().strip('"\'`').strip()
    if not s or len(s) < 2:
        return False
    return bool(re.fullmatch(r"weaver-e10-[\w-]+", s, re.IGNORECASE))


def check_skill_dependencies(skill_dir: Path, skill_md: Path = None, market_catalog: dict = None, live_market: bool = False):
    """
    技能依赖清单：识别 skill 显式声明（frontmatter dependencies/requires 等字段）
    或正文引用（skill: 调用、/斜杠命令、「使用 xxx 技能」表述）的其他技能，
    以 INFO 形式透明披露依赖关系，便于上架前评估依赖面与联动安装。

    market_catalog: 可选，市场已收录技能清单（name(lower) -> info）。传入后对每个依赖标注可达性。
    live_market:    可选，True 时联网动态核查 skillhub.cn 市场（覆盖 market_catalog，按 slug 精确匹配）。
                    二者都不传则仅静态列出依赖（零联网）。
    """
    deps, seen = [], set()
    self_names = {Path(skill_dir).name.lower(), "weaver-skill-detector", "validate_skill"}

    def add(name, strict: bool = False):
        name = (name or "").strip().strip('"\'`').strip()
        if not name or len(name) < 2:
            return
        low = name.lower()
        if low in seen or low in self_names or low in DEP_STOPWORDS:
            return
        # 合作方要求：依赖检测只关注 weaver-e10- 前缀的泛微生态技能。
        # 所有来源（frontmatter 显式声明 / 斜杠命令 / 中文引述 / 反引号标识符 / 由 xxx 提供）
        # 一律统一收口于此：非 weaver-e10- 前缀不识别为技能依赖
        # （weaver-ebuilder-/weaver-openserver-/e10-/eteams- 视为平台模块/内部引用）。
        if not low.startswith("weaver-e10-"):
            return
        # strict=True：启发式命中（斜杠命令/中文引述/由 xxx 提供/反引号标识符）必须通过
        # 「看起来像技能名」判定，过滤单单词与中文/路径噪声；frontmatter 显式声明走 strict=False。
        if strict and not looks_like_skill_name(name):
            return
        seen.add(low)
        deps.append(name)

    # 1) SKILL.md frontmatter 显式声明
    if skill_md is not None and skill_md.exists():
        text = skill_md.read_text(encoding="utf-8", errors="replace")
        fm, _, _ = extract_frontmatter(text)
        if fm:
            nm = parse_frontmatter_field(fm, "name")
            if nm:
                self_names.add(nm.strip().strip('"\'`').lower())
            for key in SKILL_DEP_FIELDS:
                # ① 内联值：dependencies: a, b 或 dependencies: [a, b]
                val = parse_frontmatter_field(fm, key)
                if val:
                    for part in re.split(r"[,，;；]", val):
                        part = part.strip().strip('[]"\'`')
                        if part and not part.startswith("-"):
                            add(part)  # frontmatter 显式声明：高置信度，不走 strict 判定
                # ② YAML 块列表：key: 换行后缩进的 "- item" 行
                block = re.search(r"^\s*" + re.escape(key) + r"\s*:\s*\n((?:\s*-\s+.*\n?)+)", fm, re.MULTILINE)
                if block:
                    for m in re.finditer(r"^\s*-\s+(.+)$", block.group(1), re.MULTILINE):
                        add(m.group(1))  # frontmatter 显式声明：高置信度，不走 strict 判定
            # ③ description 中的依赖表述：「接口地址与认证由 weaver-e10-shared 提供」
            for m in re.finditer(r"^\s*description(?:_\w+)?\s*:\s*(.+)$", fm, re.MULTILINE):
                for dm in re.finditer(r"由\s*([A-Za-z0-9][A-Za-z0-9._-]{1,60})\s*(?:提供|负责|承担)", m.group(1)):
                    add(dm.group(1), strict=True)

    # 2) 文档引用：仅从 Markdown 文档提取。
    #    - 主 SKILL.md：提示词所在，识别 skill: 调用 / 斜杠命令 / 中文引述 / 「由 xxx 提供」
    #    - references/*.md：多为接口手册，/api/xxx 类路径不应误判为技能依赖，仅识别中文引述与「由 xxx 提供」
    for rel, text in iter_text_files(skill_dir):
        if _is_detector_script(str(rel), text) or _is_detector_doc(str(rel), text):
            continue
        rel_low = str(rel).lower()
        if not rel_low.endswith((".md", ".markdown")):
            continue
        is_main = rel.name.upper() == "SKILL.MD"
        if is_main:
            # Skill 工具调用：skill: "xxx" / skill = xxx
            for m in re.finditer(r"\bskill\s*[:=]\s*['\"]?([A-Za-z0-9][A-Za-z0-9._/-]{1,60})['\"]?", text):
                add(m.group(1), strict=True)
            # 斜杠命令 /xxx：真正的 skill 斜杠命令是孤立 token（如 /commit、/review-pr），其后不接 `/`。
            # 负向前瞻排除 `/`：接 `/` 的是路径/URL 片段（如 /references/、/viewport/、/api/ebuilder/...、
            # skillhub.cn/skills/...），并非技能依赖，避免误判。命中再经 strict 标识符形状校验。
            for m in re.finditer(r"(?<![:/\w<>])\/([a-z][a-z0-9_-]{2,40})(?![A-Za-z0-9_/-])", text):
                add(m.group(1), strict=True)
            # 依赖清单式列举：「只读依赖：`a`、`b`、`c`」「依赖：`x`」（容错 markdown 加粗 ** 与空格）
            for m in re.finditer(r"依赖[*\s]*[：:][^。\n]*", text):
                for part in re.findall(r"[`'](weaver-e10-[\w-]+)[`']", m.group(0)):
                    add(part, strict=True)
        # 泛微生态高精度信号：反引号/单引号包裹的 weaver-e10- 前缀带连字符标识符
        # （如 `weaver-e10-shared`）。self_names 已排除检测器自身（weaver-skill-detector）。
        for m in re.finditer(r"[`'](weaver-e10-[\w-]+)[`']", text, re.IGNORECASE):
            add(m.group(1), strict=True)
        # 中文引述：「使用 xxx 技能」「调用 xxx skill」「委托 xxx skill」——命中须通过 strict 标识符形状校验，
        # 因此「依赖以下外部 skill」「调用本 skill」「结合通用的技能」均不会误判为依赖。
        for m in re.finditer(r"(?:使用|加载|调用|引用|依赖|基于|借助|通过|结合|委托)\s*([`']?[\u4e00-\u9fa5A-Za-z0-9_-]{2,40}[`']?)\s*(?:技能|skill(?!\.md))\b", text, re.IGNORECASE):
            g = m.group(1)
            if g.startswith("的"):
                continue
            # 自指片段（调用本/本技能/该技能/此技能等）指称本 skill 自身，非外部依赖，应跳过
            if re.search(r"(本|该|此|自身|自己|这个|那个)(技能|skill)?\s*$", g):
                continue
            # 非反引号中文片段若以「的」结尾（如 执行的/可执行的），属修饰语而非技能名，跳过
            if not g.startswith("`") and g.endswith("的"):
                continue
            # 反引号包裹的视为代码标识符（如 `weaver-e10-shared`），不受中文短语长度限制；
            # 非反引号的中文短语仍限制 ≤16 字，避免长句误判为依赖
            if not g.startswith("`") and len(g) > 16:
                continue
            add(g, strict=True)
        # 「由 xxx 提供/负责」表述（容错「会话」等中间词：由 weaver-e10-shared 会话提供）
        for m in re.finditer(r"由\s*([A-Za-z0-9][A-Za-z0-9._-]{1,60})\s*(?:会话\s*)?(?:提供|负责|承担)", text):
            add(m.group(1), strict=True)

    if not deps:
        detail = "未识别到本 skill 依赖的其他技能（可能为独立技能，无需联动安装）。"
    else:
        detail = f"本 skill 显式声明或正文引用以下其他技能：{', '.join(deps)}。"

    # 动态市场核查（联网）：覆盖静态清单，按 slug 精确匹配 skillhub.cn
    # add() 已统一收口为 weaver-e10- 前缀依赖（合作方要求：只检测 weaver-e10- 开头），
    # 此处 _is_weaver_dep 作为保险保留，防止未来扩展非前缀依赖时漏掉过滤。
    def _is_weaver_dep(d: str) -> bool:
        return d.lower().startswith("weaver-e10-")

    market_deps = [d for d in deps if _is_weaver_dep(d)] if deps else []
    if live_market and market_deps:
        try:
            market_catalog = query_skillhub_market(market_deps)
            skipped_n = len(deps) - len(market_deps)
            log(f"已动态核查 skillhub.cn 市场: {len(market_deps)} 个 weaver-e10- 前缀依赖"
                + (f"（{skipped_n} 个非 weaver-e10- 前缀依赖跳过核查）" if skipped_n else ""))
        except Exception as e:
            log(f"⚠️ 市场动态核查失败（将仅静态列出依赖，不标注可达性）: {e}")
            market_catalog = None

    dep_status = None
    if market_catalog is not None:
        dep_status = []
        for d in deps:
            if not _is_weaver_dep(d):
                dep_status.append({"name": d, "in_market": None, "market": "", "url": "",
                                   "error": "", "skipped": True})
                continue
            info = market_catalog.get(d.lower()) or {"found": False, "market": "", "url": "", "error": ""}
            dep_status.append({
                "name": d,
                "in_market": bool(info.get("found")),
                "market": info.get("market", ""),
                "url": info.get("url", ""),
                "error": info.get("error", ""),
                "skipped": False,
            })
    extra = {"deps": dep_status, "market_checked": market_catalog is not None} if dep_status is not None else None

    return [Check(
        "技能依赖清单", "依赖的其他技能", "INFO", "INFO", detail,
        suggestion="上架时建议如实披露依赖关系，确保被依赖技能已上架、名称一致且版本兼容。"
                  + (" 已对照市场清单标注各依赖可达性。" if extra else ""),
        extra=extra
    )]


def _human_size(num: int) -> str:
    """把字节数转换为人类可读大小。"""
    units = ["B", "KB", "MB", "GB", "TB"]
    size = float(num)
    for unit in units:
        if size < 1024 or unit == units[-1]:
            return f"{size:.2f} {unit}" if unit != "B" else f"{int(size)} B"
        size /= 1024
    return f"{size:.2f} TB"


def load_market_catalog(path: str) -> dict:
    """
    加载「市场已收录技能清单」为 {name(lower): info} 映射，供依赖可达性标注使用。

    info 结构: {"found": bool, "market": str, "url": str}
      - found : 是否在目标市场可搜（缺失键视为 found=False）
      - market: 市场/平台名称（如 skillhub.cn），用于报告中展示可达性来源
      - url   : 可选，技能在市场中的地址

    支持三种 JSON 格式：
      - 列表   ：["skill-a", "skill-b"]                      -> 均标记 found=True（市场名留空，由图例说明平台）
      - 简单对象：{"skill-a": true, "skill-b": false}        -> 显式标注可达性
      - 富对象 ：{"skill-a": {"found": true, "market": "skillhub.cn", "url": "https://..."}}
        也可简写为 {"skill-a": "skillhub.cn"}（字符串视为市场名，found=True）
    """
    p = Path(path).expanduser()
    if not p.exists():
        raise FileNotFoundError(f"市场清单文件不存在: {path}")
    data = json.loads(p.read_text(encoding="utf-8"))

    def norm(v):
        if isinstance(v, bool):
            return {"found": v, "market": "", "url": ""}
        if isinstance(v, str):
            return {"found": True, "market": v.strip(), "url": ""}
        if isinstance(v, dict):
            found = bool(v.get("found", True)) and not bool(v.get("not_found", False))
            return {"found": found, "market": v.get("market", "") or "", "url": v.get("url", "") or ""}
        return {"found": False, "market": "", "url": ""}

    catalog = {}
    if isinstance(data, list):
        for item in data:
            if isinstance(item, str) and item.strip():
                catalog[item.strip().lower()] = {"found": True, "market": "", "url": ""}
    elif isinstance(data, dict):
        for k, v in data.items():
            catalog[str(k).strip().lower()] = norm(v)
    return catalog


def query_skillhub_market(names):
    """
    动态联网核查给定技能名是否已在 skillhub.cn 市场发布（依赖可达性）。

    返回 {name(lower): {"found": bool, "market": "skillhub.cn", "url": str, "error": str}}。
    使用 skillhub.cn 公开检索接口 GET /api/skills?page=1&pageSize=24&sortBy=score&order=desc&keyword=<name>
    （与 skillhub.cn 前端实际发出的请求一致），按 **slug 精确相等**（或 canonicalName 以 /<name> 结尾）
    判定，避免子串误命中
    （例如 keyword=weaver-e10-shared 会同时召回其它共享规则相关结果，必须精确匹配）。

    设计取舍：本工具默认仍是「纯静态、零联网」。此函数为可选的 --market-live 模式提供
    联网能力，把「是否可搜」的判定交给 skillhub.cn 实时返回，而非依赖一份写死的清单。
    """
    catalog = {}
    for name in names:
        low = name.lower()
        entry = {"found": False, "market": "skillhub.cn", "url": "", "error": ""}
        try:
            q = urllib.parse.quote(name)
            url = f"{SKILLHUB_API}?page=1&pageSize=24&sortBy=score&order=desc&keyword={q}"
            req = urllib.request.Request(
                url, headers={"User-Agent": "weaver-skill-detector/1.0 (+https://skillhub.cn)"}
            )
            with urllib.request.urlopen(req, timeout=15) as resp:
                payload = json.loads(resp.read().decode("utf-8"))
            skills = (payload.get("data") or {}).get("skills") or []
            for s in skills:
                slug = (s.get("slug") or "").lower()
                ns = s.get("namespace") or {}
                canonical = (ns.get("canonicalName") or "") if isinstance(ns, dict) else ""
                if slug == low or canonical.lower().endswith("/" + low):
                    entry = {
                        "found": True,
                        "market": "skillhub.cn",
                        "url": f"https://skillhub.cn/skills/{canonical.lstrip('@')}" if canonical
                               else f"https://skillhub.cn/skills/{slug}",
                        "error": "",
                    }
                    break
        except Exception as e:  # 网络/解析失败：标记错误，交由调用方以「核查失败」呈现
            entry["error"] = str(e)
        catalog[low] = entry
    return catalog


def calc_file_info(target: Path, source_type: str):
    """计算被检测对象的基础信息（MD5 / SHA256 / 大小）。"""
    if not target.exists():
        return None

    md5 = hashlib.md5()
    sha = hashlib.sha256()

    if source_type == "directory":
        total = 0
        files = sorted(p for p in target.rglob("*") if p.is_file())
        for f in files:
            total += f.stat().st_size
            rel = str(f.relative_to(target)).replace("\\", "/")
            md5.update(rel.encode("utf-8"))
            md5.update(b"\x00")
            sha.update(rel.encode("utf-8"))
            sha.update(b"\x00")
            with open(f, "rb") as fh:
                for chunk in iter(lambda: fh.read(65536), b""):
                    md5.update(chunk)
                    sha.update(chunk)
        return {
            "md5": md5.hexdigest(),
            "sha256": sha.hexdigest(),
            "size": total,
            "size_human": _human_size(total),
            "scope": "目录递归",
        }

    # zip / markdown / 其他文件：对文件本身计算
    total = target.stat().st_size
    with open(target, "rb") as fh:
        for chunk in iter(lambda: fh.read(65536), b""):
            md5.update(chunk)
            sha.update(chunk)
    return {
        "md5": md5.hexdigest(),
        "sha256": sha.hexdigest(),
        "size": total,
        "size_human": _human_size(total),
        "scope": "文件本身",
    }


# ---------------------------------------------------------------------------
# 报告生成
# ---------------------------------------------------------------------------

def summarize(checks):
    errors = [c for c in checks if c.severity == "BLOCK" and c.status == "FAIL"]
    warns = [c for c in checks if c.status == "WARN"]
    if errors:
        verdict = "FAIL"
    elif warns:
        verdict = "WARN"
    else:
        verdict = "PASS"
    return verdict, len(errors), len(warns)


# ---------------------------------------------------------------------------
# 约束归属（WB = WorkBuddy 平台约束 / WV = 泛微专属约束）
#   WB    ：skillhub.cn 平台加载器与上传校验要求，任何作者的 skill 都必须满足，违反即被平台拒收。
#   WV    ：泛微生态内部治理要求（品牌、E10 账号与接口调用契约、安全合规），平台不强制但泛微不放行。
#   WB+WV ：平台有通用要求，泛微在其上追加了更严的约束。
# 详见 references/checklist.md 第 0 节「约束归属总览」。
# ---------------------------------------------------------------------------
CATEGORY_ORIGIN = {
    "基础合规": "WB+WV",
    "专项质检": "WV",
    "API 凭证完整性": "WV",
    "E10 登录与凭证契约": "WV",
    "扩展安全": "WB",
    "质量评估": "WB",
    "技能能力清单": "WB",
    "技能依赖清单": "WB+WV",
    "威胁模型": "WB",
    "行为图谱": "WB",
    "千问契约": "QW",
}
ORIGIN_DESC = {
    "WB": "WorkBuddy 平台约束",
    "WV": "泛微专属约束",
    "WB+WV": "平台通用 + 泛微追加",
    "QW": "千问办公平台约束",
    "QW+WB": "千问平台 + WorkBuddy 通用",
    "QW+WV": "千问平台 + 泛微追加",
}
ORIGIN_ORDER = ("WB", "WV", "WB+WV", "QW", "QW+WB", "QW+WV")

# ---------------------------------------------------------------------------
# 多平台规范（千问办公生态接入开发指南 2026 整合）
# ---------------------------------------------------------------------------
# 默认 weaver（泛微生态）保持既有行为；--platform qwenwork 时启用千问专属规则。
# 字段别名机制：千问规范的 frontmatter 字段命名与泛微不一致（如 displayName / name_en），
# 解析时按当前 platform 自动选用对应 key 集合；缺失信息给出差异化提示。
PLATFORMS = {
    "weaver": {
        "label": "泛微生态",
        "origin_chip": "WB+WV",
        "publish_label": "WorkBuddy 泛微上架",
        # frontmatter 字段：千问与泛微在「中文展示名 / 英文展示名 / 中文描述」上的命名差异
        "name_cn_field": "display_name",         # 中文展示名
        "name_cn_prefix_required": "泛微",       # display_name 必须以「泛微」开头
        "name_en_field": "display_name_en",      # 英文展示名
        "desc_cn_field": "description_zh",       # 中文描述
        "desc_en_field": "description_en",       # 英文描述
        "extra_name_field": None,                # 额外必填名（千问特有 author_name）
        # 打包硬性
        "max_entries": 300,                      # zip 内条目数
        "max_zip_mb": None,                      # zip 大小限制（None = 不限制）
        "skill_md_path_required": "exists",      # SKILL.md 是否存在即可
        # 正文长度
        "max_body_kb": None,                     # SKILL.md 正文 KB 限制（None = 不限制）
        # 必填 frontmatter 字段集合（用于 frontmatter 完整性 + 语法校验的关键 key 集合）
        "required_fm_keys": ("author", "version", "name", "description",
                             "description_zh", "description_en",
                             "display_name", "display_name_en"),
        # 是否需要 name 以 weaver- 开头 / 是否需要 author 锁定为「泛微网络科技股份有限公司」
        "name_prefix_required": "weaver-",
        "author_locked": "泛微网络科技股份有限公司",
        # 分类是否限定枚举
        "category_enum": None,
    },
    "qwenwork": {
        "label": "千问办公生态",
        "origin_chip": "QW",
        "publish_label": "千问上架",
        "name_cn_field": "displayName",          # 千问用 displayName 作为中文展示名
        "name_cn_prefix_required": None,          # 千问 displayName 无前缀要求
        "name_en_field": "name_en",              # 千问用 name_en 作为英文展示名
        "desc_cn_field": "description",          # 千问 description 直接是中文
        "desc_en_field": "description_en",
        "extra_name_field": "author_name",       # 千问特有必填 author_name
        "max_entries": 999,                      # 千问硬性 < 1000
        "max_zip_mb": 10,                        # 千问硬性 ≤ 10 MB
        "skill_md_path_required": "exact",       # 千问硬性 <name>/SKILL.md 精确路径
        "max_body_kb": 64,                       # 千问硬性 ≤ 64 KB
        "required_fm_keys": ("name", "displayName", "name_en",
                             "description", "description_en"),
        "name_prefix_required": None,            # 千问不强求 weaver- 前缀（无品牌前缀要求）
        "author_locked": None,                   # 千问不锁 author 为固定值
        "category_enum": ("content_creation", "visual_creative", "data_analysis",
                         "consulting_research", "productivity", "marketing_sales",
                         "ecommerce_retail", "product_development", "hr_recruiting",
                         "finance_accounting", "legal_affairs", "investment",
                         "education", "other"),
    },
}
VALID_PLATFORMS = tuple(PLATFORMS.keys())


def _origin_of(cat: str):
    return CATEGORY_ORIGIN.get(cat, "")


def render_report(target, source_type, checks, verdict, n_err, n_warn, file_info=None,
                  platform: str = "weaver"):
    lines = []
    lines.append("=" * 64)
    lines.append("泛微技能检测工具 · 检测报告")
    lines.append("=" * 64)
    lines.append(f"被检测对象 : {target}")
    lines.append(f"输入类型   : {source_type}")
    _spec = PLATFORMS.get(platform, PLATFORMS["weaver"])
    _default_tag = " · 默认" if platform == "weaver" else " · 显式指定"
    lines.append(f"检测平台   : {_spec['label']}（--platform {platform}{_default_tag}）")
    if file_info:
        lines.append(f"文件大小   : {file_info['size_human']} ({file_info['size']} bytes, {file_info['scope']})")
        lines.append(f"MD5        : {file_info['md5']}")
        lines.append(f"SHA256     : {file_info['sha256']}")
    lines.append("")
    lines.append("-" * 64)

    by_cat = {}
    for c in checks:
        by_cat.setdefault(c.category, []).append(c)

    icon = {"PASS": "✅", "FAIL": "❌", "WARN": "⚠️", "INFO": "ℹ️"}
    for cat, items in by_cat.items():
        _org = _origin_of(cat)
        lines.append(f"【{cat}" + (f" · 归属 {_org}" if _org else "") + "】")
        for c in items:
            lines.append(f"  {icon.get(c.status, '·')} [{c.status}] {c.name}")
            lines.append(f"      说明: {c.detail}")
            if c.locations:
                lines.append(f"      位置: {c.locations[0]}")
                for loc in c.locations[1:5]:
                    lines.append(f"            {loc}")
                if len(c.locations) > 5:
                    lines.append(f"            …等 {len(c.locations)} 处")
            if c.suggestion:
                lines.append(f"      建议: {c.suggestion}")
            if c.category == "技能依赖清单" and getattr(c, "extra", None) and c.extra.get("market_checked"):
                for d in c.extra.get("deps") or []:
                    suffix = f"（{d['market']}）" if d.get("market") else ""
                    if d.get("skipped"):
                        lines.append(f"      市场可达性: {d['name']} → 跳过（非 weaver-e10- 前缀）")
                    else:
                        lines.append(f"      市场可达性: {d['name']} → {'市场可搜' if d['in_market'] else '市场未收录'}{suffix}")
        lines.append("-" * 64)

    lines.append("综合判定   : " + ("不通过 ❌ FAIL" if verdict == "FAIL"
                                    else ("通过（含警告）⚠️ WARN" if verdict == "WARN"
                                          else "通过 ✅ PASS")))
    lines.append("发布结论   : " + ("✅ 可发布（无阻断性风险）" if n_err == 0
                                    else f"⛔ 不可发布（含 {n_err} 项阻断性风险）"))
    if n_err == 0:
        lines.append("上架提醒   : ⚠️ 提交前请确认市场现有版本号，新版本号必须递增"
                     "（严格大于市场已发布版本），否则上架不通过。")
    lines.append(f"阻断项     : {n_err} 项")
    lines.append(f"警告项     : {n_warn} 项")
    lines.append("-" * 64)
    lines.append("归属图例   : WB = WorkBuddy 平台约束（违反即被平台拒收/加载异常）")
    lines.append("             WV = 泛微专属约束（平台不拦，但泛微发布门禁不放行）")
    lines.append("             WB+WV = 平台通用要求 + 泛微追加约束")
    if platform == "qwenwork":
        lines.append("             QW = 千问办公平台约束（违反即被千问拒收）")
        lines.append("             QW+WB = 千问独有约束 + WorkBuddy 通用要求")
        lines.append("             QW+WV = 千问与泛微双平台上架的叠加约束")
    lines.append("=" * 64)
    return "\n".join(lines)


# ---------------------------------------------------------------------------
# HTML 报告渲染
# ---------------------------------------------------------------------------

def _esc(s):
    return (str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))


def render_html(target, source_type, checks, verdict, n_err, n_warn, file_info=None,
                platform: str = "weaver"):
    """生成「腾讯安全云鼎实验室」风格的 HTML 安全评估报告。"""
    icon = {"PASS": "✅", "FAIL": "❌", "WARN": "⚠️", "INFO": "ℹ️"}
    platform_label = PLATFORMS.get(platform, PLATFORMS["weaver"])["label"]
    platform_code = platform
    # 默认平台为 weaver（泛微 + WorkBuddy + 通用）；千问规范仅在显式指定 --platform qwenwork 时启用
    platform_default_tag = "，默认" if platform == "weaver" else "，显式指定"
    # 千问图例仅在千问模式下展示，避免默认报告出现与本次检测无关的规范说明
    qw_legend_html = "" if platform != "qwenwork" else """
            <span><span class="origin-chip origin-qw">QW</span> <b>千问办公平台约束</b>：千问生态接入开发指南 2026（zip ≤ 10 MB / 条目 &lt; 1000 / 14 个分类枚举 / 连接器契约等）</span>
            <span><span class="origin-chip origin-qwwb">QW+WB</span> <b>千问+WorkBuddy 叠加</b>：千问独有约束，且与 WorkBuddy 通用要求共存</span>
            <span><span class="origin-chip origin-qwwv">QW+WV</span> <b>千问+泛微叠加</b>：同一 skill 既要满足千问上架又要满足泛微上架时的叠加约束</span>
"""
    cls = {"PASS": "pass", "FAIL": "fail", "WARN": "warn", "INFO": "info"}
    verdict_label = {"FAIL": "不通过", "WARN": "通过（含警告）", "PASS": "通过"}[verdict]
    verdict_tag = {"FAIL": "高危风险", "WARN": "可疑风险", "PASS": "安全"}[verdict]
    verdict_cls = {"FAIL": "v-fail", "WARN": "v-warn", "PASS": "v-pass"}[verdict]

    # 发布结论：仅以「是否存在阻断性项」为准。有阻断 → 不可发布；无阻断 → 可发布（含警告亦可发布）。
    n_block = sum(1 for c in checks if c.severity == "BLOCK" and c.status == "FAIL")
    publishable = n_block == 0
    publish_label = "可发布" if publishable else "不可发布"
    publish_cls = "pub-ok" if publishable else "pub-no"
    publish_icon = "✅" if publishable else "⛔"
    if publishable:
        publish_sub = ("未发现阻断性合规/安全问题，可正常上架发布。"
                       "请注意：提交前请确认市场现有版本号，新版本号必须递增"
                       "（严格大于市场已发布版本），否则上架不通过。")
    else:
        publish_sub = f"存在 {n_block} 项阻断性风险，必须先修复并通过复检后方可提交。"

    cap_check = None
    dep_check = None
    by_cat = {}
    for c in checks:
        if c.category == "技能能力清单":
            cap_check = c
        elif c.category == "技能依赖清单":
            dep_check = c
        else:
            by_cat.setdefault(c.category, []).append(c)

    n_pass = len([c for c in checks if c.status == "PASS"])
    total = len(checks)

    def _calc_score(checks):
        score = 100
        for c in checks:
            if c.status in ("FAIL", "WARN"):
                if c.severity == "BLOCK":
                    score -= 35
                elif c.severity == "HIGH":
                    score -= 18
                elif c.severity == "MEDIUM":
                    score -= 8
                elif c.severity == "LOW":
                    score -= 3
        return max(0, min(100, score))

    score = _calc_score(checks)

    def _risk_summary():
        # 检查名多为否定式需求（如「无硬编码凭证」）；WARN/FAIL 表示需求被违反，
        # 摘要应翻转成正向发现（去掉「无」前缀），避免“无…却是可疑项”的反读歧义。
        def _positive_label(name: str) -> str:
            return name[1:] if name.startswith("无") else name

        if verdict == "FAIL":
            blocks = [_positive_label(c.name) for c in checks if c.severity == "BLOCK" and c.status == "FAIL"]
            highs = [_positive_label(c.name) for c in checks if c.severity == "HIGH" and c.status in ("FAIL", "WARN")]
            parts = []
            if blocks:
                parts.append(f"阻断项：{', '.join(blocks[:3])}" + ("..." if len(blocks) > 3 else ""))
            if highs:
                parts.append(f"高危项：{', '.join(highs[:3])}" + ("..." if len(highs) > 3 else ""))
            if not parts:
                parts.append("存在合规或安全隐患")
            return "检测到" + "；".join(parts) + "。该 skill 存在严重合规或安全隐患，建议立即修复后再提交。"
        elif verdict == "WARN":
            warns = [_positive_label(c.name) for c in checks if c.status == "WARN"]
            return f"检测到可疑项：{', '.join(warns[:4])}" + ("..." if len(warns) > 4 else "") + "。当前无阻断项，但建议在发布前人工复核并修复。"
        return "未发现阻断项与可疑项，静态检测通过。"

    def _fix_suggestions():
        sugs, seen = [], set()
        for c in checks:
            if c.status in ("FAIL", "WARN") and c.suggestion:
                key = c.suggestion.strip()
                if key and key not in seen:
                    seen.add(key)
                    sugs.append(c.suggestion)
        return sugs

    suggestions = _fix_suggestions()

    skill_name = Path(target).stem
    skill_version = "-"
    for c in checks:
        if c.name == "name 命名规范" and c.status == "PASS":
            m = re.search(r"name='([^']+)'", c.detail)
            if m:
                skill_name = m.group(1)
        if "version" in c.detail.lower():
            m = re.search(r"version[=:]\s*['\"]?([0-9.]+)", c.detail)
            if m:
                skill_version = m.group(1)

    sha256_short = file_info["sha256"][:14] + "..." if file_info and file_info.get("sha256") else "-"
    content_hash = sha256_short

    n_issues = n_err + n_warn
    n_malicious = len([c for c in checks if c.severity == "BLOCK" and c.status == "FAIL"])
    n_suspicious = n_warn
    n_categories = len(by_cat)

    THREAT_ICONS = {
        "供应链风险": "📦", "命令执行风险": "⌨️", "网络请求与数据外传": "🌐",
        "文件操作与敏感路径": "📁", "Prompt 注入风险": "💬", "远程脚本下载执行": "⬇️",
        "可疑编码/混淆": "🔣", "其他安全风险": "🛡️",
    }

    def _esc(s):
        return (str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))

    def _locations_html(c):
        if not c.locations:
            return ""
        lis = "".join(f"<li>{_esc(loc)}</li>" for loc in c.locations[:20])
        more = f"<li class='more'>…等 {len(c.locations)} 处</li>" if len(c.locations) > 20 else ""
        return f"<ul>{lis}{more}</ul>"

    def _capability_cloud(c):
        cleaned = c.detail.replace("本 skill 源码/文档中识别到以下运行时能力：", "").replace("。", "")
        caps = [x.strip() for x in re.split(r"[，,]", cleaned) if x.strip()]
        chips = "".join(f"<span class='cap-chip'>{_esc(x)}</span>" for x in caps)
        return chips if chips else "<span class='cap-empty'>未识别到特定运行时能力</span>"

    def _dependency_cloud(c):
        m = re.search(r"以下其他技能[:：](.*?)[。.]?\s*$", c.detail)
        if not m:
            return "<span class='cap-empty'>未识别到显式依赖的其他技能</span>"
        names = [x.strip() for x in re.split(r"[，,]", m.group(1)) if x.strip()]
        if not names:
            return "<span class='cap-empty'>未识别到显式依赖的其他技能</span>"
        extra = getattr(c, "extra", None) or {}
        market_checked = extra.get("market_checked")
        dep_map = {d["name"].lower(): d for d in (extra.get("deps") or [])}
        chips = []
        for x in names:
            if market_checked:
                d = dep_map.get(x.lower()) or {}
                if d.get("skipped"):
                    badge = "<span class='dep-badge skip' title='非 weaver-e10- 前缀依赖，不做市场可达性核查'>跳过核查</span>"
                elif d.get("error"):
                    badge = "<span class='dep-badge err'>核查失败</span>"
                elif d.get("in_market"):
                    src = f" · {_esc(d['market'])}" if d.get("market") else ""
                    label = f"市场可搜{src}"
                    if d.get("url"):
                        badge = (f"<a class='dep-badge ok' href='{_esc(d['url'])}' "
                                 f"target='_blank' rel='noopener'>{label}</a>")
                    else:
                        badge = f"<span class='dep-badge ok'>{label}</span>"
                else:
                    badge = "<span class='dep-badge no'>市场未收录</span>"
                chips.append(f"<span class='dep-chip'>{_esc(x)}{badge}</span>")
            else:
                chips.append(f"<span class='dep-chip'>{_esc(x)}</span>")
        return "".join(chips)

    # 引擎 chip 颜色：行为安全按 c.status，AI 引擎固定绿占位（未启用独立 AI 模型；扫描器自身作为 AI 引擎对外披露），
    # 静态引擎按 c.locations 是否非空（命中=蓝，未命中=灰）。
    ENGINE_CLASSES = {
        "行为安全": {  # 行为安全 / 行为可疑 / 行为风险 跟随 status
            "PASS": "engine-pass", "INFO": "engine-pass",
            "WARN": "engine-warn", "FAIL": "engine-fail",
        },
        "AI 引擎": {  # 评估通过 = 占位绿
            "PASS": "engine-purple", "INFO": "engine-purple",
            "WARN": "engine-warn", "FAIL": "engine-fail",
        },
        "静态引擎": {  # 命中=蓝；空=灰
            "HIT": "engine-blue", "EMPTY": "engine-grey",
        },
    }
    ENGINE_LABEL = {"行为安全": "行为安全", "AI 引擎": "AI 引擎", "静态引擎": "静态引擎"}
    ENGINE_REASON = {
        "行为安全": ("行为可疑" if False else "行为安全"),  # 实际渲染按 cls
        "AI 引擎": "AI 引擎",
        "静态引擎": ("静态命中" if False else "静态无命中"),
    }

    def _engine_label(engine_name: str, status: str, has_static_hit: bool):
        """返回 (chip_text, css_class)。"""
        if engine_name == "静态引擎":
            return ("静态命中" if has_static_hit else "静态无命中",
                    ENGINE_CLASSES["静态引擎"]["HIT" if has_static_hit else "EMPTY"])
        if engine_name == "行为安全":
            text = "行为可疑" if status in ("WARN", "FAIL") else "行为安全"
            cls = ENGINE_CLASSES["行为安全"][status]
        else:  # AI 引擎
            text = ENGINE_LABEL[engine_name]
            cls = ENGINE_CLASSES[engine_name]["PASS"]
        return text, cls

    def _threat_card(c):
        status_tip = {"PASS": "安全", "WARN": "可疑", "FAIL": "风险", "INFO": "提示"}[c.status]
        ticon = THREAT_ICONS.get(c.name, "🔍")
        bg_cls = {"PASS": "pass", "WARN": "warn", "FAIL": "fail", "INFO": "info"}[c.status]
        has_hit = bool(c.locations)
        chips_html = "".join(
            f'<span class="engine-chip {_engine_label(eng, c.status, has_hit)[1]}" title="{_esc(eng)}">'
            f'{_esc(_engine_label(eng, c.status, has_hit)[0])}</span>'
            for eng in ("行为安全", "AI 引擎", "静态引擎")
        )
        return f"""
        <div class="tcard {bg_cls}">
          <div class="tcard-icon-bg {bg_cls}-bg">{ticon}</div>
          <div class="tcard-main">
            <div class="tcard-name">{_esc(c.name)}{chips_html}</div>
            <div class="tcard-status"><span class="status-dot {bg_cls}"></span>{status_tip}</div>
          </div>
        </div>"""

    def _behavior_card(c):
        loc_html = _locations_html(c)
        bg_cls = {"PASS": "pass", "WARN": "warn", "FAIL": "fail", "INFO": "info"}[c.status]
        has_hit = bool(c.locations)
        chips_html = "".join(
            f'<span class="engine-chip {_engine_label(eng, c.status, has_hit)[1]}" title="{_esc(eng)}">'
            f'{_esc(_engine_label(eng, c.status, has_hit)[0])}</span>'
            for eng in ("行为安全", "AI 引擎", "静态引擎")
        )
        log_li = "<li>" + "&nbsp;</li>"
        if c.locations:
            log_li = "".join(
                f"<li><span class='log-ok'>✓</span><span class='log-text'>{_esc(loc)}</span></li>"
                for loc in c.locations[:20]
            )
            more = f"<li class='more'>…等 {len(c.locations)} 处</li>" if len(c.locations) > 20 else ""
            log_li = log_li + more
        else:
            log_li = f"<li><span class='log-ok'>✓</span><span class='log-text'>未发现{_esc(c.name)}相关的可疑行为。</span></li>"
        return f"""
        <div class="behavior-card {bg_cls}">
          <div class="behavior-head">
            {chips_html}
            <span class="behavior-title">{_esc(c.name)}</span>
          </div>
          <div class="behavior-detail">{_esc(c.detail)}</div>
          <div class="behavior-log">
            <div class="log-title">🔍 检测详情日志</div>
            <ul>{log_li}</ul>
          </div>
        </div>"""

    def _generic_check(c):
        loc_html = _locations_html(c)
        sugg_html = f"<div class='suggestion'>💡 建议：{_esc(c.suggestion)}</div>" if c.suggestion else ""
        return f"""
        <div class="check {cls.get(c.status, 'info')}">
          <div class="check-head">
            <span class="badge">{icon.get(c.status, '·')} {c.status}</span>
            <span class="check-name">{_esc(c.name)}</span>
            <span class="sev">{_esc(c.severity)}</span>
          </div>
          <div class="check-detail">{_esc(c.detail)}</div>
          {sugg_html}
        </div>"""

    sections_html = []
    threat_cards = by_cat.get("威胁模型") or []
    beh_cards = by_cat.get("行为图谱") or []

    # ===== 威胁模型 8 卡网格（C. 威胁捕获模型视图）=====
    threat_grid_html = ""
    if threat_cards:
        cards_html = "".join(_threat_card(c) for c in threat_cards)
        threat_grid_html = f'<div class="threat-grid">{cards_html}</div>'

    # ===== 行为图谱 tab + 卡片列表（D. 威胁行为图谱解析）=====
    behavior_html = ""
    if beh_cards:
        n_mal = sum(1 for c in beh_cards if c.status == "FAIL")
        n_sus = sum(1 for c in beh_cards if c.status == "WARN")
        n_safe = sum(1 for c in beh_cards if c.status in ("PASS", "INFO"))
        tabs = (
            f'<span class="tab active">全部引擎结果 ({len(beh_cards)})</span>'
            f'<span class="tab">检出恶意 ({n_mal})</span>'
            f'<span class="tab">检出可疑 ({n_sus})</span>'
            f'<span class="tab">评估安全 ({n_safe})</span>'
        )
        cards_html = "".join(_behavior_card(c) for c in beh_cards)
        behavior_html = f"""
        <section class="block">
          <div class="section-head">
            <h2>威胁行为图谱解析<span class="origin-chip origin-wb" title="WorkBuddy 平台约束">WB</span></h2>
            <div class="section-tabs">{tabs}</div>
          </div>
          {cards_html}
        </section>"""

    # 威胁捕获模型视图（C 组）
    threat_section_html = ""
    if threat_cards:
        threat_section_html = f"""
        <section class="block">
          <div class="section-head">
            <h2>威胁捕获模型视图<span class="origin-chip origin-wb" title="WorkBuddy 平台约束">WB</span><span class="cap-sub" style="font-weight:400;">8 类风险卡片，每类由 行为安全 / AI 引擎 / 静态引擎 三个引擎并行评估</span></h2>
          </div>
          {threat_grid_html}
        </section>"""

    # 行为图谱（D 组）跟在威胁模型区块之后
    threat_section_html = threat_section_html + behavior_html

    # ===== 其他分组的详细 check 列表 =====
    for cat, items in by_cat.items():
        if cat in ("威胁模型", "行为图谱"):
            continue  # 已单独渲染在上方两个特殊区块
        else:
            rows = "".join(_generic_check(c) for c in items)
            _org = _origin_of(cat)
            _org_chip = (
                f'<span class="origin-chip origin-{_org.lower().replace("+", "")}" '
                f'title="{_esc(ORIGIN_DESC.get(_org, ""))}">{_esc(_org)}</span>'
                if _org else ""
            )
            sections_html.append(f"""
            <section class="block">
              <h2>{_esc(cat)}{_org_chip}</h2>
              {rows}
            </section>""")

    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

    summary_html = _risk_summary()
    fix_list_html = "".join(f"<li>{_esc(s)}</li>" for s in suggestions) if suggestions else "<li>暂无待修复建议</li>"
    cap_html = _capability_cloud(cap_check) if cap_check else "<span class='muted'>未生成能力清单</span>"
    dep_html = _dependency_cloud(dep_check) if dep_check else "<span class='muted'>未生成依赖清单</span>"
    dep_market_checked = bool(dep_check and getattr(dep_check, "extra", None) and dep_check.extra.get("market_checked"))

    ring_color = "#13a35a" if score >= 80 else "#dd8c0a" if score >= 50 else "#e23b3b"
    dasharray = 2 * 3.14159 * 70
    dashoffset = dasharray * (1 - score / 100)

    CSS = """
    <style>
      :root {
        --bg:#f6f8fb; --card:#ffffff; --ink:#1b2436; --muted:#69748a; --line:#e7ecf4;
        --brand:#2f5cff; --brand2:#6a8bff; --brand-soft:#eef3ff;
        --pass:#13a35a; --fail:#e23b3b; --warn:#dd8c0a; --info:#1f9bd1;
        --pass-bg:#e8f8ef; --fail-bg:#fdeded; --warn-bg:#fdf2e1; --info-bg:#e7f4fb;
        --radius:18px; --shadow:0 8px 28px rgba(27,42,71,.07);
      }
      * { box-sizing:border-box; }
      body { margin:0; color:var(--ink); background:var(--bg);
        font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
        line-height:1.65; -webkit-font-smoothing:antialiased; padding-bottom:48px; }
      .wrap { max-width:1100px; margin:0 auto; padding:0 20px; }
      .report-header { display:flex; align-items:center; justify-content:space-between;
        padding:22px 4px 18px; }
      .rh-left { display:flex; align-items:center; gap:10px; font-size:18px; font-weight:800; }
      .rh-icon { width:36px; height:36px; border-radius:10px;
        background:linear-gradient(135deg,var(--brand),var(--brand2)); color:#fff;
        display:flex; align-items:center; justify-content:center; font-size:20px; }
      .rh-right { font-size:13px; color:var(--muted); font-weight:600; }
      .dashboard { display:grid; grid-template-columns:340px 1fr; gap:18px; margin-top:18px; }
      .dash-left { display:flex; flex-direction:column; gap:16px; }
      .dash-left > .card { flex:1; }
      .dash-right { display:flex; flex-direction:column; gap:16px; }
      .card { background:var(--card); border:1px solid var(--line); border-radius:var(--radius);
        padding:22px; box-shadow:var(--shadow); }
      .score-card { display:flex; flex-direction:column; align-items:center; text-align:center; }
      .score-ring { position:relative; width:180px; height:180px; }
      .score-ring svg { transform:rotate(-90deg); }
      .score-num { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
        font-size:42px; font-weight:800; color:var(--ink); }
      .score-label { font-size:13px; color:var(--muted); margin-top:10px; }
      .meta-list { width:100%; margin-top:10px; }
      .meta-row { display:flex; align-items:center; justify-content:space-between; padding:11px 0;
        border-bottom:1px solid var(--line); font-size:13px; }
      .meta-row:last-child { border-bottom:none; }
      .meta-row .k { color:var(--muted); }
      .meta-row .v { font-weight:600; max-width:180px; text-align:right; word-break:break-all; }
      .meta-row code.v { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px;
        background:#f4f7fb; padding:3px 8px; border-radius:6px; }
      .verdict-tag { display:inline-block; padding:3px 10px; border-radius:999px; font-size:12px; font-weight:700; }
      .verdict-tag.pass { background:var(--pass-bg); color:var(--pass); }
      .verdict-tag.warn { background:var(--warn-bg); color:var(--warn); }
      .verdict-tag.fail { background:var(--fail-bg); color:var(--fail); }
      .verdict-tag.pub-ok { background:var(--pass-bg); color:var(--pass); font-size:13px; padding:4px 12px; }
      .verdict-tag.pub-no { background:var(--fail-bg); color:var(--fail); font-size:13px; padding:4px 12px; }
      .publish-banner { display:flex; align-items:center; gap:16px; border-radius:var(--radius);
        padding:20px 26px; margin-top:16px; margin-bottom:22px; box-shadow:0 10px 30px rgba(27,42,71,.12); }
      .publish-banner .pb-icon { font-size:40px; line-height:1; filter:drop-shadow(0 2px 4px rgba(0,0,0,.15)); }
      .publish-banner .pb-main { font-size:25px; font-weight:900; letter-spacing:2px; }
      .publish-banner .pb-sub { font-size:13.5px; margin-top:5px; opacity:.94; line-height:1.6; }
      .publish-banner.ok { background:linear-gradient(135deg,#13a35a 0%,#1ec07a 100%); color:#fff; }
      .publish-banner.no { background:linear-gradient(135deg,#e23b3b 0%,#ff5b5b 100%); color:#fff; }
      .publish-banner .pb-pulse { width:12px; height:12px; border-radius:50%; background:#fff; opacity:.9;
        box-shadow:0 0 0 0 rgba(255,255,255,.7); animation:pbpulse 1.8s infinite; }
      @keyframes pbpulse { 0%{box-shadow:0 0 0 0 rgba(255,255,255,.6);} 70%{box-shadow:0 0 0 12px rgba(255,255,255,0);} 100%{box-shadow:0 0 0 0 rgba(255,255,255,0);} }
      .stat-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; }
      .stat-card { background:var(--card); border:1px solid var(--line); border-radius:14px;
        padding:16px; text-align:center; box-shadow:var(--shadow); }
      .stat-card .n { font-size:28px; font-weight:800; color:var(--ink); }
      .stat-card .t { font-size:12px; color:var(--muted); margin-top:4px; }
      .stat-card.fail .n { color:var(--fail); }
      .stat-card.warn .n { color:var(--warn); }
      .stat-card.info .n { color:var(--brand); }
      .summary-box { background:#fff9f0; border:1px solid #ffe4c2; border-radius:14px; padding:16px 18px; }
      .summary-box h3 { margin:0 0 10px; font-size:14px; display:flex; align-items:center; gap:8px; color:#92400e; }
      .summary-box p { margin:0; font-size:13.5px; color:#78350f; line-height:1.7; }
      .fix-box h3 { margin:0 0 12px; font-size:14px; display:flex; align-items:center; gap:8px; }
      .fix-box ol { margin:0; padding-left:18px; font-size:13px; color:#3a4456; line-height:1.8; }
      .cap-section { background:var(--card); border:1px solid var(--line); border-radius:var(--radius);
        padding:18px 22px; box-shadow:var(--shadow); margin-top:0; }
      .cap-head { display:flex; align-items:baseline; gap:10px; margin-bottom:14px;
        font-size:15px; font-weight:700; }
      .cap-sub { font-size:12px; color:var(--muted); font-weight:400; }
      .cap-chips { display:flex; flex-wrap:wrap; gap:12px; }
      .cap-chip { display:inline-flex; align-items:center; gap:6px; border:1.5px solid var(--brand);
        color:var(--brand); background:#fff; font-size:13px; font-weight:600;
        padding:8px 16px; border-radius:999px; box-shadow:0 2px 8px rgba(47,92,255,.10); }
      .cap-empty { color:var(--muted); font-size:13px; padding:4px 0; }
      .dep-head { display:flex; align-items:baseline; gap:10px; margin:18px 0 14px; padding-top:16px;
        border-top:1px dashed var(--line); font-size:14px; font-weight:700; }
      .dep-chip { display:inline-flex; align-items:center; gap:6px; border:1.5px solid #dd8c0a;
        color:#9a6500; background:#fffaf0; font-size:13px; font-weight:600;
        padding:8px 16px; border-radius:999px; box-shadow:0 2px 8px rgba(221,140,10,.12); }
      .dep-badge { font-size:11px; font-weight:700; padding:2px 9px; border-radius:999px; white-space:nowrap; }
      .dep-badge.ok { background:var(--pass-bg); color:var(--pass); }
      .dep-badge.no { background:#eef2f7; color:#64748b; }
      .dep-badge.err { background:#fff4e5; color:#b45309; }
      .dep-badge.skip { background:#eef2f7; color:#94a3b8; font-weight:600; }
      a.dep-badge.ok { text-decoration:none; }
      .dep-legend { margin-top:12px; font-size:12px; color:var(--muted); line-height:1.7; }
      .dep-legend .ok { color:var(--pass); font-weight:700; }
      .dep-legend .no { color:#64748b; font-weight:700; }
      .dep-legend .err { color:#b45309; font-weight:700; }
      .dep-legend .skip { color:#94a3b8; font-weight:700; }
      .block { background:var(--card); border:1px solid var(--line); border-radius:var(--radius);
        padding:22px 24px; margin-top:18px; box-shadow:var(--shadow); }
      .block h2 { margin:0 0 18px; font-size:17px; display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
      /* 约束归属徽标：WB = WorkBuddy 平台约束 / WV = 泛微专属约束 / WB+WV = 叠加 /
         QW = 千问办公平台约束 / QW+WB = 千问+WorkBuddy 叠加 / QW+WV = 千问+泛微叠加 */
      .origin-chip { font-size:11px; font-weight:700; line-height:1; padding:4px 8px; border-radius:999px;
        border:1px solid transparent; letter-spacing:.02em; }
      .origin-wb { background:#eef2ff; color:#3730a3; border-color:#c7d2fe; }
      .origin-wv { background:#fff1e6; color:#9a3412; border-color:#fed7aa; }
      .origin-wbwv { background:#ecfdf5; color:#065f46; border-color:#a7f3d0; }
      .origin-qw { background:#fef3c7; color:#92400e; border-color:#fde68a; }
      .origin-qwwb { background:#ede9fe; color:#5b21b6; border-color:#ddd6fe; }
      .origin-qwwv { background:#fce7f3; color:#9d174d; border-color:#fbcfe8; }
      .origin-legend { display:flex; flex-wrap:wrap; gap:10px 18px; margin-top:12px; font-size:12px;
        color:var(--muted); }
      .origin-legend b { color:var(--text); }
      .section-head { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap;
        gap:12px; margin-bottom:18px; }
      .section-head h2 { margin:0; }
      .section-tabs { display:flex; gap:8px; }
      .tab { font-size:12px; padding:6px 12px; border-radius:8px; background:#f4f7fb; color:var(--muted);
        cursor:default; }
      .tab.active { background:var(--brand-soft); color:var(--brand); font-weight:700; }
      .threat-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:14px; }
      .tcard { display:flex; align-items:center; gap:12px; border:1px solid var(--line);
        border-radius:14px; padding:14px; background:#fff; }
      .tcard-icon-bg { width:42px; height:42px; border-radius:12px; display:flex; align-items:center;
        justify-content:center; font-size:20px; flex:none; }
      .pass-bg { background:var(--pass-bg); }
      .warn-bg { background:var(--warn-bg); }
      .fail-bg { background:var(--fail-bg); }
      .info-bg { background:var(--info-bg); }
      .tcard-main { flex:1; min-width:0; }
      .tcard-name { font-weight:700; font-size:14px; margin-bottom:4px; }
      .tcard-status { font-size:12px; color:var(--muted); display:flex; align-items:center; gap:5px; }
      .status-dot { width:7px; height:7px; border-radius:50%; display:inline-block; }
      .status-dot.pass { background:var(--pass); }
      .status-dot.warn { background:var(--warn); }
      .status-dot.fail { background:var(--fail); }
      .status-dot.info { background:var(--info); }
      .behavior-card { border:1px solid var(--line); border-radius:14px; padding:16px 18px; margin-bottom:14px;
        background:#fff; }
      .behavior-head { display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:10px; }
      .tag { font-size:11px; font-weight:700; padding:3px 10px; border-radius:6px; flex:none; }
      .tag-red { background:#fde8e8; color:var(--fail); }
      .tag-green { background:#e6f6ec; color:var(--pass); }
      .tag-purple { background:#ede9fe; color:#7c3aed; }
      .tag-blue { background:#e7f4fb; color:var(--info); }
      .behavior-title { font-weight:700; font-size:15px; }
      .behavior-detail { color:#3a4456; font-size:13.5px; margin-bottom:12px; }
      .behavior-log { background:#0f172a; color:#e2e8f0; border-radius:12px; padding:14px 16px;
        font-size:12.5px; }
      .behavior-log .log-title { color:#94a3b8; font-size:11px; margin-bottom:8px; letter-spacing:1px; }
      .behavior-log ul { margin:0; padding-left:0; list-style:none; }
      .behavior-log li { padding:3px 0; font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
        white-space:pre-wrap; word-break:break-all; }
      .behavior-log li.more { color:#fbbf24; }
      .check { display:flex; flex-direction:column; gap:8px; border:1px solid var(--line);
        border-radius:13px; padding:14px 16px; margin-bottom:12px; background:#fcfdff;
        border-left:5px solid var(--info); }
      .check.pass { border-left-color:var(--pass); background:#f7fdf9; }
      .check.fail { border-left-color:var(--fail); background:var(--fail-bg); }
      .check.warn { border-left-color:var(--warn); background:var(--warn-bg); }
      .check-head { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
      .check-name { font-weight:700; font-size:15px; }
      .badge { font-size:12px; font-weight:700; padding:3px 10px; border-radius:999px;
        background:#eef2fb; color:var(--brand); white-space:nowrap; }
      .check.fail .badge { background:var(--fail-bg); color:var(--fail); }
      .check.warn .badge { background:var(--warn-bg); color:var(--warn); }
      .check.pass .badge { background:var(--pass-bg); color:var(--pass); }
      .sev { margin-left:auto; font-size:11px; color:var(--muted); border:1px solid var(--line);
        padding:2px 9px; border-radius:7px; }
      .check-detail { color:#3a4456; font-size:13.5px; white-space:pre-wrap; }
      .suggestion { font-size:12.5px; color:#475569; background:#eef3ff; border-radius:9px; padding:9px 12px; margin-top:6px; }
      .muted { color:var(--muted); font-size:13px; }
      /* ===== 威胁模型 / 行为图谱：引擎 chip 与深色日志 ===== */
      .engine-chip { font-size:10.5px; font-weight:700; padding:3px 9px; border-radius:999px;
        border:1px solid transparent; line-height:1; flex:none; white-space:nowrap; }
      .engine-pass { background:#e6f6ec; color:#15803d; border-color:#bbf7d0; }
      .engine-warn { background:#fdf2e1; color:#9a6500; border-color:#fde68a; }
      .engine-fail { background:#fde8e8; color:#dc2626; border-color:#fecaca; }
      .engine-purple { background:#ede9fe; color:#7c3aed; border-color:#ddd6fe; }
      .engine-blue { background:#e7f4fb; color:#1f9bd1; border-color:#bae6fd; }
      .engine-grey { background:#f4f6fb; color:#94a3b8; border-color:#e2e8f0; }
      .tcard-name { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
      .log-ok { color:#4ade80; margin-right:8px; font-weight:700; }
      .log-text { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; color:#cbd5e1;
        white-space:pre-wrap; word-break:break-all; }
      footer { text-align:center; color:var(--muted); font-size:12px; margin-top:28px; }
      @media (max-width:900px) { .dashboard { grid-template-columns:1fr; } .stat-grid { grid-template-columns:repeat(2,1fr); } }
      @media (max-width:540px) { .stat-grid { grid-template-columns:1fr; } .section-head { flex-direction:column; align-items:flex-start; } }
    </style>
    """

    BODY = f"""
    <body>
      <div class="wrap">
        <div class="report-header">
          <div class="rh-left"><div class="rh-icon">🛡️</div> Skills 安全评估报告</div>
          <div class="rh-right">泛微技能检测工具 · weaver-skill-detector</div>
        </div>

        <div class="publish-banner {publish_cls}">
          <div class="pb-icon">{publish_icon}</div>
          <div>
            <div class="pb-main">{publish_label}</div>
            <div class="pb-sub">{publish_sub}</div>
          </div>
          <div class="pb-pulse" style="margin-left:auto;"></div>
        </div>

        <div class="cap-section">
          <div class="cap-head">🧩 技能能力清单<span class="cap-sub">运行时识别到的能力（上架时建议如实披露权限范围）</span></div>
          <div class="cap-chips">{cap_html}</div>
          <div class="dep-head">🔗 依赖的其他技能<span class="cap-sub">{("已动态核查 skillhub.cn 市场可达性（点击徽标跳转）" if dep_market_checked else "frontmatter 声明或正文引用的 weaver-e10- 前缀技能（需同步上架）")}</span></div>
          <div class="cap-chips">{dep_html}</div>
          {('<div class="dep-legend"><span class="ok">市场可搜</span>：已在 skillhub.cn 实时核查命中（点击可跳转技能页），消费方可直接获取而无需额外分发 · <span class="no">市场未收录</span>：skillhub.cn 未检索到该 slug，需随包另行分发或内部安装后方可联动 · <span class="skip">跳过核查</span>：非 weaver- 前缀依赖（内部引用/误报），不做市场可达性标注 · <span class="err">核查失败</span>：联网核查异常（如网络受限），请以手动核实为准</div>' if dep_market_checked else "")}
        </div>

        <div class="dashboard">
          <div class="dash-left">
            <div class="card score-card">
              <div class="score-ring">
                <svg width="180" height="180" viewBox="0 0 180 180">
                  <circle cx="90" cy="90" r="70" fill="none" stroke="#e7ecf4" stroke-width="12"/>
                  <circle cx="90" cy="90" r="70" fill="none" stroke="{ring_color}" stroke-width="12"
                    stroke-linecap="round" stroke-dasharray="{dasharray:.2f}" stroke-dashoffset="{dashoffset:.2f}"/>
                </svg>
                <div class="score-num">{score}</div>
              </div>
              <div class="score-label">安全健康度评分</div>
              <div class="meta-list">
                <div class="meta-row"><span class="k">Skill 名称</span><span class="v">{_esc(skill_name)}</span></div>
                <div class="meta-row"><span class="k">版本</span><span class="v">{_esc(skill_version)}</span></div>
                <div class="meta-row"><span class="k">检测平台</span><span class="v">{_esc(platform_label)}（<code>--platform {_esc(platform_code)}</code>{platform_default_tag}）</span></div>
                <div class="meta-row"><span class="k">内容 Hash</span><code class="v">{_esc(content_hash)}</code></div>
                <div class="meta-row"><span class="k">发布结论</span><span class="v"><span class="verdict-tag {publish_cls}">{publish_label}</span></span></div>
              </div>
            </div>
          </div>

          <div class="dash-right">
            <div class="stat-grid">
              <div class="stat-card {'fail' if n_issues else 'info'}">
                <div class="n">{n_issues}</div>
                <div class="t">发现问题总数</div>
                <div class="t" style="font-size:11px;color:var(--muted);">需确认点</div>
              </div>
              <div class="stat-card fail">
                <div class="n">{n_malicious}</div>
                <div class="t">检出恶意行为</div>
                <div class="t" style="font-size:11px;color:var(--muted);">平台已标记</div>
              </div>
              <div class="stat-card warn">
                <div class="n">{n_suspicious}</div>
                <div class="t">检出可疑行为</div>
                <div class="t" style="font-size:11px;color:var(--muted);">平台已标记</div>
              </div>
              <div class="stat-card info">
                <div class="n">{n_categories}</div>
                <div class="t">引擎覆盖类别</div>
                <div class="t" style="font-size:11px;color:var(--muted);">安全检测面</div>
              </div>
            </div>

            <div class="summary-box">
              <h3>🛡️ 总体风险摘要</h3>
              <p>{_esc(summary_html)}</p>
            </div>

            <div class="card fix-box">
              <h3>🛠️ 修复建议指南</h3>
              <ol>{fix_list_html}</ol>
            </div>
          </div>
        </div>

{threat_section_html}

{''.join(sections_html)}

        <section class="block">
          <h2>约束归属图例</h2>
          <div class="origin-legend">
            <span><span class="origin-chip origin-wb">WB</span> <b>WorkBuddy 平台约束</b>：skillhub.cn 加载器与上传校验要求，任何作者的 skill 都必须满足，违反即被平台拒收或加载异常</span>
            <span><span class="origin-chip origin-wv">WV</span> <b>泛微专属约束</b>：泛微生态内部治理要求（品牌规范、E10 账号与接口调用契约、安全合规），平台不拦但泛微发布门禁不放行</span>
            <span><span class="origin-chip origin-wbwv">WB+WV</span> <b>叠加</b>：平台有通用要求，泛微在其上追加了更严的约束</span>
{qw_legend_html}
          </div>
        </section>

        <footer>本报告由「泛微技能检测工具 weaver-skill-detector」自动生成 · 生成时间：{_esc(now)} · 仅供提交前安全合规自检参考</footer>
      </div>
    </body>
    """

    HEAD = f"""<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Skills 安全评估报告 · {_esc(publish_label)}</title>
</head>"""

    return f"<!DOCTYPE html>\n<html lang=\"zh-CN\">\n{HEAD}\n{CSS}\n{BODY}\n</html>"

# ---------------------------------------------------------------------------
# 入口
# ---------------------------------------------------------------------------

def main():
    args = sys.argv[1:]
    if not args:
        log("用法: python validate_skill.py <skill.zip|.md|dir> [--json] [--out report.md] "
            "[--html report.html] [--market-catalog catalog.json] [--market-live] [--no-html]\n"
            "                                    [--platform {weaver,qwenwork}]")
        sys.exit(2)

    target_str = args[0]
    as_json = "--json" in args
    out_path = None
    html_path = None
    no_html = "--no-html" in args
    market_catalog_path = None
    market_live = "--market-live" in args
    platform = "weaver"  # 默认 weaver（保持向后兼容）
    for i, a in enumerate(args):
        if a == "--out" and i + 1 < len(args):
            out_path = args[i + 1]
        elif a == "--html" and i + 1 < len(args):
            html_path = args[i + 1]
        elif a == "--market-catalog" and i + 1 < len(args):
            market_catalog_path = args[i + 1]
        elif a == "--platform" and i + 1 < len(args):
            platform = args[i + 1]
            if platform not in VALID_PLATFORMS:
                log(f"❌ --platform 仅支持: {VALID_PLATFORMS}（收到: {platform}）")
                sys.exit(2)

    target = Path(target_str).expanduser().resolve()
    root, source_type, cleanup, err, zip_raw_names = prepare_skill_source(target)
    if err:
        log(f"❌ {err}")
        sys.exit(2)

    # 默认 HTML 输出路径：未显式指定 --html 且未禁用时，仅在 WARN/FAIL 后使用。
    # zip/md 输入 → 去扩展名 + "-report.html"（如 weaver-e10-hrm.zip → weaver-e10-hrm-report.html）；
    # 目录输入 → 同级 "<目录名>-report.html"。
    default_html_path = None
    if html_path is None and not no_html:
        if target.is_file():
            default_html_path = str(target.with_suffix("")) + "-report.html"
        else:
            default_html_path = str(target.parent / (target.name + "-report.html"))

    market_catalog = None
    if market_catalog_path:
        try:
            market_catalog = load_market_catalog(market_catalog_path)
            log(f"已加载市场清单: {len(market_catalog)} 项（依赖可达性标注已启用）")
        except Exception as e:
            log(f"❌ 市场清单加载失败: {e}")
            sys.exit(2)

    try:
        skill_md, skill_dir = find_skill_md(root)
        checks = []
        info = None
        # A 组打包约束红线：文件总数（按 platform 取阈值）/ 仅纯文本（禁止二进制），与 SKILL.md 是否存在无关，始终执行
        checks.extend(check_package_constraints(root, platform=platform, target=target))
        # A 组：SKILL.md 文件名大小写精确（skill.md/Skill.md 等变体 → BLOCK），始终执行
        checks.extend(check_skill_md_naming(root))
        if skill_md is not None:
            struct_checks, info = check_structure(root, skill_md, skill_dir, source_type, zip_raw_names, platform=platform)
            checks.extend(struct_checks)
            name, body = info if info else (None, "")
            if skill_dir is not None:
                checks.extend(check_weaver_compliance(skill_dir, body or "", source_type))
                # A2: 结构化文件可解析性（JSON/YAML/TOML）
                checks.extend(check_structured_files(skill_dir))
                # A3: 依赖引导完整性
                checks.extend(check_dependency_guidance(skill_dir))
                # G 组扩展安全检测（危险命令/个人路径/IDE残留/CDN/内网域名/虚假承诺/BOM/CRLF）
                checks.extend(check_extended_security(skill_dir))
                # B 红线补充：API 请求凭证完整性（Cookie+eteamsid+User-Agent，BLOCK）
                checks.extend(check_api_request_credentials(skill_dir))
                # B10 红线补充：E10 接口型技能「登录 + 凭证」契约（仅 weaver-e10- 前缀技能，BLOCK）
                checks.extend(check_weaver_e10_auth_contract(skill_dir, name))
                # B11：千问连接器就绪性契约（仅 qwenwork 模式，BLOCK；函数内自带平台守护）
                checks.extend(check_qwenwork_connector_readiness(skill_dir, name, platform=platform))
                # Q 组质量启发式检测（路由触发/上下文效率/容错降级/字段字数）
                checks.extend(check_quality_heuristics(skill_dir, skill_md, body or ""))
                # C 威胁捕获模型视图 + D 威胁行为图谱解析（8 类风险卡片 + 跨文件/跨行具体行为，INFO/WARN）
                checks.extend(check_threat_model(skill_dir))
                checks.extend(check_behavior_graph(skill_dir))
                checks.extend(check_skill_capabilities(skill_dir))
                checks.extend(check_skill_dependencies(skill_dir, skill_md, market_catalog, live_market=market_live))
        else:
            # 无 SKILL.md：若已存在大小写变体（skill.md/Skill.md 等），精确大小写检查已给出 BLOCK，
            # 此处不再重复「未找到 SKILL.md」，避免提示歧义。
            has_case_variant = any(
                p.is_file() and p.name.lower() == "skill.md" and p.name != "SKILL.md"
                for p in root.rglob("*")
            )
            if not has_case_variant:
                checks.append(Check("基础合规", "SKILL.md 存在", "FAIL", "BLOCK",
                                    "未找到 SKILL.md。",
                                    suggestion="提供标准 skill 包（含 SKILL.md）。"))

        file_info = calc_file_info(target, source_type)

        verdict, n_err, n_warn = summarize(checks)
        report = render_report(target_str, source_type, checks, verdict, n_err, n_warn,
                               file_info, platform=platform)

        if as_json:
            payload = {
                "target": str(target),
                "source_type": source_type,
                "verdict": verdict,
                "blocking": n_err,
                "warnings": n_warn,
                "file_info": file_info,
                "checks": [c.to_dict() for c in checks],
            }
            print(json.dumps(payload, ensure_ascii=False, indent=2))
        else:
            print(report)

        if out_path:
            Path(out_path).write_text(report, encoding="utf-8")
            log(f"报告已写出: {out_path}")

        html_output_path = html_path or (default_html_path if verdict != "PASS" else None)
        if html_output_path:
            html = render_html(target_str, source_type, checks, verdict, n_err, n_warn,
                               file_info, platform=platform)
            Path(html_output_path).write_text(html, encoding="utf-8")
            log(f"HTML 报告已写出: {html_output_path}")

        sys.exit(1 if verdict == "FAIL" else 0)
    finally:
        if cleanup:
            shutil.rmtree(root, ignore_errors=True)


if __name__ == "__main__":
    main()
