@hui/eslint-config (0.3.0)
Installation
@hui:registry=https://git.huili.dev/api/packages/hui/npm/npm install @hui/eslint-config@0.3.0"@hui/eslint-config": "0.3.0"About this package
@hui/eslint-config
echo 与 halcyon 两个 monorepo 共用的 eslint flat config。
对外只有一个东西:工厂函数 defineEslintConfig(options),同步返回一份 flat config 数组。
pnpm add -D @hui/eslint-config eslint typescript
消费者的 devDependencies 里就这三项。eslint 和 typescript 是本包的 peerDependencies,
两条都得由消费者自己装:
eslint不能由本包自带,否则消费者进程里会出现两个 eslint 实例(理由见下面一节)。typescript是typescript-eslint跑起来的前提——它靠 TS 自己的编译器去解析.ts。 这条依赖以前没写出来,能跑通是 pnpm 的auto-install-peers在背后补的;换个包管理器 或者把那个开关关掉,就会炸在「找不到 typescript」上。所以现在明写。
插件(@eslint/js、@stylistic/eslint-plugin、eslint-plugin-react-hooks、globals、
typescript-eslint、eslint-plugin-astro)不在这个名单里:它们全在这个包自己的 dependencies 里,装本包时一起下来,
不需要也不应该再装一遍。
这个包不在公网 npm 上
它发布在自托管的 Gitea 包仓库 https://git.huili.dev/api/packages/hui/npm/,
公网 registry.npmjs.org 上没有这个包。
装它
消费者项目的 .npmrc 里要加一行 scope 映射,@hui 开头的包才知道去哪儿取:
@hui:registry=https://git.huili.dev/api/packages/hui/npm/
这一行不是秘密,应该提交进仓库——它只说明「去哪儿取」,不含任何凭证。少了它,
上面那句 pnpm add -D @hui/eslint-config 会去公网找,然后 404。
hui 组织是公开的,装包不需要令牌。只有发布才要令牌,放在 ~/.npmrc,绝不提交:
//git.huili.dev/api/packages/hui/npm/:_authToken=<token>
令牌在 Gitea 的 Settings → Applications 里生成,要带 write:package 权限。
发它
npm publish
不用带 --registry:目标已经由 package.json 里的 publishConfig.registry 钉死。
这个字段防的不是「少敲一段参数」。npm publish 不带参数时的默认目标是公网
registry.npmjs.org。只要公网上能往 @hui 下发包,在这个目录里手滑敲一次
裸的 npm publish 就不会失败,而会成功:包被发到公网上,任何人都能看到,而且那个版本号
在 npm 上再也不能重用。要防的是「忘了加参数会成功」,不是「忘了加参数会失败」。
用法
前端 app(对应 echo 的 apps/web、apps/admin)
// apps/web/eslint.config.js
import { defineEslintConfig } from '@hui/eslint-config'
export default defineEslintConfig({
react: true,
globals: ['browser', 'node'],
// 这些是项目专有的产物目录,包的默认 ignores 里只有 `**/dist/`
ignores: ['build/', '.react-router/', '.wrangler/', 'worker-configuration.d.ts'],
})
Astro app(对应 halcyon 的 apps/web)
// 仓库根 eslint.config.js
import { defineEslintConfig } from '@hui/eslint-config'
export default [
...defineEslintConfig({
files: ['apps/web/**/*.{ts,astro}'],
astro: true,
globals: ['browser', 'node'],
}),
...defineEslintConfig({
files: ['apps/admin/**/*.{ts,tsx}'],
react: true,
globals: ['browser', 'node'],
}),
]
astro: true 检查模板、frontmatter 和 <script> 中的 TypeScript。只选 **/*.astro
也会检查其中的客户端脚本;消费者不必额外安装解析器。Astro 的解析器和推荐规则只作用于
范围内的 Astro 文件及其虚拟脚本,不会套到普通 .ts / .tsx 或其他 app 上。
模板格式仍由人工维护;这里提供代码规则与 Astro 推荐检查,不是整份文件的排版工具。
不依赖任何运行环境的包(对应 echo 的 packages/monitoring)
// packages/monitoring/eslint.config.js
import { defineEslintConfig } from '@hui/eslint-config'
export default defineEslintConfig()
省略 globals 就是一套环境全局都不注入。这不是图省事:一个既不依赖浏览器也不依赖 node 的包,
用到任何环境全局都说明那段代码不该住在这里(与 tsconfig 里写 types: [] 是同一条判据)。
node 脚本,外加一条本地规则(对应 echo 的 scripts)
// scripts/eslint.config.js
import { defineEslintConfig } from '@hui/eslint-config'
export default defineEslintConfig({
globals: ['node'],
rules: {
// interface / 类型字面量的成员分隔:跨行不加分隔符,单行内用分号且末尾不留
'@stylistic/member-delimiter-style': ['error', {
multiline: { delimiter: 'none' },
singleline: { delimiter: 'semi', requireLast: false },
}],
},
})
选项
| 选项 | 类型 | 默认 | 含义 |
|---|---|---|---|
files |
string[] |
无 | 限定检查范围,用于「一个根配置管多个 app」。原有 JS/TS/React 规则块保留 覆盖 语义;新增 Astro 层使用嵌套 files 数组与插件内部范围求交集,并自动包含客户端虚拟脚本。请带上扩展名,写 'apps/web/**/*.{ts,tsx}' 而不是 'apps/web/**' |
ignores |
string[] |
[] |
追加到默认 ignores 之上,不是替换 |
astro |
boolean |
false |
带上 Astro 推荐检查及 TypeScript 解析支持 |
react |
boolean |
false |
带上 eslint-plugin-react-hooks 的 flat recommended |
globals |
('browser' | 'node')[] |
[] |
注入哪几套环境全局名。省略 = 一个都不注入 |
rules |
object |
{} |
额外规则,合并在共享规则之后 |
默认 ignores 只有 ['**/dist/'] 一条。**/ 前缀不是保险而是必需:flat config 的忽略模式按
「相对配置文件的路径」匹配,光写 dist/ 只挡得住顶层那一个。漏掉的表现不是「多 lint 了几个文件」,
而是 eslint 去 lint 一份 tsc 生成的 .d.ts,然后报「Extra semicolon」——一个不可能修的错误
(那个文件不是手写的,改了下次构建就没了)。
行宽与方法间空行
@stylistic/max-len:代码和注释每行最多 100 字符,不自动忽略 URL、字符串、模板字符串 或正则。超长代码手动拆行;确实不能拆分的内容逐处使用局部禁用注释,说明原因。lihui/lines-between-methods:相邻类成员只要有一个是方法,就至少隔一行空行。 构造方法、get/set、普通方法、箭头函数或普通函数形式的字段都算方法, TypeScript 的类型断言不改变这一判断。两个普通数据字段可以连续排列。 空行可由eslint --fix自动补齐,不减少已有空行,并保留行尾注释和方法说明注释的位置。
export class Store {
active = false
generation = 0
getSnapshot = () => this.active
subscribe = (listener) => listener()
}
// eslint-disable-next-line @stylistic/max-len -- 外部服务要求使用完整 URL,无法拆分。
export const url = 'https://example.com/externally-issued/immutable-link/019b16d4-6a4b-7e01-82f3-61793317d69b'
消费者可通过已有的 rules 选项覆盖这两条规则,不需要额外安装插件。
为什么现在能抽这个包了
echo 的 eslint.shared.js 里留着两条「当时试过、决定不抽」的理由。它们都成立,这个包是照着它们设计的。
一、插件版本会在根与各 app 之间漂开
当时的方案是把装配抽到仓库根的一个文件里。根 node_modules 里解析不到任何 eslint 插件,
所以得先把插件加进根 package.json——于是插件在根和各 app 各有一份、两组版本范围,
而 eslint . 是从 app 目录跑的。两边漂开的表现是某条规则悄悄换了行为,比它消除的重复更糟。
这个包换的是位置,不是手法:插件不再「根和 app 各一份」,而是全部收进包自己的 dependencies。
消费者那边只剩 eslint、typescript 和这个包三项,插件没有第二处版本范围,也就没有可漂的东西。
升级插件变成升级这一个包的一件事,而且升完有快照 diff 兜着(见下面的测试一节)。
eslint 本身是 peerDependency 而不是 dependency,这条不能反过来:由包自带的话,
消费者进程里会出现两个 eslint 实例——插件注册在其中一个上、规则从另一个上查找,症状是
「规则名不认识」或者更糟的「规则静静地不生效」。
二、装配顺序写错是静默的
原文的判据是「漂了有没有人喊」:装配写错时插件没注册、规则名不认识,eslint . 直接起不来,
属于会喊的那一类,所以判定不用收。这条判据没错,但它衡量的前提是「重复留在原地」。
把顺序钉进工厂之后,问题的形状变了:消费者根本拿不到拼错的机会。不是从「会喊」改判成 「不会喊」,而是把那一格从棋盘上拿掉了。
而且「起不来」只覆盖了拼错的一半。另一半是顺序错但语法全对,那种是真的静默:
ts.configs.recommended必须在js.configs.recommended之后。它内部有一段 eslint-recommended,专门把「TS 编译器自己就管得了」的核心规则关掉(no-undef之类)。 放反了这些规则会被重新打开,然后在每个.ts文件上误报一堆——而这错法只表现为 「误报变多」,很容易被当成规则本身太严。- 本包的规则块必须是最后一个。共享规则里有几条是故意推翻上面推荐集的(关掉
no-undef、 把@typescript-eslint/no-unused-vars换成带下划线豁免的版本)。往前挪一格,这几条就被 推荐集盖回去,同样没有任何报错,只表现为「明明配了下划线豁免却还是报未使用变量」。
顺序固定为:全局 ignores → js.configs.recommended → ts.configs.recommended →
(react 为真时)eslint-plugin-react-hooks →(astro 为真时)Astro 配置 → 本包的规则块。
测试
三层,各自防的是不同的东西:
- 规则表快照。对每种选项组合算出完整配置,只留「被打开」的规则和
languageOptions.globals的键名,存成快照。它的价值不在于验证你想到的那几条,而在于你没想到的那些: typescript-eslint 升个 minor 悄悄多塞一条规则、globals换个大版本悄悄改了browser名单, 快照 diff 会当场把它摆到眼前。 - 冒烟测试。快照只证明「规则表长这样」,不证明「规则真的作用在文件上」——
files的 glob 写错、ignores多挡一层,规则表完全正常但一个文件都没被检查。所以每种组合配一个故意违规的 小文件,断言预期的规则 id 真的报出来。 - 打包安装验证(
pnpm test:pack)。前两层跑在本包自己的 node_modules 里,验不到 「别人装了这个包能不能用」。这一层pnpm pack打出 tarball,在临时目录里搭一个只装 两条 peer(eslint、typescript)和这个 tarball 的最小项目,真跑一次npx eslint。dependencies少写一个插件、把 eslint 放错位置,只有在这里才会露头。
包自己的 eslint.config.js 也用自己的工厂函数——pnpm lint 是最直接的一层自测。
这一版刻意不做的事
- import 排序(
eslint-plugin-import系列)。echo 的apps/api原先有一套,删掉的直接原因是 它的 peerDependencies 只到 eslint^9,一个子项目能把整个仓库钉在旧版本上。要做也得换eslint-plugin-import-x,那是另一件事。 - jsx-a11y。没有任何一个现有子项目在用,加进来等于凭空给所有消费者立一套没人要求过的规矩。
- 类型检查层(
ts.configs.recommendedTypeChecked)。它要求parserOptions.projectService和tsconfigRootDir,那是消费者的 tsconfig 布局,工厂替它猜只会猜错。echo 里只有apps/api用,而apps/api至今是空壳。等它长出代码、需求清楚了再加一个选项。
版本口径
-
0.3.0 新增默认 100 字符行宽与方法间空行约束。已有
^0.2.0消费者不会自动升级到 0.3.0;升级依赖时再处理项目现有代码的检查结果。本版不改变依赖或 peer 范围。 -
eslint-plugin-astro固定为1.7.0,保持 halcyon 已使用的插件版本,并兼容本包现有 Node.js 下限与 ESLint 10。0.2.0 不同时升级 Astro 插件大版本,也不新增 JSX 无障碍规则。 -
engines.node是>=22.13.0,不是>=24:halcyon 允许 22.13,而 echo 的.npmrc开了engine-strict=true,写死 24 会让 halcyon 直接装不上。 -
globals钉在^16.5.0而不是 17.x:echo 现在用的就是 16.5。迁移那一步要求「行为不变」, 换大版本会让browser/node的全局名单悄悄变。等迁移落地了再单独升,那时快照 diff 会 把变化摆出来。 -
peerDependencies.typescript是>=5.9.0 <6.1.0。下界这么定是因为两个消费者都得覆盖到: echo 用^5.9.3、halcyon 用6.0.3。上界<6.1.0是抄自typescript-eslint当前的 peer(>=4.8.4 <6.1.0)——确实是重复的一份,但不抄的代价更大。不写上界,等于告诉消费者「typescript 7 也行」。而 typescript 的最新版已经是 7.x:照着 一份没有上界的声明装,装到的就是 7,然后 eslint 一个文件都解析不了。而这不是一个装不上的 错误——pnpm 默认不开
strict-peer-dependencies,违反 peer 只换来一行unmet peer typescript@">=4.8.4 <6.1.0": found 7.0.2的警告,安装照样成功。少写上界, 交付给消费者的是一个装得上、跑不了的状态,唯一的信号是那行没人当回事的警告。所以这是一处刻意接受的重复:
typescript-eslint哪天放宽上界,这里得跟着放宽——两处 是绑在一起的,改一处就得看另一处。test/peer-range.test.js盯着这件事:它在运行时读出typescript-eslint实际声明的 peer 范围,和这里的上界比对,漂了就当场失败。
Dependencies
Dependencies
| ID | Version |
|---|---|
| @eslint/js | ^10.0.1 |
| @stylistic/eslint-plugin | ^5.10.0 |
| eslint-plugin-astro | 1.7.0 |
| eslint-plugin-react-hooks | ^7.1.1 |
| globals | ^16.5.0 |
| typescript-eslint | ^8.66.0 |
Development Dependencies
| ID | Version |
|---|---|
| eslint | ^10.8.0 |
| typescript | ^5.9.3 |
| vitest | ^3.2.4 |
Peer Dependencies
| ID | Version |
|---|---|
| eslint | ^10 |
| typescript | >=5.9.0 <6.1.0 |