test: prove ESLint to Oxlint rule parity

This commit is contained in:
Turtle
2026-07-29 14:47:50 +08:00
parent 2e6c4e23ab
commit 203767cd37
4 changed files with 91 additions and 4 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-29-oxlint-linter.md
2026-07-29-oxlint-linter.md: 5bf7bd417860b1bd9eb9fd421d871fbc4d5256ea
2026-07-29-oxlint-linter.zh.md: 56bdbdb598f13bf69049dc818fb5129fa6a987e2
2026-07-29-oxlint-linter.md: 6a6b76d3d53ecdd065c9c43c0f60644ba473672c
2026-07-29-oxlint-linter.zh.md: f534d6914ca1ba6bb9292d9f102e329d6adbc2c5
@@ -22,7 +22,7 @@ CI does not restore or save a lint-result cache. `DSH_OXLINT_THREADS` optionally
## Verification
The migrated configuration reports the same clean owned-source baseline after resolving two analyzer differences: one redundant test assertion was removed, while one structural cast required by `tsc` carries a narrow Oxlint suppression. The repository lint command exercises type-aware rules and both JavaScript compatibility plugins. Focused gate-scheduler execution covers the explicit thread-bound path, while typecheck confirms that migration-driven source edits preserve the TypeScript programs.
The migrated configuration reports the same clean owned-source baseline after resolving two analyzer differences: one redundant test assertion was removed, while one structural cast required by `tsc` carries a narrow Oxlint suppression. A committed fingerprint test normalizes severities and rule-name translations, then deep-compares every active rule and option against the exact deleted ESLint configuration blob: source is 88-to-88, examples are 87-to-87, and tests are 83-to-83, with no missing, extra, or changed pairs. The repository lint command exercises type-aware rules and both JavaScript compatibility plugins. Focused gate-scheduler execution covers the explicit thread-bound path, while typecheck confirms that migration-driven source edits preserve the TypeScript programs.
## Alternatives considered
@@ -22,7 +22,7 @@ CI 不恢复或保存 lint 结果缓存。`DSH_OXLINT_THREADS` 可以在门禁
## 验证
解决两处分析器差异后,迁移后的配置报告与迁移前一致的自有源码无问题基线:移除了一项冗余测试断言,而 `tsc` 要求的一处结构性类型转换使用了窄范围的 Oxlint 抑制指令。仓库 lint 命令会运行类型感知规则和两个 JavaScript 兼容插件。针对门禁调度器的聚焦执行覆盖显式线程上限路径,类型检查则确认迁移引发的源码改动没有破坏 TypeScript 程序。
解决两处分析器差异后,迁移后的配置报告与迁移前一致的自有源码无问题基线:移除了一项冗余测试断言,而 `tsc` 要求的一处结构性类型转换使用了窄范围的 Oxlint 抑制指令。一项已提交的指纹测试会对严重级别和规则名映射进行归一化,再以已删除 ESLint 配置的精确 blob 为基准,对每一项启用的规则及其选项进行深度比较:源码为 88 项对 88 项,示例为 87 项对 87 项,测试为 83 项对 83 项,不存在缺失、多余或发生变化的配对。仓库 lint 命令会运行类型感知规则和两个 JavaScript 兼容插件。针对门禁调度器的聚焦执行覆盖显式线程上限路径,类型检查则确认迁移引发的源码改动没有破坏 TypeScript 程序。
## 考虑过的替代方案
+87
View File
@@ -0,0 +1,87 @@
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
type Rules = Record<string, unknown>
interface Profile {
readonly count: number
readonly indexes: readonly number[]
readonly sha256: string
}
// Captured from eslint.config.mjs blob 696b08282885296830189fdafe7051a356806fc2
// after mapping @typescript-eslint/* to typescript/* and the five extension
// rules to their Oxlint core equivalents.
const profiles = {
source: {
count: 88,
indexes: [0, 3, 4],
sha256: 'da1dfd77cb6eb66be93d8d3820f9b9b68b7aa391c24680f8851c0910298f9e3b',
},
example: {
count: 87,
indexes: [0, 1, 3, 4],
sha256: '6a2606053bc1ec1de3b02611de88ea51d201dac13a1f193e4934d33c08b95f08',
},
test: {
count: 83,
indexes: [2, 3, 4],
sha256: '7995e14926a36c40bd65c474637735222a95fb030395681685f03060e50a7b78',
},
} as const satisfies Record<string, Profile>
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isUnknownArray(value: unknown): value is unknown[] {
return Array.isArray(value)
}
function severity(value: unknown): 0 | 1 | 2 {
const level = isUnknownArray(value) ? value[0] : value
if (level === 'off' || level === 0) return 0
if (level === 'warn' || level === 'warning' || level === 1) return 1
if (level === 'error' || level === 2) return 2
throw new Error(`unsupported lint severity: ${JSON.stringify(level)}`)
}
function normalizedRules(rules: Rules): Rules {
return Object.fromEntries(Object.entries(rules)
.filter(([, value]) => severity(value) > 0)
.sort(([left], [right]) => left.localeCompare(right))
.map(([name, value]) => {
const options = isUnknownArray(value) ? value.slice(1) : []
return [name, [severity(value), ...options]]
}))
}
function mergedRules(config: unknown, indexes: readonly number[]): Rules {
if (!isRecord(config) || !Array.isArray(config.overrides)) {
throw new Error('.oxlintrc.json must contain an overrides array')
}
const merged: Rules = {}
for (const index of indexes) {
const override: unknown = config.overrides[index]
if (!isRecord(override) || !isRecord(override.rules)) {
throw new Error(`.oxlintrc.json override ${index} must contain a rules object`)
}
Object.assign(merged, override.rules)
}
return normalizedRules(merged)
}
describe('Oxlint migration rule parity', () => {
const path = fileURLToPath(new URL('../.oxlintrc.json', import.meta.url))
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
it.each(Object.entries(profiles))('matches the ESLint %s profile pairwise', (_name, profile) => {
const rules = mergedRules(parsed, profile.indexes)
const fingerprint = createHash('sha256').update(JSON.stringify(rules)).digest('hex')
expect(Object.keys(rules)).toHaveLength(profile.count)
expect(fingerprint).toBe(profile.sha256)
})
})