# Bug Fixes — 代码审查修复计划

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 修复上一轮代码审查中发现的所有缺陷，涵盖 P0（功能正确性）、P1（边界条件）、P2（代码健壮性）。

**Architecture:** 针对 8 个独立缺陷逐一修复，每个任务修改 1-2 个文件 + 对应测试。无架构变更，纯局部修补。

**Tech Stack:** Python 3.10+, SQLite, FastAPI, holehe, trio, fcntl

---

### Plan Structure
- Task 1: P0 OFAC 制裁 URL 更新
- Task 2: P0 MEMORY.md 并发写锁
- Task 3: P0 trio/asyncio 嵌套 event loop
- Task 4: P1 空公司名 slug
- Task 5: P1 UN 制裁加载
- Task 6: P1 Skill 显式匹配
- Task 7: P2 chat_memory.search_history isoformat
- Task 8: P2 post_install 路径

---

### Task 1: P0 — OFAC 制裁名单 URL 更新

**Files:**
- Modify: `trade/osint/sanctions.py:235`
- Modify: `trade/osint/constants.py:49`

- [ ] **Step 1: 更新 URL**

在 `trade/osint/sanctions.py:235`，将：
```python
url = "https://www.treasury.gov/ofac/downloads/sanctions/SDN-List.csv"
```
改为：
```python
url = "https://ofac.treasury.gov/specially-designated-nationals-and-blocked-persons-list-sdn-human-readable-lists/sdn.csv"
```

在 `trade/osint/constants.py:49`，同样将：
```python
"url": "https://www.treasury.gov/ofac/downloads/sanctions/SDN-List.csv",
```
改为：
```python
"url": "https://ofac.treasury.gov/specially-designated-nationals-and-blocked-persons-list-sdn-human-readable-lists/sdn.csv",
```

- [ ] **Step 2: 验证 URL (curl)**

Run: `curl -sI "https://ofac.treasury.gov/specially-designated-nationals-and-blocked-persons-list-sdn-human-readable-lists/sdn.csv" -w "%{http_code}" -o /dev/null`
Expected: 200 or 301/302 (redirect)

- [ ] **Step 3: 运行现有测试**

Run: `python -m pytest tests/test_osint.py::TestSanctions -v`
Expected: 4 passed

- [ ] **Step 4: Commit**

```bash
git add trade/osint/sanctions.py trade/osint/constants.py
git commit -m "fix: update OFAC sanctions CSV URL (treasury.gov 已迁移到 ofac.treasury.gov)"
```

---

### Task 2: P0 — MEMORY.md 并发写竞态修复

**Files:**
- Modify: `trade/memory.py:223-305`

- [ ] **Step 1: 替换文件锁实现**

在 `trade/memory.py` 顶部 import 区增加 `import fcntl`（Unix）或 `import msvcrt`（Windows），并用 `open(..., 'w')` + `fcntl.flock` 替代 TOCTOU 锁。

将 `retain_to_hermes_memory()` 中的锁获取逻辑（行 271-300）替换为：

```python
    try:
        # 跨平台文件锁：fcntl.flock (Unix) / msvcrt.locking (Windows)
        import time as _time
        lock_dir = _Path.home() / ".hermes/memories"
        lock_dir.mkdir(parents=True, exist_ok=True)
        lock_fd = open(str(LOCK_FILE), "w")
        for _attempt in range(5):
            try:
                if os.name == "nt":
                    import msvcrt
                    msvcrt.locking(lock_fd.fileno(), msvcrt.LK_NBLCK, 1)
                else:
                    import fcntl
                    fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
                break
            except (BlockingIOError, OSError):
                _time.sleep(0.05)
        else:
            lock_fd.close()
            return False  # 无法获取锁

        try:
            existing = ""
            if MEMORY_FILE.exists():
                existing = MEMORY_FILE.read_text(encoding="utf-8")
            SECTION = "## Foreign Trade Assistant \u2014 B2B Trade Memory\n"
            if SECTION not in existing:
                content_str = SECTION + entry + "\n\n" + existing if existing else SECTION + entry
            else:
                idx_s = existing.find(SECTION) + len(SECTION)
                content_str = existing[:idx_s] + entry + "\n\n" + existing[idx_s:]
            MEMORY_FILE.write_text(content_str, encoding="utf-8")
        finally:
            if os.name == "nt":
                import msvcrt
                lock_fd.seek(0)
                msvcrt.locking(lock_fd.fileno(), msvcrt.LK_UNLCK, 1)
            else:
                import fcntl
                fcntl.flock(lock_fd.fileno(), fcntl.LOCK_UN)
            lock_fd.close()
            try:
                LOCK_FILE.unlink(missing_ok=True)
            except OSError:
                pass
        return True
    except Exception:
        return False
```

