Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b77f959
Add oxlint config and lint pipeline
LukasMod Oct 21, 2026
c2485b3
Add oxlint migration harness and probes
LukasMod Oct 21, 2026
7a967ff
Skip the oxlint React Compiler gate inside the lint pipeline and drop…
LukasMod Oct 21, 2026
a7bf01d
Use native no-unreachable-loop, drop dead oxlint settings and mapping…
LukasMod Oct 21, 2026
20cfa8b
Run the oxlint React Compiler gate inside the lint pipeline again
LukasMod Oct 21, 2026
db692e8
Report oxlint parse errors like ESLint instead of failing the whole run
LukasMod Oct 22, 2026
e5c344d
Give oxlint ESLint's import resolver settings so hosted import rules …
LukasMod Oct 22, 2026
4ae6ba4
Skip React Compiler diagnostics without a location, as eslint-plugin-…
LukasMod Oct 22, 2026
5f34c7a
Match ESLint directive placement for hosted rules: block comments onl…
LukasMod Oct 22, 2026
4e55a0f
Compile .mts and .cts files as TypeScript in the oxlint React Compile…
LukasMod Oct 22, 2026
dea26a7
Run oxlint --fix in one process, replace OXLINT_SHARDS with --shards=…
LukasMod Oct 22, 2026
2c88c58
Treat a codeless oxlint diagnostic with no location as a plugin crash…
LukasMod Oct 22, 2026
c1ade4e
Absorb new oxlint findings into its baseline on main while oxlint is …
LukasMod Oct 22, 2026
b2b03ec
Merge remote-tracking branch 'origin/main' into feat/oxlint
LukasMod Oct 23, 2026
1b20e1e
Mirror main's narrowed prefer-nullish-coalescing and absorb its findi…
LukasMod Oct 23, 2026
c60ed1c
Update the prefer-nullish-coalescing fixture to the ignorePrimitives …
LukasMod Oct 23, 2026
849340f
Report an oxlint baseline change only after checking the file changed
LukasMod Oct 23, 2026
e820459
Lint oxlint.config.mts with both linters and disable naming-conventio…
LukasMod Oct 23, 2026
27ea33d
Explain in core-rules.mjs which hosted rules are JS only and where th…
LukasMod Oct 23, 2026
871168a
Drop the parentheses after Onyx.connectWithoutView in the OnyxUtils l…
LukasMod Oct 23, 2026
78b56a6
Record the OnyxUtils message difference in the config-drift ledger
LukasMod Oct 23, 2026
5a0a2d6
Merge remote-tracking branch 'origin/main' into feat/oxlint
LukasMod Oct 26, 2026
9aeaca5
Port the oxlint config docs and comment cleanups from feat/oxlint-clean
LukasMod Oct 26, 2026
e14b240
Re-baseline the oxlint seatbelt after merging main
LukasMod Oct 26, 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
16 changes: 16 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,19 @@ server/victory-chart-renderer/.dev/

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

# Oxlint migration investigation: generated rule inventory (npm run oxlint-rule-inventory)
oxlint-migration/rule-inventory.json
oxlint-migration/rule-availability.json

# Oxlint migration investigation: raw no-deprecated report dumps from the obstacle #4 probe
oxlint-migration/dep-cmp.json
oxlint-migration/dep-only-api.json

# Python caches from the oxlint-migration comparison scripts
__pycache__/
oxlint-migration/port-probe/oxlint.fixtures.merged*.json

# Per-run Oxlint configs written by scripts/lint/oxlint/OxlintLinter.ts
.oxlintrc.js-plugins.*.json
.oxlintrc.type-aware.*.json
5 changes: 5 additions & 0 deletions .npmrc
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,8 @@ 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/*
3 changes: 2 additions & 1 deletion .oxfmtrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@
"src/libs/SearchParser/searchParser.js",
"src/libs/SearchParser/autocompleteParser.js",
"Mobile-Expensify",
"modules/group-ib-fp"
"modules/group-ib-fp",
"oxlint-migration/port-probe/fixtures"
],
"overrides": [
{
Expand Down
6 changes: 6 additions & 0 deletions config/eslint/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,12 @@ const config = defineConfig([
globalIgnores([
'!**/.storybook',
'!**/.github',
// Oxlint migration harness, deleted with ESLint (see oxlint-migration/README.md).
// config/oxlint/ is deliberately NOT ignored: it is production lint config, not an artifact.
'oxlint-migration/**',
// Conductor scratch directory, gitignored. Oxlint already skips it, so ESLint has to as
// well or the two legs lint different file sets.
'.context/**',
'.github/actions/**/index.js',
'**/*.config.js',
'**/*.config.mjs',
Expand Down
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) {
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