oxlint-contract.spec.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. import { spawnSync } from 'node:child_process'
  2. import { randomUUID } from 'node:crypto'
  3. import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
  4. import { join, relative } from 'node:path'
  5. import { fileURLToPath, pathToFileURL } from 'node:url'
  6. import { flattenDiagnosticMessageText, parseConfigFileTextToJson } from 'typescript'
  7. import { describe, expect, it } from 'vitest'
  8. const repositoryRoot = fileURLToPath(new URL('..', import.meta.url))
  9. const eslintCli = fileURLToPath(new URL('../node_modules/eslint/bin/eslint.js', import.meta.url))
  10. const oxlintCli = fileURLToPath(new URL('../node_modules/oxlint/bin/oxlint', import.meta.url))
  11. function isRecord(value: unknown): value is Record<string, unknown> {
  12. return typeof value === 'object' && value !== null && !Array.isArray(value)
  13. }
  14. function isUnknownArray(value: unknown): value is unknown[] {
  15. return Array.isArray(value)
  16. }
  17. function runStagedFormatter(paths: readonly string[]) {
  18. return spawnSync(process.execPath, [eslintCli, '--config', 'eslint.format.config.mjs', '--fix', '--no-warn-ignored', ...paths], {
  19. cwd: repositoryRoot,
  20. encoding: 'utf8',
  21. env: { ...process.env, NO_COLOR: '1' },
  22. })
  23. }
  24. function runOxlint(args: readonly string[], env: NodeJS.ProcessEnv = {}) {
  25. return spawnSync(process.execPath, [oxlintCli, ...args], {
  26. cwd: repositoryRoot,
  27. encoding: 'utf8',
  28. env: { ...process.env, NO_COLOR: '1', ...env },
  29. })
  30. }
  31. function normalizedOutput(result: ReturnType<typeof runOxlint>): string {
  32. return `${result.stdout}${result.stderr}`.replaceAll('\\', '/')
  33. }
  34. async function writeContractConfig(suffix: string): Promise<string> {
  35. const path = join(repositoryRoot, `.oxlintrc.contract-${suffix}.json`)
  36. await writeFile(path, JSON.stringify({ extends: ['./.oxlintrc.json'], ignorePatterns: [] }))
  37. return path
  38. }
  39. describe('Oxlint executable contract', () => {
  40. it('discovers the owning TypeScript project for every file class', async () => {
  41. const suffix = randomUUID()
  42. const configPath = await writeContractConfig(suffix)
  43. const probes = [
  44. ['host package source', 'packages/fs/fs-policy/src', 'packages/fs/fs-policy/tsconfig.json'],
  45. ['host package test', 'packages/fs/fs-policy/tests', 'tsconfig.host.json'],
  46. ['client package source', 'packages/client/ui-primitives/src', 'packages/client/ui-primitives/tsconfig.json'],
  47. ['client package test', 'packages/client/ui-trajectory/tests', 'tsconfig.client.json'],
  48. ['example', 'examples/headless-agent/tests', 'tsconfig.host.json'],
  49. ['website', 'website', 'tsconfig.host.json'],
  50. ] as const
  51. const source = `export function probePromise(): Promise<void> {
  52. return Promise.resolve()
  53. }
  54. probePromise()
  55. `
  56. try {
  57. const paths: Array<readonly [label: string, path: string, tsconfig: string]> = []
  58. for (const [label, parent, tsconfig] of probes) {
  59. const path = join(repositoryRoot, parent, `oxlint-contract-${suffix}.ts`)
  60. await writeFile(path, source)
  61. paths.push([label, relative(repositoryRoot, path), tsconfig])
  62. }
  63. const clientScript = 'scripts/client-bundle-purity.spec.ts'
  64. const result = runOxlint([
  65. '--config',
  66. relative(repositoryRoot, configPath),
  67. '--format',
  68. 'unix',
  69. ...paths.map(([, path]) => path),
  70. clientScript,
  71. ], { OXC_LOG: 'debug' })
  72. const output = normalizedOutput(result)
  73. expect(result.error).toBeUndefined()
  74. expect(result.status, output).toBe(1)
  75. for (const [label, path, tsconfig] of paths) {
  76. expect(output, label).toContain(`${path.replaceAll('\\', '/')}:5:1: Promises must be awaited`)
  77. expect(output, `${label} project`).toContain(
  78. `Got tsconfig for file ${join(repositoryRoot, path).replaceAll('\\', '/')}: ${join(repositoryRoot, tsconfig).replaceAll('\\', '/')}`,
  79. )
  80. }
  81. expect(output.match(/typescript\(no-floating-promises\)/g)).toHaveLength(probes.length)
  82. expect(output, 'client aggregate script project').toContain(
  83. `Got tsconfig for file ${join(repositoryRoot, clientScript).replaceAll('\\', '/')}: ${join(repositoryRoot, 'tsconfig.client.json').replaceAll('\\', '/')}`,
  84. )
  85. expect(output).not.toContain('Unmatched file:')
  86. } finally {
  87. await Promise.all([
  88. ...probes.map(([, parent]) => rm(join(repositoryRoot, parent, `oxlint-contract-${suffix}.ts`), { force: true })),
  89. rm(configPath, { force: true }),
  90. ])
  91. }
  92. }, 20_000)
  93. it('runs JavaScript compatibility and nursery rules', async () => {
  94. const suffix = randomUUID()
  95. const configPath = await writeContractConfig(suffix)
  96. const path = join(repositoryRoot, 'scripts', `oxlint-contract-${suffix}.ts`)
  97. const source = `export function firstProbe(): number {
  98. const first = 1
  99. const second = 2
  100. return first + second
  101. }
  102. export function secondProbe(): number {
  103. const first = 1
  104. const second = 2
  105. return first + second
  106. }
  107. export function hasValue(value: string): boolean {
  108. return value !== undefined
  109. }
  110. export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1
  111. `
  112. try {
  113. await writeFile(path, source)
  114. const result = runOxlint([
  115. '--config',
  116. relative(repositoryRoot, configPath),
  117. '--format',
  118. 'unix',
  119. relative(repositoryRoot, path),
  120. ])
  121. const output = normalizedOutput(result)
  122. expect(result.error).toBeUndefined()
  123. expect(result.status, output).toBe(1)
  124. expect(output).toContain('@stylistic(max-len)')
  125. expect(output).toContain('sonarjs(no-identical-functions)')
  126. expect(output).toContain('typescript(no-unnecessary-condition)')
  127. } finally {
  128. await Promise.all([
  129. rm(path, { force: true }),
  130. rm(configPath, { force: true }),
  131. ])
  132. }
  133. }, 20_000)
  134. it('keeps formatter rules aligned with Oxlint validation', async () => {
  135. const oxlintPath = join(repositoryRoot, '.oxlintrc.json')
  136. const result = parseConfigFileTextToJson(oxlintPath, await readFile(oxlintPath, 'utf8'))
  137. if (result.error !== undefined) {
  138. throw new Error(flattenDiagnosticMessageText(result.error.messageText, '\n'))
  139. }
  140. const parsed = result.config as unknown
  141. if (!isRecord(parsed) || !isUnknownArray(parsed.overrides)) {
  142. throw new Error('.oxlintrc.json must contain an overrides array')
  143. }
  144. const stylisticOverride = parsed.overrides.find((value: unknown) =>
  145. isRecord(value) && isRecord(value.rules) && '@stylistic/max-len' in value.rules)
  146. if (!isRecord(stylisticOverride) || !isRecord(stylisticOverride.rules)) {
  147. throw new Error('.oxlintrc.json must contain the @stylistic validator override')
  148. }
  149. const validatorRules = { ...stylisticOverride.rules }
  150. const maxLen = validatorRules['@stylistic/max-len']
  151. delete validatorRules['@stylistic/max-len']
  152. const formatterUrl = pathToFileURL(join(repositoryRoot, 'eslint.format.config.mjs')).href
  153. const formatterModule = await import(formatterUrl) as unknown
  154. if (!isRecord(formatterModule) || !isUnknownArray(formatterModule.default)) {
  155. throw new Error('eslint.format.config.mjs must default-export a config array')
  156. }
  157. const formatterOverride = formatterModule.default.find((value: unknown) => isRecord(value) && isRecord(value.rules))
  158. if (!isRecord(formatterOverride) || !isRecord(formatterOverride.rules)) {
  159. throw new Error('eslint.format.config.mjs must contain a rules object')
  160. }
  161. expect(validatorRules).toStrictEqual(formatterOverride.rules)
  162. expect(maxLen).toStrictEqual(['error', { code: 140, ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true }])
  163. })
  164. it('reports an unused suppression', async () => {
  165. const suffix = randomUUID()
  166. const configPath = await writeContractConfig(suffix)
  167. const path = join(repositoryRoot, 'scripts', `oxlint-contract-${suffix}.ts`)
  168. try {
  169. await writeFile(path, '// oxlint-disable-next-line no-console\nexport const value = 1\n')
  170. const result = runOxlint([
  171. '--config',
  172. relative(repositoryRoot, configPath),
  173. '--format',
  174. 'unix',
  175. relative(repositoryRoot, path),
  176. ])
  177. const output = normalizedOutput(result)
  178. expect(result.error).toBeUndefined()
  179. expect(result.status, output).toBe(0)
  180. expect(output).toContain('Unused oxlint-disable directive')
  181. } finally {
  182. await Promise.all([
  183. rm(path, { force: true }),
  184. rm(configPath, { force: true }),
  185. ])
  186. }
  187. })
  188. it('accepts an ignored-only staged selection', () => {
  189. const result = runOxlint([
  190. '--fix',
  191. '--no-error-on-unmatched-pattern',
  192. 'scripts/install-lefthook.mjs',
  193. ])
  194. expect(result.error).toBeUndefined()
  195. expect(result.status, normalizedOutput(result)).toBe(0)
  196. })
  197. it('applies staged stylistic fixes before Oxlint validation', async () => {
  198. const suffix = randomUUID()
  199. const configPath = await writeContractConfig(suffix)
  200. const directory = join(repositoryRoot, 'scripts', `.oxlint-contract-${suffix}`)
  201. const path = join(directory, 'fix.ts')
  202. try {
  203. await mkdir(directory, { recursive: true })
  204. await writeFile(path, 'const value={answer:1}; \nconsole.log(value)\n')
  205. const relativePath = relative(repositoryRoot, path)
  206. const formatResult = runStagedFormatter([relativePath])
  207. const lintResult = runOxlint(['--config', relative(repositoryRoot, configPath), '--fix', relativePath])
  208. expect(formatResult.error).toBeUndefined()
  209. expect(formatResult.status, normalizedOutput(formatResult)).toBe(0)
  210. expect(lintResult.error).toBeUndefined()
  211. expect(lintResult.status, normalizedOutput(lintResult)).toBe(0)
  212. await expect(readFile(path, 'utf8')).resolves.toBe('const value={ answer:1 }\nconsole.log(value)\n')
  213. } finally {
  214. await Promise.all([
  215. rm(directory, { recursive: true, force: true }),
  216. rm(configPath, { force: true }),
  217. ])
  218. }
  219. }, 20_000)
  220. })