它写的代码能跑,但和我们项目的写法完全不像

wescode · 2026-10-10 · 隐式约束 / 风格 / 痛点

利益声明:本文作者参与了 wescode 的开发。文中涉及 wescode 的技术描述基于内部测试数据;涉及其他工具的描述基于各家公开文档和社区反馈。

你让 AI 写一个新的接口。它写了。能跑。类型也对。

但你一看就知道不对劲:

编译器不管这些。测试也不管。 它们只管"能不能跑"和"结果对不对"。

至于"和项目风格一致吗"——那是你自己的事。


这些"不像"为什么重要

"风格不一致"听起来像是强迫症。但在一个多人维护的项目里,它是实打实的成本:

1. Code Review 的时间变长

你的同事看到 AI 生成的代码,第一反应是"这不像我们项目的风格"——然后他要逐行检查哪些是功能改动、哪些是风格差异。风格差异混在功能改动里,review 效率直线下降。

2. 后来者看不懂

三个月后有人改这段代码,发现它和周围的代码风格不一样——"这段是谁写的?为什么不一样?是有特殊原因还是就是随手写的?"他不敢改,因为怕有他不知道的原因。

3. 有些"风格"其实是逻辑

这是最容易被低估的一点。看几个例子:

例子 A:错误处理惯例 = 中间件路由

"错误都用 AppError 包装"不只是风格——上游有一个中间件专门 catch AppError 做分类处理:

// error-handler.ts — 全局中间件
app.use((err, req, res, next) => {
  if (err instanceof AppError) {
    // AppError 有 code 和 httpStatus
    return res.status(err.httpStatus).json({
      code: err.code,
      message: err.message,
    })
  }
  // 非 AppError → 500
  return res.status(500).json({ code: 'INTERNAL_ERROR' })
})

你换成原生 Error,中间件 instanceof AppError 判断失败,直接走 500 分支——用户看到一个"内部错误",而实际上只是参数校验不过。

例子 B:分层约定 = 审计追踪

"数据库操作走 Repository 层"不只是分层洁癖——审计日志挂在 Repository 的方法上:

// user-repository.ts
class UserRepository {
  async update(id: string, data: Partial<User>): Promise<User> {
    const result = await this.db.users.update(id, data)
    await this.auditLog.record('user.update', { id, changes: data })
    return result
  }
}

直接在 Service 里写 SQL,这次操作就没有审计记录。合规审计的时候一查——"这次修改没有记录",然后你花一天排查是什么时候丢的。

例子 C:命名惯例 = 序列化契约

// 项目里所有 API 响应字段用 camelCase
interface UserResponse {
  userId: string
  firstName: string
  createdAt: string
}

// AI 写的一个新接口用了 snake_case
interface OrderResponse {
  order_id: string    // 前端严格校验会拒绝这个响应
  total_amount: number
}

前端有一个全局的响应校验中间件,对着 camelCase 的 schema 做严格匹配。snake_case 的字段直接被拒——接口返回了数据,前端却显示空。

例子 D:import 顺序 = 依赖可见性

// 项目约定:第三方包 → 内部包 → 相对路径
import express from 'express'           // 第三方
import { AppError } from '@app/errors'   // 内部包
import { validate } from './utils'       // 相对路径

// AI 写的:随机顺序
import { validate } from './utils'
import express from 'express'
import { AppError } from '@app/errors'

看起来只是排列顺序?有些团队用自定义的 ESLint 规则强制 import 顺序,CI 直接不过。更隐蔽的情况是——某些打包工具对 import 顺序敏感(CSS-in-JS 的样式覆盖就和 import 顺序有关),改了顺序可能导致样式不对。

这些不是风格问题,是隐性的功能约束。但它们长得和风格问题一模一样——只有了解项目历史的人才知道区别。


AI 为什么写不出"你们的风格"

两个原因:

1. AI 没读过你所有的代码

