2 Ревизии f173d39a81 ... 448bc58c72

Автор SHA1 Съобщение Дата
  Dew-OF-Aurora 448bc58c72 fix: change cfst test url преди 2 седмици
  Dew-OF-Aurora e5ffea3903 docs: 统一评分语义并加入 Claude 验证 Hook преди 2 седмици

+ 73 - 0
.claude/hooks/check_syntax.py

@@ -0,0 +1,73 @@
+"""Check one edited repository file without executing its contents."""
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+
+PROJECT_ROOT = Path(__file__).resolve().parents[2]
+CHECK_TIMEOUT = 10
+
+
+def check_file(file_path: str, project_dir: Path) -> None:
+    project_dir = project_dir.resolve()
+    path = Path(file_path)
+    if not path.is_absolute():
+        path = project_dir / path
+    path = path.resolve()
+    try:
+        relative = path.relative_to(project_dir)
+    except ValueError:
+        return
+
+    if path.suffix == ".py":
+        compile(path.read_bytes(), str(path), "exec")
+        return
+
+    if path.suffix == ".sh" and relative.parts[:2] == ("scripts", "server"):
+        command = ["bash", "-n", str(path)]
+    elif path.suffix == ".sh" and (
+        relative.parts[:2] == ("scripts", "router")
+        or relative == Path("build_router_package.sh")
+    ):
+        command = ["sh", "-n", str(path)]
+    elif path.suffix == ".js" and relative.parts[:1] == ("substore",):
+        command = ["node", "--check", str(path)]
+    else:
+        return
+
+    result = subprocess.run(
+        command,
+        cwd=str(project_dir),
+        capture_output=True,
+        text=True,
+        timeout=CHECK_TIMEOUT,
+    )
+    if result.returncode:
+        detail = result.stderr.strip() or result.stdout.strip()
+        raise RuntimeError(detail or f"{command[0]} exited with status {result.returncode}")
+
+
+def main() -> int:
+    try:
+        payload = json.load(sys.stdin)
+        if not isinstance(payload, dict):
+            raise ValueError("Hook payload must be a JSON object")
+        if payload.get("tool_name") not in ("Write", "Edit"):
+            return 0
+        tool_input = payload.get("tool_input")
+        if not isinstance(tool_input, dict):
+            raise ValueError("Write/Edit payload must contain tool_input")
+        file_path = tool_input.get("file_path")
+        if not isinstance(file_path, str) or not file_path:
+            raise ValueError("Write/Edit payload must contain a nonempty file_path")
+        check_file(file_path, PROJECT_ROOT)
+    except (OSError, SyntaxError, ValueError, RuntimeError, subprocess.TimeoutExpired) as exc:
+        print(f"[syntax-check] {exc}", file=sys.stderr)
+        return 2
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 20 - 0
.claude/settings.json

@@ -0,0 +1,20 @@
+{
+  "env": {
+    "CLAUDE_CODE_NEW_INIT": 1
+  },
+  "hooks": {
+    "PostToolUse": [
+      {
+        "matcher": "Write|Edit",
+        "hooks": [
+          {
+            "type": "command",
+            "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/check_syntax.py\"",
+            "timeout": 15,
+            "statusMessage": "检查编辑文件的语法"
+          }
+        ]
+      }
+    ]
+  }
+}

+ 159 - 0
.claude/skills/verify-rotator/SKILL.md

@@ -0,0 +1,159 @@
+---
+name: verify-rotator
+description: 按改动范围验证 rotator 的语法与隔离测试,检查依赖并报告未验证边界;用于提交前或修改后的主动验证。
+disable-model-invocation: true
+---
+
+# 验证 rotator
+
+## 选择范围
+
+从仓库根目录执行,先说明本次范围和选择理由。
+
+1. `$ARGUMENTS` 接受单个 `python`、`server`、`router`、`substore`、`hooks` 或 `all`。未知值或未支持的组合先澄清,不作为 Shell 命令执行,也不退化为 `all`。
+2. 用户只要求计划、解释或整理已有日志时,不执行验证命令。模拟快照、历史日志与本次实测分开标注,不用真实工作区状态替换用户指定的模拟输入。
+3. 无参数时读取 `git status --short`、`git diff --stat`、`git diff --cached --stat`,再查看相关未暂存、已暂存差异和未跟踪文件。diff 不包含未跟踪内容,不能仅看 diff 就判定无改动;只读必要文件,不重新扫描全仓。
+4. 纯说明文字修改只核对事实、路径和命令,不默认运行单测。无改动且无法从用户请求确定范围时先澄清;明确指定的范围不要求工作区必须有改动。
+
+| 改动位置 | 默认范围 |
+| --- | --- |
+| `scripts/domain_updater.py`、`scripts/rotator/`、Python 配置及对应测试 | `python`;涉及 Server CLI/IPC 契约时加 `server`,涉及打包参数投影时加 `router` |
+| `scripts/server/`、`tests/test_server_mode_lifecycle.py` | `server` |
+| `scripts/router/`、`configs/config_router.conf`、`build_router_package.sh`、`tests/test_router_scripts.py` | `router` |
+| `substore/`、`tests/test_substore_operator.py` | `substore` |
+| `.claude/hooks/`、Hook 设置、`tests/test_syntax_hook.py` | `hooks` |
+| `tests/test_shell_lifecycle.py` | 根据改动方法选择 `server`、`router` 或两者;打包清理保护属于 `router` |
+
+交叉改动取受影响范围的并集,并对测试去重。新增测试按其覆盖的行为归组,不因文件未出现在表中就静默忽略。
+
+## 隔离与预检
+
+- 不运行真实 API 更新、cfst 测速、HTTP 服务、真实安装/卸载或当前工作仓库的 Server 调度脚本;不提交、推送或访问 Git 远端。下列测试中已隔离的模拟命令、无远端临时仓库和临时文件可以使用。
+- Server 集成测试必须先准备本地模拟 API 和无远端临时仓库,再禁用推送。`GIT_PUSH_ENABLED=0` 不会阻止 `ls-remote` 或 `fetch`。
+- 当前 `test_server_mode_adaptive_interval_window` 在模拟 API 配置前执行首次调度,可能访问真实 API,默认不运行。修复需把模拟配置移到首次调度前并固定时钟;验证任务不擅自修测试、删除断言或吞掉失败。
+- Python 核心测试包含向回环地址发起请求的故障模拟,不是完全不使用网络。Server 的受控用例使用 `file://` 数据;本地 cfst 用例使用临时 CSV 与 `skip_run=True`。
+- `all` 是下列受控组的并集,不是 discovery。执行前静态核查新增/修改测试的模块顶层、包初始化、fixture/helper 和涉及脚本的隔离条件;不要用导入或 discovery 来“仅列出”未经审阅的测试。整模块命令会纳入新增方法,必要时拆成已审阅的精确选择器;不能确认隔离的项列为未验证。
+
+按检查项预检依赖,缺失只阻塞对应项,不自动安装工具或伪造资产。Python 使用 3.9+;核心测试无需 pip 依赖或编译,Sub-Store 验证无需 npm 依赖。
+
+| 检查项 | 主要依赖 |
+| --- | --- |
+| Python 测试、Router 三项源码契约、Hook 单测 | Python;Hook 单测 mock 外部检查器,不需要 Node/Bash/jq |
+| Server 测试 | 按选择器检查 Python、Bash、Git;源码断言不需要启动 Git |
+| Router/打包 Shell 语法;打包清理保护测试 | sh;清理保护测试另需 Python,不需 cfst/BusyBox |
+| Sub-Store 语法;Sub-Store 单测 | Node;单测另需 Python |
+| Hook 注册查询;检查器直测 | 查询需 jq;直测需 Python 及目标文件对应的解释器 |
+| Router SSOT 打包测试 | Python、sh、打包工具、cfst 实际文件及 BusyBox 文件,见下文 |
+
+缺少 Git 但已明确范围时,不阻塞无关检查;缺少 jq 也不阻塞 Hook 单测。
+
+## 语法与配置检查
+
+只选涉及范围的命令和已审阅文件,Shell 逐文件检查。Python 示例不覆盖全部测试;按需补充本次涉及的测试文件,不无条件编译 `tests/*.py`。
+
+```sh
+# python 范围示例:缓存重定向到 /tmp
+PYTHONPYCACHEPREFIX=/tmp/pycache python3 -m py_compile scripts/domain_updater.py scripts/rotator/*.py
+
+# hooks 范围
+PYTHONPYCACHEPREFIX=/tmp/pycache python3 -m py_compile .claude/hooks/check_syntax.py tests/test_syntax_hook.py
+
+# 分别用于 server、router、substore 范围;多文件逐一检查
+for f in scripts/server/*.sh; do bash -n "$f" || exit 1; done
+for f in scripts/router/*.sh build_router_package.sh; do sh -n "$f" || exit 1; done
+node --check substore/operator_template.js
+
+# 仅对涉及的配置执行:不请求 API、不测速、不写运行时文件
+python3 -B scripts/domain_updater.py --config configs/config.server.json --print-output-settings
+python3 -B scripts/domain_updater.py --config configs/config.local.json --print-output-settings
+
+# Hook 注册查询:还须核对打印的命令确实指向项目检查器
+jq -e '.hooks.PostToolUse[] | select(.matcher == "Write|Edit") | .hooks[] | select(.type == "command") | .command' .claude/settings.json
+```
+
+语法检查不执行目标代码,不能代替行为测试。Hook 仅覆盖 Write/Edit 事件,不覆盖通过 Bash 改写文件的操作。
+
+## 按范围运行测试
+
+### python
+
+```sh
+PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v tests.test_domain_updater tests.test_cfst_local_lifecycle
+
+# 单用例示例:无网络与测速,不写仓库运行时文件
+python3 -B -m unittest -v tests.test_domain_updater.TestDomainUpdaterP0P1Bugs.test_modular_rotator_package_architecture_and_facade_integrity
+```
+
+### server
+
+以下用例在临时仓库中操作 Git,不执行真实安装。`INVOCATION_ID` 避免调度脚本尝试手动 journald tee。保留三个 Server 精确方法,不扩成整类或整模块,以免纳入时间窗口用例。
+
+```sh
+env PYTHONDONTWRITEBYTECODE=1 INVOCATION_ID=local-validation python3 -m unittest -v \
+  tests.test_shell_lifecycle \
+  tests.test_server_mode_lifecycle.TestServerModeFullLifecycle.test_server_mode_full_lifecycle_api_fetch_and_git_commit \
+  tests.test_server_mode_lifecycle.TestServerModeFullLifecycle.test_server_mode_fallback_and_silent_skip \
+  tests.test_server_mode_lifecycle.TestServerModeFullLifecycle.test_server_mode_rolling_history_prune
+```
+
+### router
+
+以下源码契约只需 Python,不依赖外部二进制:
+
+```sh
+PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v \
+  tests.test_router_scripts.TestRouterScriptsBugsAndContracts.test_p1_router_local_update_atomic_file_write \
+  tests.test_router_scripts.TestRouterScriptsBugsAndContracts.test_p2_router_local_update_filters_zero_speed \
+  tests.test_router_scripts.TestRouterScriptsBugsAndContracts.test_recheck_correction_router_cfst_params_already_supported
+```
+
+打包清理保护另需 sh;在临时目录触发配置缺失的前置失败,不需打包资产。`all` 已选整个 `tests.test_shell_lifecycle` 时不要重复执行此方法:
+
+```sh
+PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v tests.test_shell_lifecycle.TestShellLifecycleP0P1Bugs.test_p0_build_router_package_trap_prematurely_deletes_tmp_dir
+```
+
+SSOT 打包用例另需 Python、sh、打包工具和本地资产。检查 `build_router_package.sh` 中 `CFST_SRC_DIR` / `BUSYBOX_SRC` 的当前默认值及环境覆盖,确认 `CFST_SRC_DIR/cfst` 与 `BUSYBOX_SRC` 是实际存在的文件;目录存在不等于二进制存在。不要用 `--help` 探测,该参数会被忽略并继续打包。测试指定临时输出,不直接运行根目录打包命令;`--sync-from-config` 默认只改包内配置,不擅自使用 `--write-back` 回写源文件。
+
+```sh
+PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v tests.test_router_scripts.TestRouterScriptsBugsAndContracts.test_build_router_package_ssot_sync
+```
+
+缺少资产时保留源码契约和清理保护的验证,将 SSOT 打包项标为“未执行:缺少依赖”。打包成功只证明文件装配与配置投影,不证明目标路由器可运行。
+
+### substore
+
+```sh
+PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v tests.test_substore_operator
+```
+
+此模块用 Node.js 内置 `vm` 测试辅助函数,不等于验证真实 Sub-Store HTTP、缓存与端到端选源。
+
+### hooks
+
+```sh
+PYTHONDONTWRITEBYTECODE=1 python3 -m unittest -v tests.test_syntax_hook
+```
+
+修改 Hook 时,额外把实际 Write/Edit JSON 输入传给检查器,记录目标文件类型和返回码;不支持的文件、工具或仓库外路径返回 0 只是跳过,不能当作语法检查通过。
+
+分别报告三种证据:注册配置核对、检查器直测、当前会话实际触发。jq 返回 0 不代表查到了目标检查器;配置与直测通过也不代表 Hook 已加载。新增或改变注册时需验证实际触发,并恢复验证用的临时修改、清理探针;未获准验证或未观察到触发时明确标为未验证,提示用户打开 `/hooks` 或重启后复核,不声称已生效。
+
+### 完整 discovery
+
+只有静态核实所有测试(包括时间窗口用例)已隔离真实 API、时钟和发布,且具备全部依赖后,另行取得完整 discovery 的执行确认;`all` 不包含此授权:
+
+```sh
+env PYTHONDONTWRITEBYTECODE=1 INVOCATION_ID=local-validation python3 -m unittest discover -s tests -v
+```
+
+## 输出结果
+
+用以下顺序简洁报告,不把计划或缺失证据算作通过:
+
+1. **范围与理由**:所选范围、交叉覆盖和新增测试的纳入/未纳入原因。
+2. **实际执行**:命令、退出码、测试运行数量与结果;语法、配置解析和行为测试分别记录。不推断未提供的计数。
+3. **失败与未执行**:区分断言失败、加载/运行 ERROR、框架 skip、缺依赖/权限受阻/仅做计划等未执行项;保留失败原因,不自动修复。
+4. **验证边界**:受控组不是全套;列明未运行的时间窗口用例(涉及 server/all 时)、Hook 的三种证据状态,以及与目标环境端到端验证的差别。
+
+模拟输入、历史结果与本次实测分开标注。实际执行后检查 `git status --short`,保留用户原有修改,不清理不属于本次验证的文件。

