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

240 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「退役词对照」节+「对外命名纪律」节
# 同形异义豁免:机制词「去重闸」(封印重判去重的玩法判别器)与某闸档退役词后二字同形——
# 行内含「去重闸」即整行跳过(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
exit $fail