向量检索找到和当前任务最相关的几段代码给 AI 看。AI 就从这几段里学到"项目的风格"。但如果这几段里恰好有一段是别人不小心写的不规范代码(每个项目都有),AI 就学到了错的样板。

2. 隐性规矩没有标准格式

"错误用 AppError"、"数据库走 Repository"——这些约定存在于团队的口头传承和 PR review 的反馈里。它们没有被写进任何配置文件、没有 lint 规则检查、没有编译器会报错。

你可以写进 .cursorrules / CLAUDE.md——但你能写全吗?一个五年的项目里这类规矩通常有上百条。你能写出来的可能不到二十条。


ESLint 为什么管不了这件事

你可能想:这不就是 ESLint 干的事吗?

不一样。ESLint 和 CSE 解决的是两个层面的问题。

维度ESLint / Prettierwescode CSE
规则来源你手动写 .eslintrc,告诉它规则是什么零配置——从项目代码里自动统计发现规则
检查范围语法层面:分号、引号、缩进、未使用变量业务层面:错误处理惯例、模块边界、API 契约一致性
能表达什么"不允许使用 var""字符串用单引号""所有数据库操作走 Repository""catch 块统一用 AppError"
不能表达什么"所有 *Repository 只在 *Service 里被引用"这种跨文件的结构规则不是替代 ESLint——语法层面的事 ESLint 已经做得很好
维护成本配置文件要写、要维护、要跟着项目演进更新不需要配置文件,统计结果随代码变化自动更新

ESLint 是"你告诉它规则是什么,它按规则检查"。CSE 是"它从代码里自己发现规则是什么"。

举个具体例子:

// ESLint 能做到的:
// .eslintrc: "no-var": "error" → 禁止使用 var
// .eslintrc: "quotes": ["error", "single"] → 字符串用单引号

// ESLint 做不到的:
// "所有 *Repository 类只在 *Service 类里被引用"
// "catch 块里必须用 AppError 而不是原生 Error"
// "导出函数的参数个数不能比之前多"

第一组是语法规则——ESLint 的表达能力完全覆盖。第二组是跨文件的结构和业务规则——超出了 ESLint 的表达能力。CSE 从 import 图和 AST 统计里发现第二组规则,和 ESLint 互补而不是替代。


CSE 怎么发现"项目的规矩"

CSE 约束满足引擎

CSE(Constraint Satisfaction Engine,约束满足引擎)的工作原理不是猜测——是统计。它扫描项目代码,从实际的编码模式中推导出惯例。

CSE 自动推导编码惯例

13 个 Checker 覆盖什么

CSE 有 13 个可执行的 Checker,按严格程度分为 5 类:

类别严格程度代表性 Checker检查什么
安全最严格(违反即阻塞)plaintext_secret / sql_concat字面量赋给密码变量 / SQL 字符串拼接变量
数据完整性高—数据操作的完整性约束
架构中import_cycle / signature_stableimport 循环 / 导出函数签名变化
代码质量中err_discard / no_panic / nplus1 / goroutine_captureerror 被 _ 丢弃 / 非 main 代码 panic / 循环内查询 / 闭包捕获循环变量
一致性最宽松(可有意例外)exported_doc导出符号缺注释

Checker 不是人工写死的规则列表——它们是可机械判定的谓词。给一个代码 diff,Checker 输出 PASS 或 FAIL,零 LLM 推理,零歧义。

4 条推断路径——CSE 怎么"学会"你的项目规矩

CSE 发现规矩的方式有 4 条路径,从启动到运行持续积累:

路径 1:种子约束(编译期固定,冷启动即可用)

打开项目就有的基础检查——不需要索引,不需要等 CKG 建完。比如 plaintext_secret(密码变量不能赋字面量)、sql_concat(SQL 里不能拼接变量)。这是地板,保证 CSE 开箱就能工作。

具体例子:

// plaintext_secret 检测到这个:
const dbPassword = "admin123"  // 问题:字面量赋给密码变量

