Files
gplan-2/game/tests/static_scan.sh
T
DKChan 33e1f11057 test(卡② T7补漏): 补齐 §9 正向验收主力 V1/V2/V6 与全 Vn 标注 + V1/V4 变异验证
前批合回时漏建卡② §9 逐需求点「正向验收主力」断言(四桶机制),仅靠迁移旧测+V12 兜底——
V12 formed/rules 输出面不作判据,adventure 桶跨战持久若坏不会红,核心缺陷消解无回归网。本批补:
- V1 冒烟新段:同一 RunLedger 两 begin_battle 各真实 request_dig,断 adv_twin_digs 经 ctx 就地写宿主字典跨战累积=2
- V2:start_adventure 后 adventure 桶置空(feat_vouchers)
- V6 冒烟新段:次战新编排器 fx_counters_battle 天然空、battle 桶 insurance_used 读 0
- V3/V4/V5/V7/V8/V9/V10 补〔无状态化 Vn〕尾缀使 §9 可追溯
- 变异验证:V1 断 adventure 桶引用改新建空字典→3 条红;V4 断 level 桶不共享→红(证回归网真拦得住)
2026-10-21 02:45:57 +08:00

237 lines
13 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 文件集(编排唯一性)
# S8 结算路径 await 零命中(B1 结算/编排文件集)
# ── S11 — 代码注释禁用词 ──
# 禁「板级」「门票」「正主」「落籍」「完美排雷」等退役词进注释面(完整词表见术语表解析)
# 权威出处:design/gdd/术语表.md「退役词对照」节+「对外命名纪律」节
# 扫描面:game/ 全域 .gd/.tres/.md/.json/.sh(.txt 不收=第三方许可证非本仓库文本)
# 词表来源:解析「退役词对照」第一列(去括号注、斜杠拆分)+显式追加禁「完美排雷」(受对外命名纪律约束,不在退役词对照表里);显式排除「巢穴」(玩家专名/风味层,术语表自带除外注)、「旗」(单字误报率高,doc_term_audit 判例)
# 注释文本提取:.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"
# 显式追加+排除
printf '完美排雷\n' >> "$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(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(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
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
exit $fail