- tsc 顾问门:typescript devDependency + tsc-advisory 测试档;顾问性(永不阻断), 金标 10 款零误伤抽验 - recipes/ 五篇高频场景索引(命中矩形/计时器/资产回退/场景机/结算演出,「抄这段形态 +头号病根」)+ littlejs-game-dev §0.5 卡壳就查接线 - 暗资产接线:plugin-capability-map(11 注入+1 取证+6 储备逐件裁定)+ engine-capabilities-brief(≤3KB 蒸馏)+ api.d.ts/game-host.d.ts 补插件指针; 三方一致性对账门 plugin-surface-gate.py(含 cwd 锚定修)挂 pre-commit+Gitea Actions - hard_genre_batch 参数化(五品类 n=5 批跑驱动,checkpoint 续跑) - n=5 基线台账入库(gitignore 例外 add -f,只入台账不入游戏产物): wax-baseline.jsonl 25 局逐局记录(含判定仪器订正批注:cap1500 重判/图像盲 M3 重判, 只加字段零改值)——终数:操作口径 14/25=56%、质量口径 18/25=72%; 品类 narrative/heritage 5/5、trpg 4/5、puzzle/sim-business 2/5(缺口逐局有名有姓, 根因见 07-10 v2 plan §1);旧「80%/39%」口径正式作废存照 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
215 lines
11 KiB
Python
215 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""plugin-surface-gate.py —— 便宜档插件面三方一致性对账机器门(防漂移)。
|
||
|
||
背景:便宜档生成 agent 只能用「已注入」的插件——它经 `plugins.<键>` 取,不能 import。而「有哪些插件
|
||
可用」这件事在三个地方各写一份、彼此独立会漂移:
|
||
· 插件目录 = game-runtime/src/plugins/<name>/(谁真实存在,含未注入的储备件);
|
||
· prompt 白名单 = contracts/prompts/04-config/cheap-system.md 告诉模型「可用插件键」的那一行;
|
||
· host-config 注入清单 = game-runtime/games/_template/src/host-config.js 的 `const plugins = {…}`(真装配进运行时的)。
|
||
外加一份 tools.mjs 的 PLUGIN_KEY_DIR(check 的 API 静态门据它把 plugins.<键>.<方法> 映到 api.d.ts 查方法名)。
|
||
四者任一漂移都会出真事故:白名单列了但没注入 → 模型写 plugins.x → 运行时 undefined、boot 崩;注入了但白名单没列
|
||
→ 模型不知道有这件、能力白摆(诊断档 §四⑦点名的「暗资产」);目录新增一件却没人裁定它该注入还是入储备 → 悄悄成
|
||
第七个暗插件。本门把「这三方 + PLUGIN_KEY_DIR 必须对齐,且每个插件目录都被显式裁定为『注入/取证/储备』之一」编译
|
||
成 CI 与 pre-commit 消费的退出码。
|
||
|
||
裁定台账(单一事实源 = 本门的三个常量,改这里即改裁定;人读依据见 .agents/skills/plugin-capability-map.md):
|
||
· INJECTED = 三方对齐后应得的 11 键(不写死键名——从三方源解析后取交集/并集对账,键名漂移由对账本身抓);
|
||
· PROBE_DIRS = 取证不注入(runtime-probe:只在收口取证、不进 plugins 面,api.d.ts 明示);
|
||
· RESERVE_DIRS = 未注入能力储备(6 件:当前 5 注入品类是 tap/回合制,这些是动作/群体/摇杆/组合件类,
|
||
归 W-GENRE 动作线或需复合装配,本波不注入。逐件依据见 plugin-capability-map.md)。
|
||
|
||
判据:
|
||
1. 三方注入键集必须【完全相等】:prompt 白名单 == host-config 注入 == PLUGIN_KEY_DIR 键集 —— 否则硬失败并打差集。
|
||
2. 每个注入键经 PLUGIN_KEY_DIR 映到的目录必须真实存在(含 impl.js)—— 否则硬失败(注入了不存在的插件)。
|
||
3. 目录分区完备:每个真实插件目录(含 impl.js)必须恰好落在 {注入目录, PROBE_DIRS, RESERVE_DIRS} 之一 ——
|
||
多出未裁定的目录(新插件没决定注入/储备)硬失败;台账里的 PROBE/RESERVE 目录消失(插件被删/改名)硬失败。
|
||
4. 储备/取证目录绝不能出现在任何注入源里(储备件被误注入 → 必须先从 RESERVE_DIRS 挪走再注入)—— 否则硬失败。
|
||
|
||
零依赖:仅标准库(re/sys/pathlib),与同仓 docs-gate.py / rubric-sync-gate.py 同栈同风格。
|
||
|
||
用法:
|
||
python3 .agents/tools/plugin-surface-gate.py # 对本仓
|
||
python3 .agents/tools/plugin-surface-gate.py --root DIR # 对指定仓根(供负向演示跑改过的副本)
|
||
# exit 0 = 三方对齐且目录全裁定;exit 1 = 有漂移/未裁定/储备误注入
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
# ── 裁定台账(单一事实源;人读依据见 .agents/skills/plugin-capability-map.md)──────────────────────
|
||
PROBE_DIRS = {"runtime-probe"} # 取证不注入(收口取证,不进 plugins 面)
|
||
RESERVE_DIRS = { # 未注入能力储备(逐件依据见 plugin-capability-map.md)
|
||
"auto-targeting", # 索敌策略族——动作/射击品类,归 W-GENRE 动作线
|
||
"swarm-steering", # 均匀网格+群体运动学——同屏千级实体,动作/idle-sim 品类
|
||
"entity-pool", # 对象池+swap-remove——密集实体零 GC,动作品类;粒子已由 particles-juice 承接
|
||
"virtual-joystick", # 虚拟摇杆——连续移动输入,当前 5 品类全 tap/回合制,且与 handleTap 输入契约不同轴
|
||
"canvas-ui-kit", # 卡片选择器/滚动列表/顶栏——组合件,需 draw:hudUi 复合装配;可为 TRPG/经营未来注入候选
|
||
"hit-feedback", # 反馈档位编排——需 juice+sfx 复合装配,与直调 juice/audioMusic 重叠(skill 已教直调)
|
||
}
|
||
NON_PLUGIN_DIRS = {"_example"} # 示例脚手架,非真插件
|
||
|
||
# 三方源在仓内的相对路径。
|
||
PROMPT_MD = "contracts/prompts/04-config/cheap-system.md"
|
||
HOST_CONFIG = "game-runtime/games/_template/src/host-config.js"
|
||
TOOLS_MJS = "game-runtime/tools/amodel-gen/tools.mjs"
|
||
PLUGINS_DIR = "game-runtime/src/plugins"
|
||
|
||
|
||
def _fail(msgs: list[str]) -> None:
|
||
print("✘ plugin-surface-gate 未过:")
|
||
for m in msgs:
|
||
print(" - " + m)
|
||
|
||
|
||
def parse_prompt_whitelist(root: Path) -> set[str] | None:
|
||
"""从 cheap-system.md 抽「可用插件键(…已注入…):k1 / k2 / …」那一行的键集。抽不到返回 None。"""
|
||
p = root / PROMPT_MD
|
||
try:
|
||
for line in p.read_text(encoding="utf-8").splitlines():
|
||
if "可用插件键" in line and "已注入" in line:
|
||
# 取中文冒号「:」后的部分,按 / 切,只留 [A-Za-z] 词元。
|
||
after = line.split(":", 1)[-1] if ":" in line else line
|
||
keys = {t for t in re.findall(r"[A-Za-z][A-Za-z0-9]*", after)}
|
||
return keys or None
|
||
except OSError:
|
||
return None
|
||
return None
|
||
|
||
|
||
def parse_host_inject(root: Path) -> set[str] | None:
|
||
"""从 _template/src/host-config.js 抽 `const plugins = { k1, k2, … }` 的键集。抽不到返回 None。"""
|
||
p = root / HOST_CONFIG
|
||
try:
|
||
txt = p.read_text(encoding="utf-8")
|
||
except OSError:
|
||
return None
|
||
m = re.search(r"const\s+plugins\s*=\s*\{([^}]*)\}", txt)
|
||
if not m:
|
||
return None
|
||
# 对象是简写键(shorthand),逐词元取标识符即键名。
|
||
keys = {t for t in re.findall(r"[A-Za-z_][A-Za-z0-9_]*", m.group(1))}
|
||
return keys or None
|
||
|
||
|
||
def parse_plugin_key_dir(root: Path) -> dict[str, str] | None:
|
||
"""从 tools.mjs 抽 `const PLUGIN_KEY_DIR = { key: 'dir', … }` 映射。抽不到返回 None。"""
|
||
p = root / TOOLS_MJS
|
||
try:
|
||
txt = p.read_text(encoding="utf-8")
|
||
except OSError:
|
||
return None
|
||
m = re.search(r"const\s+PLUGIN_KEY_DIR\s*=\s*\{(.*?)\}", txt, re.S)
|
||
if not m:
|
||
return None
|
||
pairs = dict(re.findall(r"(\w+)\s*:\s*'([^']+)'", m.group(1)))
|
||
return pairs or None
|
||
|
||
|
||
def list_real_plugin_dirs(root: Path) -> set[str] | None:
|
||
"""列 src/plugins/ 下含 impl.js 的真实插件目录名。目录不存在返回 None。"""
|
||
d = root / PLUGINS_DIR
|
||
if not d.is_dir():
|
||
return None
|
||
out = set()
|
||
for sub in d.iterdir():
|
||
if sub.is_dir() and (sub / "impl.js").is_file():
|
||
out.add(sub.name)
|
||
return out
|
||
|
||
|
||
def run(root: Path) -> int:
|
||
msgs: list[str] = []
|
||
|
||
whitelist = parse_prompt_whitelist(root)
|
||
inject = parse_host_inject(root)
|
||
key_dir = parse_plugin_key_dir(root)
|
||
dirs = list_real_plugin_dirs(root)
|
||
|
||
# 任一源解析不出 = 结构被改坏/路径漂移,硬失败(不静默放行)。
|
||
if whitelist is None:
|
||
msgs.append(f"prompt 白名单解析失败(找不到「可用插件键…已注入…」行):{PROMPT_MD}")
|
||
if inject is None:
|
||
msgs.append(f"host-config 注入清单解析失败(找不到 const plugins = {{…}}):{HOST_CONFIG}")
|
||
if key_dir is None:
|
||
msgs.append(f"PLUGIN_KEY_DIR 解析失败:{TOOLS_MJS}")
|
||
if dirs is None:
|
||
msgs.append(f"插件目录不存在:{PLUGINS_DIR}")
|
||
if msgs:
|
||
_fail(msgs)
|
||
return 1
|
||
|
||
key_dir_keys = set(key_dir.keys())
|
||
|
||
# 判据 1:三方注入键集完全相等。
|
||
if not (whitelist == inject == key_dir_keys):
|
||
msgs.append("三方注入键集不一致(prompt 白名单 / host-config 注入 / PLUGIN_KEY_DIR 必须完全相等):")
|
||
msgs.append(f" prompt 白名单({len(whitelist)}):{sorted(whitelist)}")
|
||
msgs.append(f" host-config 注入({len(inject)}):{sorted(inject)}")
|
||
msgs.append(f" PLUGIN_KEY_DIR({len(key_dir_keys)}):{sorted(key_dir_keys)}")
|
||
only_wl = whitelist - inject - key_dir_keys
|
||
only_hc = inject - whitelist - key_dir_keys
|
||
only_kd = key_dir_keys - whitelist - inject
|
||
missing_wl = (inject & key_dir_keys) - whitelist
|
||
if only_wl:
|
||
msgs.append(f" 仅在 prompt 白名单(列了但没注入→运行时 undefined、boot 崩):{sorted(only_wl)}")
|
||
if only_hc:
|
||
msgs.append(f" 仅在 host-config(注入了但白名单/静态门没列→模型不知道有它、能力白摆):{sorted(only_hc)}")
|
||
if only_kd:
|
||
msgs.append(f" 仅在 PLUGIN_KEY_DIR:{sorted(only_kd)}")
|
||
if missing_wl:
|
||
msgs.append(f" 注入了但 prompt 白名单漏列:{sorted(missing_wl)}")
|
||
|
||
injected_dirs = {key_dir[k] for k in key_dir_keys}
|
||
|
||
# 判据 2:每个注入键的目录真实存在(含 impl.js)。
|
||
for k in sorted(key_dir_keys):
|
||
if key_dir[k] not in dirs:
|
||
msgs.append(f"注入键 {k} 映到目录 src/plugins/{key_dir[k]}/ 不存在或缺 impl.js")
|
||
|
||
# 判据 4:储备/取证目录绝不能出现在任何注入源(键名≠目录名,按目录集判)。
|
||
reserve_probe = RESERVE_DIRS | PROBE_DIRS
|
||
bad_injected = injected_dirs & reserve_probe
|
||
if bad_injected:
|
||
msgs.append(f"储备/取证目录被误注入(须先从 RESERVE_DIRS/PROBE_DIRS 台账挪走再注入):{sorted(bad_injected)}")
|
||
|
||
# 判据 3:目录分区完备——每个真实插件目录恰好落在 {注入, PROBE, RESERVE} 之一;NON_PLUGIN 豁免。
|
||
classified = injected_dirs | PROBE_DIRS | RESERVE_DIRS | NON_PLUGIN_DIRS
|
||
unclassified = dirs - classified
|
||
if unclassified:
|
||
msgs.append("发现未裁定的插件目录(新插件必须显式裁定:注入 / 取证 PROBE_DIRS / 储备 RESERVE_DIRS 三选一,"
|
||
"并在 plugin-capability-map.md 写一句依据):")
|
||
msgs.append(f" {sorted(unclassified)}")
|
||
# 台账里登记的 PROBE/RESERVE 目录若已从盘上消失(删/改名)→ 台账过时,硬失败。
|
||
ghost = (PROBE_DIRS | RESERVE_DIRS) - dirs
|
||
if ghost:
|
||
msgs.append(f"台账登记的取证/储备目录在盘上不存在(插件被删/改名?更新本门台账与 plugin-capability-map.md):{sorted(ghost)}")
|
||
|
||
if msgs:
|
||
_fail(msgs)
|
||
return 1
|
||
|
||
print(f"✔ plugin-surface-gate 全绿:{len(key_dir_keys)} 注入键三方对齐"
|
||
f"(prompt 白名单 == host-config == PLUGIN_KEY_DIR);"
|
||
f"{len(PROBE_DIRS)} 取证 + {len(RESERVE_DIRS)} 储备目录全登记在册,{len(dirs)} 个真实插件目录全部已裁定。")
|
||
return 0
|
||
|
||
|
||
def main() -> int:
|
||
# 缺省仓根锚定到脚本自身位置(<repo>/.agents/tools/ → parents[2] = 仓根),不吃 cwd——
|
||
# 2026-07-09 验收实测:cwd 在别处跑会把四路解析全报「失败」,是误导性假漂移(pre-commit/CI 恰好
|
||
# 都从仓根跑才没暴露)。--root 覆盖位保留(负向演示跑改过的副本用)。
|
||
root = Path(__file__).resolve().parents[2]
|
||
argv = sys.argv[1:]
|
||
if "--root" in argv:
|
||
i = argv.index("--root")
|
||
if i + 1 < len(argv):
|
||
root = Path(argv[i + 1]).resolve()
|
||
return run(root)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|