fix: preserve lint migration contracts

This commit is contained in:
Tianyi Cui
2026-07-29 22:38:20 +08:00
parent 5fb4bd66d7
commit e91ef39f5a
22 changed files with 401 additions and 214 deletions

View File

@@ -1,6 +1,7 @@
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { flattenDiagnosticMessageText, parseConfigFileTextToJson } from 'typescript'
import { describe, expect, it } from 'vitest'
type Rules = Record<string, unknown>
@@ -12,22 +13,22 @@ interface Profile {
}
// Captured from eslint.config.mjs blob 696b08282885296830189fdafe7051a356806fc2
// after mapping @typescript-eslint/* to typescript/* and the five extension
// after mapping @typescript-eslint/* to typescript/* and the four extension
// rules to their Oxlint core equivalents.
const profiles = {
source: {
count: 88,
indexes: [0, 3, 4],
indexes: [0, 1, 4, 5],
sha256: 'da1dfd77cb6eb66be93d8d3820f9b9b68b7aa391c24680f8851c0910298f9e3b',
},
example: {
count: 87,
indexes: [0, 1, 3, 4],
indexes: [0, 1, 2, 4, 5],
sha256: '6a2606053bc1ec1de3b02611de88ea51d201dac13a1f193e4934d33c08b95f08',
},
test: {
count: 83,
indexes: [2, 3, 4],
indexes: [0, 3, 4, 5],
sha256: '7995e14926a36c40bd65c474637735222a95fb030395681685f03060e50a7b78',
},
} as const satisfies Record<string, Profile>
@@ -75,7 +76,11 @@ function mergedRules(config: unknown, indexes: readonly number[]): Rules {
describe('Oxlint migration rule parity', () => {
const path = fileURLToPath(new URL('../.oxlintrc.json', import.meta.url))
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
const result = parseConfigFileTextToJson(path, readFileSync(path, 'utf8'))
if (result.error !== undefined) {
throw new Error(flattenDiagnosticMessageText(result.error.messageText, '\n'))
}
const parsed: unknown = result.config
it.each(Object.entries(profiles))('matches the ESLint %s profile pairwise', (_name, profile) => {
const rules = mergedRules(parsed, profile.indexes)

View File

@@ -0,0 +1,119 @@
import { spawnSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const repositoryRoot = fileURLToPath(new URL('..', import.meta.url))
const eslintCli = fileURLToPath(new URL('../node_modules/eslint/bin/eslint.js', import.meta.url))
const oxlintCli = fileURLToPath(new URL('../node_modules/oxlint/bin/oxlint', import.meta.url))
function runStagedFormatter(paths: readonly string[]) {
return spawnSync(process.execPath, [eslintCli, '--config', 'eslint.format.config.mjs', '--fix', '--no-warn-ignored', ...paths], {
cwd: repositoryRoot,
encoding: 'utf8',
env: { ...process.env, NO_COLOR: '1' },
})
}
function runOxlint(args: readonly string[]) {
return spawnSync(process.execPath, [oxlintCli, ...args], {
cwd: repositoryRoot,
encoding: 'utf8',
env: { ...process.env, NO_COLOR: '1' },
})
}
function normalizedOutput(result: ReturnType<typeof runOxlint>): string {
return `${result.stdout}${result.stderr}`.replaceAll('\\', '/')
}
async function writeContractConfig(suffix: string): Promise<string> {
const path = join(repositoryRoot, `.oxlintrc.contract-${suffix}.json`)
await writeFile(path, JSON.stringify({ extends: ['./.oxlintrc.json'], ignorePatterns: [] }))
return path
}
describe('Oxlint executable contract', () => {
it('runs type-aware rules for every owned TypeScript file class', async () => {
const suffix = randomUUID()
const configPath = await writeContractConfig(suffix)
const probes = [
['host package source', 'packages/fs/fs-policy/src', 'host-source.ts'],
['host package test', 'packages/fs/fs-policy/tests', 'host-test.spec.ts'],
['client package source', 'packages/client/ui-primitives/src', 'client-source.ts'],
['client package test', 'packages/client/ui-trajectory/tests', 'client-test.spec.ts'],
['client aggregate script', 'scripts', 'client-bundle-purity.spec.ts'],
['example', 'examples', 'example.ts'],
['website', 'website', 'website.ts'],
] as const
const directories: string[] = []
const source = `function probePromise(): Promise<void> {
return Promise.resolve()
}
probePromise()
`
try {
const paths: Array<readonly [label: string, path: string]> = []
for (const [label, parent, filename] of probes) {
const directory = join(repositoryRoot, parent, `.oxlint-contract-${suffix}`)
directories.push(directory)
await mkdir(directory, { recursive: true })
const path = join(directory, filename)
await writeFile(path, source)
paths.push([label, relative(repositoryRoot, path)])
}
const result = runOxlint([
'--config',
relative(repositoryRoot, configPath),
'--format',
'unix',
...paths.map(([, path]) => path),
])
const output = normalizedOutput(result)
expect(result.error).toBeUndefined()
expect(result.status, output).toBe(1)
for (const [label, path] of paths) {
expect(output, label).toContain(`${path.replaceAll('\\', '/')}:5:1: Promises must be awaited`)
}
expect(output.match(/typescript\(no-floating-promises\)/g)).toHaveLength(probes.length)
} finally {
await Promise.all([
...directories.map(directory => rm(directory, { recursive: true, force: true })),
rm(configPath, { force: true }),
])
}
}, 20_000)
it('applies staged stylistic fixes before Oxlint validation', async () => {
const suffix = randomUUID()
const configPath = await writeContractConfig(suffix)
const directory = join(repositoryRoot, 'scripts', `.oxlint-contract-${suffix}`)
const path = join(directory, 'fix.ts')
try {
await mkdir(directory, { recursive: true })
await writeFile(path, 'const value={answer:1}; \nconsole.log(value)\n')
const relativePath = relative(repositoryRoot, path)
const formatResult = runStagedFormatter([relativePath])
const lintResult = runOxlint(['--config', relative(repositoryRoot, configPath), '--fix', relativePath])
expect(formatResult.error).toBeUndefined()
expect(formatResult.status, normalizedOutput(formatResult)).toBe(0)
expect(lintResult.error).toBeUndefined()
expect(lintResult.status, normalizedOutput(lintResult)).toBe(0)
await expect(readFile(path, 'utf8')).resolves.toBe('const value={ answer:1 }\nconsole.log(value)\n')
} finally {
await Promise.all([
rm(directory, { recursive: true, force: true }),
rm(configPath, { force: true }),
])
}
}, 20_000)
})

View File

@@ -121,7 +121,7 @@ describe('Oxlint gate', () => {
})
})
it('passes the configured native thread bound to Oxlint', () => {
it('passes the configured worker bound to both Oxlint backends', () => {
const subject = withEnv('DSH_OXLINT_THREADS', '4', () =>
withPnpmEntrypoint(() => gatesForMode('ci-lint')[0]))
@@ -130,10 +130,11 @@ describe('Oxlint gate', () => {
displayCommand: 'pnpm exec oxlint . --threads=4',
command: process.execPath,
args: ['/private/pnpm.cjs', 'exec', 'oxlint', '.', '--threads=4'],
env: { GOMAXPROCS: '4' },
})
})
it('rejects a non-positive or non-integer native thread bound', () => {
it('rejects a non-positive or non-integer worker bound', () => {
expect(() => withEnv('DSH_OXLINT_THREADS', 'auto', () =>
withPnpmEntrypoint(() => gatesForMode('ci-lint'))))
.toThrow('DSH_OXLINT_THREADS must be a positive integer')

View File

@@ -376,22 +376,25 @@ function ciWindowsObservationalGates(): Gate[] {
]
}
function lintGate(oxlintTargets: readonly string[] = ['.']): Gate {
const threadArgs = oxlintThreadArgs()
if (threadArgs.length > 0) {
return pnpmExec('lint', ['oxlint', ...oxlintTargets, ...threadArgs], { label: 'lint' })
function lintGate(): Gate {
const threadBound = oxlintThreadBound()
if (threadBound !== undefined) {
return pnpmExec('lint', ['oxlint', '.', `--threads=${threadBound}`], {
label: 'lint',
env: { GOMAXPROCS: threadBound },
})
}
return pnpmScript('lint', 'lint')
}
function oxlintThreadArgs(): string[] {
function oxlintThreadBound(): string | undefined {
const raw = process.env.DSH_OXLINT_THREADS
if (raw === undefined || raw === '') return []
if (raw === undefined || raw === '') return undefined
const parsed = Number.parseInt(raw, 10)
if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) {
throw new Error(`run-gates: DSH_OXLINT_THREADS must be a positive integer, got ${JSON.stringify(raw)}.`)
}
return [`--threads=${raw}`]
return raw
}
function coverageGate(): Gate {