Skip to content
Draft
Show file tree
Hide file tree
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 Nov 5, 2026
94238a5
Add ESLint 4.x lint snapshot under lint-parity
LukasMod Nov 5, 2026
ee555b7
Switch tsconfig to bundler resolution for tsgolint, keep CommonJS build
LukasMod Nov 5, 2026
2549d8f
Add Oxlint 1.90.0 type-aware config with JS plugins for ESLint-only r…
LukasMod Nov 5, 2026
17c1d88
Add ESLint vs Oxlint parity report and close config gaps
LukasMod Nov 5, 2026
9556a80
Gate ESLint and Oxlint with warning-count thresholds instead of a bas…
LukasMod Nov 5, 2026
447020d
Add lint-parity README with results and gating recommendation
LukasMod Nov 5, 2026
171ddf1
Derive Oxlint config from eslint-config-expensify presets at load time
LukasMod Nov 5, 2026
7010e3c
Drop Oxlint rule-name mapping and directive wrapper, migrate disable …
LukasMod Nov 6, 2026
bf673e2
Keep earlier options for severity-only JS plugin rules and trim Oxlin…
LukasMod Nov 6, 2026
89a3eda
Reword Oxlint config comments in plain language and drop local-only l…
LukasMod Nov 6, 2026
415b8c7
Make Oxlint the main lint script and keep ESLint as lint:eslint
LukasMod Nov 6, 2026
f379858
Replace Oxlint max-warnings gate with an App-style seatbelt that auto…
LukasMod Nov 6, 2026
0642b5e
Trim seatbelt comments and run test:lint by file for Node 24
LukasMod Nov 6, 2026
62e4a6c
Migrate only eslint-config-expensify presets and keep Onyx overrides …
LukasMod Nov 6, 2026
f382c6b
Keep CommonJS tsconfig for GitHub Action builds
LukasMod Nov 6, 2026
eee5353
Merge dual disable comments into oxlint-disable now that ESLint is gone
LukasMod Nov 6, 2026
582d06f
Harden Oxlint config derivation with checks ported from App review
LukasMod Nov 6, 2026
9de42e9
Run ts-node on CommonJS so build:docs does not rely on ESM detection
LukasMod Nov 6, 2026
bb3968a
chore: Bump eslint-config-expensify to 4.0.20
LukasMod Nov 9, 2026
66b5fac
chore: Build oxlint config with eslint-config-expensify
LukasMod Nov 9, 2026
5854fc9
chore: Upgrade oxlint to 1.92 and use the latest eslint-config-expensify
LukasMod Nov 9, 2026
dbaaa07
chore: Use the latest eslint-config-expensify build
LukasMod Nov 9, 2026
6eece7e
chore: Use the latest eslint-config-expensify build
LukasMod Nov 9, 2026
38d76c8
fix: Restore no-unresolved and the underscore exception for TS files
LukasMod Nov 9, 2026
e94e512
chore: Lint the oxlint config files
LukasMod Nov 9, 2026
6679091
style: Format oxlint.config.mts with prettier
LukasMod Nov 9, 2026
ea8f2a0
chore: Bump eslint-config-expensify to 3561252
LukasMod Nov 10, 2026
a2e2aac
chore: Bump eslint-config-expensify to 8dddd5a and add oxc-parser
LukasMod Nov 10, 2026
d25f294
chore: Bump eslint-config-expensify to 312d582
LukasMod Nov 10, 2026
cc8cd68
chore: Bump eslint-config-expensify to c8b9b3c and extend preset rule…
LukasMod Nov 10, 2026
2b43d29
chore: Fix oxlint override comments and compute warningCount once
LukasMod Nov 10, 2026
1ce94c1
chore: Bump eslint-config-expensify to adaccd2
LukasMod Nov 10, 2026
a845f5e
chore: Bump eslint-config-expensify to 95cf16f
LukasMod Nov 10, 2026
b1e6a54
chore: Bump eslint-config-expensify to 72fcf03
LukasMod Nov 11, 2026
8481c8b
docs: Tidy oxlint comments and JSDoc
LukasMod Nov 12, 2026
c95a8fb
chore: Bump eslint-config-expensify to c909982
LukasMod Nov 12, 2026
2f56d52
Merge branch 'main' of https://github.com/Expensify/react-native-onyx…
LukasMod Nov 12, 2026
01a8846
chore: Raise the oxlint baseline for tests added on main
LukasMod Nov 12, 2026
d84feef
chore: Bump eslint-config-expensify to 59d8bd1
LukasMod Nov 12, 2026
d43bd4b
chore: Bump eslint-config-expensify to df0c4ed
LukasMod Nov 12, 2026
728a362
chore: Bump eslint-config-expensify to 3517bc0
LukasMod Nov 12, 2026
957f0e2
chore: Bump eslint-config-expensify to 6a3a784
LukasMod Nov 13, 2026
ddfdceb
chore: Bump eslint-config-expensify to c36e57c
LukasMod Nov 13, 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
8 changes: 8 additions & 0 deletions .github/actions/javascript/tsconfig.json
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"
}
}
48 changes: 44 additions & 4 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ name: Lint JavaScript
on:
pull_request:
types: [opened, synchronize]
push:
branches: [main]

