Skill 管不住代码风格时:把项目约定写成 ESLint 护栏

Skill 很适合写项目约定:目录怎么分、组件怎么拆、哪些写法要避开、改代码前要看哪些文件。问题是,Skill 只是提示,它不能真的拦住 Agent 下一次继续写偏。

这次整理的是另一层护栏:把一部分高频代码风格和项目约定写成 ESLint 规则,少数跨文件资源问题用脚本检查。Agent 写偏以后,编辑器能直接打波浪线,pnpm lint 或 CI 也能把问题暴露出来。对人也一样,review 不需要反复解释同一句口径。

这篇先做合集,因为很多规则本身很小,不值得单独写一篇文章。Composable 和私有模块边界那组规则先不放进来,它们牵涉目录 ownership、父子模块关系和 composable 分层,更适合单独展开。

规则不是为了统一审美

这类规则不等于 Prettier,也不只是「我喜欢这样写」。它们适合挡三类问题:

  • 写法会破坏工具链,比如动态 i18n key 让扫描、补全、缺失检查都失效。
  • 写法会绕过构建前反馈,比如静态资源路径写错,但 TS 因为 *.svg 模块声明继续放行。
  • 写法会让后续维护成本变高,比如把固定尺寸藏进 JS style object,或者把一次性映射表命名成看似共享的常量。

这些规则第一版大多应该用 warn。先让危险结构变得可见,比一上来让 CI 拦死所有历史代码更稳。真正能明确导致运行时错误的规则,比如 import type 被当运行时值使用,可以更早升成 error

no-dynamic-i18n-t-key:i18n key 要静态

i18n 里最容易被 Agent 写偏的是 key。动态 key 看起来灵活:

t(`profile_${section.value}_title`)

这会让 IDE 插件、静态扫描和翻译平台都看不见真实 key。缺失文案、拼错 key、未翻译内容都会被推迟到运行时才暴露。更稳的写法是显式列出分支,让每个 key 都紧贴 t()

const title = computed(() => {
  if (section.value === 'birthday') return t('profile_edit_birthday_title')
  if (section.value === 'height') return t('profile_edit_height_title')
  return t('profile_edit_title')
})

规则 no-dynamic-i18n-t-key 只管翻译函数第一个参数的形态:它要能被静态工具直接读到。需要根据状态切换文案时,宁可显式列出分支,也不要把 key 拼在模板字符串里。

动态 i18n key 诊断示例正在加载代码工作区...

no-i18n-t-fallback:不要在调用点藏兜底

fallback 能让缺失 key 短期不坏,但文案来源会变成「语言包 + 调用点兜底」两套体系。后续整理多语言时,没人知道页面到底应该信哪个来源。

规则 no-i18n-t-fallback 提醒调用点不要给翻译函数传 fallback。缺失 key 应该进入 i18n 流程,而不是被某个组件内部静默补掉。

i18n fallback 诊断示例正在加载代码工作区...

no-chinese-user-text-literal:裸中文文案先进入清单

裸中文文案要单独看。它不一定每次都是错,有些功能早期确实会先按设计稿保留中文,再等语言平台补齐。但这种情况需要进入文案清单,而不是散在组件里等人肉搜索。

规则 no-chinese-user-text-literal 扫描 JS / TS 运行时字符串、模板字符串静态片段、Vue template 文本和静态属性。它不扫注释,因为中文注释服务的是维护者,不是用户可见文案。

这条规则不建议全仓默认开启。它更像迁移工具:对某个目录临时打开,收集候选项,确认 key,再替换。

裸中文用户文案诊断示例正在加载代码工作区...

no-dom-query-in-component:组件显式拿 DOM

组件里直接 querySelector()getElementById() 通常不是第一选择。它依赖 class、data attribute 或全局 id,一旦拆组件、重命名样式、Teleport 弹窗,或者同一组件同时渲染多份,就容易拿错节点。