- [ ] **Step 2: 运行测试验证无回归**

Run: `python -m pytest tests/ -v --tb=short -k "not osint and not chat" -q`
Expected: all pass

- [ ] **Step 3: Commit**

```bash
git add trade/memory.py
git commit -m "fix: 修复 MEMORY.md 并发写 TOCTOU 竞态，改用 fcntl.flock 文件锁"
```

---

### Task 3: P0 — trio/asyncio 嵌套 event loop 修复

**Files:**
- Modify: `trade/email_intel.py:306-346`

- [ ] **Step 1: 重构 email_background_check 为纯同步执行**

问题：`email_background_check()` 调用 `trio.run()` 运行异步 holehe，但它又被 `osint_full_check()` 通过 `asyncio.run_in_executor()` 调用，形成嵌套 event loop。

修复：在 `email_background_check()` 中将 `trio.run(lambda: _run_holehe_async(email))` 改为在单独的子进程中运行，彻底隔离 event loop。

将 `email_background_check` 中的调用部分（行 322-332）改为：

```python
    try:
        import contextlib
        import io
        import subprocess
        import sys

        # 在子进程中运行 holehe，彻底隔离 trio/asyncio event loop
        # holehe 使用 trio，与 asyncio 的 event loop 不兼容
        try:
            result = subprocess.run(
                [sys.executable, "-c", _HOLEHE_WORKER_SCRIPT, email],
                capture_output=True, text=True, timeout=120,
            )
            if result.returncode != 0:
                return _error_result(email, f"holehe error: {result.stderr.strip() or 'unknown'}")
            raw_results = json.loads(result.stdout)
        except subprocess.TimeoutExpired:
            return _error_result(email, "holehe timeout after 120s")
        except json.JSONDecodeError as exc:
            return _error_result(email, f"holehe parse error: {exc}")
    except Exception as exc:
        return _error_result(email, f"holehe error: {exc}")
```

在 `trade/email_intel.py` 顶部（import 区之后）增加 worker script 常量：

```python
_HOLEHE_WORKER_SCRIPT = r"""
import json, sys
from holehe.core import get_functions, import_submodules, launch_module
import httpx, trio

email = sys.argv[1].strip()

modules = import_submodules("holehe.modules")
websites = get_functions(modules, args=None)

out = []
client = httpx.AsyncClient(timeout=10)

async def _run():
    try:
        httpx._client._async.USE_CLIENT_TIMEOUT = False
    except Exception:
        pass
    async with trio.open_nursery() as nursery:
        for website in websites:
            nursery.start_soon(launch_module, website, email, client, out)
    await client.aclose()
    out.sort(key=lambda i: i.get("name", ""))

trio.run(_run)
print(json.dumps(out, default=str))
"""
```

- [ ] **Step 2: 运行 osint 测试**

Run: `python -m pytest tests/test_osint.py -v --tb=short`
Expected: all osint tests pass (email_background_check may be mocked; verify no regression)

- [ ] **Step 3: Commit**

```bash
git add trade/email_intel.py
git commit -m "fix: email_background_check 改为子进程执行 holehe，消除 trio/asyncio 嵌套 event loop"
```

---

### Task 4: P1 — 空公司名导致空 slug

**Files:**
- Modify: `trade/company.py:46-52` (_slugify)
- Modify: `trade/company.py:244-254` (create)
- Test: `tests/test_business.py` (new test)

