第 11 章:语义代码检查——Semantic code linting
第 11 章:语义代码检查——Semantic code linting
传统 linter 管格式、管语法、管已知的坏模式,但它管不了"这段命名跟它做的事不是一回事""这条日志把用户个人信息写进了明文""这里按注释的说法调用 API,其实是误用"。这些判断没有固定写法,只有语义。本章讲怎么把这类检查做成一层可运行的代码检查,以及为什么它更适合当顾问而不是法官。
正则和 AST 管不了的那部分
先分清两类代码检查。第一类是机械检查:缩进、命名规范、未使用变量、循环复杂度、已知危险函数调用。它们有确定的形式,用正则或 AST 就能判,跑得快、零误报、零成本。ESLint、golangci-lint、ruff 覆盖的就是这一层,别拿 Jev 去替代它们。
第二类是语义检查,它问的是"这段代码是不是表达了一个坏意思",而这种"坏"没有固定写法:
- 命名与实现不符:一个叫
validate()的函数其实在做数据清洗;一个叫is_ready()的方法有副作用。 - 注释与实现不符:注释说"返回缓存的用户",代码却每次都打数据库。
- API 误用:用了某个库的接口,但不符合它的调用约定(比如忘了关连接、参数顺序反了、把异步当同步用)。
- 安全模式:这条日志把邮箱和手机号写进了明文;这个拼接留下了注入面。
这些判断用正则写不出来——只要换一种措辞,模式就失效了。而它们又恰好是代码评审里最花人力的部分。语义检查的价值,就是把这些"只能靠读"的判断,变成机器能批量问出来的问题。
语义检查适合当顾问,而不是法官
在 VS Code 里插一条检查,最怕的不是漏报,是误报。一个阻断性的检查一旦误报,开发者就会立刻把它关掉,往后它再准也没用了。所以在 CI 里,语义检查更合适的定位是 advisory(顾问)层:
- 高置信的问题给明确指向(文件 + 行号 + 理由),让人一眼能判断;
- 拿不准的标成 uncertain,而不是"没问题";
- 只有超过阈值、且规则明确的才允许阻断构建,其余只做提示。
这不是保守,是对误报成本的清醒认识。代码检查的实际收益 = 抓到的问题 − 误报带来的信任损耗。当误报开始让团队反感,收益就转负了。
只查变更行,把成本摁住
一个仓库动辄几十万行,全量跑语义检查既慢又贵,也毫无必要——上一版已经通过检查的代码,没有理由重查一遍。真正的增量是这次 diff 改了哪些行。
于是管道变成:取 diff → 只对**变更的行(或变更的函数)**提问。这样成本随改动量增长,而不是随仓库大小增长,也顺带压低了误报的地毯式噪声。
粒度也要选:按行问定位最准,但同一段逻辑被拆成多行时会切碎上下文;按函数(或 hunk)问能带上更多上下文、减少请求数,代价是报告的位置不如行级精确。多数项目的折中是——先定位到变更的函数,再要求模型指出具体行号,让上下文和定位各取所需。
# 只取出本次改动新增/修改的行,连同文件与行号
git diff --unified=0 --no-color HEAD~1 -- '*.py' \
| grep -E '^\+[^+]' | sed 's/^+//' > changed_lines.txtdef lint_hunk(file: str, hunk: str, rule: str, threshold: float = 0.7) -> list[dict]:
d = client.systemone(
state=hunk,
questions={"violates": {"type": "noul", "question": rule}},
instructions="只依据给出的代码片段判断;不确定时倾向判否,避免误报。",
)
p = d["violates"].probability
if p >= threshold:
return [{"file": file, "confidence": p, "rule": rule}]
return []注意 instructions 里那句"不确定时倾向判否"——这是人为把检查器的误报优先调成谨慎优先。顾问层的阈值可以更严,因为它的目标是"指出得对",不是"一个都别跑掉"。
实例一:semcheck——规则就是一句英文
arturobermejo/semcheck 是一个 Go 写的 linter,它的规则直接写成大白话问题,例如:
"does this log call write personal data?"(这条日志调用写了个人数据吗?)
对规则匹配到的每一段代码,它问 Jev 一个 Noul,超过该规则阈值就报告。它的两条内置规则在三个开源项目里的抽样结果里,12 条发现全部正确(12/12)。
这个项目示范了语义检查最该有的手感:规则是可读的句子,不是一段配置。写规则的人不需要会写正则,只需要能说清"我要退出的是哪种坏味道"。这也让规则容易评审——把规则摊开给团队看,哪条太宽、哪条太窄,一眼就能议。与之呼应的是 ckorhonen/jev-lint,它用 Jev Noul 加本地阈值,在 Claude Code 和 Codex 边改边标团队规则违规,并支持按仓库落不同的规则包。
实例二:JevGate 与 Perch——把 Noul / Choice / Score 组合成一条检查
单一 Noul 只能回答"有没有问题"。要给出可行动的检查结果,往往要把三种输出组合起来。
Tech-Byte-Frontier/jevgate 是一个 CI 与编码代理闸门:它先在本地解析代码,然后针对一个函数、一份文件大纲、一对候选复制的代码、或一个测试,问 Jev 的 Noul、Choice 和 Score。凡是置信度达到 0.80 的回答,就落成带文件和行号的 review 或 consider 发现;review 让构建失败,而未定的文件保持 uncertain 而不是被清成干净。这个"未定不等于干净"的处理很关键:检查器不该用一个含糊的默认值,把没把握的地方伪装成通过。
lakeday-org/perch 也是一个语义代码检查器,但它的上下文更宽:对每个方法,连同它的调用者和被调用者一起看,问一个 Noul(有没有 bug)、一个 Choice(是哪一类、在哪一行)、一个 Score(严重程度),再加语言相关的 CWE Noul 检查,以及用句子写在仓库 / 文件 / 方法三级上的自定义规则,任何超过下限的回答都让 CI 失败。
两个项目合起来说明了三种输出的分工:Noul 判"是不是有问题",Choice 判"是哪一类、在哪一行",Score 判"多严重"。检查结果因此不是一句"这里可疑",而是一条可排序、可定位、可解释的发现。
实例三:local-decider——规则先行,Jev 只兜底
pusucip25/local-decider 是一个浏览器代理的动作前闸门,它的设计几乎就是本章主张的范本:先跑一条确定性的规则链,只有在规则定不了的地方才问 Jev——换句话说,凡是规则能判的,永远不送到权重上去。它给出 allow / confirm / block 三种结果,并且只用一个环境变量就能在托管的 jev-latest 和本地 Jev 兼容端口之间切换。
它的实测数据也印证了"规则先行"的价值:在 112 个已标注用例上,规则本身就解决掉了 61 个,其中 58 个判断正确(95.1%),并且在不可逆工具上没有任何一次误放行(0 false allows)。也就是说,超过一半的判断根本不需要模型,而模型只负责那条规则覆盖不到的长尾。它还特意从 DOM 读取最终状态,而不是听信代理自己的汇报——验证要看事实,不看声明。
这个模式在语义代码检查里同样成立:命名规范、依赖白名单、文件路径这些能用规则判的先判;把"这段日志有没有写个人信息""这个函数名和它的行为符不符"这些留给 Jev。成本与误报都被规则先砍掉了一大截。
顺带一提:把检查扩展到内容产物
语义检查的对象不限于代码,一类同构的实践出现在内容产物上。klauswg 的 jev-suite 里,jev-proof 用每个事实一次 Noul+Choice 调用核验视频字幕里的赞助广告是否满足验收规则,置信度闸门由代码掌握,90 样本校准下门槛内准确率 0.922,0/15 注入翻转;jev-fidelity 对每个事实单元问 Jev 编辑是否保留了原意(保留了 / 等价 / 漂移 / 丢失),闸门设在 0.70,55 样本校准下 91/92 判断正确、0/20 注入翻转;jev-rental 把租房帖里的每条说法分进"到现场再验 / 需补证据 / 高风险话术"三桶,50 样本校准 0.910 门槛内准确率、0/10 注入翻转。三者都反复强调同一个数字——注入翻转为零,意思是:往待检查的文本里塞入诱导性指令,判定结果不会被带跑。 对安全检查来说,这个不变量比准确率更关键。jyatesdotdev/jev-logtriage 则把折叠后的 Loki 日志一次调用问出 Noul、Score、Choice,再把答案在代码里映射成"抑制 / 观察 / 复核 / 通知 / 呼叫",低置信一律走复核、且什么都不自动执行。
误报成本与阈值,是这类系统的核心参数
回顾整章,语义代码检查的工程重心其实不在模型,而在阈值与定位:
flowchart TD
C["改动行"] --> R{"能用规则判?"}
R -- 能 --> X["本地规则
零成本"]
R -- 不能 --> J["Jev:Noul / Choice / Score"]
J --> P{"置信度 >= 阈值?"}
P -- 是且规则硬 --> B["阻断构建(review)"]
P -- 是但规则软 --> A["顾问提示(consider)"]
P -- 否 --> U["标为 uncertain
不伪装成干净"]DanRWilloughby/snifftest 之类的检查器给出的数据很能说明误报的分量:它以 0.7 为阈值对每段文字问十个 Boolean 问题,中位数 182 ms,在 54 段干净文本里只误报了 1 段,而作为对照的某个小模型误报了 37 段。mblode/taste-lint 也采取同样策略:可度量的规则留在本地且始终生效,而语义上的"味道"判断用 Jev 概率,只有明确的发现才会让一次运行失败。这些数字共同指向一条经验——语义检查要能上线,靠的不是更高的召回,而是更低的误报和更清晰的定位。
小结
- 语义检查补的是正则与 AST 管不了的那部分:命名与实现不符、注释与实现不符、API 误用、安全模式。
- 它更适合当 CI 里的 advisory 层:高置信才阻断,拿不准标
uncertain而不是"干净",因为误报会直接摧毁开发者对它的信任。 - 只对 diff 的变更行提问,成本随改动量而非仓库规模增长。
- semcheck 把规则写成大白话句子,两条内置规则在三个开源项目里 12/12 判断正确;jev-lint 边改边标。
- JevGate 组合
Noul/Choice/Score,0.80以上落成带行号的发现、review让构建失败、未定保持uncertain;Perch 连调用者/被调用者一起看。 - local-decider 规则先行、Jev 只兜底:112 例中规则解决 61 例、正确 58 例(95.1%),不可逆工具 0 误放行;jev-suite 三项目在门槛内准确率 0.910–0.922,注入翻转均为 0。
在仓库里守住"代码有没有坏意思"之后,下一章我们把判定带进数据管道:怎么把非结构化文本变成能喂给预测模型的特征,又怎么用语义信号做需求预测。