import { existsSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join, relative, resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' import { LinterToolError, type DiagnosticSeverity, type LintDiagnostic } from '../diagnostic.js' import { ruleInfo } from '../rules/catalog.js' import { DOMAIN_OXLINT_IGNORE_PATTERNS } from './ignore-patterns.js' import { runOxlintProcess, type OxlintProcessOptions } from './process.js' type OxlintOutput = { diagnostics?: OxlintDiagnostic[] number_of_files?: number } type OxlintDiagnostic = { message?: string code?: string severity?: string url?: string help?: string filename?: string labels?: Array<{ span?: { offset?: number; length?: number; line?: number; column?: number } }> } export type OxlintResult = { diagnostics: LintDiagnostic[] files: number } export async function runOxlint( root: string, fix: boolean, options: OxlintProcessOptions = {}, ): Promise { const packageJson = join(root, 'package.json') const projectRequire = createRequire(packageJson) const sdkRequire = createRequire(import.meta.url) const oxlintPackage = resolvePackage('oxlint/package.json', projectRequire, sdkRequire) const bin = join(dirname(oxlintPackage), 'bin', 'oxlint') const config = projectConfig(root) ?? internalConfigPath() const args = ['.', '--format=json', '--no-error-on-unmatched-pattern', '--config', config] // Config-file ignores are rooted at the config's own directory. Enforce the // SDK's traversal boundary at the target process as well so the internal // fallback config and imported presets cannot discover dependency/build trees. for (const pattern of DOMAIN_OXLINT_IGNORE_PATTERNS) args.push('--ignore-pattern', pattern) if (fix) args.push('--fix') const { status, signal, stdout, stderr } = await runOxlintProcess(bin, args, root, options) if (status !== 0 && status !== 1) { const outcome = signal ? `signal ${signal}` : `status ${status ?? 'unknown'}` const detail = failureDetail(stderr, stdout) throw new LinterToolError(`Oxlint failed with ${outcome}.${detail ? `\n${detail}` : ''}`, { code: 'OXLINT_FAILED', }) } let output: OxlintOutput try { const parsed: unknown = JSON.parse(stdout) if (!isOxlintOutput(parsed)) throw new TypeError('Unexpected Oxlint report shape.') output = parsed } catch (error) { const detail = failureDetail(stderr, stdout) || `Oxlint exited with status ${status}` throw new LinterToolError(`Oxlint did not produce a diagnostic report.\n${detail}`, { code: 'OXLINT_INVALID_OUTPUT', cause: error, }) } return { diagnostics: (output.diagnostics ?? []).map((diagnostic) => normalizeDiagnostic(root, diagnostic), ), files: output.number_of_files ?? 0, } } function resolvePackage( id: string, projectRequire: NodeJS.Require, sdkRequire: NodeJS.Require, ): string { try { return projectRequire.resolve(id) } catch (projectError) { try { return sdkRequire.resolve(id) } catch { throw new LinterToolError( 'Oxlint is not installed. Add the scaffolded Oxlint dev dependency and run your package manager install.', { code: 'OXLINT_NOT_INSTALLED', cause: projectError }, ) } } } function isOxlintOutput(value: unknown): value is OxlintOutput { if (!value || typeof value !== 'object' || Array.isArray(value)) return false const output = value as OxlintOutput return ( (output.diagnostics === undefined || Array.isArray(output.diagnostics)) && (output.number_of_files === undefined || typeof output.number_of_files === 'number') ) } function failureDetail(stderr: string, stdout: string): string { const detail = stderr.trim() || stdout.trim() const limit = 4_000 return detail.length <= limit ? detail : `…${detail.slice(-limit)}` } function projectConfig(root: string): string | undefined { for (const name of [ 'oxlint.config.ts', 'oxlint.config.js', 'oxlint.config.mjs', 'oxlint.config.cjs', '.oxlintrc.json', '.oxlintrc.jsonc', ]) { const path = join(root, name) if (existsSync(path)) return path } return undefined } function internalConfigPath(): string { const current = fileURLToPath(import.meta.url) const extension = current.endsWith('.ts') ? '.ts' : '.js' return join(dirname(current), `config${extension}`) } function normalizeDiagnostic(root: string, diagnostic: OxlintDiagnostic): LintDiagnostic { const id = normalizeCode(diagnostic.code ?? 'oxlint/unknown') const info = ruleInfo(id) const span = diagnostic.labels?.[0]?.span return { id, severity: normalizeSeverity(diagnostic.severity), message: diagnostic.message ?? info?.message ?? 'Oxlint reported a violation.', ...(diagnostic.help || info?.help ? { help: diagnostic.help ?? info?.help } : {}), ...(diagnostic.url || info?.url ? { url: diagnostic.url ?? info?.url } : {}), owner: info?.owner ?? 'generic', ...(diagnostic.filename ? { location: { path: relativePath(root, diagnostic.filename), line: span?.line ?? 1, column: span?.column ?? 1, ...(span?.offset !== undefined ? { offset: span.offset } : {}), ...(span?.length !== undefined ? { length: span.length } : {}), }, } : {}), } } function normalizeCode(code: string): string { const match = /^([^()]+)\(([^()]+)\)$/.exec(code) return match ? `${match[1]}/${match[2]}` : code } function normalizeSeverity(severity: string | undefined): DiagnosticSeverity { return severity === 'error' ? 'error' : 'warning' } function relativePath(root: string, filename: string): string { const absolute = resolve(root, filename) const path = relative(root, absolute) return sep === '/' ? path : path.split(sep).join('/') }