// sql_concat 检测到这个:
const query = `SELECT * FROM users WHERE id = ${userId}`  // 问题:SQL 拼接

路径 2:项目统计推断(自动,核心路径)

扫描项目代码的 AST 和 import 图,统计出模式。这是 CSE 最核心的能力——不需要你写任何配置:

推断方式看什么发现什么举例
统计模式遍历所有 catch 块95% 用了 AppError → 这是惯例AI 用原生 Error → 标记违反惯例
结构模式分析 import 图的边所有 *Repository 只在 *Service 里被引用 → 模块边界AI 在 Controller 里直接引用 Repository → 标记违反模块边界
签名模式比对导出函数签名函数 arity(参数个数和类型)稳定不变 → API 契约AI 修改后参数个数变了 → 标记签名变化
依赖模式Tarjan 强连通分量import 图中存在循环 → 架构问题AI 新增的 import 形成了循环依赖 → 阻塞

统计推断的关键细节:它不需要 100% 一致才生成规则。95% 的 catch 块用 AppError 就够了——那 5% 可能是有意的例外。CSE 标注的是"偏离了多数模式",不是"违反了绝对规则"。

这些推断随 CKG 索引进度分级激活——索引建到 30% 就开始部分推断,不需要等全部建完。

路径 3:L2.5 行为回归学习(自动)

验证系统(L2.5)检测到行为回归时,如果回归原因是"导出函数签名变了",自动注册一条 signature_stable 约束。下次有人改同一个函数时,CSE 会提前检查签名是否变化。

路径 4:用户教学(你的操作驱动)

你拒绝或修改了 AI 的编辑 → CSE 分析 diff,看能不能反推出规矩。比如你把 AI 写的 throw new Error('not found') 改成了 throw new AppError('NOT_FOUND', 404)——CSE 学到"这个项目的 catch 分支用 AppError"。

4 条路径的关系:路径 1 是地板(冷启动),路径 2 是主力(覆盖 80%),路径 3 和 4 是飞轮加速器(越用越准)。


一个完整的 before/after 流程

看一个 CSE 从检测到修正的完整过程:

场景: 你让 AI 写一个用户删除接口。

AI 生成的代码(before):

// user-service.ts — AI 写的
async function deleteUser(id: string) {
  // 直接在 Service 里写 SQL(违反分层约定)
  await db.query('DELETE FROM users WHERE id = $1', [id])
  
  // 用原生 Error(违反错误处理惯例)
  if (!id) throw new Error('id is required')
  
  // 没有返回值类型注解(违反导出函数规范)
  return { success: true }
}

CSE 标注了三个问题:

⚠️ [模块边界] db.query() 直接调用——项目中 95% 的数据库操作通过 *Repository 类(内部测试数据)
⚠️ [错误处理] throw new Error()——项目中 93% 的 throw 使用 AppError(内部测试数据)
⚠️ [签名规范] 导出函数缺少返回值类型注解——项目中 88% 的导出函数有明确返回类型(内部测试数据)

修正后的代码(after):

// user-service.ts — 修正后
async function deleteUser(id: string): Promise<DeleteResult> {
  if (!id) throw new AppError('INVALID_INPUT', 400, 'id is required')
  
  return this.userRepository.delete(id)
  // Repository.delete 内部已有审计日志
}

三行问题全部解决。而这三个问题里有两个——分层和错误处理——不只是风格,是功能(审计日志和中间件路由)。


CSE 和 ESLint 的详细能力对比

代码理解深度对比

