Files
gplan-2/tools/path_check.py

239 lines
11 KiB
Python
Raw Permalink 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 -*-
"""B3 路径核对器(圣经 §8.3② 挂点实现 · 立绘定档批 2026-10-07)。
职责:扫描 asset-path-map.md 声明的全部路径 + 代码侧素材常量,
报「悬空(声明存在但盘上无)/ 退役未清(仍引用已退役命名)」。
为什么需要(2026-10-06 实证三处悬空):素材不入 git,路径错了不会报错,
只会在实机静默回退(UiKit.load_tex 的 exists 守卫)⇒ 肉眼难发现。
三层校验:
L1 目录级 asset-path-map §1 声明的路径存在性(candidates 按需创建豁免)
L2 文件级 §3 game/assets/art 语义子目录现役清单逐件存在性
L3 代码级 grep 代码里的 res://assets/art 引用,核对盘上是否存在
+ 检出已知退役命名(board_floor/board_wall/pillar_dungeon_*)
用法:
python tools/path_check.py [--repo <仓库根>] [--code <代码根>]
退出码: 0 通过 / 1 有红灯
"""
import os
import re
import sys
# asset-path-map §1 目录分工总表(逐条对应文档,勿复写第二份事实源)
DECLARED_DIRS = [
# (路径, 角色, 按需创建豁免)
("design/art/candidates", "设计侧源档(档案库,游戏禁引用)", True),
("game/assets/art", "运行时素材唯一落地处", False),
("game/assets/ui/icons", "UI 图标(唯一入 git 素材目录)", False),
("artwork/mockups", "混合样张/评审产物", False),
]
# asset-path-map §3 game/assets/art 语义子目录
DECLARED_SUBDIRS = ["character", "fx", "board", "ui"]
# §3 现役清单(文件名按 §8.3 域键式)
ACTIVE_FILES = [
"character/character_hero_idle.png",
"character/character_hero_walk.png",
"character/character_hero_portrait.png",
"fx/shovel_swing_f0.png",
"fx/shovel_swing_f1.png",
"fx/shovel_swing_f2.png",
"board/seal_mark.png",
]
# 立绘八敌按数据表键生成(键集=STANDEE_TIER_H,见 standee_asset_check)
ENEMY_KEYS = ("bugbear", "drider", "duergar", "duergar_king", "giant_spider",
"goblin", "hook_horror", "ogre_chief")
ACTIVE_FILES += ["character/character_enemy_%s.png" % k for k in ENEMY_KEYS]
# asset-path-map v3.9 已退役命名(检出即红)
RETIRED_PATTERNS = [
(re.compile(r"board_floor"), "board_floor 命名已退役(→terrain_floor_<board_type>_<n>)"),
(re.compile(r"board_wall"), "board_wall 命名已退役"),
(re.compile(r"pillar_dungeon"), "pillar_dungeon_* 命名已退役"),
(re.compile(r"production/mockups"), "production/mockups 为废路径(→artwork/mockups)"),
]
# 路径末尾要允许 printf 转换符(%s/%d/%0Nd)——否则正则会在 '%' 处截断,
# 拿到 "res://assets/art/board/terrain_" 这种半截串,既不像模板也不像真实
# 路径,两边都判错。初版即栽在此处(2 处假悬空),现让 %s/%d 一并吃掉。
RES_RE = re.compile(r"res://assets/art/[A-Za-z0-9_/\.\-%]+")
# ⚠ 路径模板(printf 式)不是可解析的真实路径——常量里存的是
# "res://assets/art/board/terrain_%s_%d.png" 这类格式串,盘上当然没有
# 字面同名文件。初版未识别⇒ 假悬空。识别到 % 转换符即跳过(模板有独立的
# 运行时守卫与 test 覆盖,不在本器执法面)。
FMT_MARKERS = ("%s", "%d", "%0", "{")
def is_path_template(ref):
return any(m in ref for m in FMT_MARKERS)
def main():
argv = sys.argv[1:]
repo = "."
code = "game"
if "--repo" in argv:
repo = argv[argv.index("--repo") + 1]
if "--code" in argv:
code = argv[argv.index("--code") + 1]
problems = []
notes = []
# ⚠ worktree 素材环境判定(素材不入 git ⇒ worktree 素材靠人工拷贝同步,
# 常见三种态,须区分否则满屏假红):
# full —— 主区形态,art 下素材齐全,全量校验
# partial —— worktree 常态:character/ 被拷进来了(跑测试三件套惯例),
# fx/board/ui 没拷 ⇒ 缺的那些是「未同步」不是「悬空」
# empty —— art 下 PNG 总数为 0,素材整体未同步
# 只有 full 态才把缺失判红灯;partial/empty 判 SKIP 并提示同步。
artdir = os.path.join(repo, "game", "assets", "art")
present = [s for s in DECLARED_SUBDIRS
if os.path.isdir(os.path.join(artdir, s))]
png_total = sum(1 for dp, _, ns in os.walk(artdir)
for n in ns if n.lower().endswith(".png"))
if len(present) == len(DECLARED_SUBDIRS):
env = "full"
elif png_total == 0:
env = "empty"
else:
env = "partial"
synced = env != "full"
if synced:
notes.append("素材环境 = **%s**(子目录就位 %d/%d,PNG 共 %d)—— "
"worktree 素材靠人工拷贝同步,缺项属未同步非悬空"
% ({"partial": "部分同步", "empty": "空"}[env],
len(present), len(DECLARED_SUBDIRS), png_total))
notes.append("缺失子目录:%s(如需完整校验请对**主区**跑本器)"
% ("、".join(s for s in DECLARED_SUBDIRS
if s not in present) or "无"))
else:
notes.append("素材环境 = 完整(主区形态),全量校验")
print("=== L1 目录级(asset-path-map §1)===")
for rel, role, exempt in DECLARED_DIRS:
p = os.path.join(repo, rel)
ok = os.path.isdir(p)
if ok:
print("OK %-30s %s" % (rel, role))
elif exempt:
print("SKIP %-30s 按需创建豁免(文档已注非作废)" % rel)
elif synced and rel.startswith(("game/", "artwork/")):
print("SKIP %-30s 素材未同步豁免 —— %s" % (rel, role))
else:
print("FAIL %-30s 悬空:声明存在但盘上无 —— %s" % (rel, role))
problems.append("目录悬空: " + rel)
print("\n=== L2 文件级(asset-path-map §3 现役清单)===")
for rel in ACTIVE_FILES:
p = os.path.join(repo, "game", "assets", "art", rel)
sub = rel.split("/")[0]
if os.path.isfile(p):
print("OK %s" % rel)
elif synced:
print("SKIP %-44s 素材未同步豁免" % rel)
else:
print("FAIL %-44s 悬空(现役清单声明但盘上无)" % rel)
problems.append("文件悬空: " + rel)
for sub in DECLARED_SUBDIRS:
p = os.path.join(artdir, sub)
if not os.path.isdir(p):
if synced:
print("SKIP 子目录 %-40s 素材未同步豁免" % sub)
else:
print("FAIL 子目录 %-40s 悬空" % sub)
problems.append("子目录悬空: " + sub)
print("\n=== L3 代码级 res:// 引用核对 ===")
# ⚠ 口径:Godot 的 res:// 根 = 项目目录 game/(project.godot 所在处),
# 不是仓库根。所以 res://assets/art/x.png → <repo>/game/assets/art/x.png。
# 2026-10-07 初版误按仓库根解析 ⇒ 全部引用假悬空,已修。
refs = {}
for dirpath, dirnames, names in os.walk(os.path.join(repo, code)):
dirnames[:] = [d for d in dirnames
if d not in (".git", ".godot", "import", "node_modules")]
for fn in names:
if not fn.endswith((".gd", ".tscn", ".tres")):
continue
fp = os.path.join(dirpath, fn)
try:
txt = open(fp, "r", encoding="utf-8", errors="ignore").read()
except OSError:
continue
for m in RES_RE.findall(txt):
refs.setdefault(m, []).append(os.path.relpath(fp, repo))
if not refs:
print("(代码侧无 res://assets/art 硬引用——符合 §2 第4步禁 preload 纪律)")
templates = []
for ref, where in sorted(refs.items()):
if is_path_template(ref):
templates.append((ref, where))
continue
p = os.path.join(repo, "game", *ref[len("res://"):].split("/"))
if os.path.exists(p):
print("OK %-52s ← %d 处" % (ref, len(where)))
elif synced:
print("SKIP %-52s 无素材环境豁免 ← %s" % (ref, where[0]))
else:
print("FAIL %-52s 悬空 ← %s" % (ref, ", ".join(where[:3])))
problems.append("代码引用悬空: " + ref)
if templates:
print("SKIP 路径模板 %d 条(printf 格式串,非字面路径;运行时由守卫兜底)"
% len(templates))
for ref, where in templates:
print(" · %-46s ← %s" % (ref, where[0]))
print("\n=== 退役命名扫描 ===")
# ⚠ 口径纪律:退役命名**只在代码与素材清单里是违规**;叙述性文档提到这些名字属正常,
# 不是违规。故扫描面只含:代码(.gd/.tscn) + 素材清单(design/art/*.md),其余一律不扫;
# .git/.workbuddy/.zcode/archive 在目录层就排除。
# 2026-10-07 初版全仓扫 .md 曾把历史记述全判成违规,故收窄到上述两面(当日还额外跳过
# 根目录会话状态文件,该文件已于 2026-10-18 用户裁废除,跳过项一并删除;
# 「版本历史」文件名跳过项亦于 2026-10-19 变更记录节禁设批删除——此类节改由
# gdd_lint.py --changelog-only 全 design/ 扫,本工具不再分派豁免)。
hits = 0
scan_exts = (".gd", ".tscn")
for dirpath, dirnames, names in os.walk(repo):
dirnames[:] = [d for d in dirnames
if d not in (".git", ".godot", "archive", "node_modules",
".workbuddy", ".zcode")]
for fn in names:
fp = os.path.join(dirpath, fn)
rel = os.path.relpath(fp, repo)
if any(s in rel for s in (".workbuddy", ".zcode")):
continue
is_code = fn.endswith(scan_exts)
is_manifest = (fn.endswith(".md") and os.sep + "art" + os.sep in fp)
if not (is_code or is_manifest):
continue
try:
txt = open(fp, "r", encoding="utf-8", errors="ignore").read()
except OSError:
continue
# 叙述性豁免:命中行若同时含「退役/已废/更正/改判」等记述词,
# 视为「在讲这段历史」而非「仍在用这个名字」
for rx, why in RETIRED_PATTERNS:
for line in txt.splitlines():
if rx.search(line) and not any(
w in line for w in ("退役", "已废", "更正", "改判",
"原写", "原名", "作废")):
print("FAIL %-52s %s" % (rel, why))
print(" └─ %s" % line.strip()[:90])
problems.append("退役命名: " + rel)
hits += 1
break
if not hits:
print("OK 无退役命名残留(代码 + 素材清单;叙述性历史记述已豁免)")
for nt in notes:
print("\n注:%s" % nt)
print("\n路径机检完成:%d 处问题" % len(problems))
return 1 if problems else 0
if __name__ == "__main__":
sys.exit(main())