第一章:Rust与Golang互操的底层原理与边界认知
Rust 与 Go 的互操作并非语言原生支持的“无缝桥接”,而是依赖于各自对 C ABI(Application Binary Interface)的严格遵循——这是二者跨语言调用的唯一稳定契约。Rust 通过 extern "C" 声明导出函数,Go 则通过 //export 指令和 cgo 工具链调用 C 兼容符号,双方均放弃自身运行时特性(如 Rust 的 panic 捕获、Go 的 goroutine 调度),仅传递 POD(Plain Old Data)类型。
C ABI 是唯一可信中介
- Rust 导出函数必须禁用栈展开:
#[no_mangle]+extern "C"+#[panic_handler]自定义(避免 unwind 跨越语言边界) - Go 端需禁用 cgo 的默认安全检查:编译时添加
-gcflags="all=-l" -ldflags="-s -w"减少符号干扰 - 双方字符串交互须统一为
*const std::ffi::CStr(Rust)与*C.char(Go),禁止直接传递String或[]byte
内存管理权责必须显式划分
| 操作方 | 分配内存 | 释放内存 | 风险示例 |
|---|---|---|---|
| Rust | CString::new().unwrap().into_raw() |
CString::from_raw(ptr) |
Go 误调 C.free() → double-free |
| Go | C.CString("hello") |
C.free(unsafe.Pointer(ptr)) |
Rust 误调 drop() → use-after-free |
实现一个安全的字符串回传示例
// rust_lib/src/lib.rs
use std::ffi::{CString, CStr};
use std::os::raw::c_char;
#[no_mangle]
pub extern "C" fn greet_rust(name: *const c_char) -> *mut c_char {
if name.is_null() {
return CString::new("").unwrap().into_raw();
}
let c_name = unsafe { CStr::from_ptr(name) };
let name_str = c_name.to_string_lossy();
let response = format!("Hello from Rust, {}!", name_str);
CString::new(response).unwrap().into_raw() // 内存所有权移交 Go
}
Go 侧调用后必须用 C.free() 释放该指针,否则造成 Rust 堆内存泄漏。任何非 C ABI 兼容类型(如 Vec<u8>、struct 含 drop 实现)均不可直接跨语言传递——这是不可逾越的边界。
第二章:内存模型冲突与数据生命周期陷阱
2.1 Rust所有权语义在CGO调用链中的隐式失效
当 Rust 代码通过 extern "C" 导出函数供 C/Go 调用时,所有权语义在跨语言边界处彻底“静默退场”。
数据同步机制
Rust 的 Box<String> 或 Vec<u8> 若直接传递裸指针(如 *const u8)给 Go,其内存生命周期不再受 Drop 约束——Go 运行时既不识别 Drop,也无法触发析构。
#[no_mangle]
pub extern "C" fn rust_get_data() -> *const u8 {
let data = Box::new([1, 2, 3, 4]);
let ptr = Box::into_raw(data) as *const u8; // ⚠️ 所有权移交,但无释放契约
ptr
}
逻辑分析:
Box::into_raw解除所有权,返回裸指针;Go 侧无对应Box::from_raw调用路径,导致内存泄漏。参数ptr不携带长度或释放函数地址,属不完整 ABI。
关键风险点
- Rust 值在 CGO 边界上失去
Drop保障 - 引用计数(如
Arc)无法跨语言共享控制块 CString转换虽安全,但CStr::as_ptr()仍返回不可拥有指针
| 风险类型 | 是否可静态检测 | CGO 侧能否修复 |
|---|---|---|
| 内存泄漏 | 否 | 否(需 Rust 提供 free 回调) |
| Use-after-free | 否 | 否(Go 无法感知 Rust 生命周期) |
2.2 Go runtime GC对Rust裸指针的非预期回收行为
当通过 cgo 在 Go 中调用 Rust 函数并返回 *mut T(如 *mut libc::c_int)时,Go runtime 无法识别该裸指针的生命周期,可能在任意 GC 周期中回收其指向的 Rust 堆内存。
GC 可见性盲区
Go 的垃圾收集器仅跟踪 Go 分配的堆对象及显式注册的 C.malloc 内存。Rust 使用 Box::into_raw() 创建的裸指针不向 Go runtime 注册引用关系,导致 GC 认为该内存“不可达”。
典型误用示例
// Rust side
#[no_mangle]
pub extern "C" fn get_data() -> *mut i32 {
let boxed = Box::new(42);
Box::into_raw(boxed) // ❌ 无所有权移交协议
}
逻辑分析:
Box::into_raw()解除 Rust 所有权管理,但未通知 Go runtime;Go GC 不感知该指针存在,可能在后续任意时刻回收底层内存,造成悬垂指针。
安全桥接方案对比
| 方案 | 是否阻止 GC | 需手动释放 | 跨语言安全 |
|---|---|---|---|
C.malloc + C.free |
✅(Go 知晓) | ✅ | ✅ |
Box::into_raw |
❌(GC 盲区) | ✅(Rust 侧) | ❌(竞态风险) |
graph TD
A[Go 调用 Rust] --> B[Rust 分配 Box<i32>]
B --> C[Box::into_raw → *mut i32]
C --> D[Go 接收裸指针]
D --> E[Go GC 扫描:未发现引用]
E --> F[回收 underlying memory]
F --> G[后续 deref → SIGSEGV]
2.3 跨语言栈帧中Drop实现与defer执行顺序错位
当 Rust 的 Drop trait 与 C/C++ 的 defer(或等效 RAII 清理逻辑)在 FFI 边界共存时,析构时机因运行时栈管理模型差异而发生错位。
栈帧生命周期冲突
- Rust 使用基于作用域的精确 Drop 插入(MIR 级),在块退出前静态插入
drop_in_place - C++ 的
defer(如通过std::unique_ptr或自定义 RAII 类)依赖调用者栈帧的return指令触发 - FFI 调用中,Rust 函数返回后其栈帧立即解构,但 C++ 侧可能仍在同一栈帧中执行后续语句
执行顺序错位示例
// Rust side (lib.rs)
#[no_mangle]
pub extern "C" fn process_data(ptr: *mut Data) -> i32 {
let data = unsafe { Box::from_raw(ptr) }; // owns memory
// ... processing ...
0 // ← Drop runs HERE, before C returns
}
此处
Box::from_raw构造的data在函数末尾自动Drop,释放内存;但若 C 侧在process_data()返回后仍尝试访问ptr,即触发 use-after-free。Rust 的 Drop 是栈帧退出时立即执行,而 C 的defer语义常被误认为“函数返回后统一执行”。
关键差异对比
| 维度 | Rust Drop | C++ RAII / defer |
|---|---|---|
| 触发时机 | MIR 插入点(编译期确定) | 析构函数调用(运行期栈 unwind) |
| FFI 边界可见性 | 不可被 C 观察到 | 完全由 C 运行时控制 |
graph TD
A[Rust function entry] --> B[Allocate Box<Data>]
B --> C[Process logic]
C --> D[Rust Drop inserted here<br/>Box::drop → deallocate]
D --> E[C returns to caller]
E --> F[C may still hold stale ptr]
2.4 C FFI ABI对齐差异引发的结构体字段越界读写
当 Rust 与 C 通过 FFI 交互时,若双方对同一结构体的字段对齐(alignment)约定不一致,将导致内存布局错位,进而触发越界读写。
对齐差异根源
C 编译器(如 GCC/Clang)默认按 max(字段对齐要求) 自动填充;Rust 默认启用 repr(C) 时虽兼容 C 布局,但若缺失显式 #[repr(C, packed)] 或误用 #[repr(align(N))],仍可能偏离目标 ABI。
典型越界场景
// C header: assume 8-byte alignment on x86_64
struct Packet {
uint32_t len;
uint8_t data[0]; // flexible array member
};
// ❌ 危险:Rust 默认可能插入额外填充
#[repr(C)]
pub struct Packet {
pub len: u32,
pub data: [u8; 0], // 若 C 端未严格对齐,data 地址偏移 ≠ 4
}
逻辑分析:
data字段在 C 中紧随len后(偏移=4),但若 Rust 编译器因 ABI 模式差异插入 4 字节填充,则data实际偏移为 8 —— 向data写入将覆盖后续内存,造成静默越界。
| 字段 | C 偏移 | Rust(无 packed)偏移 |
风险 |
|---|---|---|---|
len |
0 | 0 | — |
data |
4 | 8(若对齐至 8 字节) | 越界写入 4B |
graph TD
A[C struct layout] -->|offset=4| B[data starts here)
C[Rust reprC default] -->|offset=8| D[data starts here)
B --> E[Safe access]
D --> F[Overwrite next field]
2.5 静态链接时Rust panic hook与Go signal handler的竞态覆盖
当 Rust 与 Go 代码通过 cgo 静态链接共存时,二者对底层信号(如 SIGABRT、SIGSEGV)的处理机制存在隐式冲突。
竞态根源
- Rust 默认注册
std::panic::set_hook,但底层依赖sigaction设置SIGUSR1/SIGABRT用于 unwind; - Go 运行时独占
SIGTRAP、SIGQUIT等,并在runtime/signal_unix.go中调用sigprocmask屏蔽/重定向信号; - 静态链接导致两者
libc符号合并,signal()或sigaction()调用相互覆盖。
典型冲突表现
// Go runtime 初始化(简化)
func siginit() {
signal(SIGSEGV, sigtramp) // Go 安装 handler
}
// Rust panic hook(链接后可能被 Go 覆盖)
std::panic::set_hook(Box::new(|info| {
eprintln!("Rust panic: {}", info);
std::process::abort(); // → raises SIGABRT
}));
逻辑分析:
std::process::abort()调用raise(SIGABRT),若此时 Go 已接管SIGABRT并执行其默认动作(如打印 stack trace + exit),Rust 的 panic hook 将永不触发;反之亦然。参数SIGABRT是 POSIX 标准终止信号,但语义由最后注册的 handler 决定。
解决路径对比
| 方案 | 可行性 | 风险 |
|---|---|---|
禁用 Go signal 处理器(GODEBUG=asyncpreemptoff=1) |
低(破坏 goroutine 抢占) | 调度失效 |
Rust 使用 std::hint::unreachable_unchecked() 替代 abort |
中(绕过信号) | 未定义行为 |
在 main 前手动 sigaction(SIGABRT, ...) 绑定统一 handler |
高(需 #[no_mangle] + extern "C") |
需协调两语言运行时 |
graph TD
A[静态链接 Rust+Go] --> B{谁先初始化?}
B -->|Rust 先| C[注册 panic hook]
B -->|Go 先| D[接管 SIGABRT/SIGSEGV]
C --> E[Go 覆盖 sigaction → hook 失效]
D --> F[Rust abort → Go handler 执行]
第三章:线程与运行时协同失效场景
3.1 Rust std::thread::spawn与Go goroutine调度器的栈空间争用
Rust 的 std::thread::spawn 为每个线程分配固定大小(通常 2MB)的内核栈,而 Go 的 goroutine 初始栈仅 2KB,按需动态增长收缩——这是栈空间争用差异的根源。
栈内存模型对比
| 特性 | Rust 线程栈 | Go goroutine 栈 |
|---|---|---|
| 初始大小 | ~2 MiB(OS 级固定分配) | 2 KiB(用户态动态管理) |
| 扩展机制 | 不可扩展(溢出 panic) | 按需复制扩容(~4x 增长) |
| 调度粒度 | OS 线程(重量级) | M:N 用户态调度器(轻量) |
典型争用场景示例
use std::thread;
// 启动 10k 线程 → 占用约 20 GiB 虚拟内存(即使空闲)
for _ in 0..10_000 {
thread::spawn(|| std::hint::spin_loop());
}
该代码触发大量内核栈预留,易引发 ENOMEM 或加剧 TLB 压力;而等效 Go 代码 for i := 0; i < 10000; i++ { go func(){}() } 仅初始占用 ~20 MiB。
调度器视角下的内存竞争
graph TD
A[应用请求并发] --> B{调度策略}
B -->|Rust spawn| C[向内核申请独立栈]
B -->|Go go| D[从 mcache 分配小栈 + GC 回收]
C --> E[虚拟内存碎片 & OOM 风险]
D --> F[紧凑布局 & 按需迁移]
3.2 Go P-threads绑定模式下Rust async executor的死锁传播
当 Go 程序以 GOMAXPROCS=1 + GODEBUG=schedtrace=1 启动,并通过 cgo 调用 Rust FFI 接口启动 tokio::runtime::Builder::new_current_thread() 时,单 OS 线程绑定导致调度器不可抢占。
数据同步机制
Go 的 M-P-G 模型中,P(Processor)被独占绑定后,Rust executor 的 park() 无法被唤醒——因 Go runtime 不触发 before/after hook 调度点。
// 在 cgo 导出函数中启动 executor(危险!)
#[no_mangle]
pub extern "C" fn rust_async_entry() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.spawn(async {
tokio::time::sleep(Duration::from_secs(1)).await; // 此处永久阻塞
});
// ❗无显式 .shutdown_timeout() → Go 主线程无法返回
}
逻辑分析:new_current_thread() 依赖外部调用 park() 唤醒,但 Go 单 P 模式下不移交控制权;spawn() 仅入队,未驱动轮询;Duration::from_secs(1) 需定时器驱动,而定时器任务亦无法调度。
| 场景 | 是否触发死锁 | 根本原因 |
|---|---|---|
GOMAXPROCS=1 + current_thread |
是 | 无备用 P 执行 timer task |
GOMAXPROCS>1 + multi_thread |
否 | 其他 P 可接管 executor 轮询 |
graph TD
A[Go 主线程] -->|cgo 调用| B[Rust executor park()]
B --> C{P 是否空闲?}
C -->|否,P 被 Go 独占| D[永久休眠]
C -->|是,P 可调度| E[定时器唤醒 → 继续执行]
3.3 TLS(线程局部存储)跨语言传递时的上下文丢失与污染
当 C/C++ 与 Python/Java 等语言通过 FFI(如 ctypes、JNI)混编时,TLS 变量无法自动跨运行时边界继承。
常见污染场景
- 主线程 TLS 值被子线程 C 回调意外覆盖
- Python
threading.local()与 C 的__thread不共享存储空间 - JNI AttachCurrentThread 后未显式初始化 Java TLS 上下文
典型错误示例
// 错误:假设 TLS 在跨语言调用中自动延续
__thread int request_id = 0;
void handle_from_python() {
// 此处 request_id 为未定义值(非继承自 Python 线程)
log("req=%d", request_id); // 可能输出随机值
}
__thread由 GCC 管理,仅限当前编译单元及同一运行时;Python 的 GIL 线程与 C 线程栈完全隔离,request_id初始为 0 或栈垃圾值,导致上下文丢失。
安全传递方案对比
| 方式 | 跨语言可见 | 需手动管理 | 性能开销 |
|---|---|---|---|
| 线程ID + 全局哈希 | ✅ | ✅ | 中 |
| 参数透传(显式) | ✅ | ✅ | 低 |
| 运行时桥接 TLS API | ⚠️(需绑定) | ✅ | 高 |
graph TD
A[Python线程] -->|传参/回调| B[C函数]
B --> C[读取__thread]
C --> D[未初始化→污染]
A --> E[显式传入ctx_ptr]
E --> F[C安全访问TLS副本]
第四章:序列化与类型系统不兼容性风险
4.1 serde_json与encoding/json在浮点数精度与NaN处理上的语义分裂
浮点数序列化行为差异
serde_json(Rust)默认保留完整IEEE 754双精度位数,而 Go 的 encoding/json 对浮点数执行 fmt.Sprintf("%g", f) 裁剪——导致 0.1 + 0.2 在 Rust 中序列化为 "0.30000000000000004",Go 中为 "0.3"。
NaN 处理的语义鸿沟
// Rust: serde_json serializes NaN as "null" by default (RFC 7159 violation)
let val = serde_json::json!(f64::NAN); // → null
serde_json遵循 JSON 规范禁止 NaN,强制转null;而encoding/json默认 panic,需显式注册json.Number或自定义 marshaler 才能输出"NaN"(非标准)或"null"。
关键差异对比表
| 行为 | serde_json (Rust) | encoding/json (Go) |
|---|---|---|
0.1+0.2 |
"0.30000000000000004" |
"0.3" |
f64::NAN |
"null"(静默) |
panic(默认) |
f64::INFINITY |
"null" |
"null"(需配置) |
数据同步机制
// Go: 必须显式处理 NaN 才能跨语言兼容
func (n *SafeFloat) MarshalJSON() ([]byte, error) {
if math.IsNaN(n.V) { return []byte("null"), nil }
return json.Marshal(n.V)
}
此定制 marshaler 统一将 NaN 映射为
null,与serde_json对齐,避免反序列化时类型断裂。
4.2 Rust enum变体映射到Go interface{}时的零值歧义与反射失真
当 Rust 的 enum(如 Option<T> 或自定义 Result<T, E>)经 FFI 或序列化桥接至 Go,其变体常被转为 interface{}。此时 None、Err(e) 等“空态”变体易被 Go 反射误判为零值。
零值混淆示例
// 假设 Rust 枚举经 cbindgen 导出为:
// type RustOption struct { Tag uint8; Value *int32 } // Tag=0→None, Tag=1→Some
var opt RustOption
fmt.Println(reflect.ValueOf(opt).IsNil()) // false —— 但语义上等价于 None
RustOption 是非指针结构体,IsNil() 永远返回 false,而开发者常误用 == nil 判断变体状态。
反射失真根源
| Go 类型 | reflect.Kind | 是否可 IsNil() | 语义零值含义 |
|---|---|---|---|
*T |
Ptr | ✅ | 指针未初始化 |
struct{Tag:uint8} |
Struct | ❌ | 字段全零 ≠ 枚举 None |
interface{} |
Interface | ✅ | 底层 nil → 无值 |
安全映射策略
- 强制使用
*RustEnum传递,确保IsNil()语义对齐; - 在桥接层注入
IsNone() bool方法,避免反射误判; - 使用
unsafe.Sizeof校验 Rust 枚举布局一致性。
graph TD
A[Rust enum] -->|FFI/serde| B[Go struct]
B --> C{reflect.ValueOf}
C --> D[Kind == Struct]
D --> E[IsNil() == false<br>即使 Tag==0]
E --> F[语义零值丢失]
4.3 字节序、padding及packed属性在C-ABI桥接层的隐式转换漏洞
当C结构体通过FFI(如Rust extern "C" 或Python ctypes)跨语言传递时,编译器对齐填充(padding)与目标平台字节序(endianness)的隐式假设常导致内存布局错位。
数据同步机制
// 假设目标平台为小端ARM64,但生成代码默认按x86-64 ABI对齐
typedef struct {
uint16_t id; // offset 0
uint32_t flag; // offset 4(x86-64:pad 2B after id → offset 4)
uint8_t code; // offset 8(ARM64:若未显式__attribute__((packed)),可能因align(4)插入额外pad)
} __attribute__((packed)) Packet; // 关键:仅此声明可消除padding,但不解决字节序
该结构在x86-64上sizeof=7,但在未加packed时GCC默认按最大成员(uint32_t)对齐→实际sizeof=12,造成读越界。
隐式转换风险点
- 字节序:
uint32_t flag在网络字节序(BE)协议中需ntohl(),但ABI桥接层常忽略; - Padding:不同编译器/平台对
#pragma pack或aligned解释不一致; packed副作用:禁用CPU对齐访问,触发ARM上的Alignment fault异常。
| 因素 | 安全影响 | 检测建议 |
|---|---|---|
| 缺失packed | 结构体尺寸膨胀,越界读 | offsetof()校验偏移 |
| 字节序混淆 | 数值解析错误(如0x0100→256 vs 1) | static_assert(__BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__) |
graph TD
A[C源结构体定义] --> B{是否显式packed?}
B -->|否| C[ABI填充不可控]
B -->|是| D[字节序是否标准化?]
D -->|否| E[数值语义错误]
D -->|是| F[安全桥接]
4.4 Go unsafe.Pointer与Rust std::ffi::CStr双向转换中的NUL截断与内存泄漏
NUL截断风险根源
Go 中 C.CString 生成的 C 字符串以 \0 结尾,但若原始 Go 字符串含内嵌 \0,则 CStr::from_ptr() 会在首个 \0 处截断,丢失后续数据。
内存泄漏典型场景
// ❌ 危险:CStr::from_ptr() 不拥有所有权,未配对 free()
let c_str = std::ffi::CStr::from_ptr(ptr as *const i8);
// ptr 由 Go 的 C.CString 分配,必须由 C.free() 释放
逻辑分析:
CStr::from_ptr仅创建只读视图,不接管内存;ptr需由 Go 侧调用C.free释放。若 Rust 忘记通知 Go 释放,或 Go 未暴露释放接口,则发生泄漏。
安全转换对照表
| 方向 | 安全方式 | 风险操作 |
|---|---|---|
| Go → Rust | CStr::from_bytes_with_nul_unchecked + 显式长度校验 |
from_ptr(无长度约束) |
| Rust → Go | CString::new().unwrap().into_raw() + C.free 回调 |
CString::as_ptr()(悬垂指针) |
双向生命周期协同流程
graph TD
A[Go: C.CString] --> B[Rust: into_raw → owned ptr]
B --> C[Rust: CStr::from_bytes_with_nul]
C --> D[Go: C.free when done]
第五章:Clippy+Staticcheck自动化检查清单与工程落地实践
检查工具链集成路径选择
在 Rust 1.75+ 项目中,我们采用 cargo-clippy v0.1.73 与 staticcheck v2023.1.5 的双引擎协同模式。关键在于避免重复告警——Clippy 负责语言惯用法(如 if-let-some 替代冗余 match),Staticcheck 专注跨函数数据流缺陷(如 SA1019 标记已弃用 API 的深层调用链)。CI 流水线中通过 --fix 参数自动修复 Clippy 可修正项(如 clippy::needless_borrow),而 Staticcheck 仅报告不自动修改,确保语义安全。
GitHub Actions 自动化配置片段
以下为 .github/workflows/lint.yml 核心节选,支持多版本 Rust 并行检查:
- name: Run Clippy & Staticcheck
run: |
rustup component add clippy
curl -sfL https://install.goreleaser.com/github.com/dominikh/go-staticcheck.sh | sh -s -- -b $(pwd)/bin latest
export PATH="$(pwd)/bin:$PATH"
cargo clippy --all-targets --all-features -- -D warnings -A clippy::style
staticcheck -go=1.21 ./...
告警分级治理策略
| 严重等级 | 触发条件 | 处理方式 | 示例规则 |
|---|---|---|---|
| Blocker | 内存安全风险(如 unsafe 误用) |
CI 直接失败,禁止合并 | clippy::not_unsafe_ptr_arg |
| Critical | 并发逻辑缺陷 | 强制 PR 评论并标记 reviewer | staticcheck::SA2002 |
| Warning | 性能可优化项 | 自动生成 fix suggestion | clippy::string_add |
企业级检查清单落地案例
某金融风控服务(Rust + tonic gRPC)在接入该方案后,首周捕获 3 类高危问题:
clippy::cast_precision_loss:f64转f32导致精度丢失,在风控阈值计算中引发偏差;staticcheck::SA1019:tokio::timer::Delay(已废弃)被间接依赖的hyper 0.13引入,触发全链路升级;clippy::large_enum_variant:ResponseEnum中Success变体携带 2MB JSON 字段,导致栈溢出风险,重构为Box<dyn Serialize>。
配置文件定制化实践
项目根目录下 clippy.toml 与 .staticcheck.conf 实现差异化管控:
# clippy.toml
# 禁用团队明确接受的风格(如允许 `else if`)
clippy::else_if_without_else = "allow"
# 启用实验性规则验证新特性
clippy::future_not_send = "warn"
// .staticcheck.conf
{
"checks": ["all", "-ST1000", "-SA1019"],
"initialisms": ["API", "HTTP", "ID"]
}
增量检查性能优化
针对千级模块仓库,启用 --changed-since=origin/main 参数使 Clippy 仅扫描变更文件,平均耗时从 8.2min 降至 1.4min;Staticcheck 通过 -exclude 过滤 target/ 和 proto/ 目录,规避 protobuf 生成代码的误报。
开发者体验增强措施
VS Code 插件 rust-analyzer 配置中启用 "rust-analyzer.cargo.checkOnSave.command": "clippy",配合 staticcheck 的 LSP 支持,实现实时双引擎提示。当检测到 clippy::panic 时,自动插入 #[cfg(test)] 条件编译注释模板。
检查结果可视化看板
使用 cargo-scout 生成 HTML 报告,嵌入 Jenkins 构建产物,展示各模块 Clippy/Staticcheck 告警密度热力图,并关联 Git blame 定位责任人。
团队协作规范固化
在 CONTRIBUTING.md 中明确定义:所有 PR 必须通过 cargo clippy --no-deps 基础检查,且 Staticcheck 报告中 Critical 级别告警需在 24 小时内闭环,否则由 Tech Lead 触发强制 review。
