games-development-ai/.agents/tools/plugin-surface-gate.py
lili cbfd4d871b
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(acceptance): 闭合 playtest v3 与 A+ 可信消费链
固化 Match-3 生产者、视觉、音频与双 Judge 证据闭包。

将《山海行纪》r1.1 绑定新的不可变 release,并以生产预检现场核验 bundle、Registry/2 和 25 项 Writer 快照。

同步地图1平衡锁值、跨游戏回归修复、验收契约与 SoT 证据。
2026-07-28 20:16:13 -07:00

297 lines
16 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_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 键(不写死键名——从三方源解析后取交集/并集对账,键名漂移由对账本身抓);
· PROTECTED_INJECTION_DIRS = 只由指定品类 L1 固定装配、不进入通用 all-plugins/prompt 面的受保护能力;
· 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)必须恰好落在 {通用注入目录, PROTECTED_INJECTION_DIRS, PROBE_DIRS, RESERVE_DIRS} 之一 ——
多出未裁定的目录(新插件没决定注入/储备)硬失败;台账里的 PROBE/RESERVE 目录消失(插件被删/改名)硬失败。
4. 储备/取证/受保护品类目录绝不能出现在通用注入源里——否则硬失败。
零依赖:仅标准库(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 面)
PROTECTED_INJECTION_DIRS = {
# 只进 _template-puzzle L1;固定 Match-3 几何/selection mask/可信 probe,不进通用 all-plugins。
"match3-producer-profile",
# 只进标准 Match-3;独立封存视觉事务、固定阶段并统一输入锁,不进入通用插件面。
"match3-visual-timeline",
# 只进标准 Match-3;宿主独占单一音频图、主题 BGM、阶段 cue 与静音,不进入 L3。
"match3-audio-director",
}
# 受保护能力同时声明唯一官方模板、实例键、能力返回键和是否允许进入 L3 plugins;
# gate 会核对实例、注册偏序、返回读口与 host-only 边界。
PROTECTED_INJECTION_BINDINGS = {
"match3-producer-profile": (
"game-runtime/games/_template-puzzle/src/host-config.js",
"match3ProducerProfile",
"match3.orthogonal-swap-v1",
"producerCapability",
True,
),
"match3-visual-timeline": (
"game-runtime/games/_template-puzzle/src/host-config.js",
"match3VisualTimeline",
"match3.orthogonal-swap-v1",
"visualTimelineCapability",
True,
),
"match3-audio-director": (
"game-runtime/games/_template-puzzle/src/host-config.js",
"match3AudioDirector",
"match3.orthogonal-swap-v1",
"audioDirectorCapability",
False,
),
}
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:储备/取证目录绝不能出现在任何注入源(键名≠目录名,按目录集判)。
non_generic = RESERVE_DIRS | PROBE_DIRS | PROTECTED_INJECTION_DIRS
bad_injected = injected_dirs & non_generic
if bad_injected:
msgs.append("储备/取证/受保护品类目录被误注入通用面"
f"(须先从对应台账挪走再注入):{sorted(bad_injected)}")
# 受保护能力只允许出现在登记的官方模板;全部进入 registerOrder 与独立能力返回键,
# 只有声明 expose_to_l3=True 的能力才能进入 plugins。
template_hosts = sorted((root / "game-runtime/games").glob("_template*/src/host-config.js"))
if set(PROTECTED_INJECTION_BINDINGS) != PROTECTED_INJECTION_DIRS:
msgs.append("PROTECTED_INJECTION_BINDINGS 与 PROTECTED_INJECTION_DIRS 不一致")
for plugin_dir, (relative_host, plugin_key, profile_id, return_key, expose_to_l3) in PROTECTED_INJECTION_BINDINGS.items():
expected_host = root / relative_host
try:
source = expected_host.read_text(encoding="utf-8")
except OSError:
msgs.append(f"受保护能力 {plugin_dir} 的唯一模板不存在:{relative_host}")
continue
plugins_match = re.search(r"const\s+plugins\s*=\s*\{([^}]*)\}", source, re.S)
register_match = re.search(r"const\s+registerOrder\s*=\s*\[([^]]*)\]", source, re.S)
injected_by_object = bool(plugins_match and plugin_key in plugins_match.group(1))
injected_by_guard = bool(re.search(
rf"plugins\.{re.escape(plugin_key)}\s*=\s*{re.escape(plugin_key)}\b", source,
))
is_l3_injected = injected_by_object or injected_by_guard
if plugin_dir not in source:
msgs.append(f"受保护能力 {plugin_dir} 未在唯一模板实例化:{relative_host}")
if expose_to_l3 and not is_l3_injected:
msgs.append(f"受保护能力 {plugin_dir} 未按 {plugin_key} 注入唯一模板 L3 plugins:{relative_host}")
if not expose_to_l3 and is_l3_injected:
msgs.append(f"host-only 受保护能力 {plugin_dir} 禁止注入 L3 plugins:{relative_host}")
registered_in_array = bool(register_match and plugin_key in register_match.group(1))
registered_by_guard = bool(re.search(
rf"registerOrder\.splice\([^;]*\b{re.escape(plugin_key)}\b", source,
))
if not (registered_in_array or registered_by_guard):
msgs.append(f"受保护能力 {plugin_dir} 未进入唯一模板 registerOrder:{relative_host}")
if not re.search(rf"{re.escape(return_key)}\s*:\s*{re.escape(plugin_key)}\b", source):
msgs.append(f"受保护能力 {plugin_dir} 未从唯一模板返回 {return_key}:{relative_host}")
if profile_id not in source or not re.search(
r"interactionProfileId\s*===\s*MATCH3_INTERACTION_PROFILE_ID", source,
):
msgs.append(f"受保护能力 {plugin_dir} 未按显式 profile 条件装配:{profile_id}")
unexpected = []
for candidate in template_hosts:
candidate_source = candidate.read_text(encoding="utf-8")
if candidate != expected_host and (plugin_dir in candidate_source or plugin_key in candidate_source):
unexpected.append(str(candidate.relative_to(root)))
if unexpected:
msgs.append(f"受保护能力 {plugin_dir} 出现在未登记官方模板:{unexpected}")
# 判据 3:目录分区完备——每个真实插件目录恰好落在 {注入, PROBE, RESERVE} 之一;NON_PLUGIN 豁免。
classified = injected_dirs | PROTECTED_INJECTION_DIRS | PROBE_DIRS | RESERVE_DIRS | NON_PLUGIN_DIRS
unclassified = dirs - classified
if unclassified:
msgs.append("发现未裁定的插件目录(新插件必须显式裁定:通用注入 / 受保护品类注入 / 取证 / 储备四选一,"
"并在 plugin-capability-map.md 写一句依据):")
msgs.append(f" {sorted(unclassified)}")
# 台账里登记的 PROBE/RESERVE 目录若已从盘上消失(删/改名)→ 台账过时,硬失败。
ghost = (PROTECTED_INJECTION_DIRS | PROBE_DIRS | RESERVE_DIRS) - dirs
if ghost:
msgs.append(f"台账登记的受保护注入/取证/储备目录在盘上不存在"
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(PROTECTED_INJECTION_DIRS)} 受保护品类注入 + {len(PROBE_DIRS)} 取证 + "
f"{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())