+ 45 - 0
.claude/skills/verify-rotator/evals/evals.json

@@ -0,0 +1,45 @@
+{
+  "skill_name": "verify-rotator",
+  "evals": [
+    {
+      "id": 1,
+      "prompt": "我想运行不带参数的 /verify-rotator。这里是模拟状态:已暂存 CLAUDE.md 的说明文字修改,另有未跟踪的 docs/validation-note.md;没有源码、配置或测试改动。请基于这个快照给出验证范围与理由,只做计划,不执行项目命令。",
+      "expected_output": "基于给定模拟快照,同时考虑已暂存与未跟踪的纯说明改动;计划核对事实、路径和命令,不安排单测或扩大到 all,不声称本次验证通过。",
+      "files": [],
+      "expectations": [
+        "范围同时包含已暂存的 CLAUDE.md 说明修改与未跟踪的 docs/validation-note.md,不仅根据普通 diff 判断改动。",
+        "将验证限定为文档事实、路径和命令的核对,不因缺省参数安排单测或扩大到 all。",
+        "依据给定模拟快照制定计划,不用声称读取到的真实工作区状态替换模拟输入。",
+        "明确这是未执行的计划,不报告本次测试通过、已验证文件内容或虚构成功计数。"
+      ]
+    },
+    {
+      "id": 2,
+      "prompt": "/verify-rotator router。这次修改的是 build_router_package.sh。模拟预检结果:Python、sh、tar/gzip 可用,Node/jq 不可用;cfst_linux_armv5 目录存在,但其中没有 cfst 文件,BusyBox 输入也不存在。请列出可执行的验证计划、受阻项及原因,不执行命令。",
+      "expected_output": "列出 Router 源码契约、Shell 语法及打包清理保护测试的计划;cfst 实际文件和 BusyBox 缺失只阻塞 SSOT 打包,不因 Node/jq 不可用阻塞无关项。不运行整组 Server,不用打包脚本 --help 探测,不把计划写成通过结果。",
+      "files": [],
+      "expectations": [
+        "计划包括 sh 语法检查及 Router 的原子写入、零速过滤、cfst 参数支持三项源码契约检查。",
+        "纳入打包前置失败不误删既有临时目录的清理保护测试,明确它无需 cfst/BusyBox,而不因此运行整组 Server。",
+        "识别 cfst 目录存在但实际 cfst 文件缺失以及 BusyBox 缺失,将 SSOT 打包测试列为受阻,而非宣称资产齐全。",
+        "Node/jq 缺失不阻塞 Router 的 sh 语法、Python 源码契约和清理保护检查。",
+        "不建议用打包脚本 --help 探测、不伪造资产、不执行真实测速或使用 --write-back 回写源配置。",
+        "明确可执行计划与因依赖受阻的项,且不把任何计划项记为本次已通过。"
+      ]
+    },
+    {
+      "id": 3,
+      "prompt": "/verify-rotator all,先给计划和状态报告,不执行任何命令。模拟新增文件 tests/test_remote_smoke.py 在模块顶层调用 urllib.request.urlopen(\"https://example.invalid/status\")。附带的历史日志有一个 unittest 加载 ERROR,其余组尚未运行;jq 输出了项目 Hook 命令,但没有当前会话触发记录。请说明哪些检查可以纳入、哪些还不能,并准确整理已有证据。",
+      "expected_output": "保持受控 all,先静态审查新测试,不导入有外部请求副作用的模块、不运行 discovery,并保留时间窗口用例排除。准确区分历史加载 ERROR、尚未执行与仅有注册证据的 Hook;不宣称全套通过或 Hook 已加载,不生成没有证据的成功计数。",
+      "files": [],
+      "expectations": [
+        "将 all 解释为受控组并集而非 discovery,并保留 test_server_mode_adaptive_interval_window 的默认排除。",
+        "识别新增模块的顶层外部请求,计划先静态审查隔离条件,不通过导入或 discovery 列出测试,并说明该项暂不纳入执行的原因。",
+        "将唯一已知错误标为历史 unittest 加载 ERROR,不混同断言失败、框架 skip 或本次新产生的错误。",
+        "将其余组标为尚未执行或待条件确认,不宣称全套通过、不捏造退出码或成功测试数量。",
+        "将 jq 输出限定为历史注册配置证据;检查器直测及当前会话触发没有证据,不声称 Hook 已加载或实际触发。",
+        "列出可纳入的受控检查及依赖或隔离前提,不为得到通过结果而删除断言、吞掉错误或静默忽略新增测试。"
+      ]
+    }
+  ]
+}

+ 9 - 1
.gitignore

@@ -1,7 +1,15 @@
 runtime/
 cfip_runtime/
 local_runtime/
-.claude/
+.claude/**
+!.claude/settings.json
+!.claude/hooks/
+!.claude/hooks/check_syntax.py
+!.claude/skills/
+!.claude/skills/verify-rotator/
+!.claude/skills/verify-rotator/SKILL.md
+!.claude/skills/verify-rotator/evals/
+!.claude/skills/verify-rotator/evals/evals.json
 cfst*/
 busybox*/
 vmess/

+ 48 - 149
CLAUDE.md

@@ -1,154 +1,53 @@
 # CLAUDE.md
 
-## What This Repo Does
-
-自动选优 Cloudflare 优选域名/IP,写入运行时文件供 Sub-Store 等下游消费。
-
-两种独立运行模式:
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
 
-| 模式 | 数据源 | 配置 | 输出目录 | Git 自动化 |
-|------|--------|------|----------|-----------|
-| Server | 远程 API | `configs/config.server.json` | `runtime/` | `runtime-state` 分支 |
-| Router (Python) | 本地 `cfst` | `configs/config.local.json` | `cfip_runtime/` | 无 |
-| Router (BusyBox) | 本地 `cfst` | `configs/config_router.conf` | 由配置决定 | 无 |
-
-## File Map
+`AGENTS.md` 仅作入口;项目约束只维护在此处。使用与部署说明见 [README.md](README.md)。
 
