games-development-ai/.agents/tools/plugin-surface-gate.py
lili 85f48228cd
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(cheap-gen): W-AXIS 波3 知识层补强+收尾——tsc 顾问门+recipes+暗资产接线+n=5 基线台账
- 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>
2026-07-10 04:27:18 -07:00

215 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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_DIRcheck 的 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())