- [ ] **Step 1: 在 _slugify 中处理空输入**

```python
def _slugify(name: str) -> str:
    """Convert a company name to a URL-safe slug."""
    slug = name.lower().strip()
    if not slug:  # 空输入 → 返回固定后备值
        return "company"
    slug = re.sub(r"[^\w\s-]", "", slug)
    slug = re.sub(r"[_\s]+", "-", slug)
    slug = re.sub(r"--+", "-", slug)
    return slug.strip("-") or "company"
```

- [ ] **Step 2: 在 create() 中增加 name 校验**

在 `create()` 函数开头（`if not slug:` 之前）增加：

```python
    if not name or not name.strip():
        raise ValueError("Company name cannot be empty")
```

- [ ] **Step 3: 编写测试**

在 `tests/test_business.py` 的 `TestCompanyCRUD` 类中增加：

```python
    def test_create_empty_name(self, test_db):
        """空公司名应抛出 ValueError。"""
        from trade import company
        with pytest.raises(ValueError, match="empty"):
            company.create(name="")
        with pytest.raises(ValueError, match="empty"):
            company.create(name="   ")
```

- [ ] **Step 4: 运行测试**

Run: `python -m pytest tests/test_business.py::TestCompanyCRUD::test_create_empty_name -v`
Expected: PASS

- [ ] **Step 5: 全量测试**

Run: `python -m pytest tests/ -v --tb=short -q`
Expected: 127 passed

- [ ] **Step 6: Commit**

```bash
git add trade/company.py tests/test_business.py
git commit -m "fix: 空公司名校验 + _slugify 空字符串 fallback 为 'company'"
```

---

### Task 5: P1 — UN 制裁列表总是用 fallback

**Files:**
- Modify: `trade/osint/sanctions.py:272-278`

- [ ] **Step 1: 标记 UN 制裁为已知限制**

`_load_un_sanctions` 只使用 fallback 数据是设计上的已知限制（UN 没有直接的 CSV 端点）。追加注释说明这一限制，并扩充 fallback 数据使之更有实际意义。

```python
def _load_un_sanctions() -> None:
    """加载联合国安理会制裁名单。

    UN 制裁名单目前没有提供机器可读的 CSV 端点（仅 HTML 表格页面），
    因此使用本地维护的 fallback 数据。未来可通过以下方式改进：
      1. 定期从 https://www.un.org/securitycouncil/content/un-sc-consolidated-list 抓取
      2. 使用第三方维护的 sanctions 数据集

    注意：OFAC SDN 已覆盖大部分国际贸易制裁实体，
    UN / EU 制裁名单与 OFAC 高度重叠。
    """
    _sanctions_cache["UN"] = _get_fallback_un_entries()
```

- [ ] **Step 2: 确保测试仍然通过**

Run: `python -m pytest tests/test_osint.py::TestSanctions -v`
Expected: 4 passed

- [ ] **Step 3: Commit**

```bash
git add trade/osint/sanctions.py
git commit -m "docs: UN 制裁名单限制说明 + fallback 原因注释"
```

---

### Task 6: P1 — Skill Router 显式匹配截断

**Files:**
- Modify: `trade/skill_router.py:177-188`

- [ ] **Step 1: 修复子串匹配为精确匹配**

当用户输入"用 b2b"时，`"b2b" in "b2b-osint"` 会匹配，但这是不完整的 skill 名。修复：要求匹配到的 skill name 必须完整匹配 `normalized_match` 中的一个完整词。

```python
    if explicit_match:
        matched_text = explicit_match.group(0)
        # 归一化：将空格和下划线转为连字符，以匹配 "用 b2b email intel" 这种写法
        normalized_match = matched_text.lower().replace(" ", "-").replace("_", "-")
        # 提取 normalized_match 中所有 "b2b-xxx" 形式的完整 token
        import re as _re
        candidates = _re.findall(r'b2b-[\w-]+', normalized_match)
        if candidates:
            skill_name_candidate = next(
                (name for c in candidates
                 for name in skill_names()
                 if name == c),  # 精确匹配，而非子串
                None,
            )
            if skill_name_candidate:
                return get_skill_by_name(skill_name_candidate)
```

