返回文章

语言层:代码写错的那一秒,就该被拦住

这是五层约束系列的第一篇。母篇《从能跑到可交付:一套前端 AI 协作系统的设计笔记 》把五层一起讲了,密度太高;这个系列逐层拆开,每篇只讲一层:为什么要有它,具体怎么干。我用的系统是开源的 frontend-harness-cli ,文中所有规则都能在仓库里找到对应实现。

一个凌晨两点才爆炸的 bug

先看一个具体的场景。

AI 给你写了一个格式化价格的函数:接收一个价格,返回带两位小数的字符串。它内部调用了 price.toFixed(2)。演示数据里价格是数字 19.9,跑得好好的。上线之后,后端某个接口把价格字段改成了字符串 "19.9"——JSON 里一个引号之差。页面没报错,函数也没报错,直到某个用户点开那个页面,toFixed 在字符串上不存在,整个页面白屏。

这个 bug 是什么时候出生的?不是接口改字段的那天,是更早:函数接收参数的那一刻,没人规定它到底该收什么。 在 JavaScript 里这很正常——参数就是个名字,什么都能往里塞,塞错了不在写的时候炸,在跑的时候炸,而且专挑没人看着的时候炸。

语言层要解决的就是这一件事:把"跑的时候才炸"提前成"写的时候就炸"。

为什么第一层必须是语言层

五层约束的顺序是语言、样式、状态、工程、美学。这个顺序不是按重要性排的,是按反馈速度排的。

语言层的检查工具是编译器。它有三个别层没有的性质:

第一,快。一次类型检查是秒级甚至毫秒级,在你写完一个函数的当下就能跑完。样式检查要等文件成形,评测要等功能写完,视觉对比要等页面能打开——都比它慢。

第二,准。编译器报错会精确到文件、行、列,还会告诉你它期待什么、实际拿到了什么。它不说"这段代码感觉不太对",它说"第 42 行,这个位置需要 number,你给了 string"。

第三,它不过,后面全是噪声。代码编译不过,你去查它样式合不合规范、状态定义闭不闭合,查出来的结论都没有意义——因为这段代码根本运行不起来。所以语言层必须排第一:它不是最重要的一层,是其余四层能成立的前提。

落到 AI 写代码的场景,这件事更关键。人写代码会自己留个心眼,知道"这个值可能为空";模型不会——它的默认假设是世界一切正常,字段永远存在,类型永远对得上。你没法靠提醒让它小心,只能靠编译器在它写错的当下就把路堵死。

设计一:严格模式全开,不挑着开

TypeScript 的 strict 不是一个开关,是一组开关的打包:包括 strictNullChecks(null 和 undefined 是独立类型,不能冒充其他类型)、noImplicitAny(推不出来类型就报错,不许偷偷当 any)等一共七八项。

我的规则是:全开,不许挑着关。

为什么?因为挑着关的人,关掉的永远是最"碍事"的那个,而最碍事的恰恰是最有用的。以 strictNullChecks 为例:关掉它,user.profile.name 这种代码永远合法——哪怕 profile 有一半情况是 null。打开它,编译器会逼你在访问之前先回答一个问题:profile 为空时怎么办?

这个问题正是 AI 最容易跳过的。它写代码时默认主线畅通:用户存在、订单存在、接口返回存在。真实世界的 bug 一大半出在"不存在"上:未登录、已删除、加载中、请求失败。严格模式的价值,就是把这些"不存在的情况你想过没有",从靠经验变成靠编译器逐个盘问。盘问很烦,但每一问都是一个未来的凌晨两点。

设计二:堵后门——any 和 @ts-ignore 怎么管

只有严格模式是不够的,因为 TypeScript 留了后门:

  • 写 any,等于告诉编译器"这块别看了";
  • 写 as someType 强行断言,等于"我说它是什么它就是什么";
  • 在报错行上面加一行 @ts-ignore,等于把这一行的错误直接消音。

这三样对人已经很危险,对 AI 是致命的。因为 AI 的行为模式是"想办法让任务通过":当类型错误挡路时,正面修类型要理解数据从哪来、到哪去,成本高;在报错行贴一个 @ts-ignore,成本是一行注释。你不堵后门,它每次都会选便宜的那条路,而且选得理直气壮——代码"能编译了"。

我系统里的具体管法分三档,不是简单的一禁了之:

第一档,规范层:栈包(harness init 装进项目的 standards 文件)里写明——不用 any、不用 @ts-ignore,除非在旁边注释里写清理由。注意这个"除非":真实项目里总有第三方库类型缺失这类正当理由,一刀切全禁,执行者就会改用更隐蔽的绕法(比如先转 unknown 再转目标类型)。留一个要写理由的口子,例外就从"偷偷摸摸"变成"有据可查"。