规则 no-dom-query-in-component 提醒组件和页面优先把 DOM owner 留在模板结构里:单节点用 template ref,列表节点用 function ref,子组件内部节点通过 emit 或暴露方法交给父层。这么写会啰嗦一点,但依赖关系更清楚。

组件 DOM query 诊断示例正在加载代码工作区...

no-static-px-inline-style:固定尺寸回到样式层

固定 px style object 是另一类相似问题。buttonStyle = { width: '44px' } 看起来只是写法差异,实际会把静态视觉约束藏进脚本层。

规则 no-static-px-inline-style 提醒固定尺寸回到 class、BEM、scoped style 或 Tailwind @apply。脚本里的 :style 更适合放真正动态的值,比如运行时计算出来的 CSS 变量。

固定 px 内联样式诊断示例正在加载代码工作区...

这两条规则都要允许精确放行。第三方 SDK 容器、外部宿主 DOM 或历史兼容层,可能确实只能查全局节点;运行时变量生成的尺寸,也可能确实只能通过 CSS 变量传下去。规则要把这些例外写进 options,不要把例外写死在源码里。

no-missing-static-asset-import:资源路径要真实存在

静态资源 import 也很容易被 Agent 写错。比如 import icon from './assets/empty.svg',只要项目里有 declare module '*.svg',TypeScript 只知道「这种后缀可以被 import」,不会顺手确认这个文件真的存在。路径拼错、文件改名、alias 指错目录,常常要等 dev server、Vite 构建或页面运行时才露出来。

规则 no-missing-static-asset-import 做的是很朴素的事:遇到图片、音频、视频、JSON 这类静态资源 import,就按当前文件路径或配置的 alias 去文件系统里查一次。它适合设成 error,因为资源文件不存在属于确定会坏的问题。

静态资源路径诊断示例正在加载代码工作区...

check-figma-svg-frame:Figma 节点不要拿内部 glyph

另一个资源问题来自 Figma 还原。很多图标在 Figma 里是「外层可点击 frame + 内部 glyph」。如果导出时拿了内部 glyph,再在 Vue 里给 <img> 补背景、圆角、padding 和阴影,视觉可能凑得像,但交互热区、缩放、对齐和后续替换都会变得脆弱。

这个检查更适合做成脚本,因为它需要读 SVG 文件、看 viewBox / preserveAspectRatio,还要结合 Vue 模板和样式判断。源码包里把它做成 check-figma-svg-frame.mjs:扫描 Vue 文件里的 SVG import 和 <img>,发现疑似内部 glyph 又被 CSS 补外壳时直接报出来。它的目标很窄:提醒还原设计稿时尽量回到原始 Figma frame,少用 CSS 自己重画一层。

Figma SVG frame 诊断示例正在加载代码工作区...

no-redundant-watch-source-compare:watch 里不要堆无意义防御

Agent 还有一个常见习惯:写 watch(source, (next, previous) => { ... }) 时,先补一句 if (next === previous) return。看起来像防御式编程,实际多数情况下只是噪音。

Vue 的 watch 默认就是 source 变化后才触发。除非项目明确打开了 deep、监听的是复杂对象、或者 source getter 每次返回新引用,否则 next === previous 这类比较通常没有实际保护作用。它还会把真正该看的业务条件往后挤,比如「当前 tab 是否还有效」「请求是否正在进行」「cursor 是否还有下一页」。

规则 no-redundant-watch-source-compare 会保留真实业务比较,只提示这种和 watch 触发机制重复的 next/previous 早退。它的价值在于提醒 Agent 把注意力放回真实状态机。

watch 无意义比较诊断示例正在加载代码工作区...

no-type-import-used-as-value:type import 不能当运行时值

no-type-import-used-as-value 最硬。import type 引入的符号只存在于类型空间,如果后面拿它访问枚举值或静态成员,就已经是运行时错误风险。TypeScript 编译通常能发现,但 ESLint 在编辑器里更早打标,Agent 也更容易立刻修正。

