Files
gplan-2/game/tests/static_scan.sh
T

291 lines
16 KiB
Bash
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 bash
# tests/static_scan.sh — 静态扫描域 S1–S8 可执行子集
# 规则全集与动机:design/engineering/architecture/architecture.md「测试与质量域」节
# 正式工程(core/ 存在)时执行;否则优雅跳过(工程在 game/core——仓库根布局)。
# 纪律:新违规案例必须先补本脚本规则再修码(design/engineering/code-conventions.md 缺陷-规范对照表)。
set -u
cd "$(dirname "$0")/../.." # S 闸规则路径均为仓根相对(落点错会致 game/core 守卫恒跳过=静默空转)
if [ ! -d game/core ]; then
echo "[static_scan] game/core 不存在(工程未创建)——跳过,退出 0"
exit 0
fi
fail=0
# ── 扫描路径单点 + 零命中自检 ──
# 静默空转比红更危险:S3 曾把路径写成裸 `core`(仓根无此目录,实际是 game/core),
# 恒零命中恒绿、什么都不扫,直到本批才被发现。故每条规则的扫描路径必须是单点变量,
# 且规则自己先数一遍扫描面文件数——数为零一律判红(这样路径写歪时自检会跟着一起歪、一起红)。
DIR_CORE="game/core"
DIR_PRES="game/presentation"
scan_files() { # scan_files <目录> — 该目录下 .gd 文件数
find "$1" -name '*.gd' 2>/dev/null | wc -l
}
require_scan_face() { # require_scan_face <规则名> <目录> — 扫描面为空即判红
local n
n=$(scan_files "$2")
if [ "$n" -eq 0 ]; then
echo "[static_scan] $1 FAIL: 扫描面为空——$2 下零个 .gd(规则路径写错导致的静默空转)"
fail=1
return 1
fi
return 0
}
# S9 — 类型闸(code-conventions §4 机器闸:产品码无类型容器/有值返回缺类型/参数缺类型)
if python tools/audit_type_hints.py; then
echo "[static_scan] S9 ok"
else
echo "[static_scan] S9 FAIL: 类型闸存量未归零(R1/R2/R3 见上清单)"
fail=1
fi
# S10 — 文件头闸(code-conventions §1.4.1:## 载体+日期/变更叙事黑名单;行数上限已停用)
if python tools/audit_file_headers.py; then
echo "[static_scan] S10 ok"
else
echo "[static_scan] S10 FAIL: 文件头存量见上清单"
fail=1
fi
# S1/S2 — 玩法代码禁 randomize()/自建 RandomNumberGenerator/全局随机函数(绕开 RngService 的口子)
# 违规签名=裸 randomize/rand* 调用(实例方法 .randi() 排除)
# + RngService 之外的 RandomNumberGenerator.new();接收服务注入流的类型标注不属违规(core 五文件 15 处实证)
if require_scan_face "S1/S2" "$DIR_CORE"; then
hits=$(grep -rnE '\brandomize[[:space:]]*\(|(^|[^.[:alnum:]_])randi[[:space:]]*\(|(^|[^.[:alnum:]_])randf[[:space:]]*\(|(^|[^.[:alnum:]_])randf_range[[:space:]]*\(' \
"$DIR_CORE" "$DIR_PRES" --include='*.gd' 2>/dev/null | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' | grep -v 'rng-seed-exempt' | wc -l)
hits2=$(grep -rn 'RandomNumberGenerator\.new()' "$DIR_CORE" "$DIR_PRES" --include='*.gd' 2>/dev/null | grep -vc rng_service)
if [ "$hits" -gt 0 ] || [ "$hits2" -gt 0 ]; then echo "[static_scan] S1/S2 FAIL: 全局随机/RNG 自建命中 $hits+$hits2 处"; grep -rnE '\brandomize[[:space:]]*\(|(^|[^.[:alnum:]_])randi[[:space:]]*\(|(^|[^.[:alnum:]_])randf[[:space:]]*\(|(^|[^.[:alnum:]_])randf_range[[:space:]]*\(' "$DIR_CORE" "$DIR_PRES" --include='*.gd' 2>/dev/null; grep -rn 'RandomNumberGenerator\.new()' "$DIR_CORE" "$DIR_PRES" --include='*.gd' 2>/dev/null | grep -v rng_service; fail=1; else echo "[static_scan] S1/S2 ok"; fi
fi
# S4 — core/ 禁 extends Control/Node2D、禁 .tscn preload(headless 债)
if require_scan_face "S4" "$DIR_CORE"; then
hits=$(grep -rnE 'extends[[:space:]]+(Control|Node2D)\b|\.tscn' "$DIR_CORE" --include='*.gd' 2>/dev/null | wc -l)
if [ "$hits" -gt 0 ]; then echo "[static_scan] S4 FAIL: core 引擎/UI 依赖命中 $hits 处"; grep -rnE 'extends[[:space:]]+(Control|Node2D)\b|\.tscn' "$DIR_CORE" --include='*.gd' 2>/dev/null; fail=1; else echo "[static_scan] S4 ok"; fi
fi
# S3 — core/ 禁 ConfigDB 外 data 表加载(schema class_name 直引豁免)
# 扫描路径必须写 game/core——写裸 `core` 仓根无此目录,恒零命中恒绿(静默空转)。
# 豁免:①注释行 ②game/core/config_db.gd 自身(数值单一事实源的现行事实源,它的单点 load 是本规则的例外)。
if require_scan_face "S3" "$DIR_CORE"; then
hits=$(grep -rnE '\b(load|preload)[[:space:]]*\(|ResourceLoader\.load' "$DIR_CORE" --include='*.gd' 2>/dev/null \
| grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' \
| grep -v "$DIR_CORE/config_db.gd" \
| grep -v 'res://tests/' | wc -l)
if [ "$hits" -gt 0 ]; then echo "[static_scan] S3 WARNING(人工复核——class_name 直引误报需逐条排除): $hits 处"; grep -rnE '\b(load|preload)[[:space:]]*\(|ResourceLoader\.load' "$DIR_CORE" --include='*.gd' 2>/dev/null | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' | grep -v "$DIR_CORE/config_db.gd"; else echo "[static_scan] S3 ok(扫描面 $DIR_CORE 共 $(scan_files "$DIR_CORE") 个 .gd,非静默空转)"; fi
fi
# S5 — core/ 禁裸引用 autoload 全局标识符(ConfigDB / RngService 直接写 = --script 模式 parse error)
# 形态:标识符后紧跟 `.`(成员访问)才算裸引用;排除注释行,并排除被引号/斜杠包裹的字符串字面量
# (如 "[ConfigDB/%s]" 日志前缀、"/root/ConfigDB" 取节点路径)——那是文案与路径,不是标识符引用。
if require_scan_face "S5" "$DIR_CORE"; then
hits=$(grep -rnE '(^|[^.[:alnum:]_"/])(ConfigDB|RngService)[[:space:]]*\.' "$DIR_CORE" --include='*.gd' 2>/dev/null \
| grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' | wc -l)
if [ "$hits" -gt 0 ]; then echo "[static_scan] S5 FAIL: core 裸引用 autoload 全局标识符命中 $hits 处(--script 模式 parse error)"; grep -rnE '(^|[^.[:alnum:]_"/])(ConfigDB|RngService)[[:space:]]*\.' "$DIR_CORE" --include='*.gd' 2>/dev/null | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#'; fail=1; else echo "[static_scan] S5 ok(扫描面 $DIR_CORE 共 $(scan_files "$DIR_CORE") 个 .gd)"; fi
fi
# TODO(正式工程落地后精化为扫描配置并升格为硬失败):
# S6 core↔core 信号连接只许方法 Callable(禁 lambda 捕获 self);发射者禁反向引用
# S7 B2/B3/B4 API 符号命中文件 ⊆ B1 文件集(编排唯一性)
# ── S11 — 代码注释禁用词 ──
# 禁「板级」「门票」「正主」「落籍」「完美排雷」等退役词进注释面(完整词表见术语表解析)
# 权威出处:design/gdd/术语表.md「退役词对照」节+「对外命名纪律」节
# 同形异义豁免:机制词「去重闸」(封印重判去重的玩法判别器)与某闸档退役词后二字同形——
# 行内含「去重闸」即整行跳过(2026-10-22 测试术语重构批随闸档用语入表增设,否则 adventure_feats_test.gd
# 在册断言标签成片误红;代价=该行同时出现的真闸档用词也不报,靠文档域 08 闸兜住)
# 扫描面:game/ 全域 .gd/.tres/.md/.json/.sh(.txt 不收=第三方许可证非本仓库文本)
# 注释文本提取:.gd/.sh 取 # 后文本;.tres 取 ; 后文本;.md 取整行;.json 无注释语法(保留扩展名但不产生注释段)
# 三层豁免依序:①闸门自指(行内含检查/禁令形态即跳过)②显式留档豁免单 4 处 ③非注释面天然不取
if [ -d game/core ]; then
S11_TABLE="design/gdd/术语表.md"
S11_FAIL_LOCAL=0
if [ ! -f "$S11_TABLE" ]; then
echo "[static_scan] S11 FAIL: 术语表文件不存在($S11_TABLE)"
S11_FAIL_LOCAL=1
else
# 解析词表:「退役词对照」第一列→去括号注→斜杠拆分
S11_WFILE=$(mktemp)
awk '/^## 退役词对照/{f=1;next} f && /^## /{exit} f && /^\|/ && !/^\|---/ && !/退役词/{
sub(/^\| */, ""); sub(/ *\|.*/, "")
gsub(/([^)]*)/, ""); gsub(/\([^)]*\)/, "")
gsub(/^ *| *$/, "")
n = split($0, parts, " / ")
for (i=1;i<=n;i++){gsub(/^ *| *$/,"",parts[i]); if(parts[i]!="") print parts[i]}
}' "$S11_TABLE" > "$S11_WFILE"
grep -vx '巢穴' "$S11_WFILE" | grep -vx '旗' | grep -vx 'flag_\*' | sort -u > "${S11_WFILE}.x" && mv "${S11_WFILE}.x" "$S11_WFILE"
# 自检二:词表解析非空
S11_WC=$(grep -c . "$S11_WFILE" 2>/dev/null || echo 0)
if [ "$S11_WC" -eq 0 ]; then
echo "[static_scan] S11 FAIL: 词表解析为空——术语表结构可能已变"
S11_FAIL_LOCAL=1
fi
# 自检一:扫描面非空
S11_FC=$(find game/ -type f \( -name '*.gd' -o -name '*.tres' -o -name '*.md' -o -name '*.json' -o -name '*.sh' \) 2>/dev/null | wc -l)
if [ "$S11_FC" -eq 0 ]; then
echo "[static_scan] S11 FAIL: 扫描面为空——路径写错导致的静默空转"
S11_FAIL_LOCAL=1
fi
if [ "$S11_FAIL_LOCAL" -eq 0 ]; then
# 扫描三个文件类型并合并结果
S11_ALL_HITS=$(
# .gd/.sh:取 # 后注释
find game/ -type f \( -name '*.gd' -o -name '*.sh' \) -print0 2>/dev/null \
| xargs -0 awk -v wf="$S11_WFILE" '
BEGIN{nw=0;while((getline w<wf)>0)words[++nw]=w;close(wf)}
/#/{
cs=index($0,"#"); cmt=substr($0,cs+1)
for(i=1;i<=nw;i++){
if(index(cmt,words[i])>0){
if($0~/contains\(|find\(|grep|has_char\(|禁「|禁『|零「|零『/)next
if(index(cmt,"去重闸")>0)next
if(FILENAME~"enemy.tres"&&FNR==653)next
if(FILENAME~"level_runner"&&FNR==832)next
if(FILENAME~"adventure_runner"&&FNR==403)next
if(FILENAME~"damage_math_test"&&FNR==36)next
print FILENAME":"FNR":"words[i]
next
}
}
}
' 2>/dev/null
# .tres:取 ; 后注释
find game/ -type f -name '*.tres' -print0 2>/dev/null \
| xargs -0 awk -v wf="$S11_WFILE" '
BEGIN{nw=0;while((getline w<wf)>0)words[++nw]=w;close(wf)}
/;/{
cs=index($0,";"); cmt=substr($0,cs+1)
for(i=1;i<=nw;i++){
if(index(cmt,words[i])>0){
if($0~/contains\(|find\(|grep|has_char\(|禁「|禁『|零「|零『/)next
if(index(cmt,"去重闸")>0)next
if(FILENAME~"enemy.tres"&&FNR==653)next
print FILENAME":"FNR":"words[i]
next
}
}
}
' 2>/dev/null
# .md:整行即注释面
find game/ -type f -name '*.md' -print0 2>/dev/null \
| xargs -0 awk -v wf="$S11_WFILE" '
BEGIN{nw=0;while((getline w<wf)>0)words[++nw]=w;close(wf)}
{
for(i=1;i<=nw;i++){
if(index($0,words[i])>0){
if($0~/contains\(|find\(|grep|has_char\(|禁「|禁『|零「|零『/)next
if(index($0,"去重闸")>0)next
print FILENAME":"FNR":"words[i]
next
}
}
}
' 2>/dev/null
)
S11_HITS=0
if [ -n "$S11_ALL_HITS" ]; then
S11_HITS=$(echo "$S11_ALL_HITS" | grep -c .)
fi
if [ "$S11_HITS" -gt 0 ]; then
echo "[static_scan] S11 FAIL: 代码注释禁用词命中 $S11_HITS 处"
echo "$S11_ALL_HITS" | head -50
if [ "$S11_HITS" -gt 50 ]; then echo " ...(截断,共 $S11_HITS 处)"; fi
fail=1
else
echo "[static_scan] S11 ok(扫描面 $S11_FC 个文件,词表 $S11_WC 词)"
fi
else
fail=1
fi
rm -f "$S11_WFILE"
fi
fi
# ── S12 — 效果引擎无状态化旧件零残留(卡② V10/F1;禁 counters_export/counters_seed/_reset_tree_limited_counters 等旧符号与带引号旧 id 字面回归)──
# 扫描面:game/ 全域 .gd 与 game/data/tables/*.tres(改名与旧符号同时存在于数据行/测试/smoke,非仅 core——不扩面即空转)
# 带引号旧 id 匹配("twin_digs" 不误伤 "adv_twin_digs"——引号锚定;round_instinct_atk 不匹 "instinct_atk")
# 自检:扫描面文件数为零即判红(防静默空转)
if [ -d game/core ]; then
S12_SYM='counters_export|counters_seed|counters_merge_export|counters_seed_snapshot|_reset_tree_limited_counters|"tree_sense_sharp_used"|"tree_sense_guard_used"|"twin_digs"|"instinct_atk"'
S12_FACE=$(find game/ -type f \( -name '*.gd' -o -name '*.tres' \) 2>/dev/null)
S12_FC=$(echo "$S12_FACE" | grep -c .)
if [ "$S12_FC" -eq 0 ]; then
echo "[static_scan] S12 FAIL: 扫描面为空——路径写错导致的静默空转"
fail=1
else
S12_HITS=$(echo "$S12_FACE" | tr '\n' '\0' | xargs -0 grep -lEn "$S12_SYM" 2>/dev/null | wc -l)
if [ "$S12_HITS" -gt 0 ]; then
echo "[static_scan] S12 FAIL: 无状态化旧件残留命中 $S12_HITS 文件"
echo "$S12_FACE" | tr '\n' '\0' | xargs -0 grep -En "$S12_SYM" 2>/dev/null
fail=1
else
echo "[static_scan] S12 ok(扫描面 $S12_FC 个 .gd/.tres,旧件零残留)〔无状态化 V10〕"
fi
fi
fi
# ── S13 — 第三方质量规则闸(gdstyle v0.4.0,版本钉死)──
# 规则说明自持本头注(随 S11/S12 惯例):gdstyle(github.com/atelico/gdstyle,Rust 单二进制)只开
# quality/* 抓真缺陷(重复 load/循环内 await/循环内分配/不可达代码/自比较/字典重复键/残留
# print 等),format/*/naming/*/order/* 与 S9/S10 职能重叠全关;扫描面=产品码三目录(与 S9 同口径,
# probe/sim/tests 测量面直构惯例不在面)。存量基线 tools/gdstyle_baseline.txt 用稳定身份键(函数级带数
# 条目=文件|规则|函数名,类级/文件级带数条目=文件|规则,均带登记数值;无数字条目=文件|规则|消息全文
# 裸键)——消息里的行数/分支数等计数漂移不假红;判定=基线外新违规红(NEW)+同键指标较登记值恶化红
# (WORSENED),改小/持平不红。基线重生成=人工专用(python tools/gdstyle_gate.py --refresh,附对账;
# 键数骤降逾 10% 须 --force),禁清基线换绿。二进制本机固定路径
# D:/code/gdstyle/gdstyle.exe(不入库),GDSTYLE_BIN 环境变量覆盖;版本与钉定版不符即红(升级须重测)。
if python tools/gdstyle_gate.py; then
echo "[static_scan] S13 ok"
else
echo "[static_scan] S13 FAIL: 基线外新违规/指标恶化或二进制/版本/扫描面问题(清单见上)"
fail=1
fi
# ── S8 — 结算路径 await 零命中(B1 结算/编排文件集)──
# 规则(architecture.md「静态扫描域 S1–S8 承接清单」S8 行 / D2 AC-14 口径):结算本体是
# 可回放纯逻辑步骤流(同种子同输入逐位复现),await 会打断确定性——「模块归属」B1 行已写明
# 「禁 await——切片 88 冒烟实证」。本闸为防回归防线(2026-10-24 人工 grep 盘面干净)。
# 扫描面:B1 域文件集(与 architecture.md「模块归属」B1 行同口径)=battle_orchestrator.gd
# + settlement/ 五段段类 + damage_controller.gd,共 7 文件,显式清单见 S8_FILES。
# 执行侧字面值豁免:脚本必须带字面值才能工作;文件集与架构文档冲突时以 B1 域文件集为准。
# 判据:文件集内出现 await 关键字即红(词边界 \bawait\b,任何形态——await 信号/协程/
# get_tree());排除注释行(行首 #/##)与字符串字面量(双引号包裹段内的 await 非代码),
# 口径仿 S1/S2 的 grep 排除手法。
# 自检:清单内文件缺位(路径写错/被改名)即判红——防扫面为空静默绿。
S8_FILES="game/core/battle_orchestrator.gd game/core/settlement/aura_segment.gd game/core/settlement/combat_segment.gd game/core/settlement/decay_segment.gd game/core/settlement/grant_segment.gd game/core/settlement/verdict_segment.gd game/core/damage_controller.gd"
S8_MISS=0
for f in $S8_FILES; do
if [ ! -f "$f" ]; then
echo "[static_scan] S8 FAIL: 文件集缺位——$f 不存在(路径写错/被改名导致的静默空转)"
S8_MISS=1
fi
done
if [ "$S8_MISS" -eq 1 ]; then
fail=1
else
S8_HITS=$(grep -rnE '\bawait\b' $S8_FILES 2>/dev/null \
| grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' \
| grep -vE '"[^"]*\bawait\b[^"]*"' \
| wc -l)
if [ "$S8_HITS" -gt 0 ]; then
echo "[static_scan] S8 FAIL: 结算路径 await 命中 $S8_HITS 处(同种子逐位回放不可被 await 打断)"
grep -rnE '\bawait\b' $S8_FILES 2>/dev/null | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' | grep -vE '"[^"]*\bawait\b[^"]*"'
fail=1
else
echo "[static_scan] S8 ok(扫描面 B1 文件集 7 个 .gd,await 零命中)"
fi
fi
exit $fail