第二档,扫描层:harness scan 的结构维度会真的去数。源码先做掩码处理——把字符串和注释内容遮掉,避免"在注释里讨论 @ts-ignore"被误伤——然后统计 : any、as any 这类用法和 @ts-ignore/@ts-nocheck 的出现次数,按个数扣分。它给这类问题的判语写得很直白:“每一个 any,都是你让编译器别看的地方”;“消音不是修复”。

第三档,评测层:如果某个功能为了让检查通过而用了抑制注释,rubric 里的 pitfall 条目会直接触发一票否决——这属于作弊,不属于扣分项。

三档的逻辑是同一条:约束的强度要和绕过它的收益成正比。 写规范是提醒,扫描扣分是代价,评测否决是红线。

设计三:类型检查卡在每个任务之后,不等 CI

很多团队也有类型检查,但它躺在 CI 里:代码提交了、合并请求开了,流水线跑五分钟,红了,再回来改。这条反馈链太长了。

在我的工作流里,顺序是这样钉的(写在栈包里,属于门禁项):

  • 每个任务做完,typecheck 和 lint 必须通过,才许记这个任务完成;
  • 进评测之前,build 必须通过——因为有一类错误(比如 Next.js 的服务端/客户端边界违规)只在构建时才暴露,类型检查是看不见的。

为什么卡得这么细?因为修复成本随距离指数上升。类型错误在刚写完的当下修,上下文还在,改一个函数签名就好;攒到评测阶段才发现,十几个任务全建在这个错误签名上,修一处要连带动一片。更实际的一点:由谁修,决定修的质量。 刚写完时修,修的人(或 AI)知道这段代码为什么这么写;隔了几个阶段再修,接手的往往是另一个上下文,只能对着报错猜意图——猜的修复最容易引入新问题。

这也是"反馈回路越短越好"在这套系统里最具体的样子:不是口号,是把一条命令钉在任务的完成条件里。

设计四:函数签名是写给下一个 AI 看的合同

语言层还有一条偏"软"的规则:函数签名必须把话说全——参数类型、返回类型、可能的失败方式,看签名就知道,不用读实现。

对人写的代码,这叫"类型即文档",是老生常谈。但在这套系统里它有个新的理由:读你代码的下一个读者,是另一个上下文全新的 AI。 它没有你们之前的对话,不知道这个函数"约定俗成"返回什么。它能依靠的只有签名和类型。如果签名写着返回 User,实际可能返回 null 表示未找到,它就会放心地 .name 下去——然后在运行时炸。签名说谎,比没签名更危险。

具体要求有两条。一是返回值不许含糊:可能失败、可能为空,就用类型说出来(返回 User | null,或者用带错误分支的结果类型),不许靠调用方"应该知道"。二是参数不许用大而全的对象糊弄:需要什么字段就声明什么,不许传一个什么都有的大对象进来各取所需——那样类型系统帮你检查不了任何东西,因为表面上全都是"合法"的。

机器怎么验这一层

规则写在文档里只是愿望,这一层靠三处机器校验落地:

一是任务门禁:上面说的 typecheck/lint/build,都是内核在状态推进时真跑的命令,不过就停在原地,状态文件里记着失败原因。

二是扫描的实测模式:项目配置了编译命令后,harness scan 会真跑一遍 tsc,拿退出码和诊断信息当证据——哪一行、什么错,进报告。没有配置编译命令时,扫描退回静态推断(数 any、数抑制注释、看结构),并在报告里注明这一维是静态口径。实测和推断是两回事,混着报就是骗人,这一点第七篇(分数为什么会撒谎)会展开。

三是评测的命令项:每个功能的 rubric 里,“能编译"从来都是一条 command 检查,而不是让评判者"看一眼觉得没问题”。

这一层管不了什么

把边界说清,免得对它期待过高。

语言层只能拦"形状错误":类型不对、可能为空、参数传错。它拦不住**“形状全对、意思全错”**的 bug:价格算错了 10%,但类型是 number,一路绿灯;该传用户 A 的 id 传了用户 B 的,类型同样是 string。这些要靠后面的层和阶段去管:状态层管数据从哪来对不对,评测阶段管功能做没做对。

所以五层是一个接力:语言层保证"这段代码形状合法",后面的层再逐级保证"数据是准的、功能是对的、页面是像的"。下一篇讲样式层:为什么颜色和间距这种"看起来无所谓"的东西,要被收进一个只有固定选项的调色板里。