Files
gplan-2/tools/path_check.py
T
DKChan c167affae2 chore(卫生): 闸域表口径对齐机器+删退役仪表盘工具+清基线 08b 记录(2026-10-18 用户点令三项)
①architecture.md 闸域表第三列与机器侧不一致已修:原写代码域触发含 `game/`、`tools/`、`artwork/`,而 worktree-guard 现行判定只有 `_p.startswith("game/")`(:149-151)、run_all_gates.sh 头注也只认触及 game/——`7bdf53e3` 改判时漏改的第二处。同时查出 roadmap.md:88「重闸」步仍带同一条旧口径与「改闸件本身→全量」旧条款(该义务已由 7bdf53e3 换成「闸件自身改动不占合回全量,质量义务=落批时变异验证」),一并换代。

②删 `tools/loop_dashboard.py`(529 行):roadmap 早在 2026-09-27 重整批就宣布它退役、职责交 06 与状态记录,全仓现行文档/闸序列/技能零调用(git grep 只命中自身);它的主要数据源之一 active.md 快照节已于本日废除,「台账健康」栏(撞号检测与下一编号建议)随编号方案一起失效。它唯一不重复的能力=引用版本与被引文档现行版本的落后比对,已按现存格式重立为 `doc-consistency-check` 技能第 10 项人工核对(比对面从已消失的 07「当前批次来源」行改到 06 的闭合凭据)。试跑该新件即命中真问题:06 有一条凭据写「元进度.md v1.0」而该文档现行已 v1.21,另有一条 character-design.md v1.9 取不到现行版本可核。

③删 `game/tests/.gates/baseline.json` 的「08b active.md 体量」记录(基线 13 闸→12),本批 diff 因此触及 game/,按域判据跑全量档(代码域+文档域)。附带 `tools/path_check.py` 去掉已失效的 active.md 跳过项并把注释里「排除台账类文件」的旧说法改成与实际扫描面一致的表述(实查该工具扫的只有 .gd/.tscn 与 design/art/*.md,从未跳过 *checklist*/*memory*)。
2026-10-18 16:18:13 +08:00

240 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 -*-
"""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/flag_seal.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 用户裁废除,跳过项一并删除)。
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
and "版本历史" not in fn)
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())