-```
-configs/                       # 统一配置仓
-├── config.server.json         # Server 模式完整配置
-├── config.local.json          # Python 本地 cfst 模式配置
-└── config_router.conf         # BusyBox shell 模式配置
+## 项目与入口
 
-scripts/
-├── domain_updater.py          # 顶层外观门面(Facade,100% 向后兼容 CLI 与 API)
-├── rotator/                   # 核心领域高内聚模块子包
-│   ├── config.py              # 配置校验、路径解析与输出设定
-│   ├── filter.py              # 域名清洗、正则匹配与黑名单过滤
-│   ├── sources.py             # 远程 API 与本地 cfst 数据源适配
-│   ├── scoring.py             # 评分排名、平局决胜、迟滞防抖与候选构建
-│   ├── storage.py             # 原子落盘写入与状态读写
-│   ├── notify.py              # 通知命令执行与超时防护
-│   └── main.py                # 主轮换工作流编排与 CLI 参数解析
-├── server/                    # Linux / Debian 服务端运维脚本
-│   ├── run_update_and_commit.sh # 实际调度器 + Git 自动提交与滚动修剪(模式 A)
-│   ├── install_debian.sh      # systemd timer/service 安装
-│   └── uninstall_debian.sh    # systemd 卸载
-├── router/                    # 路由器 BusyBox 嵌入式脚本
-│   ├── router_local_update.sh # 运行 cfst 并原子写输出
-│   └── router_local_http.sh   # BusyBox httpd 提供运行时文件
-
-docs/
-├── README.md                  # 文档导航中心
-├── workflow.md                # 业务全景工作流图 (Mermaid)
-└── reviews/                   # 架构审查、分歧裁决与修复台账归档
-    ├── ARCHITECTURE_REVIEW_UNIFIED.md  # 终审统一报告与演进台账(唯一权威)
-    ├── ARCHITECTURE_REVIEW.md          # 历史初审报告归档
-    └── ARCHITECTURE_REVIEW_RECHECK.md  # 历史复核报告归档
-
-substore/
-└── operator_template.js       # Sub-Store 脚本操作符,消费运行时 JSON
-
-tests/
-├── test_domain_updater.py     # Python 核心选优、极性、回退、配置校验及 Facade 完整性
-├── test_shell_lifecycle.py    # Shell 部署、发布安全、凭证保护、Git 历史滚动修剪及分层门面完整性
-├── test_router_scripts.py     # 路由器 BusyBox 脚本文件原子性、测速过滤及 SSOT 打包同步
-└── test_substore_operator.py  # Sub-Store 算子类型安全与时效降级
-
-build_router_package.sh        # 打包路由器部署 tarball → vmess.tar.gz(支持 --sync-from-config)
-```
-
-## Common Commands
-
-### 语法校验
-
-```bash
-env PYTHONPYCACHEPREFIX=/tmp/pycache python3 -m py_compile scripts/rotator/*.py scripts/domain_updater.py
-bash -n scripts/server/*.sh scripts/router/*.sh build_router_package.sh
-node -c substore/operator_template.js
-```
-
-### 自动化测试
-
-```bash
-# 运行全套测试(覆盖 Python 核心、Shell 部署、路由器脚本及 Sub-Store 算子)
-python3 -m unittest discover tests -v
-```
-
-### 运行
-
-```bash
-# Server 模式单次运行
-python3 scripts/domain_updater.py --config configs/config.server.json
-
-# 本地 cfst 模式单次运行
-python3 scripts/domain_updater.py --config configs/config.local.json
-
-# 查看解析后的输出路径(不执行更新)
-python3 scripts/domain_updater.py --config configs/config.server.json --print-output-settings
-
-# Server 调度器入口
-bash scripts/server/run_update_and_commit.sh configs/config.server.json
-
-# 强制提交
-bash scripts/server/run_update_and_commit.sh --force-commit configs/config.server.json
-
-# BusyBox 路由器
-sh scripts/router/router_local_update.sh configs/config_router.conf
-sh scripts/router/router_local_http.sh configs/config_router.conf
-
-# 打包路由器部署包(默认打包 configs/config_router.conf)
-./build_router_package.sh
-
-# 打包路由器部署包(SSOT 单源模式:自动从 configs/config.local.json 投影同步测速与输出参数)
-./build_router_package.sh --sync-from-config configs/config.local.json
-```
-
-### systemd 安装/卸载
-
-```bash
-sudo bash scripts/server/install_debian.sh --config configs/config.server.json
-sudo bash scripts/server/uninstall_debian.sh
-```
-
-`install_debian.sh --help` 查看完整参数(peak/offpeak 间隔、时区等)。
-
-## Testing
-
-- **全套自动化回归测试**:运行 `python3 -m unittest discover tests -v`(覆盖 4 大模块 22 项用例,秒级执行,零第三方依赖)。
-- **语法校验**:使用 `py_compile` 与 `bash -n` / `sh -n` 快速检查语法。
-- **路径解析验证**:`python3 scripts/domain_updater.py --config configs/config.server.json --print-output-settings`。
-- **本地测试服务器端时,仅运行 `python3 scripts/domain_updater.py --config configs/config.server.json` 进行输出验证,严禁直接运行 `scripts/server/run_update_and_commit.sh` 调度/Git 推送脚本。**
-
-## Key Constraints
-
-- `config.json` / `config.example.json` 已废弃,不要重新引入。
-- 输出路径由配置驱动,不要硬编码 `runtime/current_domain.txt` 之类的路径。
-- `runtime/` 和 `cfip_runtime/` 在 `main` 分支被 gitignore。
-- 只有 Server 模式向 `runtime-state` 分支提交。
-- `state.json` 用于 fallback(保存上次正常值),两种模式都依赖它。
-- `substore_vars.json` 是给下游消费者的轻量契约,不要把完整运行时数据塞进去。
-- BusyBox shell 脚本需兼容 POSIX sh,不能用 bash 特性。
-- `router_local_http.sh` 使用配置中的 BusyBox 二进制的 httpd applet,不使用系统 httpd。
-- SubStore 脚本的 `$arguments.domain` 参数为最高优先级覆盖,`$arguments.domain_override` 控制开关,不受任何模式和策略影响。
-- **`vps789.com` API 指标极性与业务公式(Score SSOT 权威规范)**:
-  实测原始数据已逆向破解并确证 `vps789.com` API 中所有 `*Score` 字段(`ydScore`, `dxScore`, `ltScore`, `avgScore`)的真实物理含义:
-  - 上游公式严格为**综合成本惩罚分(Cost / Penalty Metric,越小越优 / 高尔夫计分制)**:
-    $$\text{Score} = \text{LatencyAvg} + \text{PkgLostRateAvg} \times 50$$
-    $$\text{avgScore} = \frac{\text{ydScore} + \text{dxScore} + \text{ltScore}}{3}$$
-  - 零丢包时分数等于平均延迟(ms);丢包每增加 1% 加罚 50 分。
-  - **极性结论**:所有 `*Score` 与延迟、丢包完全同向,必须严格遵循 **越小越优**。
-  - **策略落地**:平局决胜 `tie_breakers` 中必须指定 `{"field": "yd_score", "order": "asc"}`(低分优胜);若将来在加权计算中使用,也必须遵循 `prefer_lower: true` / `order: "asc"`。历史审查曾将其误解为百分制健康分(误配 desc),现已永久纠偏。
-
-## Where to Look
-
-- 想了解架构终审与缺陷复现清单 → `docs/reviews/ARCHITECTURE_REVIEW_UNIFIED.md`
-- 想了解自动化测试用例 → `tests/`
-- 想了解配置字段含义 → 直接读对应的 config JSON/conf 文件,字段名即文档
-- 想了解评分/筛选逻辑 → `scripts/domain_updater.py` / `scripts/rotator/`
-- 想了解 git 提交/推送流程 → `scripts/server/run_update_and_commit.sh`
-- 想了解 systemd 部署细节 → `scripts/server/install_debian.sh`
-- 想了解下游消费方式 → `substore/operator_template.js`
-- 想了解完整工作流图 → `docs/workflow.md`(Mermaid 流程图)
-- 想了解项目综合文档中心 → `docs/README.md`
-- 想了解用户快速上手文档 → `README.md`
+从远程 API 或本地 `cfst` 选出 Cloudflare 域名/IP,原子写入运行时文件,供 Sub-Store 等下游读取。
+
+| 运行方式       | 配置                           | 入口与职责                                                                                    |
+| -------------- | ------------------------------ | --------------------------------------------------------------------------------------------- |
+| Server         | `configs/config.server.json` | Python 负责 API 选优;`scripts/server/run_update_and_commit.sh` 负责调度、提交与推送        |
+| 本地 Python    | `configs/config.local.json`  | `scripts/domain_updater.py` 调用本地 `cfst`,只落盘                                       |
+| BusyBox 路由器 | `configs/config_router.conf` | `scripts/router/router_local_update.sh` 测速与落盘;`router_local_http.sh` 提供 HTTP 访问 |
+
+输出目录以配置为准。当前两个 Python 配置均使用 `runtime/`,路由器使用 `/tmp/vmess_runtime`;不同模式需要独立保存状态时,应配置不同目录,避免覆盖共用的状态和导出文件。
+
+## 执行边界
+
+- Python 编排在 `scripts/rotator/main.py`;`scripts/domain_updater.py` 是直接重导出门面。mock 编排依赖时 patch 实际使用处(如 `rotator.main.fetch_api_json`),只替换门面同名导出不会影响编排。
+- Python 本地 `cfst` 分支保留 CSV 顺序;`-dd` 只绕过下载速度筛选,不触发 Python 重排。`skip_run=true` 只跳过测速执行,仍会写出运行文件。
+
+流程图见 [docs/workflow.md](docs/workflow.md),文档索引见 [docs/README.md](docs/README.md)。[统一审查台账](docs/reviews/ARCHITECTURE_REVIEW_UNIFIED.md) 用于追溯设计与修复背景;其中的结论需结合当前代码、配置和测试核实,不能代替上游指标定义。
+
+## 开发约束
+
+这些约束保护现有部署与消费者。内部结构可以重构;改变外部行为时,应同步迁移配置、调用方和测试,不必永久保留每个历史实现。
+
+- **接口兼容**:Server 脚本依赖 CLI 的 `--print-output-settings` 和 `--result-json-file` 解析路径及运行状态,下游依赖输出中的配置键、状态与候选信息。变更这些接口时一起检查调用方,避免脚本能执行但读取错误。
+  CLI 退出码 `0` 也包括 last-good 回退;判断是否取得新值应读取结果 JSON 的 `status`,不能只看退出码。
+- **配置与路径**:输出路径、文件名和 JSON 键由配置驱动,避免多处硬编码。仓库自带配置放在 `configs/`,`--config` 仍支持外部配置;不为旧文件名复制一套默认配置。相对输出目录的解析规则见 `build_output_settings()`,改动时覆盖仓库配置和外部配置两种场景。
+- **写入与回退**:运行时文件需单文件原子替换,防止并发读到半截内容;`state.json` 的 last-good 值支持故障回退。回退必须标明状态,且应遵守适用的排除规则。改变输出字段或删除产物前检查消费者;当前 Sub-Store 模板读取完整 JSON,`substore_vars.json` 是额外的轻量导出,仓库内未见其消费方。
+- **发布边界**:运行时发布与源码分支隔离,只提交配置指定的运行时文件,避免夹带源码或凭据。默认分支 `runtime-state`、去重规则和历史长度属于可调整策略;改动时验证下游地址、状态传播及修剪失败恢复。凭据不得写入日志或作为命令行参数传递。
+- **路由器环境**:路由器脚本和打包脚本由 `sh` 执行,保持 POSIX sh。HTTP 服务使用 BusyBox 的 `httpd` applet,优先配置指定的二进制,允许现有备用路径;不能误用固件的管理后台 `httpd`。改动测速或输出参数时检查 `--sync-from-config` 打包映射,避免部署参数漂移。
+- **Sub-Store 行为**:有效的手动 `domain` 优先于缓存和自动来源;`domain_override=false` 仅关闭手动覆盖,仍可自动替换。保持主机值校验以及仅修改名称匹配的 VMess 节点的范围,避免改坏无关节点。
+
+当前实现边界:Python API 回退复查 `domain_filter`,尚未复查 `record_filter`;Sub-Store 的路由器时效检查仅在 `default` 模式启用,缺失或无效时间戳会放行。修改相关逻辑时应针对这些边界验证,不能把约束当作已经全面实现的保证。
+
+### 评分语义
+
+- `record_mapping.field_map` 定义上游字段映射,例如 `yd_score` → `ydScore`。上游 `*Score` 的含义、单位和公式必须有上游依据,不能凭名称、少量样本或历史审查断言。
+- `score_value` 是本项目按 `scoring` 配置计算的排序值。权重、`prefer_lower`、`order`、平局决胜和切换迟滞属于本项目策略,不等同于上游评分。
+
+## 验证与运行
+
+按改动范围验证;具体命令、依赖及隔离要求见 [.claude/skills/verify-rotator/SKILL.md](.claude/skills/verify-rotator/SKILL.md),可手动调用 `/verify-rotator`。纯文档修改核对事实、路径与命令即可。
+
+- **本地模式测试落盘**:用户要求“在本地测试某种模式”时,一律以配置文件设置为测试工作目录,按该模式配置在根目录或其子目录生成并保留结果,不将结果改放到 `/tmp` 等仓库外临时目录。
+- 不在当前工作仓库直接运行 Server 调度脚本;验证应使用无远端的临时仓库和本地模拟 API。用户要求本地 Server 模式实测时,将隔离仓库及结果放在当前项目根目录下的专用子目录。`GIT_PUSH_ENABLED=0` 只禁用 push,不禁止 fetch。
+- 当前时间窗口测试在设置模拟 API 前执行首次调度,不能保证离线;默认验证排除此用例并明确报告。
+- `build_router_package.sh` 不支持 `--help`,传入后仍会打包;`--sync-from-config` 默认只修改包内配置,`--write-back` 才回写源配置。
+- 编辑后的 Hook 只检查语法,不能代替测试;真实运行与部署命令见 [README.md](README.md)。

+ 82 - 59
README.md

@@ -1,6 +1,6 @@
 # VMess Domain / IP Rotator
 
-> 🚀 **Cloudflare 优选 IP / 域名全自动轮换系统**  
+> 🚀 **Cloudflare 优选 IP / 域名全自动轮换系统**
 > 支持多源选优评分、双频自适应调度、Git 孤儿分支状态总线与路由器嵌入式闪存防擦写设计。
 
 ---
@@ -9,25 +9,25 @@
 
 本项目针对不同运行环境提供三大解耦运行模式:
 