type import 运行时误用诊断示例正在加载代码工作区...

prefer-keyed-object-map:离散状态用映射表

prefer-keyed-object-map 处理离散 key 的多分支判断。连续三元或 if 链都在比较同一个状态时,对象映射通常更容易扫读:

const label =
  {
    [Status.Ready]: 'Ready',
    [Status.Error]: 'Error',
  }[status] ?? 'Unknown'
离散状态映射诊断示例正在加载代码工作区...

prefer-inline-single-use-map:单次映射别伪装复用

prefer-inline-single-use-map 处理反方向的问题:一张对象表只被索引读取一次,却被命名成共享常量。这个名字会暗示它有复用语义,读者还要跳来跳去确认只有一个使用点。

单次映射表诊断示例正在加载代码工作区...

prefer-inline-trivial-computed:简单表达不用包 computed

prefer-inline-trivial-computed 提醒不要用 computed() 只包一层静态 i18n 调用或简单模板 class map。这样的 computed 看起来像会随状态变化,实际只是把一段普通表达式藏远了。

薄 computed 诊断示例正在加载代码工作区...

prefer-to-refs-props:props 读取形态保持一致

prefer-to-refs-props 用来统一组件脚本里的 props 访问方式。项目约定先 toRefs(props) 时,就不要在同一类代码里混用 props.xxxtoRef(props, key) 和 Ref 参数。

props 读取形态诊断示例正在加载代码工作区...

no-redundant-indexed-record-satisfies:立即索引的 Record 不再重复标注

no-redundant-indexed-record-satisfies 处理完整 Record 映射表被立即索引的场景。对象字面量已经由 key 集合约束,马上索引读取时,再套一层 satisfies Record<...> 往往只是在增加类型噪音。

立即索引 Record 诊断示例正在加载代码工作区...

这些规则都不适合第一版写 autofix。映射表里可能有注释、复杂类型、函数副作用或团队特意保留的命名。Lint 负责提示结构异味,最终改法应该留给开发者和 Agent 按上下文判断。

源码包可以直接迁移

下面这个源码包包含规则实现、脚本检查、RuleTester 测试、flat config 示例和 Agent 接入 prompt。读者复制后应该按自己的项目命名空间、目录结构、i18n 函数、资源 alias 和脚本命令调整。

请把 Agent Code Style ESLint Guardrails 接入当前项目。
工具包根路径:https://shengsheng.fun/files/agent-code-style-eslint-guardrails/kits/agent-code-style-eslint-guardrails/
先读 README.md、MANIFEST.json、FILES.json、CHANGELOG.md、AGENT_PROMPT.md,再按 FILES.json 读取源码;迁移 copy/eslint/rules/、copy/scripts/ 和 copy/tests/,按项目调整 i18n 函数、asset aliases、ignoredPathPatterns,并先以 warn 接入风格规则。
项目约定型 ESLint 护栏源码正在加载代码工作区...

迁移时最重要的是配置,而不是复制文件。i18n 函数名、资源 alias、忽略路径和脚本扫描目录都必须贴着目标项目改。否则规则看起来接入了,实际要么漏报,要么把正常代码扫成噪音。

总结

  • Skill 适合描述项目约定,ESLint 适合把偏离约定的代码变成编辑器和 CI 都能看到的信号。
  • 资源路径、Figma 节点这类跨文件问题可以用脚本检查补上,不必强行塞进 ESLint。
  • 项目约定型规则先用 warn 更稳,等误报和例外都梳理清楚,再把运行时风险高的规则升成 error
  • 小规则可以合成一个源码包,但主题边界要清楚;composable / 私有模块边界这种需要完整背景的规则,单独写文章更合适。
  • 规则源码必须配置化。i18n 函数、资源 alias、忽略路径和历史例外都属于项目契约,不能藏在公开源码包的默认值里。