#!/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 w0)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 w0)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 w0)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