Skip to content
Merged
Show file tree
Hide file tree
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 Oct 21, 2026
f378b72
Re-baseline the oxlint seatbelt against current main
LukasMod Oct 21, 2026
c915d1a
Trim oxlint migration comments to workaround explanations
LukasMod Oct 21, 2026
f9533a9
Skip the oxlint React Compiler gate inside the lint pipeline and drop…
LukasMod Oct 21, 2026
26c2b62
Use native no-unreachable-loop, drop dead oxlint mappings, clarify wo…
LukasMod Oct 21, 2026
aa8ee27
Merge main into feat/oxlint-clean
LukasMod Oct 21, 2026
1fb7903
Re-baseline the oxlint seatbelt after merging main
LukasMod Oct 21, 2026
9090e32
Run the oxlint React Compiler gate inside the lint pipeline again
LukasMod Oct 21, 2026
71aede4
Report oxlint parse errors like ESLint instead of failing the whole run
LukasMod Oct 22, 2026
fe1b3e2
Give oxlint ESLint's import resolver settings so hosted import rules …
LukasMod Oct 22, 2026
221d0b6
Skip React Compiler diagnostics without a location, as eslint-plugin-…
LukasMod Oct 22, 2026
7c4ae50
Match ESLint directive placement for hosted rules: block comments onl…
LukasMod Oct 22, 2026
ff17244
Compile .mts and .cts files as TypeScript in the oxlint React Compile…
LukasMod Oct 22, 2026
c6d452c
Run oxlint --fix in one process, replace OXLINT_SHARDS with --shards=…
LukasMod Oct 22, 2026
69bef7b
Treat a codeless oxlint diagnostic with no location as a plugin crash…
LukasMod Oct 22, 2026
9e50d9d
Absorb new oxlint findings into its baseline on main while oxlint is …
LukasMod Oct 22, 2026
c495bc3
Report an oxlint baseline change only after checking the file changed
LukasMod Oct 23, 2026
f8fb9a0
Lint oxlint.config.mts with both linters and disable naming-conventio…
LukasMod Oct 23, 2026
a23cdab
Explain in core-rules.mjs which hosted rules are JS only and where th…
LukasMod Oct 23, 2026
8869c12
Merge main into feat/oxlint-clean
LukasMod Oct 23, 2026
6673787
Drop the parentheses after Onyx.connectWithoutView in the OnyxUtils l…
LukasMod Oct 23, 2026
602bf37
Rename withEslintDirectiveIds to withEslintDirectiveIDs
LukasMod Oct 23, 2026
a92fc59
Reword the OnyxUtils lint message so the reviewer bot does not match it
LukasMod Oct 23, 2026
6053df8
Say ESLint also replaces the restricted globals list for GitHub Actio…
LukasMod Oct 23, 2026
bff2d27
Shorten the seatbelt comment in the oxlint workflow
LukasMod Oct 23, 2026
4e19649
Add a header comment to each oxlint config file
LukasMod Oct 23, 2026
04f1cf4
Add JSDoc to the oxlint config functions
LukasMod Oct 23, 2026
c9c3bb7
Say what OxlintLinter does in its class comment
LukasMod Oct 23, 2026
963b162
Mirror main's narrowed prefer-nullish-coalescing in the oxlint config
LukasMod Oct 23, 2026
9d18084
Re-baseline the oxlint seatbelt after merging main
LukasMod Oct 23, 2026
27af8ee
Merge remote-tracking branch 'origin/main' into feat/oxlint-clean
LukasMod Oct 23, 2026
1171139
Write the ESTree types inline in the oxlint JSDoc so knip finds no un…
LukasMod Oct 23, 2026
26441b2
Re-baseline the oxlint seatbelt after merging main
LukasMod Oct 23, 2026
fb608d1
Merge main into feat/oxlint-clean
LukasMod Oct 26, 2026
5316e0e
Re-baseline the oxlint seatbelt after merging main
LukasMod Oct 26, 2026
a196995
Merge main into feat/oxlint-clean
LukasMod Oct 27, 2026
6eca4cc
Mirror the new no-unsafe-onyx-read, no-onyx-get-snapshot-key and no-m…
LukasMod Oct 27, 2026
de96f43
Re-baseline the oxlint seatbelt after merging main
LukasMod Oct 27, 2026
6c0c0de
Drop the temporary oxlint release-age exclusions and the unused vite-…
LukasMod Oct 27, 2026
9dcae47
Merge main into feat/oxlint-clean
LukasMod Oct 27, 2026
eff851a
Share one React Compiler verdict across lint and trim the oxlint brid…
LukasMod Oct 27, 2026
de1452a
Re-baseline the oxlint seatbelt after merging main
LukasMod Oct 27, 2026
6d0fcfe
Bump oxlint, oxlint-tsgolint and oxc-transform-react and re-baseline …
LukasMod Oct 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .github/workflows/oxlint.yml
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
5 changes: 5 additions & 0 deletions .github/workflows/preDeploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ jobs:
uses: ./.github/workflows/lint.yml
secrets: inherit

# Left out of confirmPassingBuild's `needs` on purpose: Oxlint is a shadow gate that runs here to auto-tighten its baseline.
oxlint:
uses: ./.github/workflows/oxlint.yml
secrets: inherit

oxfmt:
uses: ./.github/workflows/oxfmt.yml

Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,7 @@ server/victory-chart-renderer/.dev/

# Bun lockfiles (VCR uses npm workspaces + package-lock.json; bun is runtime-only)
bun.lock

# Per-run Oxlint configs written by scripts/lint/oxlint/OxlintLinter.ts
.oxlintrc.js-plugins.*.json
.oxlintrc.type-aware.*.json
7 changes: 7 additions & 0 deletions .npmrc
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,10 @@ min-release-age-exclude[]=@expensify/react-native-wallet
min-release-age-exclude[]=react-fast-pdf
min-release-age-exclude[]=react-native-key-command
min-release-age-exclude[]=react-native-qrcode-svg
# List each native binding scope too, or npm silently skips the optional dependency.
min-release-age-exclude[]=oxlint
min-release-age-exclude[]=@oxlint/*
min-release-age-exclude[]=oxc-transform-react
min-release-age-exclude[]=@oxc-transform-react/*
min-release-age-exclude[]=oxlint-tsgolint
min-release-age-exclude[]=@oxlint-tsgolint/*
176 changes: 176 additions & 0 deletions config/oxlint/eslintDirectives.mjs
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) {

Copy link
Copy Markdown
Contributor

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

Copy link
Copy Markdown
Contributor Author

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

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};
Loading
Loading