Repository navigation
[NoQA] Oxlint with eslint-config-expensify #889
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
LukasMod
wants to merge
44
commits into
Expensify:main
Choose a base branch
from
callstack-internal:LukasMod/experiment-oxlint-parity-no-seatbelt
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+2,509
−350
Draft
Changes from all commits
Commits
Show all changes
44 commits
Select commit
Hold shift + click to select a range
1c23c39
Upgrade eslint-config-expensify to 4.0.19 without seatbelt
LukasMod 94238a5
Add ESLint 4.x lint snapshot under lint-parity
LukasMod ee555b7
Switch tsconfig to bundler resolution for tsgolint, keep CommonJS build
LukasMod 2549d8f
Add Oxlint 1.90.0 type-aware config with JS plugins for ESLint-only r…
LukasMod 17c1d88
Add ESLint vs Oxlint parity report and close config gaps
LukasMod 9556a80
Gate ESLint and Oxlint with warning-count thresholds instead of a bas…
LukasMod 447020d
Add lint-parity README with results and gating recommendation
LukasMod 171ddf1
Derive Oxlint config from eslint-config-expensify presets at load time
LukasMod 7010e3c
Drop Oxlint rule-name mapping and directive wrapper, migrate disable …
LukasMod bf673e2
Keep earlier options for severity-only JS plugin rules and trim Oxlin…
LukasMod 89a3eda
Reword Oxlint config comments in plain language and drop local-only l…
LukasMod 415b8c7
Make Oxlint the main lint script and keep ESLint as lint:eslint
LukasMod f379858
Replace Oxlint max-warnings gate with an App-style seatbelt that auto…
LukasMod 0642b5e
Trim seatbelt comments and run test:lint by file for Node 24
LukasMod 62e4a6c
Migrate only eslint-config-expensify presets and keep Onyx overrides …
LukasMod f382c6b
Keep CommonJS tsconfig for GitHub Action builds
LukasMod eee5353
Merge dual disable comments into oxlint-disable now that ESLint is gone
LukasMod 582d06f
Harden Oxlint config derivation with checks ported from App review
LukasMod 9de42e9
Run ts-node on CommonJS so build:docs does not rely on ESM detection
LukasMod bb3968a
chore: Bump eslint-config-expensify to 4.0.20
LukasMod 66b5fac
chore: Build oxlint config with eslint-config-expensify
LukasMod 5854fc9
chore: Upgrade oxlint to 1.92 and use the latest eslint-config-expensify
LukasMod dbaaa07
chore: Use the latest eslint-config-expensify build
LukasMod 6eece7e
chore: Use the latest eslint-config-expensify build
LukasMod 38d76c8
fix: Restore no-unresolved and the underscore exception for TS files
LukasMod e94e512
chore: Lint the oxlint config files
LukasMod 6679091
style: Format oxlint.config.mts with prettier
LukasMod ea8f2a0
chore: Bump eslint-config-expensify to 3561252
LukasMod a2e2aac
chore: Bump eslint-config-expensify to 8dddd5a and add oxc-parser
LukasMod d25f294
chore: Bump eslint-config-expensify to 312d582
LukasMod cc8cd68
chore: Bump eslint-config-expensify to c8b9b3c and extend preset rule…
LukasMod 2b43d29
chore: Fix oxlint override comments and compute warningCount once
LukasMod 1ce94c1
chore: Bump eslint-config-expensify to adaccd2
LukasMod a845f5e
chore: Bump eslint-config-expensify to 95cf16f
LukasMod b1e6a54
chore: Bump eslint-config-expensify to 72fcf03
LukasMod 8481c8b
docs: Tidy oxlint comments and JSDoc
LukasMod c95a8fb
chore: Bump eslint-config-expensify to c909982
LukasMod 2f56d52
Merge branch 'main' of https://github.com/Expensify/react-native-onyx…
LukasMod 01a8846
chore: Raise the oxlint baseline for tests added on main
LukasMod d84feef
chore: Bump eslint-config-expensify to 59d8bd1
LukasMod d43bd4b
chore: Bump eslint-config-expensify to df0c4ed
LukasMod 728a362
chore: Bump eslint-config-expensify to 3517bc0
LukasMod 957f0e2
chore: Bump eslint-config-expensify to 6a3a784
LukasMod ddfdceb
chore: Bump eslint-config-expensify to c36e57c
LukasMod File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| { | ||
| "$schema": "https://json.schemastore.org/tsconfig", | ||
| "extends": "../../../tsconfig.json", | ||
| "compilerOptions": { | ||
| "module": "commonjs", | ||
| "moduleResolution": "node" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,4 +31,6 @@ jobs: | |
| env: | ||
| CI: true | ||
|
|
||
| - run: npm run test:types | ||
| - run: npm run test:types | ||
|
|
||
| - run: npm run test:lint | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,164 @@ | ||
| // Runs Oxlint, applies the seatbelt (seatbelt.mjs) and prints the result. | ||
| // | ||
| // npm run lint lint the whole repo | ||
| // npm run lint -- lib/Onyx.ts tests lint the given paths | ||
| // npm run lint -- --show-warnings also print the grandfathered warnings | ||
| // npm run lint -- --format=json print a JSON report | ||
| // npm run lint -- --fix apply Oxlint's auto-fixes | ||
| import {spawnSync} from 'node:child_process'; | ||
| import path from 'node:path'; | ||
| import {fileURLToPath} from 'node:url'; | ||
| import {parseArgs} from 'node:util'; | ||
|
|
||
| import {LINT_SEVERITY, applySeatbelt, resolveSeatbeltOptions} from './seatbelt.mjs'; | ||
|
|
||
| const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); | ||
| // Called directly rather than through npx, which passes the whole command line to `sh -c` as one string. | ||
| const OXLINT_BIN = path.join(projectRoot, 'node_modules', '.bin', 'oxlint'); | ||
| const FATAL_EXIT_CODE = 2; | ||
| const JS_PLUGIN_CRASH_MESSAGE = 'Error running JS plugin'; | ||
|
|
||
| /** | ||
| * Prints the reason, then up to 2000 characters of the output, and exits the process with the fatal exit code. | ||
| * @param {string} reason - What went wrong. | ||
| * @param {string} [output] - Output to show after the reason. | ||
| */ | ||
| function fail(reason, output = '') { | ||
| console.error([reason, output.trim().slice(0, 2000)].filter(Boolean).join('\n')); | ||
| process.exit(FATAL_EXIT_CODE); | ||
| } | ||
|
|
||
| /** | ||
| * Runs the Oxlint binary and returns its stdout, stderr and exit code. Exits the process if the binary cannot start. | ||
| * @param {string[]} args - Command line arguments for Oxlint. | ||
| */ | ||
| function runOxlint(args) { | ||
| const result = spawnSync(OXLINT_BIN, args, {cwd: projectRoot, encoding: 'utf8', maxBuffer: 512 * 1024 * 1024}); | ||
| if (result.error) { | ||
| fail(`Could not run ${OXLINT_BIN}: ${result.error.message}`); | ||
| } | ||
| return {stdout: result.stdout ?? '', stderr: result.stderr ?? '', exitCode: result.status ?? FATAL_EXIT_CODE}; | ||
| } | ||
|
|
||
| /** | ||
| * Oxlint's JSON report leaves out files without findings, and the seatbelt needs them to notice fixed errors. | ||
| * @param {string[]} targets - Paths or globs passed to Oxlint. | ||
| */ | ||
| function listLintedFiles(targets) { | ||
| return runOxlint(['--debug=files', ...targets]) | ||
| .stdout.split('\n') | ||
| .map((line) => line.trim()) | ||
| .filter(Boolean) | ||
| .map((file) => path.resolve(projectRoot, file)); | ||
| } | ||
|
|
||
| /** | ||
| * `typescript(no-explicit-any)` becomes `typescript/no-explicit-any` and `eslint(no-undef)` becomes `no-undef`, | ||
| * the names oxlint.config uses, so baseline rows can be matched to config and disable comments by name. | ||
| * @param {string} code - Rule code from an Oxlint diagnostic. | ||
| */ | ||
| function toRuleID(code) { | ||
| const match = /^([\w@/.-]+)\((.+)\)$/.exec(code); | ||
| if (!match) { | ||
| return code; | ||
| } | ||
| return match[1] === 'eslint' ? match[2] : `${match[1]}/${match[2]}`; | ||
| } | ||
|
|
||
| function toLintMessage(diagnostic) { | ||
| const span = diagnostic.labels?.[0]?.span; | ||
| const text = [diagnostic.message, diagnostic.help, diagnostic.note].filter(Boolean).join('\n'); | ||
| return { | ||
| filePath: path.resolve(projectRoot, diagnostic.filename), | ||
| ruleID: diagnostic.code ? toRuleID(diagnostic.code) : null, | ||
| severity: ['warning', 'advice'].includes(diagnostic.severity) ? LINT_SEVERITY.WARNING : LINT_SEVERITY.ERROR, | ||
| // A diagnostic without a rule is a file Oxlint could not parse. | ||
| message: diagnostic.code ? text : `Parsing error: ${text}`, | ||
| line: span?.line ?? 0, | ||
| column: span?.column ?? 0, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Runs Oxlint with type-aware rules on the targets and returns its findings as lint messages. | ||
| * Exits the process if Oxlint crashes or its output is unusable. | ||
| * @param {string[]} targets - Paths or globs to lint. | ||
| * @param {boolean} fix - Whether to apply Oxlint's auto-fixes. | ||
| */ | ||
| function lint(targets, fix) { | ||
| const {stdout, stderr, exitCode} = runOxlint(['--type-aware', '--format', 'json', ...(fix ? ['--fix'] : []), ...targets]); | ||
| // Oxlint exits 1 for findings. Anything higher, or no JSON at all, means it crashed or rejected the config. | ||
| const start = stdout.indexOf('{'); | ||
| const end = stdout.lastIndexOf('}'); | ||
| if (exitCode > 1 || start < 0 || end <= start) { | ||
| fail(`Oxlint failed (exit ${exitCode}).`, `${stderr}\n${stdout}`); | ||
| } | ||
| let report; | ||
| try { | ||
| report = JSON.parse(stdout.slice(start, end + 1)); | ||
| } catch { | ||
| fail('Could not parse Oxlint JSON output.', stdout); | ||
| } | ||
| if (!Array.isArray(report?.diagnostics)) { | ||
| fail('Oxlint JSON output has no `diagnostics` array.', stdout); | ||
| } | ||
| // A JS plugin crash has no rule and no location. It hides every JS plugin finding in the file, so fail the run | ||
| // instead of reporting a parse error. | ||
| const crashes = report.diagnostics.filter((diagnostic) => !diagnostic.code && (diagnostic.message.startsWith(JS_PLUGIN_CRASH_MESSAGE) || !diagnostic.labels?.length)); | ||
| if (crashes.length) { | ||
| fail( | ||
| `Oxlint reported ${crashes.length} JS plugin crash(es):`, | ||
| crashes | ||
| .slice(0, 5) | ||
| .map((diagnostic) => ` ${diagnostic.filename}: ${diagnostic.message.split('\n').slice(0, 3).join(' ')}`) | ||
| .join('\n'), | ||
| ); | ||
| } | ||
| return report.diagnostics.map(toLintMessage); | ||
| } | ||
|
|
||
| /** | ||
| * Formats messages as `file:line:column` blocks followed by an error count. Warnings are listed and counted only with showWarnings. | ||
| * @param {object[]} messages - Findings after the seatbelt. | ||
| * @param {number} errorCount - Number of errors in messages. | ||
| * @param {number} warningCount - Number of warnings in messages. | ||
| * @param {boolean} showWarnings - Whether to list and count warnings. | ||
| */ | ||
| function formatStylish(messages, errorCount, warningCount, showWarnings) { | ||
| const visible = showWarnings ? messages : messages.filter((message) => message.severity === LINT_SEVERITY.ERROR); | ||
| const lines = visible.map((message) => { | ||
| const location = `${path.relative(projectRoot, message.filePath)}:${message.line}:${message.column}`; | ||
| const level = message.severity === LINT_SEVERITY.ERROR ? 'error' : 'warning'; | ||
| return `${location}\n ${level} ${message.message}${message.ruleID ? ` ${message.ruleID}` : ''}`; | ||
| }); | ||
| const summary = `${errorCount} error${errorCount === 1 ? '' : 's'}${showWarnings ? `, ${warningCount} warning${warningCount === 1 ? '' : 's'}` : ''}`; | ||
| return [...lines, summary].join('\n\n'); | ||
| } | ||
|
|
||
| const {values, positionals} = parseArgs({ | ||
| allowPositionals: true, | ||
| options: { | ||
| 'show-warnings': {type: 'boolean', default: false}, | ||
| format: {type: 'string', default: 'stylish'}, | ||
| fix: {type: 'boolean', default: false}, | ||
| }, | ||
| }); | ||
| if (!['stylish', 'json'].includes(values.format)) { | ||
| fail(`Unknown format "${values.format}". Expected stylish or json.`); | ||
| } | ||
|
|
||
| const targets = positionals.length ? positionals : ['.']; | ||
| const lintedFiles = listLintedFiles(targets); | ||
| if (!lintedFiles.length) { | ||
| fail(`Oxlint matched no files for: ${targets.join(' ')}`); | ||
| } | ||
|
|
||
| const {messages} = applySeatbelt(lint(targets, values.fix), resolveSeatbeltOptions(projectRoot), lintedFiles); | ||
| const errorCount = messages.filter((message) => message.severity === LINT_SEVERITY.ERROR).length; | ||
| const warningCount = messages.length - errorCount; | ||
| if (values.format === 'json') { | ||
| console.log(JSON.stringify({messages, errorCount, warningCount})); | ||
| } else { | ||
| console.log(formatStylish(messages, errorCount, warningCount, values['show-warnings'])); | ||
| } | ||
| process.exitCode = errorCount > 0 ? 1 : 0; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.