jobs:
lint:
# os-botify commits version bumps and seatbelt tightening to main; those commits need no lint run.
if: ${{ github.actor != 'os-botify[bot]' }}
runs-on: ubuntu-latest
steps:
# v5.0.0
Expand All @@ -21,10 +25,7 @@ jobs:

- run: npm ci

- run: npm run lint
env:
CI: true

# Runs before lint, which may rewrite the seatbelt file and would fail this diff check.
- name: Verify there's no Prettier diff
run: |
npm run prettier -- --loglevel silent
Expand All @@ -33,3 +34,42 @@ jobs:
echo 'Error: Prettier diff detected! Please run `npm run prettier` and commit the changes.'
exit 1
fi

- run: npm run lint
Comment thread
LukasMod marked this conversation as resolved.
Resolved
env:
CI: true

# On main, lint rewrites the seatbelt file when merged PRs fixed grandfathered errors. os-botify commits it back.
- name: Check for tightened seatbelt baseline
id: seatbelt
if: github.event_name == 'push'
run: |
if git diff --quiet config/oxlint/oxlint.seatbelt.tsv; then
echo "tightened=false" >> "$GITHUB_OUTPUT"
echo "::notice::oxlint.seatbelt.tsv unchanged; skipping auto-commit."
else
echo "tightened=true" >> "$GITHUB_OUTPUT"
fi

- name: Generate os-botify token
id: token
if: steps.seatbelt.outputs.tightened == 'true'
uses: actions/create-github-app-token@9d97a4282b2c51a2f4f0465b9326399f53c890d4
with:
app-id: ${{ secrets.OS_BOTIFY_APP_ID }}
private-key: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }}

# If another commit changed the file meanwhile, the API rejects the stale sha. The next push to main
# tightens again, so this step is allowed to fail.
- name: Auto-commit tightened seatbelt baseline
if: steps.seatbelt.outputs.tightened == 'true'
continue-on-error: true
run: |
file=config/oxlint/oxlint.seatbelt.tsv
gh api --method PUT "/repos/${{ github.repository }}/contents/$file" \
--field message="Auto-tighten oxlint seatbelt baseline" \
--field content="$(base64 -w0 "$file")" \
--field branch=main \
--field sha="$(git rev-parse "HEAD:$file")"
env:
GITHUB_TOKEN: ${{ steps.token.outputs.token }}
4 changes: 3 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,6 @@ jobs:
env:
CI: true

- run: npm run test:types
- run: npm run test:types

- run: npm run test:lint
164 changes: 164 additions & 0 deletions config/oxlint/lint.mjs
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;
Loading
Loading