-| 运行模式 | 数据源 | 配置文件 | 运行时落盘路径 | 状态同步与发布 | 典型适用环境 |
-| :--- | :--- | :--- | :--- | :--- | :--- |
-| **服务器模式** | 远程监控 API | `configs/config.server.json` | `runtime/` | 自动提交并推送至 `runtime-state` 孤儿分支 | Linux VPS / 云服务器 (systemd) |
-| **本地 Python 模式** | 本机 `cfst` 测速 | `configs/config.local.json` | `runtime/` | 纯本地文件落盘,供本地工具/脚本直接读取 | 本地开发机 / 具备 Python 环境的 PC |
-| **BusyBox 路由器模式**| 路由器 `cfst` 测速 | `configs/config_router.conf` | `cfip_runtime/` 或 `/tmp` | 启动微型 HTTP 服务供局域网直接拉取 | 华硕梅林 / OpenWrt / 嵌入式路由器 |
+| 运行模式                     | 数据源              | 配置文件                       | 运行时落盘路径                | 状态同步与发布                             | 典型适用环境                       |
+| :--------------------------- | :------------------ | :----------------------------- | :---------------------------- | :----------------------------------------- | :--------------------------------- |
+| **服务器模式**         | 远程监控 API        | `configs/config.server.json` | `runtime/`                  | 自动提交并推送至`runtime-state` 孤儿分支 | Linux VPS / 云服务器 (systemd)     |
+| **本地 Python 模式**   | 本机`cfst` 测速   | `configs/config.local.json`  | `runtime/`                  | 纯本地文件落盘,供本地工具/脚本直接读取    | 本地开发机 / 具备 Python 环境的 PC |
+| **BusyBox 路由器模式** | 路由器`cfst` 测速 | `configs/config_router.conf` | `cfip_runtime/` 或 `/tmp` | 启动微型 HTTP 服务供局域网直接拉取         | 华硕梅林 / OpenWrt / 嵌入式路由器  |
 
 ---
 
 ## 📑 快速导航
 
 - [1. 目录架构与输出约定](#1-目录架构与输出约定)
-- [2. 服务器模式(API -> 域名)](#2-服务器模式api---域名)
+- [2. 服务器模式(API -> 域名)](#2-服务器模式api---域名)
   - [2.1 快速执行](#21-快速执行)
   - [2.2 一键部署与服务管理 (systemd)](#22-一键部署与服务管理-systemd)
   - [2.3 Git 自动推送(Push)与凭据认证](#23-git-自动推送push与凭据认证)
   - [2.4 高峰/非高峰双频自适应调度](#24-高峰非高峰双频自适应调度)
   - [2.5 核心规则:筛选与评分策略](#25-核心规则筛选与评分策略)
   - [2.6 日志格式与日常维护速查](#26-日志格式与日常维护速查)
-- [3. 本地 Python 模式(本机 cfst -> IP)](#3-本地-python-模式本机-cfst---ip)
+- [3. 本地 Python 模式(本机 cfst -> IP)](#3-本地-python-模式本机-cfst---ip)
 - [4. BusyBox 路由器模式(路由器 cfst + HTTP)](#4-busybox-路由器模式路由器-cfst--http)
   - [4.1 目录拓扑与关键配置](#41-目录拓扑与关键配置)
   - [4.2 [最佳实践] 延长路由器闪存寿命(动静分离防擦写)](#42-最佳实践-延长路由器闪存寿命动静分离防擦写)
@@ -69,12 +69,12 @@ vmess-domain-rotator/
 
 所有运行模式均产生统一结构的标准输出,保障下游消费一致性:
 
-| 产物文件名 | 文件说明 | 典型消费场景 |
-| :--- | :--- | :--- |
-| `current_domain.txt` / `current_ip.txt` | 纯文本格式的当前优选值 | 极简脚本、命令行直接读取 |
-| `current_domain.json` / `current_ip.json` | 完整 JSON 状态报告与候选池列表 | 详细分析面板、具备完整解析能力的客户端 |
-| `substore_vars.json` | 轻量键值对(`AUTO_DOMAIN` 或 `AUTO_CFIP`、`STATUS`、`UPDATED_AT`) | **Sub-Store 算子专用**(免去完整 JSON 解析开销) |
-| `state.json` | 持久化状态沉淀(记录 `last_good_domain` / `last_good_ip`) | **双轨容灾 Fallback**(异常或断网时平滑回退) |
+| 产物文件名                                    | 文件说明                                                                   | 典型消费场景                                           |
+| :-------------------------------------------- | :------------------------------------------------------------------------- | :----------------------------------------------------- |
+| `current_domain.txt` / `current_ip.txt`   | 纯文本格式的当前优选值                                                     | 极简脚本、命令行直接读取                               |
+| `current_domain.json` / `current_ip.json` | 完整 JSON 状态报告与候选池列表                                             | 详细分析面板、具备完整解析能力的客户端                 |
+| `substore_vars.json`                        | 轻量键值对(`AUTO_DOMAIN` 或 `AUTO_CFIP`、`STATUS`、`UPDATED_AT`) | **Sub-Store 算子专用**(免去完整 JSON 解析开销) |
+| `state.json`                                | 持久化状态沉淀(记录`last_good_domain` / `last_good_ip`)              | **双轨容灾 Fallback**(异常或断网时平滑回退)    |
 
 > [!NOTE]
 > **候选信息稀疏语义**:JSON 输出中的 `top_candidates` 仅在字段非空时输出真实键值(如 `avg_latency`、`loss_rate`、`download_speed`、`region`),彻底消除空字符串/空数组/`null` 占位污染。
@@ -108,26 +108,26 @@ sudo bash scripts/server/install_debian.sh [options]
 
 #### 安装脚本参数详解
 
-| 参数选项 | 默认值 | 参数说明 |
-| :--- | :--- | :--- |
-| `--config <path>` | `configs/config.server.json` | 配置文件路径(支持相对于仓库根目录的相对路径) |
-| `--interval <value>` | `30min` | 定时任务基础轮询间隔(`--offpeak-interval` 别名) |
-| `--peak-start <hour>` | `19` | 晚高峰起始小时(`0-23`,如 19 代表 19:00) |
-| `--peak-end <hour>` | `24` | 晚高峰截止小时(`1-24`,如 24 代表 24:00) |
-| `--peak-tz <timezone>`| `Asia/Shanghai` | 高峰期时钟判定的参考时区 |
-| `--peak-interval <val>`| `10min` | 高峰期域名更新间隔(如 `5min`、`10m`) |
-| `--offpeak-interval <v>`| `30min` | 非高峰期域名更新间隔(如 `30min`、`1h`) |
-| `--user <name>` | 当前 `sudo` 用户 | 运行 systemd 服务的非 root 系统用户 |
-| `--group <name>` | 当前 `sudo` 用户组 | 运行 systemd 服务的系统用户组 |
-| `--git-push <0\|1>` | `1` | 选出新域名后是否自动推送到远端 Git 仓库 |
-| `--git-push-remote <n>`| `origin` | 推送的 Git 远程分支仓库名 |
-| `--git-http-username <u>`| `git` | HTTPS 认证用户名 |
-| `--git-http-token <t>` | 空 | HTTPS 访问令牌(用于非交互式免密推送) |
-| `--git-http-token-file <f>`| 空 | 从安全文件中读取访问令牌(权限限定 `600`,推荐) |
-| `--git-use-credential-store <0\|1>`| `1` | 是否使用 `git credential.helper store` 持久化凭据 |
-| `--git-credentials-file <f>`| 空 | 自定义凭据存储文件路径(隔离多项目凭据) |
-| `--no-install-deps` | 否 | 跳过 `apt-get` 依赖检测与自动安装 |
-| `-h, --help` | - | 显示帮助信息 |
+| 参数选项                             | 默认值                         | 参数说明                                            |
+| :----------------------------------- | :----------------------------- | :-------------------------------------------------- |
+| `--config <path>`                  | `configs/config.server.json` | 配置文件路径(支持相对于仓库根目录的相对路径)      |
+| `--interval <value>`               | `30min`                      | 定时任务基础轮询间隔(`--offpeak-interval` 别名) |
+| `--peak-start <hour>`              | `19`                         | 晚高峰起始小时(`0-23`,如 19 代表 19:00)        |
+| `--peak-end <hour>`                | `24`                         | 晚高峰截止小时(`1-24`,如 24 代表 24:00)        |
+| `--peak-tz <timezone>`             | `Asia/Shanghai`              | 高峰期时钟判定的参考时区                            |
+| `--peak-interval <val>`            | `10min`                      | 高峰期域名更新间隔(如`5min`、`10m`)           |
+| `--offpeak-interval <v>`           | `30min`                      | 非高峰期域名更新间隔(如`30min`、`1h`)         |
+| `--user <name>`                    | 当前`sudo` 用户              | 运行 systemd 服务的非 root 系统用户                 |
+| `--group <name>`                   | 当前`sudo` 用户组            | 运行 systemd 服务的系统用户组                       |
+| `--git-push <0\|1>`                 | `1`                          | 选出新域名后是否自动推送到远端 Git 仓库             |
+| `--git-push-remote <n>`            | `origin`                     | 推送的 Git 远程分支仓库名                           |
+| `--git-http-username <u>`          | `git`                        | HTTPS 认证用户名                                    |
+| `--git-http-token <t>`             | 空                             | HTTPS 访问令牌(用于非交互式免密推送)              |
+| `--git-http-token-file <f>`        | 空                             | 从安全文件中读取访问令牌(权限限定`600`,推荐)   |
+| `--git-use-credential-store <0\|1>` | `1`                          | 是否使用`git credential.helper store` 持久化凭据  |
+| `--git-credentials-file <f>`       | 空                             | 自定义凭据存储文件路径(隔离多项目凭据)            |
+| `--no-install-deps`                | 否                             | 跳过`apt-get` 依赖检测与自动安装                  |
+| `-h, --help`                       | -                              | 显示帮助信息                                        |
 
 #### 快速安装命令示例
 
@@ -191,11 +191,11 @@ flowchart TD
      ```bash
      # 开启 store 助手
      git config --global credential.helper store
-     
+
      # 零交互录入凭据
      printf 'url=%s\nusername=%s\npassword=%s\n\n' \
        "$(git remote get-url origin)" "你的用户名" "你的Token" | git credential approve
-     
+
      # 只读验证(零提交测试,不提示输入密码即代表成功)
      git ls-remote origin
      ```
@@ -215,6 +215,7 @@ flowchart TD
 > **Q2:如果之前已经在分支上产生过历史提交,会有影响吗?**
 >
 > **完全不影响,且这正是脚本的标准工作模式。**
+>
 > 1. **代码分支隔离**:开发人员在 `main` 上的提交与 `runtime-state` 分支物理隔离,互不干扰。
 > 2. **智能状态防抖**:脚本每次运行都会比对 `runtime-state` 最新 HEAD。若优选值未变且状态为 `ok`,自动触发**静默跳过**,杜绝无效提交刷屏。
 > 3. **模式 A 历史滚动修剪**:当提交累积达到阈值(默认 50 次)时,脚本会自动通过 `commit-tree` 创建 Baseline Snapshot 并执行 Squashed Rebase,始终将孤儿分支历史锁定在轻量窗口。
@@ -226,16 +227,25 @@ flowchart TD
 为了平衡晚高峰的网络实时性与低谷期的系统资源开销,系统内置了**双频自适应调度引擎**:
 
 #### 调度算法原理
-1. **定时器最小周期唤醒**:  
-   `install_debian.sh` 提取高峰与非高峰周期的最小值作为底层的唤醒基频(通常为 10 分钟):
-   $$\text{TIMER\_INTERVAL} = \min(\text{Peak Interval}, \text{Off-Peak Interval})$$
-2. **时区强制对齐**:  
-   通过 `TZ="Asia/Shanghai" date` 强制指定时区,机房位于 UTC 时区也能精准捕捉北京时间晚高峰(默认 19:00 - 24:00,支持跨午夜配置)。
+
+1. **定时器最小周期唤醒**:`install_debian.sh` 提取高峰与非高峰周期的最小值作为底层的唤醒基频(通常为 10 分钟):
+
+   $$
+   \text{TIMER\_INTERVAL} = \min(\text{Peak Interval}, \text{Off-Peak Interval})
+   $$
+2. **时区强制对齐**:通过 `TZ="Asia/Shanghai" date` 强制指定时区,机房位于 UTC 时区也能精准捕捉北京时间晚高峰(默认 19:00 - 24:00,支持跨午夜配置)。
 3. **高峰期直接放行**:处于晚高峰时,每次定时器唤醒均执行全量更新(10 分钟更新一次)。
-4. **非高峰期容差判定**:  
+4. **非高峰期容差判定**:
    在低谷期,脚本只在长周期边界(如 30 分钟边界:0 分或 30 分)执行。为防止定时器调度产生微小时间漂移(如 29m 58s 或 30m 02s 唤醒),内置**容差窗口**算法:
-   $$\text{Tolerance} = \frac{\text{Peak Interval} + 1}{2}$$
-   $$\text{Remainder} = \text{Current Minute} \pmod{\text{Off-Peak Interval}}$$
+
+   $$
+   \text{Tolerance} = \frac{\text{Peak Interval} + 1}{2}
+   $$
+
+   $$
+   \text{Remainder} = \text{Current Minute} \pmod{\text{Off-Peak Interval}}
+   $$
+
    - 若 $\text{Remainder} \le \text{Tolerance}$ 或 $\text{Remainder} \ge (\text{Off-Peak} - \text{Tolerance})$:判定为边界唤醒,**放行执行**。
    - 否则:判定为中间冗余唤醒,记录 `off-peak run skipped` 并**安全退出 0**,零多余开销。
 
@@ -244,21 +254,25 @@ flowchart TD
 ### 2.5 核心规则:筛选与评分策略
 
 #### 1. 字段映射与多维过滤
+
 - **字段映射 (`record_mapping`)**:将第三方 API 字段标准映射为 `domain`、`created_at`、`yd_latency`、`yd_pkg_lost_rate`、`yd_score` 等标准键。
 - **记录过滤 (`record_filter`)**:在解析前剔除黑名单语义(如排除 `host_provider == "CF优选IP"`)。
 - **域名正则过滤 (`domain_filter`)**:通过正则表达式剔除纯 IPv4 记录,仅保留物理域名。
 
 #### 2. 加权算分与平局决胜
+
 - **加权评分公式**:
-  $$\text{Score} = \frac{\sum (\text{Field Value} \times \text{Weight})}{\sum \text{Weights}}$$
-  默认针对中国移动线路加权:`yd_latency`(权重 1.0,越小越优)+ `yd_pkg_lost_rate`(权重 10.0,越小越优)。
-- **指标极性实证 (Score 指标为成本惩罚分)**:逆向实测确证 `vps789` 接口中的 `yd_score`(以及 `dx_score`, `lt_score`, `avg_score`)并非百分制健康分,而是**综合成本惩罚分(Cost / Penalty Metric,越小越优 / 高尔夫计分制)**,其实质计算规则为:
-  $$\text{Score} = \text{LatencyAvg} + \text{PkgLostRateAvg} \times 50$$
-  零丢包时分数等于平均延迟(ms);丢包每增加 1% 加罚 50 分。因此它与延迟、丢包完全同向(**越小越优**)。
+  $$
+  \text{score\_value} = \frac{\sum (\text{方向调整后的字段值} \times \text{权重})}{\sum \text{权重}}
+  $$
+
+  当前配置针对中国移动线路加权:`yd_latency`(权重 1.0,越小越优)+ `yd_pkg_lost_rate`(权重 10.0,越小越优)。参与字段、权重及字段级 `prefer_lower` / `order` 可按业务需求调整。
+- **上游评分字段**:`yd_score`(以及 `dx_score`、`lt_score`、`avg_score`)是上游 API 对应 `*Score` 的映射,含义、单位和计算公式由上游定义。本项目不重新计算这些字段,也不将其固定解释为健康分或成本分;配置排序方向前,应核对所用数据源的指标定义。它们与本项目根据配置生成的 `score_value` 是不同字段。
 - **时效性避坑**:针对 `vps789` API 数据源,接口返回的 `createdTime` 实际为「域名纳入监控池的建档时间」而非当次拨测时间。在服务器模式下默认设置 `within_hours: 0`(禁用时效过滤),防止误杀老牌稳定域名。
-- **平局决胜 (`tie_breakers`)**:得分相同时,依次以 `yd_score`(**升序 asc,惩罚分低者胜**) $\to$ `avg_latency`(升序 asc) $\to$ `created_at`(降序 desc) $\to$ `domain`(升序 asc)决胜。
+- **平局决胜 (`tie_breakers`)**:当前配置在 `score_value` 相同时,依次以 `yd_score`(升序 asc,数值较小者优先) $\to$ `avg_latency`(升序 asc) $\to$ `created_at`(降序 desc) $\to$ `domain`(升序 asc)决胜。`yd_score` 当前不参与加权;是否使用及排序方向均由配置决定。
 
 #### 3. 双轨容灾机制 (Fallback)
+
 - **触发条件**:API 请求超时(`error_use_last_good`)或所有候选均被过滤(`fallback_last_good`)。
 - **回退流程**:自动从 `state.json` 中读取 `last_good_domain`,**并对其再次执行黑名单与合法性复检**(杜绝故障节点复活);提交信息自动标注 `chore(fallback):`,且在值未变时抑制重复提交。
 
@@ -267,10 +281,13 @@ flowchart TD
 ### 2.6 日志格式与日常维护速查
 
 #### 统一日志格式与 journald tee
+
 所有服务端脚本统一输出标准日志格式:
+
 ```text
 [YYYY-MM-DD HH:MM:SS] [vmess-domain-rotator] <message>
 ```
+
 > [!TIP]
 > 手动在终端执行 `run_update_and_commit.sh` 时,脚本内置的 **`journald tee`** 技术会同时向终端(stdout/stderr)以及系统守护进程(`systemd-cat`)输出双流,确保手动调试信息与定时任务日志完整融合在同一系统时间线中。
 
@@ -313,6 +330,7 @@ cat runtime/state.json
 - **配置关键**(`configs/config.local.json`):
   - `cfst_local.work_dir`:测速工作目录(如 `./cfst_darwin_arm64`)
   - `cfst_local.binary`:测速程序路径(如 `./cfst`)
+  - `cfst_local.run_args`:cfst 参数;测速地址统一为 `-url https://speed.cloudflare.com/__down?bytes=50000000`,避免依赖默认地址
   - `cfst_local.skip_run`:设为 `true` 时仅解析现有 `result.csv`,设为 `false` 时每次调用二进制执行测速。
 - **下载测速保护**:系统自动剔除 `download_speed == 0.00` 的失效死节点;当在 `run_args` 中显式指定 `-dd`(禁用下载测速)时,自动转为按平均延迟排序。
 
@@ -337,12 +355,14 @@ cat runtime/state.json
     ├── cfst / ip.txt / result.csv         # 测速二进制与网段文件
 ```
 
+测速地址由 `config_router.conf` 的 `CFST_URL` 指定,默认 `https://speed.cloudflare.com/__down?bytes=50000000`,与本地 Python 模式保持一致。
+
 ---
 
 ### 4.2 [最佳实践] 延长路由器闪存寿命(动静分离防擦写)
 
 > [!WARNING]
-> **闪存寿命保护警示**  
+> **闪存寿命保护警示**
 > 路由器的 `/jffs` 分区位于 NAND/NOR 物理 Flash 闪存介质上。如果将高频定时生成的测速结果 `result.csv` 和运行时临时文件直接写入闪存,**长期高频擦写将严重缩短路由器硬件寿命**!
 
 本项目全面支持**动静分离与绝对路径重定向**,通过将高频临时读写重定向至 RAM 内存文件系统(`/tmp`),实现硬件零磨损:
@@ -438,12 +458,15 @@ cru a vmess_watchdog "*/5 * * * * if ! ps | grep -q '$HTTP_PROCESS_PATTERN'; the
 [`substore/operator_template.js`](file:///Users/aurora/Project/vmess-domain-rotator/substore/operator_template.js) 提供了高度灵活的动态节点注入能力:
 
 ### 5.1 工作模式与速度切换
+
 - `VALUE_SOURCE_MODE = "default"`:路由器优先;当路由器实测下载速度低于阈值(`ROUTER_MIN_SPEED_MB_PER_S = 5`)时,自动无缝切换至服务器模式。
 - `VALUE_SOURCE_MODE = "server_only"`:仅从服务器远端分支拉取。
 - `VALUE_SOURCE_MODE = "router_only"`:仅从局域网路由器 HTTP 拉取。
 
 ### 5.2 URL 参数强制覆盖
+
 在 Sub-Store 订阅链接参数中传入参数即可实现强行注入(拥有最高优先级,绕过测速与模式判断):
+
 - `domain=my.preferred.com`:强制覆盖所有节点的地址为此域名或 IP。
 - `domain_override=false`:临时关闭覆盖,恢复自动选优逻辑。
 
@@ -465,11 +488,11 @@ node -c substore/operator_template.js
 
 ### 测试套件覆盖范围
 
-| 测试套件文件 | 核心覆盖断言与安全边界 |
-| :--- | :--- |
-| **`tests/test_server_mode_lifecycle.py`** | Server 模式全生命周期(API 选优、孤儿分支 Worktree 提交、自适应时间拦截、静默抑制、模式 A 历史滚动修剪) |
-| **`tests/test_cfst_local_lifecycle.py`** | 本地 `cfst` 模式全生命周期(CSV 解析、零速节点过滤、`-dd` 延迟免测排序、Fallback 容灾与路径防漂移契约) |
-| **`tests/test_domain_updater.py`** | Python 选优核心算法(`yd_score` 成本惩罚分升序平局决胜、加权计算、迟滞防抖 Hysteresis、黑名单复检) |
-| **`tests/test_shell_lifecycle.py`** | Shell 脚本生命周期安全(`uninstall_debian.sh` 防空服务名误删库、Token 动态环境零泄露、`ls-remote` 退出码精细判定) |
-| **`tests/test_router_scripts.py`** | 路由器脚本原子性(`.tmp.$$` 替换写入、测速过滤、SSOT 打包配置同步) |
-| **`tests/test_substore_operator.py`** | Sub-Store 脚本算子(XSS/非法 HTML 过滤、数据源超时降级、类型安全断言) |
+| 测试套件文件                                      | 核心覆盖断言与安全边界                                                                                                 |
+| :------------------------------------------------ | :--------------------------------------------------------------------------------------------------------------------- |
+| **`tests/test_server_mode_lifecycle.py`** | Server 模式全生命周期(API 选优、孤儿分支 Worktree 提交、自适应时间拦截、静默抑制、模式 A 历史滚动修剪)               |
+| **`tests/test_cfst_local_lifecycle.py`**  | 本地`cfst` 模式全生命周期(CSV 解析、零速节点过滤、`-dd` 延迟免测排序、Fallback 容灾与路径防漂移契约)             |
+| **`tests/test_domain_updater.py`**        | Python 选优核心算法(当前配置的`yd_score` 升序平局决胜、加权计算、迟滞防抖 Hysteresis、黑名单复检)                  |
+| **`tests/test_shell_lifecycle.py`**       | Shell 脚本生命周期安全(`uninstall_debian.sh` 防空服务名误删库、Token 动态环境零泄露、`ls-remote` 退出码精细判定) |
+| **`tests/test_router_scripts.py`**        | 路由器脚本原子性(`.tmp.$$` 替换写入、测速过滤、SSOT 打包配置同步)                                                  |
+| **`tests/test_substore_operator.py`**     | Sub-Store 脚本算子(XSS/非法 HTML 过滤、数据源超时降级、类型安全断言)                                                 |

+ 3 - 1
configs/config.local.json

@@ -11,7 +11,9 @@
       "-o",
       "result.csv",
       "-p",
-      "10"
+      "10",
+      "-url",
+      "https://speed.cloudflare.com/__down?bytes=50000000"
     ],
     "result_file": "result.csv",
     "run_timeout_sec": 600,

+ 1 - 1
configs/config_router.conf

@@ -8,7 +8,7 @@ CFST_TEST_COUNT=""
 CFST_DOWNLOAD_COUNT=""
 CFST_DOWNLOAD_TIME=""
 CFST_PORT="443"
-CFST_URL=""
+CFST_URL="https://speed.cloudflare.com/__down?bytes=50000000"
 CFST_HTTPING="0"
 CFST_HTTPING_CODE=""
 CFST_CFCOLO=""

+ 3 - 3
docs/reviews/ARCHITECTURE_REVIEW.md

@@ -457,7 +457,7 @@ BusyBox 版可保留精简 shell 实现,但必须遵循同一份输出契约
 分三层:`候选资格 → 排序评分 → 是否值得切换`。
 
 - **候选资格**:合法域名/IP;必需指标存在且可转数值;指标在允许范围内;测量未过期;API 记录通过业务成功校验。
-- **排序**:需确认各字段业务方向 —— 尤其 **`yd_score` 是否越低越好**,应以 API 语义为准,不能仅凭字段名猜测(当前 `prefer_lower: true` 是全局的)。
+- **排序**:上游字段的含义、单位和计算公式以数据源定义为准,不能仅凭字段名猜测。`yd_score` 映射上游 `ydScore`;当前配置不将它用于加权,仅在平局决胜中以 `order: "asc"` 排序。字段权重与方向属于本项目的自定义策略,可在核对上游定义后调整。
 - **切换阶段**:最小改善幅度、最短保持时间、连续多次胜出才切换。
 
 ### 6.6 `[合]` 迟滞(hysteresis):当前切换过于频繁
@@ -612,7 +612,7 @@ BusyBox 版可保留精简 shell 实现,但必须遵循同一份输出契约
 
 1. 切换迟滞 `switch_margin_pct`(6.6)。
 2. `top_candidates` 用于下游 failover(7.3)。
-3. 确认 `yd_score` 的真实业务方向(6.5)。
+3. 调整数据源或评分策略时,核对上游字段定义并同步更新映射、权重和排序方向(6.5)。
 4. 清理一次性迁移代码:`build_output_settings` 的 legacy key 拒绝逻辑([domain_updater.py:941-957](scripts/domain_updater.py#L941-L957))在配置迁移完成后是永久负担。
 5. 修正文档漂移:README §2 与 §4.2 / §5.6 关于 `runtime/` 与 `cfip_runtime/` 的说法互相矛盾,且与出厂配置不完全一致。
 
@@ -705,7 +705,7 @@ git ls-remote origin runtime-state                 # 远端是否与本地一致
 | 打包(权限/路径/原子)                               | P2 ×3     | 补充"构建不可复现"                                | 一致并加深     |
 | 状态字段语义                                         | §5.2      | —                                                | 原文档独有     |
 | 指标单位统一                                         | §5.3      | —                                                | 原文档独有     |
-| 选优三层规则                                         | §5.4      | 补充`yd_score` 方向存疑                         | 一致并加深     |
+| 选优三层规则                                         | §5.4      | 补充上游字段定义与自定义排序方向的区分             | 一致并加深     |
 | 迟滞/切换阈值                                        | §5.4 概念 | **量化数据 + `switch_margin_pct`**        | 一致并加深     |
 | 统一快照发布                                         | §5.5      | —                                                | 原文档独有     |
 | Sub-Store 流程                                       | §6        | 补充参数化 + 节点预检 + 死代码说明                | 一致并加深     |

+ 41 - 130
docs/reviews/ARCHITECTURE_REVIEW_UNIFIED.md

@@ -61,44 +61,20 @@ flowchart LR
 
 ## 2. 本次审查新增发现(两份既有报告均漏检的「查漏补缺」)
 
-以下 **8 个问题**在初审报告(`ARCHITECTURE_REVIEW.md`)和复核报告(`ARCHITECTURE_REVIEW_RECHECK.md`)中**均未被发现**,其中包含基于 `runtime-state` 线上真实运行数据证实的 **P0 级选优方向反转 Bug**。
+本节保留 **8 项审查记录**。其中 §2.1 已于 **2026-10-18** 更正为上游字段说明与自定义评分策略记录;其余条目保留既有审查结论。
 
 ---
 
-### 2.1 【P0 严重业务反转】【✅ 已修复】`config.server.json` 中 `yd_score` 极性反转,导致算法优先挑选劣化节点
+### 2.1 【文档已统一】上游 `yd_score` 字段与本项目自定义评分策略
 
 - **位置**:
-  - `config.server.json:68-83`
-  - `scripts/domain_updater.py:482-490`(`validate_config`)
-  - `scripts/domain_updater.py:708-735`(`compute_weighted_average`)
-  - `README.md:221-225`
-- **现象与证据**:
-  `config.server.json` 的选优配置如下:
-  ```json
-  "selection": {
-    "strategy": "weighted_average",
-    "prefer_lower": true,
-    "weights": {
-      "yd_latency": 1.0,
-      "yd_pkg_lost_rate": 10.0,
-      "yd_score": 5.0
-    }
-  }
-  ```
-  1. 在 `vps789.com` 接口中,`yd_latency`(移动延迟,越小越好)和 `yd_pkg_lost_rate`(移动丢包率,越小越好)是**越小越优(负向指标)**;而 `yd_score`(对应接口 `ydScore`)是**综合健康度得分,越大越优(正向指标,通常在 75~110 之间)**。
-  2. 核查远端 `vmess-domain-rotator/runtime-state` 分支的真实生产提交记录可铁证这一点:
-     - 在提交 `4fd8a65`(`2026-06-05T11:32:56Z`)中:
-       - `icook.tw`:`yd_latency = 49`, `yd_pkg_lost_rate = 1.9`, `yd_score = 92` -> 加权分 `(49*1 + 1.9*10 + 92*5)/16 = 33.0`(第 1名)
-       - `www.fastsoso.cn`:`yd_latency = 36`, `yd_pkg_lost_rate = 0`, `yd_score = 109` -> 加权分 `(36*1 + 0*10 + 109*5)/16 = 36.3125`(因 `109` 分更高,在 `prefer_lower: true` 下反而排到第 3 名!)
-     - 30 分钟后,在提交 `1a38eea`(`2026-06-05T12:08:25Z`)中:
-       - `www.fastsoso.cn` 的移动延迟从 `36ms` **恶化翻倍**到 `74ms`,上游接口将其健康分 `yd_score` 从 `109` **下调惩罚**至 `78`。
-       - 然而因为 `prefer_lower: true`,其加权分变为 `(74*1 + 0*10 + 78*5)/16 = 29.0`,**节点延迟暴涨、健康分暴跌后,反而被本脚本逆向提拔为全网第 1 名(最优域名)!**
-  3. 更严重的是,由于 `yd_score` 数值量级在 `80~109`,乘以权重 `5.0` 后高达 `400~545`,而 `yd_latency * 1.0` 仅 `35~75`,`yd_score` 实际上主导了 **85%~90% 的总评分权重**。
-  4. 同时,`domain_updater.py:486` 中硬编码了 `if not is_int_or_float(weight) or float(weight) <= 0: raise ValueError(...)`,**从代码层面禁止了负数权重**,且不支持为每个字段单独指定方向(`asc`/`desc`)。此外 `README.md:221-225` 的文档示例中只写了 `yd_latency` 和 `yd_pkg_lost_rate`,与实际 `config.server.json` 脱节。
-- **修复方案与落地状态**:
-  - **✅ 已落地修复**:在 `config.server.json` 中移出 `weighted_fields`,转移至 `tie_breakers`;在 `scripts/domain_updater.py` 中支持字段级 `order: desc/asc` 与迟滞防抖 `switch_margin_pct: 5.0`。
-  - **【2026-10 实测终局纠偏注记】**:经对线上真实多节点全量 Payload 数学逆向,确证上游公式严格为 $\text{Score} = \text{LatencyAvg} + \text{PkgLostRateAvg} \times 50$(综合成本惩罚分,越小越优)。原审查误将其推断为百分制健康分(误配 desc),现已在 `config.server.json`、`CLAUDE.md` 与测试中永久纠偏为 `order: "asc"`。
-  - **测试验证**:`tests/test_domain_updater.py` 中用例 `test_p0_yd_score_polarity_fix_and_healthy_ranking` 100% 验证通过。
+  - [`configs/config.server.json`](../../configs/config.server.json):字段映射、加权字段与平局决胜配置
+  - [`scripts/rotator/scoring.py`](../../scripts/rotator/scoring.py):按配置计算 `score_value` 并排序
+- **字段含义**:`yd_score` 是上游 API 字段 `ydScore` 的映射;`dx_score`、`lt_score`、`avg_score` 同理。其含义、单位与计算公式由上游定义,本项目不重新计算这些字段,也不将其固定解释为健康分或成本分。不能仅凭字段名、少量样本或单个延迟指标的变化推断上游评分的方向。
+- **自定义策略**:本项目的 `score_value` 由 `scoring.weighted_fields`、权重及字段级 `prefer_lower` / `order` 计算;平局决胜由 `scoring.tie_breakers` 决定。是否使用某个上游评分字段以及如何排序,均属于配置选择。
+- **当前配置**:仅以 `yd_latency`(权重 1.0)和 `yd_pkg_lost_rate`(权重 10.0)加权,均配置为较小值优先。`yd_score` 不参与加权,仅在平局决胜中配置为 `order: "asc"`(数值较小者优先)。这描述的是当前策略,不是对所有数据源的固定规则。
+- **审查结论更正(2026-10-18)**:撤回此前根据样本推断上游评分公式、将旧加权配置定性为 P0 业务错误,以及据此推荐 `yd_score` 降序的结论。将该字段移出加权、改为升序平局决胜属于评分策略调整;更换数据源或调整策略时,应核对上游定义并同步更新配置、测试和文档。
+- **测试边界**:`test_p0_yd_score_polarity_fix_and_healthy_ranking` 与 `test_empirical_score_metric_asc_tie_breaking_and_consistency` 是现有测试名称,验证当前配置下的排序行为,不能据此证明上游实际计算公式。
 
 ---
 
@@ -210,7 +186,7 @@ flowchart LR
 | 编号 | 分歧点 | `ARCHITECTURE_REVIEW.md`(初审) | `ARCHITECTURE_REVIEW_RECHECK.md`(复核) | 本次终审裁定与实证依据 |
 | :--- | :--- | :--- | :--- | :--- |
 | **D-01** | **线上 `runtime-state` 是否已在 2026-07-27 停摆?** | 认为 `2026-07-27` 后无自动 commit,推断线上服务已停摆或数据源卡死(§2.2) | 指出远端 `vmess-domain-rotator/runtime-state` 在 `2026-10-06` 仍有提交,初审误看了本地过期分支(§2.1) | **复核正确,且深层原因已查明**:<br>远端 `vmess-domain-rotator/runtime-state` 最新提交为 `2026-10-11T00:34:14Z`(`ff09327`)。为什么 7 月底到 10 月初 commit 稀少?因为:① `run_update_and_commit.sh` 仅在文件内容变化时才 commit;② 7 月底以来 `baka.fun` 一直稳居第 1;③ 10 月初的几次 commit(`7672a03`、`56404a9`、`bfc3933`、`a7c6fdc`、`ff09327`)全都是因为上游偶发报错/空数据导致 `status` 在 `ok` 与 `error`/`fallback_last_good` 之间翻转触发的! |
-| **D-02** | **581 次历史提交是否说明域名频繁抖动、急需 `switch_margin_pct` 防抖?** | 认为 581 次提交证明轮换过频,建议作为高优项引入 10% 切换阈值(§2.1) | 指出 commit 数不等于域名切换数(状态恢复也会产生 commit),防抖属锦上添花(§2.2) | **复核更准确**:<br>当前造成无效 commit 的首要元凶是 **`yd_score` 极性反转(2.1 节)** 与 **`ok <-> fallback` 状态翻转重复提交(2.3 节)**。应在修复这两项后,将 `switch_margin_pct` 作为可选策略增强项,而非最高优止血项。 |
+| **D-02** | **581 次历史提交是否说明域名频繁抖动、急需 `switch_margin_pct` 防抖?** | 认为 581 次提交证明轮换过频,建议作为高优项引入 10% 切换阈值(§2.1) | 指出 commit 数不等于域名切换数(状态恢复也会产生 commit),防抖属锦上添花(§2.2) | **复核更准确**:<br>`ok <-> fallback` 状态翻转会造成重复提交(§2.3);不能将提交数量直接等同于域名切换次数,也不能据此推断某个上游评分字段的方向。`switch_margin_pct` 是可选策略增强项,评分策略按 §2.1 的字段与配置约定维护。 |
 | **D-03** | **`run_notify()` 子进程失败是否会抛异常触发 `last_good` 回退?** | 认为通知命令返回非 0 会抛异常中断主流程(§3.1.4) | 指出 `subprocess.run(..., check=False)` 不会因非 0 退出码抛异常,真正问题是污染 `stdout`、无 `timeout` 及未捕获 `OSError`(§2.3) | **复核完全正确**:<br>`domain_updater.py:999` 未传 `check=True`,退出码非 0 不会抛异常。真正的风险是:① 命令注入(`shell=True`);② 子进程输出直接打到 `stdout` 冲掉最后一行 JSON;③ 无 `timeout` 可永久挂起定时任务。 |
 | **D-04** | **`install_debian.sh` 写入 `.updater.env` 是否存在引号闭合注入?** | 认为用双引号包裹变量写入 `.updater.env` 会因特殊字符闭合引号导致语法错误(§3.3.2) | 指出 `write_env_file` 使用的是单引号包裹 + `sed "s/'/'\\\\''/g"` 转义,单引号路径安全,真正隐患在 `systemd` `EnvironmentFile=` 解析规则及换行符(§2.4) | **复核完全正确**:<br>`install_debian.sh:349-358` 已对单引号做了 POSIX 标准转义,Shell `source` 是安全的;但若变量含换行符或特殊控制字符,或结合 `run_update_and_commit.sh:97` 的进程参数暴露(2.4 节),仍需加固。 |
 | **D-05** | **是否应将 `cfst_local` 与 `api` 选优流水线强行统一为同一套打分模型?** | 建议把 `cfst_local` 也并入统一的 `filter -> score -> sort` 评分管线(§4.2) | 反对强行用 `api` 权重评分覆盖 `cfst` 原生测速排序,认为会破坏 `cfst` 自身语义(§2.5) | **折中裁定(各取所长)**:<br>不应强行用 `weighted_average` 重排 `cfst` 结果,但**必须统一两者前后的「准入校验层」与「产物契约层」**:即 `cfst_local` 同样需要经过合法 IP/域名校验、开启下载测速时过滤 `download_speed <= 0`(见 2.7 节)、去重,并输出与 `api` 模式结构一致的 `state.json`。 |
@@ -294,7 +270,7 @@ flowchart TD
     subgraph 统一核心管线
         S1 --> V["1. 候选准入校验层<br/>• 合法域名/IP 格式校验<br/>• 过滤规则 & 黑名单 (含 last_good 复检)<br/>• CFST 零下载速度过滤 (download_speed > 0)"]
         S2 --> V
-        V --> M["2. 极性感知评分排序层<br/>• 支持字段级 prefer_lower (修复 yd_score 反转)<br/>• 支持可选切换迟滞阈值 (switch_margin_pct)"]
+        V --> M["2. 配置驱动评分排序层<br/>• 按上游字段定义配置 prefer_lower / order<br/>• 支持可选切换迟滞阈值 (switch_margin_pct)"]
         M --> F["3. 统一状态机与回退层<br/>• 状态枚举: ok | fallback_empty | fallback_error<br/>• 回退时保留上次有效 top_candidates"]
     end
 
@@ -323,7 +299,7 @@ flowchart TD
 
 | 阶段 | 优先级 | 进度状态 | 改动目标 | 涉及文件 | 验收与测试验证 |
 | :--- | :--- | :--- | :--- | :--- | :--- |
-| **Phase 1:止血与高危安全修复** | **P0** | **✅ 已完成 (100%)** | 1. 修复 `config.server.json` 中 `yd_score` 极性反转问题,在 `domain_updater.py` 支持字段级正负向与平局决胜<br>2. 修复 `uninstall_debian.sh` 空 `SERVICE_NAME` 导致 `rm -rf /var/lib/` 漏洞<br>3. 修复 `build_router_package.sh` `trap` 提前注册导致误删 `TMP_DIR` 风险<br>4. 修复 `domain_updater.py` `run_notify()` 的 `shell=True` 命令注入、无超时及 `stdout` 污染问题 | `config.server.json`<br>`scripts/domain_updater.py`<br>`scripts/uninstall_debian.sh`<br>`build_router_package.sh` | 测试通过:`test_p0_yd_score_polarity_fix_and_healthy_ranking`、`test_p0_notify_stdout_isolation_and_timeout`、`test_p0_uninstall_debian_empty_service_name_deletes_var_lib`、`test_p0_build_router_package_trap_prematurely_deletes_tmp_dir` |
+| **Phase 1:止血与高危安全修复** | **P0** | **✅ 已完成 (100%)** | 1. 调整 `config.server.json` 自定义评分策略,并在 `domain_updater.py` 支持字段级方向与平局决胜(§2.1 已更正文档,该策略调整本身不列为 P0 缺陷)<br>2. 修复 `uninstall_debian.sh` 空 `SERVICE_NAME` 导致 `rm -rf /var/lib/` 漏洞<br>3. 修复 `build_router_package.sh` `trap` 提前注册导致误删 `TMP_DIR` 风险<br>4. 修复 `domain_updater.py` `run_notify()` 的 `shell=True` 命令注入、无超时及 `stdout` 污染问题 | `config.server.json`<br>`scripts/domain_updater.py`<br>`scripts/uninstall_debian.sh`<br>`build_router_package.sh` | 测试通过:`test_p0_yd_score_polarity_fix_and_healthy_ranking`、`test_p0_notify_stdout_isolation_and_timeout`、`test_p0_uninstall_debian_empty_service_name_deletes_var_lib`、`test_p0_build_router_package_trap_prematurely_deletes_tmp_dir` |
 | **Phase 2:发布链路与消费端可靠性加固** | **P1** | **✅ 已完成 (100%)** | 1. 统一 `fallback_last_good` 与 `error_use_last_good` 处理,回退且值未变时执行回退静默,不向远端推送噪音 commit<br>2. 对 `last_good` 强制执行当前 `domain_filter` / 格式复检;彻底失败时补发 `run_notify`<br>3. `run_update_and_commit.sh` 区分 `ls-remote` 退出码 `2` 与 `128`,改用 `GIT_CONFIG_*` 环境变量安全传递 Git Token<br>4. `domain_updater.py` 与 `router_local_update.sh` 全面改为同目录临时文件 + `mv` 原子写入,并过滤 `download_speed == 0` 的测速失败节点<br>5. `substore/operator_template.js` 修复非字符串强转 `"[object Object]"` Bug,增加域名/IP 格式校验与本地数据源过期降级(`local_max_age_sec: 86400`) | `scripts/domain_updater.py`<br>`scripts/run_update_and_commit.sh`<br>`scripts/router_local_update.sh`<br>`substore/operator_template.js` | 测试通过:`test_p1_run_update_and_commit_passes_token_in_env_not_argv`、`test_p1_run_update_and_commit_ls_remote_distinguishes_error_code`、`test_p1_run_update_and_commit_fallback_silence`、`test_p1_extract_value_object_stringification_hazard_prevented`、`test_p1_extract_value_host_validation_enforced`、`test_p1_router_local_update_atomic_file_write`、`test_p1_last_good_bypasses_blacklist_prevented`、`test_p1_error_fallback_stdout_contains_domain_key` |
 | **Phase 3:工程化、跨平台打包与自动化测试** | **P2** | **✅ 已完成 (100%)** | 1. 补齐 `validate_config()` 对 `api` / `parser` / `selection` 的完整结构校验<br>2. 修复 `router_local_http.sh` PID 复用误杀与 `router_local_update.sh` JSON 转义及零速 IP 过滤(确认 `-dt/-dn/-dd` 已就绪)<br>3. 修复 `build_router_package.sh` 在 macOS 下的 `.DS_Store` / AppleDouble 打包污染与跨架构 `httpd` 静态检测<br>4. 建立 `tests/` 自动化测试套件(覆盖评分极性、过滤、回退、Shell 安全与 Sub-Store 解析)并修正 `README.md`、`workflow.md`、`CLAUDE.md` 文档偏差 | `scripts/router_local_http.sh`<br>`scripts/router_local_update.sh`<br>`build_router_package.sh`<br>`README.md`<br>`workflow.md`<br>`CLAUDE.md`<br>`tests/` | 测试通过:`test_p2_validate_config_enforces_api_structure`、`test_p2_cfst_filters_zero_download_speed`、`test_p2_result_json_file_decouples_ipc_from_stdout`、`test_p2_router_local_update_filters_zero_speed`、`test_recheck_correction_router_cfst_params_already_supported`、`test_p2_operator_template_checks_staleness`;18 项测试 0.38s 全绿 |
 
@@ -337,7 +313,7 @@ flowchart TD
 
 | 编号 | 问题摘要 | 代码行号 | 初审报告 | 复核报告 | 本次终审实证与验证结论 | 严重度 | 修复状态 |
 | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
-| **PY-01** | **`yd_score` 评分极性反转**:`config.server.json` 将正向健康分 `yd_score`(权重 5.0)与负向延迟/丢包混用且 `prefer_lower: true`;`validate_config` 禁止负权重,导致延迟翻倍、健康分暴跌的劣化节点反而排第 1 | `config.server.json:68-83`<br>`domain_updater.py:486, 708-735` | ❌ 漏检 | ❌ 漏检 | ✅ **线上实证确认**:`runtime-state` 提交 `4fd8a65 -> 1a38eea` 证明 `www.fastsoso.cn` 延迟从 36ms 恶化到 74ms、`yd_score` 从 109 跌至 78 后,加权分从 36.31 降至 29.0 逆向夺冠 | **P0** | **✅ 已修复**<br>移出加权改入平局决胜降序;测试 `test_p0_yd_score_polarity_fix_and_healthy_ranking` 通过 |
+| **PY-01** | **上游评分字段与自定义策略说明**:`yd_score` 映射上游 `ydScore`;是否参与加权及排序方向由配置决定 | `configs/config.server.json`<br>`scripts/rotator/scoring.py` | §6.5 提醒核对上游字段定义 | — | **文档更正(2026-10-18)**:撤回原 P0 定性和上游公式推断;当前配置仅以延迟、丢包加权,`yd_score` 用于升序平局决胜 | **配置策略 / 文档** | **✅ 已统一**<br>现有评分测试验证当前配置行为,不证明上游公式 |
 | **PY-02** | **通知命令 Shell 注入(RCE)**:`run_notify()` 用 `.format()` 将外部 API 的 `{domain}` / `{error}` 拼入命令并传给 `subprocess.run(..., shell=True)` | `domain_updater.py:988-1000` | ✅ §3.1.4 | ✅ §3.1 | ✅ **源码确认**:API 返回恶意字符串或特殊字符即可执行任意命令或破坏 Shell 语法 | **P0** | **✅ 已修复**<br>改用环境变量传递变量;测试 `test_p0_notify_stdout_isolation_and_timeout` 通过 |
 | **PY-03** | **通知子进程无超时、污染 `stdout`、未捕获 `OSError`**:未设 `timeout`,未将 `stdout` 重定向到 `stderr` | `domain_updater.py:999-1002` | ⚠️ 误判非0退出码会抛异常 | ✅ §2.3 修正 | ✅ **源码确认**:`check=False` 不会因非 0 退出码抛异常,但通知输出到 `stdout` 会冲掉最后一行 JSON,导致外层脚本 `tail -n 1` 解析崩溃 | **P1** | **✅ 已修复**<br>`stdout=sys.stderr` 并设 `timeout=15`;测试 `test_p0_notify_stdout_isolation_and_timeout` 通过 |
 | **PY-04** | **彻底失败(无 `last_good`)时漏调 `run_notify()`**:当抛异常且无 `last_good` 可回退时,直接 `return 1` 未发告警 | `domain_updater.py:1125-1149` | ❌ 漏检 | ❌ 漏检 | ✅ **源码确认**:最需要告警的「完全无可用输出」场景反而静默退出 | **P1** | **✅ 已修复**<br>异常且无 `last_good` 时补调 `run_notify(..., "", "error")` |
@@ -348,7 +324,7 @@ flowchart TD
 | **PY-09** | **文件落盘非原子写入**:`write_selected_outputs()` 直接 `write_text()` 覆写 `.txt`、`.json`、`state.json` | `domain_updater.py:1023-1069` | ✅ §3.1.5 | ✅ §3.1 | ✅ **源码确认**:写入中途被中断或并发读取会产生半截损坏 JSON | **P1** | **✅ 已修复**<br>`write_text_file` / `write_json_file` 采用 `.tmp.$$` + `os.replace` 原子写入 |
 | **PY-10** | **`vps789` API 的 `createdTime` 实为建档时间而非测速时间** | `config.server.json:45, 63`<br>`domain_updater.py:669-678` | ⚠️ §2.2 误认为测速时间并建议开 `within_hours: 24` | ⚠️ §3.2 沿用初审假设 | ✅ **线上实证确认**:`baka.fun` 在 7 月和 10 月的 `created_raw` 恒为 `"2026-05-28 20:14:47"`,开启 `within_hours: 24` 会误杀所有老牌稳定域名 | **P1** | **✅ 已修复**<br>修正文档说明,默认禁用 `within_hours` 防误杀老牌稳定域名 |
 | **PY-11** | **`validate_config()` 漏检 `api`、`parser`、`domain_filter`、`selection` 顶层字典存在性** | `domain_updater.py:375-525` | ❌ 漏检 | ❌ 漏检 | ✅ **源码确认**:`source.type == "api"` 时未检查 `cfg.get("api")`,`--print-output-settings` 可通过但运行时抛 `KeyError` | **P2** | **✅ 已修复**<br>严格校验顶层字典与 `api.url`;测试 `test_p2_validate_config_enforces_api_structure` 通过 |
-| **PY-12** | **缺少切换迟滞(Hysteresis / `switch_margin_pct`)机制**:新旧第 1 名分差极小时也会触发轮换 | `domain_updater.py:905-923` | ✅ §2.1 | ✅ §2.2(定性为增强项) | ✅ **源码确认**:在修复 `yd_score` 极性后,增加可选 `switch_margin_pct`(如 5%~10%)可进一步抑制边界抖动 | **P2** | **✅ 已修复**<br>`choose_domain()` 实现迟滞防抖,配置 `switch_margin_pct: 5.0` |
+| **PY-12** | **缺少切换迟滞(Hysteresis / `switch_margin_pct`)机制**:新旧第 1 名分差极小时也会触发轮换 | `domain_updater.py:905-923` | ✅ §2.1 | ✅ §2.2(定性为增强项) | ✅ **源码确认**:在既定自定义评分策略下,增加可选 `switch_margin_pct`(如 5%~10%)可进一步抑制边界抖动 | **P2** | **✅ 已修复**<br>`choose_domain()` 实现迟滞防抖,配置 `switch_margin_pct: 5.0` |
 
 ---
 
@@ -399,105 +375,40 @@ flowchart TD
 
 本节针对上述 P0/P1/P2 问题提供可直接落地的代码级修改参考。
 
-### 8.1 修复 `config.server.json` 评分极性反转与 `domain_updater.py` 评分/迟滞模型(对应 PY-01, PY-12)
+### 8.1 配置驱动的评分与迟滞策略(对应 PY-01, PY-12)
+
+#### 1) 当前 `configs/config.server.json` 配置示例
+
+评分设置位于 `scoring`,`selection` 仅设置候选数量。下例与当前配置一致;使用其他数据源时,应先核对上游字段定义,再决定是否使用 `yd_score` 及其排序方向。
 
-#### 1) `config.server.json` 修正建议
-在 `selection` 中支持显式的指标极性定义(或者立即从负向加权中移除正向指标 `yd_score`,或通过 `field_directions` 指定 `yd_score` 为越大越优):
 ```json
-  "selection": {
+{
+  "scoring": {
+    "enabled": true,
     "strategy": "weighted_average",
+    "weighted_fields": [
+      {"field": "yd_latency", "weight": 1.0, "prefer_lower": true},
+      {"field": "yd_pkg_lost_rate", "weight": 10.0, "prefer_lower": true}
+    ],
     "prefer_lower": true,
+    "within_hours": 0,
     "switch_margin_pct": 5.0,
-    "weights": {
-      "yd_latency": 1.0,
-      "yd_pkg_lost_rate": 10.0
-    },
-    "field_prefer_lower": {
-      "yd_latency": true,
-      "yd_pkg_lost_rate": true,
-      "yd_score": false
-    },
     "tie_breakers": [
-      {
-        "field": "yd_score",
-        "order": "desc"
-      },
-      {
-        "field": "yd_pkg_lost_rate",
-        "order": "asc"
-      },
-      {
-        "field": "yd_latency",
-        "order": "asc"
-      }
+      {"field": "yd_score", "order": "asc"},
+      {"field": "avg_latency", "order": "asc"},
+      {"field": "created_at", "order": "desc"},
+      {"field": "domain", "order": "asc"}
     ]
-  }
+  },
+  "selection": {"top_n": 3}
+}
 ```
 
-#### 2) `scripts/domain_updater.py` 极性感知评分与防抖迟滞(Hysteresis)实现
-```python
-def compute_weighted_average(
-    record: Dict[str, Any],
-    weights: Dict[str, Any],
-    prefer_lower: bool = True,
-    field_prefer_lower: Optional[Dict[str, bool]] = None,
-) -> Optional[float]:
-    if not weights:
-        return None
-    field_dirs = field_prefer_lower or {}
-    total_weight = 0.0
-    weighted_sum = 0.0
-    for field, raw_weight in weights.items():
-        try:
-            weight = float(raw_weight)
-        except Exception:
-            continue
-        if weight <= 0:
-            continue
-        value = to_float(record.get(field))
-        if value is None:
-            return None
-        # 若单字段极性与全局 prefer_lower 相反,则取相反数参与加权
-        is_field_lower_better = bool(field_dirs.get(field, prefer_lower))
-        effective_value = value if (is_field_lower_better == prefer_lower) else -value
-        weighted_sum += effective_value * weight
-        total_weight += weight
-    if total_weight <= 0:
-        return None
-    return weighted_sum / total_weight
-
-
-def apply_hysteresis_if_needed(
-    ranked: List[Dict[str, Any]],
-    last_good: str,
-    selection_cfg: Dict[str, Any],
-) -> Dict[str, Any]:
-    """当新第 1 名相比仍在候选池中的 last_good 领先幅度不足 switch_margin_pct 时,保持 last_good。"""
-    best = ranked[0]
-    margin_pct = to_float(selection_cfg.get("switch_margin_pct")) or 0.0
-    if margin_pct <= 0 or not last_good or best.get("domain") == last_good:
-        return best
-
-    prefer_lower = bool(selection_cfg.get("prefer_lower", True))
-    best_score = to_float(best.get("score"))
-    if best_score is None:
-        return best
-
-    for cand in ranked[1:]:
-        if cand.get("domain") == last_good:
-            last_score = to_float(cand.get("score"))
-            if last_score is None or last_score == 0:
-                break
-            improvement_pct = (
-                ((last_score - best_score) / abs(last_score)) * 100.0
-                if prefer_lower
-                else ((best_score - last_score) / abs(last_score)) * 100.0
-            )
-            if improvement_pct < margin_pct:
-                return cand
-            break
-    return best
-```
+#### 2) 当前实现入口与约定
+
+- [`parse_scored_records()` / `rank_scored_records()`](../../scripts/rotator/scoring.py) 按配置计算本项目的 `score_value`,再按全局方向与 `tie_breakers` 排序。字段级方向与全局方向不一致时,先调整该字段值的符号再加权;上游 `yd_score` 的值按映射读取。
+- [`choose_domain()`](../../scripts/rotator/scoring.py) 使用传入的 `switch_margin_pct` 执行可选迟滞策略;[`scripts/rotator/main.py`](../../scripts/rotator/main.py) 从 `scoring` 读取该设置并传入。
+- 加权字段的取舍、权重、方向与迟滞阈值共同构成自定义策略。当前 `yd_score` 升序平局决胜不表示本项目定义了上游的评分公式,相关测试也仅验证配置行为。
 
 ---
 
@@ -757,7 +668,7 @@ function extractValue(rawText, format, jsonKeys, maxAgeSec) {
 
 | 测试文件 | 覆盖范围与断言目标 | 用例数 | 执行耗时 | 测试状态 |
 | :--- | :--- | :--- | :--- | :--- |
-| [`tests/test_domain_updater.py`](./tests/test_domain_updater.py) | • P0 极性反转真实数据排序回归与平局决胜<br>• run_notify 标准输出隔离与超时保护<br>• 异常回退包含 domain/ip 键<br>• 黑名单 last_good 禁止回退复活<br>• 本地 cfst 过滤 0.00 下载速度<br>• `--result-json-file` IPC 解耦<br>• validate_config 严格顶层结构校验<br>• **[方案B] rotator 子包解耦与 Facade 契约完整性** | 8 项 | ~0.15s | **✅ 全部通过 (PASS)** |
+| [`tests/test_domain_updater.py`](./tests/test_domain_updater.py) | • 当前自定义加权策略与 `yd_score` 升序平局决胜(不验证上游公式)<br>• run_notify 标准输出隔离与超时保护<br>• 异常回退包含 domain/ip 键<br>• 黑名单 last_good 禁止回退复活<br>• 本地 cfst 过滤 0.00 下载速度<br>• `--result-json-file` IPC 解耦<br>• validate_config 严格顶层结构校验<br>• **[方案B] rotator 子包解耦与 Facade 契约完整性** | 8 项 | ~0.15s | **✅ 全部通过 (PASS)** |
 | [`tests/test_shell_lifecycle.py`](./tests/test_shell_lifecycle.py) | • `uninstall_debian.sh` 空服务名安全校验(严禁 `rm -rf /var/lib/`)<br>• `build_router_package.sh` 延迟注册 trap 防止误删工作目录<br>• `run_update_and_commit.sh` 采用 `GIT_CONFIG_*` 环境变量传递 Token(argv 零暴露)<br>• 严格区分 `git ls-remote` 退出码 `2` vs `128`<br>• 回退静默跳过噪音 Git commit<br>• **[方案B] 模式A Git 历史滚动修剪(50 commit 滚动保留)端到端验证**<br>• **[结构优化] scripts 分层物理结构与 configs/ 目录结构集中收敛完整性断言** | 7 项 | ~0.75s | **✅ 全部通过 (PASS)** |
 | [`tests/test_router_scripts.py`](./tests/test_router_scripts.py) | • `router_local_update.sh` `.tmp.$$` + `mv -f` 原子替换写入<br>• 测速结果优先过滤 `download_speed > 0`<br>• 纠偏初审报告关于 `-dt/-dn/-dd` 参数支持情况<br>• **[方案B] `build_router_package.sh --sync-from-config` SSOT 单源参数自动投影验证** | 4 项 | ~0.50s | **✅ 全部通过 (PASS)** |
 | [`tests/test_substore_operator.py`](./tests/test_substore_operator.py) | • `extractValue()` 严格类型守卫(拒绝非字符串对象强转为 `[object Object]`)<br>• 域名 (RFC 1123) 与 IPv4/IPv6 正则校验(拒绝 HTML 报错与非法字符)<br>• `LOCAL_MAX_AGE_SEC` (24h) 本地路由器源超时自动降级 | 3 项 | ~0.06s | **✅ 全部通过 (PASS)** |
@@ -797,7 +708,7 @@ python3 -m unittest discover tests -v
     - `config.py`:配置严格校验、路径解析与输出映射
     - `filter.py`:域名与 IP 验证、正则清洗与黑名单排除
     - `sources.py`:远程 API 抓取与本地 CFST 结果解析
-    - `scoring.py`:健康评分、字典序排序、迟滞防抖与候选列表构造
+    - `scoring.py`:自定义加权评分、字典序排序、迟滞防抖与候选列表构造
     - `notify.py`:通知命令执行与超时防护
     - `main.py`:主轮换工作流编排与 CLI 参数解析
   - [`scripts/domain_updater.py`](./scripts/domain_updater.py) 重构为精简的外观门面(Facade),全面重新导出所有符号,保持 100% 向后兼容,现存 systemd、cron 及自动化测试零修改直接运行。

+ 1 - 1
scripts/router/router_local_update.sh

@@ -49,7 +49,7 @@ CFST_TEST_COUNT=${CFST_TEST_COUNT:-}
 CFST_DOWNLOAD_COUNT=${CFST_DOWNLOAD_COUNT:-}
 CFST_DOWNLOAD_TIME=${CFST_DOWNLOAD_TIME:-}
 CFST_PORT=${CFST_PORT:-443}
-CFST_URL=${CFST_URL:-}
+CFST_URL=${CFST_URL:-"https://speed.cloudflare.com/__down?bytes=50000000"}
 CFST_HTTPING=${CFST_HTTPING:-0}
 CFST_HTTPING_CODE=${CFST_HTTPING_CODE:-}
 CFST_CFCOLO=${CFST_CFCOLO:-}

+ 181 - 0
tests/test_syntax_hook.py

@@ -0,0 +1,181 @@
+import importlib.util
+import io
+import json
+import subprocess
+import tempfile
+import unittest
+from contextlib import redirect_stderr, redirect_stdout
+from pathlib import Path
+from unittest import mock
+
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+SPEC = importlib.util.spec_from_file_location(
+    "rotator_syntax_hook", PROJECT_ROOT / ".claude" / "hooks" / "check_syntax.py"
+)
+syntax_hook = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(syntax_hook)
+
+
+class TestSyntaxHook(unittest.TestCase):
+    def setUp(self):
+        temporary = tempfile.TemporaryDirectory(prefix="rotator-syntax-test-")
+        self.addCleanup(temporary.cleanup)
+        self.root = Path(temporary.name).resolve()
+
+    def fixture(self, relative, content):
+        path = self.root / relative
+        path.parent.mkdir(parents=True, exist_ok=True)
+        path.write_text(content, encoding="utf-8")
+        return path
+
+    def invoke(self, payload, raw=False):
+        stdout = io.StringIO()
+        stderr = io.StringIO()
+        data = payload if raw else json.dumps(payload)
+        with mock.patch.object(syntax_hook, "PROJECT_ROOT", self.root):
+            with mock.patch("sys.stdin", io.StringIO(data)):
+                with redirect_stdout(stdout), redirect_stderr(stderr):
+                    result = syntax_hook.main()
+        return result, stdout.getvalue(), stderr.getvalue()
+
+    def test_python_is_compiled_without_execution_or_bytecode(self):
+        path = self.fixture("scripts/a file.py", "raise RuntimeError('must not run')\n")
+        result = self.invoke({"tool_name": "Write", "tool_input": {"file_path": str(path)}})
+        self.assertEqual(result, (0, "", ""))
+        self.assertEqual(list(self.root.rglob("__pycache__")), [])
+
+    def test_python_syntax_errors_are_reported_for_write_and_edit(self):
+        path = self.fixture("scripts/broken.py", "def broken(:\n")
+        for tool in ("Write", "Edit"):
+            with self.subTest(tool=tool):
+                code, stdout, stderr = self.invoke(
+                    {"tool_name": tool, "tool_input": {"file_path": str(path)}}
+                )
+                self.assertEqual(code, 2)
+                self.assertEqual(stdout, "")
+                self.assertIn("[syntax-check]", stderr)
+                self.assertIn("broken.py", stderr)
+
+    def test_relative_paths_resolve_from_project_root(self):
+        self.fixture("scripts/relative.py", "value = 1\n")
+        result = self.invoke(
+            {"tool_name": "Edit", "tool_input": {"file_path": "scripts/relative.py"}}
+        )
+        self.assertEqual(result, (0, "", ""))
+
+    def test_shell_and_javascript_use_expected_checkers(self):
+        cases = (
+            ("scripts/server/a file.sh", ["bash", "-n"]),
+            ("scripts/router/a file.sh", ["sh", "-n"]),
+            ("build_router_package.sh", ["sh", "-n"]),
+            ("substore/a file.js", ["node", "--check"]),
+        )
+        for relative, checker in cases:
+            with self.subTest(path=relative):
+                path = self.fixture(relative, "content is not executed\n")
+                command = checker + [str(path)]
+                completed = subprocess.CompletedProcess(command, 0, "", "")
+                with mock.patch.object(syntax_hook.subprocess, "run", return_value=completed) as run:
+                    result = self.invoke(
+                        {"tool_name": "Edit", "tool_input": {"file_path": str(path)}}
+                    )
+                self.assertEqual(result, (0, "", ""))
+                run.assert_called_once_with(
+                    command,
+                    cwd=str(self.root),
+                    capture_output=True,
+                    text=True,
+                    timeout=10,
+                )
+
+    def test_unsupported_files_and_tools_are_skipped(self):
+        for relative in ("README.md", "configs/config.json", "other.sh", "other.js"):
+            with self.subTest(path=relative):
+                path = self.fixture(relative, "not valid source code (\n")
+                with mock.patch.object(syntax_hook.subprocess, "run") as run:
+                    result = self.invoke(
+                        {"tool_name": "Write", "tool_input": {"file_path": str(path)}}
+                    )
+                self.assertEqual(result, (0, "", ""))
+                run.assert_not_called()
+        path = self.fixture("scripts/broken.py", "def broken(:\n")
+        result = self.invoke({"tool_name": "Read", "tool_input": {"file_path": str(path)}})
+        self.assertEqual(result, (0, "", ""))
+
+    def test_outside_paths_are_skipped(self):
+        temporary = tempfile.TemporaryDirectory(prefix="rotator-syntax-outside-")
+        self.addCleanup(temporary.cleanup)
+        outside = Path(temporary.name).resolve() / "outside.py"
+        outside.write_text("def broken(:\n", encoding="utf-8")
+        result = self.invoke(
+            {"tool_name": "Write", "tool_input": {"file_path": str(outside)}}
+        )
+        self.assertEqual(result, (0, "", ""))
+
+    def test_symlink_escapes_are_skipped(self):
+        temporary = tempfile.TemporaryDirectory(prefix="rotator-syntax-outside-")
+        self.addCleanup(temporary.cleanup)
+        outside = Path(temporary.name).resolve() / "outside.py"
+        outside.write_text("def broken(:\n", encoding="utf-8")
+        link = self.root / "linked.py"
+        try:
+            link.symlink_to(outside)
+        except (OSError, NotImplementedError) as exc:
+            self.skipTest(f"Symlinks unavailable: {exc}")
+        result = self.invoke({"tool_name": "Edit", "tool_input": {"file_path": str(link)}})
+        self.assertEqual(result, (0, "", ""))
+
+    def test_external_syntax_failures_are_reported(self):
+        path = self.fixture("substore/broken.js", "const value = ;\n")
+        completed = subprocess.CompletedProcess([], 1, "", "syntax failure")
+        with mock.patch.object(syntax_hook.subprocess, "run", return_value=completed):
+            code, stdout, stderr = self.invoke(
+                {"tool_name": "Edit", "tool_input": {"file_path": str(path)}}
+            )
+        self.assertEqual(code, 2)
+        self.assertEqual(stdout, "")
+        self.assertIn("syntax failure", stderr)
+
+    def test_missing_tools_and_timeouts_are_reported(self):
+        path = self.fixture("substore/operator.js", "const value = 1;\n")
+        failures = (
+            FileNotFoundError("node unavailable"),
+            subprocess.TimeoutExpired(["node", "--check", str(path)], 10),
+        )
+        for failure in failures:
+            with self.subTest(failure=type(failure).__name__):
+                with mock.patch.object(syntax_hook.subprocess, "run", side_effect=failure):
+                    code, stdout, stderr = self.invoke(
+                        {"tool_name": "Write", "tool_input": {"file_path": str(path)}}
+                    )
+                self.assertEqual(code, 2)
+                self.assertEqual(stdout, "")
+                self.assertIn(str(failure), stderr)
+
+    def test_missing_python_file_is_reported(self):
+        code, stdout, stderr = self.invoke(
+            {"tool_name": "Edit", "tool_input": {"file_path": "scripts/missing.py"}}
+        )
+        self.assertEqual(code, 2)
+        self.assertEqual(stdout, "")
+        self.assertIn("missing.py", stderr)
+
+    def test_invalid_payloads_are_reported(self):
+        payloads = (
+            "not json",
+            "[]",
+            json.dumps({"tool_name": "Write", "tool_input": None}),
+            json.dumps({"tool_name": "Edit", "tool_input": {"file_path": 42}}),
+            json.dumps({"tool_name": "Edit", "tool_input": {"file_path": ""}}),
+        )
+        for payload in payloads:
+            with self.subTest(payload=payload):
+                code, stdout, stderr = self.invoke(payload, raw=True)
+                self.assertEqual(code, 2)
+                self.assertEqual(stdout, "")
+                self.assertIn("[syntax-check]", stderr)
+
+
+if __name__ == "__main__":
+    unittest.main()