Repository navigation
[NoQA] Oxlint linting #101292
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
Merged
roryabraham
merged 43 commits into
Expensify:main
from
callstack-internal:feat/oxlint-clean
Oct 27, 2026
+6,774
−116
Merged
[NoQA] Oxlint linting #101292
Changes from all commits
Commits
Show all changes
43 commits
Select commit
Hold shift + click to select a range
2b8377c
Add oxlint config and lint pipeline
LukasMod f378b72
Re-baseline the oxlint seatbelt against current main
LukasMod c915d1a
Trim oxlint migration comments to workaround explanations
LukasMod f9533a9
Skip the oxlint React Compiler gate inside the lint pipeline and drop…
LukasMod 26c2b62
Use native no-unreachable-loop, drop dead oxlint mappings, clarify wo…
LukasMod aa8ee27
Merge main into feat/oxlint-clean
LukasMod 1fb7903
Re-baseline the oxlint seatbelt after merging main
LukasMod 9090e32
Run the oxlint React Compiler gate inside the lint pipeline again
LukasMod 71aede4
Report oxlint parse errors like ESLint instead of failing the whole run
LukasMod fe1b3e2
Give oxlint ESLint's import resolver settings so hosted import rules …
LukasMod 221d0b6
Skip React Compiler diagnostics without a location, as eslint-plugin-…
LukasMod 7c4ae50
Match ESLint directive placement for hosted rules: block comments onl…
LukasMod ff17244
Compile .mts and .cts files as TypeScript in the oxlint React Compile…
LukasMod c6d452c
Run oxlint --fix in one process, replace OXLINT_SHARDS with --shards=…
LukasMod 69bef7b
Treat a codeless oxlint diagnostic with no location as a plugin crash…
LukasMod 9e50d9d
Absorb new oxlint findings into its baseline on main while oxlint is …
LukasMod c495bc3
Report an oxlint baseline change only after checking the file changed
LukasMod f8fb9a0
Lint oxlint.config.mts with both linters and disable naming-conventio…
LukasMod a23cdab
Explain in core-rules.mjs which hosted rules are JS only and where th…
LukasMod 8869c12
Merge main into feat/oxlint-clean
LukasMod 6673787
Drop the parentheses after Onyx.connectWithoutView in the OnyxUtils l…
LukasMod 602bf37
Rename withEslintDirectiveIds to withEslintDirectiveIDs
LukasMod a92fc59
Reword the OnyxUtils lint message so the reviewer bot does not match it
LukasMod 6053df8
Say ESLint also replaces the restricted globals list for GitHub Actio…
LukasMod bff2d27
Shorten the seatbelt comment in the oxlint workflow
LukasMod 4e19649
Add a header comment to each oxlint config file
LukasMod 04f1cf4
Add JSDoc to the oxlint config functions
LukasMod c9c3bb7
Say what OxlintLinter does in its class comment
LukasMod 963b162
Mirror main's narrowed prefer-nullish-coalescing in the oxlint config
LukasMod 9d18084
Re-baseline the oxlint seatbelt after merging main
LukasMod 27af8ee
Merge remote-tracking branch 'origin/main' into feat/oxlint-clean
LukasMod 1171139
Write the ESTree types inline in the oxlint JSDoc so knip finds no un…
LukasMod 26441b2
Re-baseline the oxlint seatbelt after merging main
LukasMod fb608d1
Merge main into feat/oxlint-clean
LukasMod 5316e0e
Re-baseline the oxlint seatbelt after merging main
LukasMod a196995
Merge main into feat/oxlint-clean
LukasMod 6eca4cc
Mirror the new no-unsafe-onyx-read, no-onyx-get-snapshot-key and no-m…
LukasMod de96f43
Re-baseline the oxlint seatbelt after merging main
LukasMod 6c0c0de
Drop the temporary oxlint release-age exclusions and the unused vite-…
LukasMod 9dcae47
Merge main into feat/oxlint-clean
LukasMod eff851a
Share one React Compiler verdict across lint and trim the oxlint brid…
LukasMod de1452a
Re-baseline the oxlint seatbelt after merging main
LukasMod 6d0fcfe
Bump oxlint, oxlint-tsgolint and oxc-transform-react and re-baseline …
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,72 @@ | ||
| name: Oxlint check (non-blocking) | ||
|
|
||
| on: | ||
| workflow_call: | ||
| pull_request: | ||
| types: [opened, synchronize] | ||
| branches-ignore: [staging, production] | ||
| paths: ['**.js', '**.ts', '**.tsx', '**.json', '**.mjs', '**.cjs', 'oxlint.config.mts', 'config/oxlint/**', 'scripts/lint/**', '.github/workflows/oxlint.yml'] | ||
|
|
||
| concurrency: | ||
| group: ${{ github.ref == 'refs/heads/main' && format('{0}-{1}', github.ref, github.sha) || github.ref }}-oxlint | ||
| cancel-in-progress: true | ||
|
|
||
| jobs: | ||
| oxlint: | ||
| name: Oxlint check (non-blocking) | ||
| if: ${{ github.event.head_commit.author.name != 'OSBotify' || github.event_name == 'push' }} | ||
| runs-on: blacksmith-8vcpu-ubuntu-2404 | ||
| steps: | ||
| - name: Checkout | ||
| uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 | ||
| with: | ||
| token: ${{ github.event_name == 'push' && secrets.OS_BOTIFY_COMMIT_TOKEN || github.token }} | ||
|
|
||
| - name: Setup Node | ||
| uses: ./.github/actions/composite/setupNode | ||
|
|
||
| - name: Lint JavaScript and TypeScript with Oxlint | ||
| id: oxlint | ||
| continue-on-error: true | ||
| # oxlint runs JS plugins on one thread (oxc#26621), so OxlintLinter splits them into 4 shards on this 8 vCPU runner. | ||
| # While oxlint is a non-blocking check, the post-merge run both adds and removes entries from the oxlint.seatbelt.tsv. | ||
| # Remove this once oxlint becomes the blocking linter. | ||
| env: | ||
| SEATBELT_INCREASE: ${{ github.event_name == 'push' && github.event.head_commit.author.name != 'OSBotify' && 'all' || '' }} | ||
| run: npm run lint -- --linter=oxlint --shards=4 | ||
|
|
||
| - name: Check for an updated oxlint-seatbelt baseline | ||
| id: seatbelt_diff | ||
| if: github.event_name == 'push' | ||
| run: | | ||
| if git diff --quiet config/oxlint/oxlint.seatbelt.tsv; then | ||
| echo "updated=false" >> "$GITHUB_OUTPUT" | ||
| else | ||
| echo "updated=true" >> "$GITHUB_OUTPUT" | ||
| fi | ||
|
|
||
| - name: Report the Oxlint result without failing the job | ||
| run: | | ||
| if [[ "${{ steps.oxlint.outcome }}" != "success" ]]; then | ||
| echo "::warning::Oxlint found errors this PR adds (see the step log). This check is temporarily non-blocking while oxlint runs next to ESLint." | ||
| elif [[ "${{ steps.seatbelt_diff.outputs.updated }}" == "true" ]]; then | ||
| echo "::notice::This merge changed the list of known oxlint errors (config/oxlint/oxlint.seatbelt.tsv). OSBotify commits the update. Temporary while oxlint runs next to ESLint." | ||
| else | ||
| echo "::notice::Oxlint found no new errors." | ||
| fi | ||
|
|
||
| - name: Setup git for OSBotify | ||
| if: steps.seatbelt_diff.outputs.updated == 'true' | ||
| uses: Expensify/GitHub-Actions/setupGitForOSBotify@main | ||
| with: | ||
| OP_VAULT: ${{ vars.OP_VAULT }} | ||
| OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} | ||
| SETUP_AS_APP: false | ||
|
|
||
| - name: Auto-commit updated oxlint-seatbelt baseline | ||
| if: steps.seatbelt_diff.outputs.updated == 'true' | ||
| continue-on-error: true | ||
| run: | | ||
| git add config/oxlint/oxlint.seatbelt.tsv | ||
| git commit -m "Auto-update oxlint-seatbelt baseline" | ||
| git push origin main |
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
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 |
|---|---|---|
| @@ -0,0 +1,176 @@ | ||
| /** | ||
| * oxlint runs some ESLint rules as JS plugins under a new name. For example, | ||
| * `@typescript-eslint/naming-convention` becomes `hosted/naming-convention`. oxlint doesn't know the | ||
| * two are the same rule, so an existing `eslint-disable-next-line @typescript-eslint/naming-convention` | ||
| * comment wouldn't hide its report. The wrappers here read those comments and drop what ESLint would hide. | ||
| */ | ||
|
|
||
| const JUSTIFICATION = /\s-{2,}\s/; | ||
| const KINDS = ['eslint-disable-next-line', 'eslint-disable-line', 'eslint-disable', 'eslint-enable']; | ||
| const ALL_RULES = ' all'; | ||
|
|
||
| const cache = new Map(); | ||
|
|
||
| /** | ||
| * Turns a comment like `eslint-disable-next-line rule-a, rule-b -- reason` into its kind and the rules | ||
| * it names. A directive that names no rule applies to every rule. | ||
| * @param {{type: 'Line' | 'Block', value: string, loc?: {start: {line: number, column: number}, end: {line: number, column: number}}}} comment | ||
| * @returns {{kind: string, ruleIds: string[]} | null} null when the comment is not a directive | ||
| */ | ||
| function parseComment(comment) { | ||
| const text = String(comment.value ?? '').trim(); | ||
| const kind = KINDS.find((candidate) => text === candidate || (text.startsWith(candidate) && /^\s/.test(text.slice(candidate.length)))); | ||
| if (!kind) { | ||
| return null; | ||
| } | ||
| const body = text.slice(kind.length).split(JUSTIFICATION)[0]; | ||
| const ruleIds = body | ||
| .split(',') | ||
| .map((id) => id.trim()) | ||
| .filter(Boolean); | ||
| return {kind, ruleIds: ruleIds.length ? ruleIds : [ALL_RULES]}; | ||
| } | ||
|
|
||
| /** | ||
| * Collects a file's disable comments in a form that is quick to check for each report. A | ||
| * `-line`/`-next-line` comment becomes the line it covers. `eslint-disable`/`eslint-enable` comments | ||
| * stay in source order, because the last one before a report decides. | ||
| * @param {Array<Parameters<typeof parseComment>[0]>} comments | ||
| * @returns {{lines: Map<string, Set<number>>, blocks: Array<{line: number, column: number, ruleId: string, disable: boolean}>}} | ||
| */ | ||
| function parseDirectives(comments) { | ||
| const lines = new Map(); | ||
| const blocks = []; | ||
|
|
||
| for (const comment of comments) { | ||
| const parsed = parseComment(comment); | ||
| const start = comment.loc?.start?.line; | ||
| const end = comment.loc?.end?.line; | ||
| if (!parsed || start == null || end == null) { | ||
| continue; | ||
| } | ||
| // ESLint reads `eslint-disable` and `eslint-enable` only from block comments. | ||
| const isBlockKind = parsed.kind === 'eslint-disable' || parsed.kind === 'eslint-enable'; | ||
| if (isBlockKind && comment.type !== 'Block') { | ||
| continue; | ||
| } | ||
| for (const ruleId of parsed.ruleIds) { | ||
| if (parsed.kind === 'eslint-disable-next-line' || parsed.kind === 'eslint-disable-line') { | ||
| // ESLint ignores a multi-line `-line` directive but applies a multi-line `-next-line` | ||
| // to the line after the comment ends. | ||
| if (parsed.kind === 'eslint-disable-line' && start !== end) { | ||
| continue; | ||
| } | ||
| if (!lines.has(ruleId)) { | ||
| lines.set(ruleId, new Set()); | ||
| } | ||
| lines.get(ruleId).add(parsed.kind === 'eslint-disable-line' ? start : end + 1); | ||
| } else { | ||
| blocks.push({line: start, column: comment.loc.start.column ?? 0, ruleId, disable: parsed.kind === 'eslint-disable'}); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return {lines, blocks}; | ||
| } | ||
|
|
||
| /** | ||
| * Every wrapped rule checks the same file's comments, so they are parsed once per file. | ||
| * @param {string | undefined} filename | ||
| * @param {Array<Parameters<typeof parseComment>[0]>} comments | ||
| * @returns {ReturnType<typeof parseDirectives>} | ||
| */ | ||
| function directivesFor(filename, comments) { | ||
| const key = filename ?? '<unknown>'; | ||
| if (!cache.has(key)) { | ||
| cache.set(key, parseDirectives(comments)); | ||
| } | ||
| return cache.get(key); | ||
| } | ||
|
|
||
| /** | ||
| * Tells whether ESLint would hide a report of `eslintRuleId` at `position`. As in ESLint, the last | ||
| * `eslint-disable`/`eslint-enable` before the report, by line and then column, that names the rule | ||
| * (or no rule) wins. | ||
| * @param {ReturnType<typeof parseDirectives>} directives | ||
| * @param {string} eslintRuleId | ||
| * @param {{line: number, column: number} | null} position | ||
| * @returns {boolean} | ||
| */ | ||
| function isSuppressed(directives, eslintRuleId, position) { | ||
| if (position == null) { | ||
| return false; | ||
| } | ||
| const {line, column} = position; | ||
| if (directives.lines.get(eslintRuleId)?.has(line) || directives.lines.get(ALL_RULES)?.has(line)) { | ||
| return true; | ||
| } | ||
| let disabled = false; | ||
| for (const block of directives.blocks) { | ||
| const isAfterReport = block.line > line || (block.line === line && block.column > column); | ||
| if (isAfterReport || (block.ruleId !== ALL_RULES && block.ruleId !== eslintRuleId)) { | ||
| continue; | ||
| } | ||
| disabled = block.disable; | ||
| } | ||
| return disabled; | ||
| } | ||
|
|
||
| /** | ||
| * Finds the line and column a report points at, to check it against the disable comments. Rules pass | ||
| * either a descriptor with `loc` or `node`, or a node in the legacy `report(node, message)` form. | ||
| * @param {unknown[]} args | ||
| * @returns {{line: number, column: number} | null} | ||
| */ | ||
| function reportPosition(args) { | ||
| const first = args[0]; | ||
| const candidates = [first?.loc?.start, first?.loc, first?.node?.loc?.start, first?.node?.loc]; | ||
| const point = candidates.find((candidate) => typeof candidate?.line === 'number'); | ||
| return point ? {line: point.line, column: typeof point.column === 'number' ? point.column : 0} : null; | ||
| } | ||
|
|
||
| /** | ||
| * Makes an oxlint JS plugin rule respect `eslint-disable` comments written for the ESLint rule it | ||
| * copies. Without it, `// eslint-disable-next-line react-hooks/exhaustive-deps` would not hide the | ||
| * oxlint copy's report, because oxlint calls that copy `hosted/exhaustive-deps`. | ||
| * @param {import('eslint').Rule.RuleModule} rule | ||
| * @param {string} eslintRuleId the ID ESLint reports this rule under, like `react-hooks/exhaustive-deps` | ||
| * @returns {import('eslint').Rule.RuleModule} | ||
| */ | ||
| function withEslintDirectiveIDs(rule, eslintRuleId) { | ||
| return { | ||
| ...rule, | ||
| create(context) { | ||
| const filteredContext = Object.create(context, { | ||
| report: { | ||
| // Keeps every argument: ESLint also accepts the legacy `report(node, message)` form. | ||
| value(...args) { | ||
| const position = reportPosition(args); | ||
| if (position != null) { | ||
| const sourceCode = context.sourceCode ?? context.getSourceCode(); | ||
| const filename = context.filename ?? context.getFilename(); | ||
| if (isSuppressed(directivesFor(filename, sourceCode.getAllComments()), eslintRuleId, position)) { | ||
| return; | ||
| } | ||
| } | ||
| return context.report(...args); | ||
| }, | ||
| }, | ||
| }); | ||
| return rule.create(filteredContext); | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * withEslintDirectiveIDs for a whole plugin at once. `toEslintId` gives the ESLint name of each rule, | ||
| * like `naming-convention` to `@typescript-eslint/naming-convention`. | ||
| * @param {Record<string, import('eslint').Rule.RuleModule>} rules | ||
| * @param {(name: string) => string} toEslintId maps an oxlint rule name to its ESLint ID | ||
| * @returns {Record<string, import('eslint').Rule.RuleModule>} | ||
| */ | ||
| function withEslintDirectiveIDsFor(rules, toEslintId) { | ||
| return Object.fromEntries(Object.entries(rules).map(([name, rule]) => [name, withEslintDirectiveIDs(rule, toEslintId(name))])); | ||
| } | ||
|
|
||
| export {withEslintDirectiveIDs, withEslintDirectiveIDsFor}; | ||
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking forward to removing this after we consolidate on Oxlint
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agree. At this point, I’ve tried to play it safe by maintaining maximum parity with ESLint results and avoiding codemod changes. We can experiment later and compare how it performs without each workaround. Some of them improve timings, while others are just there for safety or to maintain parity with ESLint-disable behavior. It should be easier to experiment too when whole repo check is much faster