- [ ] **Step 2: 运行 skill router 测试**

Run: `python -m pytest tests/ -v --tb=short -k "skill" -q`
Expected: all skill-related tests pass

- [ ] **Step 3: Commit**

```bash
git add trade/skill_router.py
git commit -m "fix: skill router 显式匹配改为精确匹配，避免 '用 b2b' 截断误匹配"
```

---

### Task 7: P2 — chat_memory.search_history isoformat 比较

**Files:**
- Modify: `trade/chat_memory.py:249-256`

- [ ] **Step 1: 将 isoformat 替换为 strftime**

SQLite `created_at` 字段存储格式为 `YYYY-MM-DD HH:MM:SS`（由 `datetime('now','localtime')` 生成），而 Python `isoformat()` 输出 `YYYY-MM-DDTHH:MM:SS`。虽然字符串比较时差异只在同秒内显现，但应保持格式一致。

```python
        if start:
            start_str = start.strftime("%Y-%m-%d %H:%M:%S")
            rows = conn.execute(
                "SELECT id, company_id, library_id, query, response, files_read, created_at "
                "FROM conversations WHERE company_id = ? AND created_at >= ? "
                "ORDER BY id ASC LIMIT ?",
                (company_id, start_str, limit),
            ).fetchall()
```

- [ ] **Step 2: 运行测试**

Run: `python -m pytest tests/ -v --tb=short -k "conversation" -q`
Expected: all conversation-related tests pass

- [ ] **Step 3: Commit**

```bash
git add trade/chat_memory.py
git commit -m "fix: search_history 时间比较改用 strftime 匹配 SQLite 格式"
```

---

### Task 8: P2 — post_install 路径查找修复

**Files:**
- Modify: `trade/post_install.py:68,142,144`

- [ ] **Step 1: 修复开发 vs 安装模式的路径查找**

问题：`Path(__file__).parent.parent` 在 `pip install .`（非 -e）时指向 `site-packages/` 而非项目根目录，导致 `skills/` 和 `.trade-template/` 目录找不到。

`_get_package_skills_dir()` 已正确处理此情况（先搜 sys.path），但 `install_skills()` 中的 `self_dir` 和 `template_dir` 仍直接使用 `parent.parent`。

修复：将 `template_dir` 的查找也走 `_get_package_skills_dir()` 的同级路径逻辑。

```python
def install_skills() -> None:
    """Main entry point: install Trade skills into Hermes and set up Trade data dir."""
    hermes_home = _get_hermes_home()
    trade_home = _get_trade_home()

    hermes_skills_dir = hermes_home / "skills"

    # Find package skills
    package_skills = _get_package_skills_dir()
    if package_skills is None:
        print("[post_install] ERROR: Could not find skills directory.", file=sys.stderr)
        print("[post_install] Expected: <package-root>/skills/b2b-*/SKILL.md", file=sys.stderr)
        sys.exit(1)

    # 查找 .trade-template — 与 skills 同级的模板目录
    # 优先从 package_skills 的父目录查找（pip install . 场景）
    # 其次从本脚本所在目录查找（pip install -e . 开发模式）
    template_dir = package_skills.parent / ".trade-template"
    if not template_dir.is_dir():
        self_dir = Path(__file__).parent.parent  # project root (dev install fallback)
        template_dir = self_dir / ".trade-template"
```

- [ ] **Step 2: 验证脚本可独立运行**

Run: `python -c "from trade.post_install import install_skills; print('module loads OK')"`
Expected: module loads OK (may print post_install messages)

- [ ] **Step 3: Commit**

```bash
git add trade/post_install.py
git commit -m "fix: post_install 模板路径优先从 package_skills 同级目录查找"
```

---

## Verification

全部修复完成后运行：

```bash
python -m pytest tests/ -v
```

预期：所有测试通过（126 + 新增测试 = 127+ passed）。