能力ESLintCSE说明
语法风格(分号、引号、缩进)✅不覆盖CSE 不替代 ESLint 的语法检查
未使用变量 / 未使用 import✅不覆盖同上
import 顺序✅(需配置插件)不覆盖ESLint 已经做得够好
跨文件模块边界无法表达支持(import 图统计)*Repository 只在 *Service 里引用
错误处理惯例⚠️ 需要自定义规则支持(自动统计发现)catch 块用 AppError 还是原生 Error
导出函数签名稳定性不支持支持(signature_stable)参数个数或类型变了自动标注
SQL 注入风险⚠️ 需要安全插件支持(sql_concat 内置)字符串拼接 SQL 变量
密码硬编码⚠️ 需要安全插件支持(plaintext_secret 内置)密码变量赋字面量
循环依赖⚠️ 需要插件支持(import_cycle 内置)Tarjan 强连通分量检测
零配置不支持(必须写 .eslintrc)支持(从代码统计推导)CSE 不需要配置文件

最好的组合不是二选一,而是两个都用: ESLint 管语法层面的硬规则(分号、引号、缩进),CSE 管业务层面的统计惯例(模块边界、错误处理、签名稳定性)。

CSE 的能力边界

CSE 基于统计,有几件事它做不了:

1. 有业务原因但代码里看不出来的约束

"数据库走 Repository 因为审计日志挂在那"——CSE 能发现"数据库操作都走 Repository"这个模式,但它不知道原因是审计日志。如果某天有人有合理理由直接写 SQL(比如批量迁移脚本),CSE 会标注但不知道这是合理的例外。

2. 有意的例外

项目里 95% 的 catch 块用 AppError,你在一个工具函数里有意用了原生 Error——CSE 会标出来。你确认"这是有意的" → CSE 不再对这处提醒。Confidence 衰减机制:一条约束如果长时间没有被触发验证,其置信度会自然衰减,最终被淘汰。

3. 全新的模式

如果项目刚引入了一个新框架,还没有足够的代码形成统计模式——CSE 在样本不够时不会强行推断。它会等积累到足够的样本(通常 5+ 个同类实例)才开始标注。

CSE 不声称自己 100% 正确。 它声称的是:统计覆盖 80% 的编码惯例,你只需要处理 20% 的例外和边界情况。比起手写 100 条 .cursorrules 然后祈祷没漏掉——这个折中更现实。


两种对策的组合

对策一:手写规则文件(覆盖有业务原因的约束)

.cursorrules(Cursor)/ CLAUDE.md(Claude Code)/ .github/copilot-instructions.md(Copilot)。

把你能想到的规矩写进去。AI 每次新对话都会读这个文件。

有用,但有限。 你写不全、规矩在变文件不跟着变、简化版的规矩缺少上下文——"用 AppError"写了,"因为中间件 catch 它做分类"没写,AI 遇到新场景就可能自作主张绕过去。

对策二:从代码里自动推导(覆盖统计可发现的惯例)

wescode 的 CSE 走这条路。打开项目就开始扫描,不需要你写任何规则文件。AI 生成的代码如果违反了统计出来的模式,自动标注——"这里用了原生 Error,但项目里 95% 的地方用 AppError"。

不完美——它基于统计,有误报。它也只能检测代码层面可统计的模式,对"数据库走 Repository 因为审计日志挂在那"这种业务原因的约束,推导不出背后的原因(但能推导出模式本身)。

最好的组合:CSE 自动推导覆盖 80% 的编码惯例(你不用管),规则文件覆盖 20% 的业务约束(你只需要维护最关键的那几条)。维护量从"想全 100 条约定"降到"写清楚 20 条有业务原因的规矩"。


你能做什么

  1. 不管用什么工具,至少写一个最小规则文件——把最容易出错的 5-10 条约定写进去。不求全,但求覆盖"违反了会出 bug"的那几条
  2. PR Review 时关注风格差异——如果 AI 的代码风格和周围不一致,不要只改风格——想想"这是纯风格还是有功能原因"
  3. 新人入职时多说两句"为什么"——"错误用 AppError"不够,要说"错误用 AppError,因为 middleware 会 catch 它做分类"。有"为什么"的约定更不容易被忽略——不管是被人还是被 AI 忽略

如果你不想手动维护一份 100 条的规则文件——wescode 的 CSE 帮你自动推导大部分。你只需要维护那些有业务原因的少数几条。