Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 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
d7509f6
Merge remote-tracking branch 'upstream/main' into feat/oxlint
LukasMod Oct 27, 2026
a2a9a71
Mirror the new no-unsafe-onyx-read, no-onyx-get-snapshot-key and no-m…
LukasMod Oct 27, 2026
f9a173d
Re-baseline the oxlint seatbelt after merging main
LukasMod Oct 27, 2026
78418f9
Probe no-unsafe-onyx-read, no-onyx-get-snapshot-key and no-manual-mem…
LukasMod Oct 27, 2026
22ed16f
Bump oxlint, oxlint-tsgolint and oxc-transform-react and re-baseline …
LukasMod Oct 27, 2026
718bb99
Merge remote-tracking branch 'upstream/main' into feat/oxlint
LukasMod Oct 27, 2026
51376fe
Re-baseline the oxlint seatbelt after merging main
LukasMod Oct 27, 2026
6bf9e41
Share one React Compiler verdict across lint and trim the oxlint brid…
LukasMod Oct 27, 2026
d32ee6d
Prove the oxlint bridge's validation list against eslint-plugin-react…
LukasMod Oct 27, 2026
2d734ad
Merge remote-tracking branch 'upstream/main' into feat/oxlint
LukasMod Oct 27, 2026
ffbd75b
Say where each tool anchors the preserve-manual-memoization findings …
LukasMod Oct 27, 2026
0f005d5
Drop the two-compiler wording left in the oxlint migration harness af…
LukasMod Oct 27, 2026
02fe6c4
Drop the unused vite-plus override
LukasMod Oct 27, 2026
76740b7
Merge remote-tracking branch 'upstream/main' into feat/oxlint
LukasMod Oct 29, 2026
aaa76a7
Merge origin/main into feat/oxlint-v1
LukasMod Nov 3, 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
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,18 @@ 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
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
7 changes: 4 additions & 3 deletions config/oxlint/reactCompilerRust.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,10 @@ function buildMessage(category, error) {
* category it does not know, so an oxc-transform-react bump that changes the categories fails the run.
* @param {string} filename
* @param {string} sourceText
* @param {Record<string, unknown>} [environment] the lint-only validations; the harness passes variants
* @returns {Array<{ruleName: string, loc: {start: {line: number, column: number}, end: {line: number, column: number}}, message: string}>}
*/
function analyze(filename, sourceText) {
function analyze(filename, sourceText, environment = ENVIRONMENT) {
let result;
try {
const buildOptions = oxcReactCompilerConfig();
Expand All @@ -176,7 +177,7 @@ function analyze(filename, sourceText) {
// Without it the binding returns only fatal diagnostics.
reportDiagnostics: true,
flowSuppressions: false,
environment: {...buildOptions.environment, ...ENVIRONMENT},
environment: {...buildOptions.environment, ...environment},
},
});
} catch (error) {
Expand Down Expand Up @@ -254,4 +255,4 @@ function reactCompilerDiagnostics(filename, sourceText) {
return cache.get(filename);
}

export {RULE_BY_CATEGORY, reactCompilerDiagnostics};
export {analyze, CONFIG_CATEGORY, ENVIRONMENT, IGNORED_CATEGORIES, RULE_BY_CATEGORY, reactCompilerDiagnostics};
3 changes: 3 additions & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,14 @@
"Ephermeral",
"Erste",
"Español",
"codemod",
"detekt",
"oxfmtrc",
"oxlint",
"oxlintrc",
"tsgo",
"tsgolint",
"ungated",
"Fabrikam",
"fillyjonk",
"hemulen",
Expand Down Expand Up @@ -1308,6 +1310,7 @@
"tests/unit/hooks/useLetterAvatars.test.tsx",
"tests/unit/usePersonalDetailSearchSelectorTest.tsx",
"config/oxlint/oxlint.seatbelt.tsv",
"INVESTIGATION DOCS/**",
"oxlint.config.mts",
"tests/unit/BankAccountUtilsTest.ts",
"tests/unit/__snapshots__/ReportAlternateTextParityTest.ts.snap",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {RuleTester} from 'eslint';

// eslint-disable-next-line import/extensions -- Node resolves this file directly when the oxlint-migration harness imports it, so the extension is required
import {create, meta} from '../no-direct-personal-details-list.js';

const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
});

// Mirrors tests/unit/NoDirectPersonalDetailsListRuleTest.ts. oxlint-migration/rule-tester harvests
// this file and replays the cases through both tools, so the rule is proven to report on oxlint's
// bridged AST, not just on ESLint's.

ruleTester.run(
'no-direct-personal-details-list',
{create, meta},
{
valid: [
'const [personalDetail] = usePersonalDetail(accountID);',
'const [personalDetails] = usePersonalDetailsByIDs(accountIDs);',
'const personalDetail = getPersonalDetail(accountID);',
'type Update = OnyxUpdate<typeof ONYXKEYS.PERSONAL_DETAILS_LIST>;',
'type Data = Partial<Record<typeof ONYXKEYS.PERSONAL_DETAILS_LIST, PersonalDetailsList>>;',
'const [metadata] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_METADATA);',
'const [list] = useOnyx(ONYXKEYS.COLLECTION.REPORT);',
'const key = SOME_OTHER_KEYS.PERSONAL_DETAILS_LIST;',
'const key = ONYXKEYS[PERSONAL_DETAILS_LIST];',
],
invalid: [
{code: 'const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);', errors: 1},
{code: 'const [login] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(accountID)});', errors: 1},
{code: "const [personalDetails] = useOnyx(ONYXKEYS['PERSONAL_DETAILS_LIST']);", errors: 1},
{code: 'const [personalDetails] = useOnyx(ONYXKEYS[`PERSONAL_DETAILS_LIST`]);', errors: 1},
{code: 'Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[accountID]: null});', errors: 1},
{code: 'const update = {onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PERSONAL_DETAILS_LIST, value: personalDetails};', errors: 1},
{code: 'Onyx.connectWithoutView({key: ONYXKEYS.PERSONAL_DETAILS_LIST, callback: (value) => value});', errors: 1},
{code: 'const masks = {[ONYXKEYS.PERSONAL_DETAILS_LIST]: {allowList: []}};', errors: 1},
{code: 'const [all] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {});', errors: 2},
],
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {RuleTester} from 'eslint';

// eslint-disable-next-line import/extensions -- Node resolves this file directly when the oxlint-migration harness imports it, so the extension is required
import {create, meta} from '../no-direct-pre-insert-fullscreen-under-rhp.js';

const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
});

const importNavigation = "import Navigation from '@libs/Navigation/Navigation';";

ruleTester.run(
'no-direct-pre-insert-fullscreen-under-rhp',
{create, meta},
{
valid: [
{
code: `${importNavigation}\nNavigation.preInsertFullscreenUnderRHP(route);`,
filename: 'src/hooks/usePreMountDestination/index.ts',
},
{
code: `${importNavigation}\nNavigation.preInsertFullscreenUnderRHP(route);`,
filename: 'src/libs/Navigation/Navigation.ts',
},
{code: 'const Navigation = {preInsertFullscreenUnderRHP: () => {}};\nNavigation.preInsertFullscreenUnderRHP(route);'},
{code: `${importNavigation}\nNavigation.navigate(route);`},
{code: `${importNavigation}\nconst preInsert = Navigation.preInsertFullscreenUnderRHP;`},
],
invalid: [
{
code: `${importNavigation}\nNavigation.preInsertFullscreenUnderRHP(route);`,
errors: [{messageId: 'useHookInstead'}],
},
{
code: `${importNavigation}\nNavigation['preInsertFullscreenUnderRHP'](route);`,
errors: [{messageId: 'useHookInstead'}],
},
{
code: `${importNavigation}\nconst {preInsertFullscreenUnderRHP} = Navigation;\npreInsertFullscreenUnderRHP(route);`,
errors: [{messageId: 'useHookInstead'}],
},
{
code: `${importNavigation}\nconst preInsert = Navigation.preInsertFullscreenUnderRHP;\npreInsert(route);`,
errors: [{messageId: 'useHookInstead'}],
},
{
code: "import * as Navigation from '@src/libs/Navigation/Navigation';\nNavigation.preInsertFullscreenUnderRHP(route);",
errors: [{messageId: 'useHookInstead'}],
},
{
code: "import Navigation from '../../libs/Navigation/Navigation';\nNavigation.preInsertFullscreenUnderRHP(route);",
errors: [{messageId: 'useHookInstead'}],
filename: 'src/pages/home/ReportScreen.tsx',
},
],
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {RuleTester} from 'eslint';

// eslint-disable-next-line import/extensions -- Node resolves this file directly when the oxlint-migration harness imports it, so the extension is required
import {create, meta} from '../no-layout-spacing-conditional.js';

const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
parserOptions: {ecmaFeatures: {jsx: true}},
},
});

ruleTester.run(
'no-layout-spacing-conditional',
{create, meta},
{
valid: [
'const {cardPaddingHorizontal} = useLayoutSpacing(); const style = [styles.flexRow, cardPaddingHorizontal];',
'const style = shouldUseNarrowLayout ? styles.flexColumn : styles.flexRow;',
'const style = shouldUseNarrowLayout ? styles.alignItemsCenter : styles.alignItemsStart;',
'const style = isSelected ? styles.ph5 : styles.ph8;',
'const style = shouldUseNarrowLayout ? {marginBottom: variables.bottomTabHeight} : undefined;',
'const jsx = <View style={shouldUseNarrowLayout ? styles.w100 : styles.flex1} />;',
'const style = styles.ph5;',
'const style = shouldUseNarrowLayout ? styles.pb2 : styles.pb5;',
'const style = shouldUseNarrowLayout ? styles.mt3 : styles.mt5;',
'const style = shouldUseNarrowLayout ? styles.gap3 : styles.gap5;',
'const style = shouldUseNarrowLayout ? styles.ph3 : styles.ph2;',
'const style = shouldUseNarrowLayout ? styles.mh5 : styles.mh2;',
'const style = isLargeScreenWidth ? styles.mr3 : styles.mr2;',
'const style = shouldUseNarrowLayout ? styles.p3 : styles.p4;',
'const style = shouldUseNarrowLayout ? styles.ph5 : styles.mh8;',
'const style = shouldUseNarrowLayout ? styles.ph5 : undefined;',
'const style = isLargeScreenWidth ? styles.ph5 : styles.ph8;',
'const style = isMediumScreenWidth ? styles.ph5 : styles.ph8;',
],
invalid: [
{
code: 'const style = shouldUseNarrowLayout ? styles.ph5 : styles.ph8;',
errors: [{messageId: 'layoutSpacingConditionalKnown', data: {test: 'shouldUseNarrowLayout', first: 'ph5', second: 'ph8', replacement: 'cardPaddingHorizontal'}}],
},
{
code: 'const style = isSmallScreenWidth ? styles.p5 : styles.p8;',
errors: [{messageId: 'layoutSpacingConditionalKnown', data: {test: 'isSmallScreenWidth', first: 'p5', second: 'p8', replacement: 'cardPadding'}}],
},
{
code: 'const style = shouldUseNarrowLayout ? styles.mhn5 : styles.mhn8;',
errors: [{messageId: 'layoutSpacingConditionalKnown', data: {test: 'shouldUseNarrowLayout', first: 'mhn5', second: 'mhn8', replacement: 'cardEdgeToEdge'}}],
},
{
code: 'const style = shouldUseNarrowLayout ? [styles.ph5, styles.pb5] : [styles.ph8, styles.pb8];',
errors: [{messageId: 'layoutSpacingConditionalKnown'}],
},
{
code: 'const style = shouldUseNarrowLayout ? styles.ph5 : [styles.ph8, styles.pt3];',
errors: [{messageId: 'layoutSpacingConditionalKnown'}],
},
{
code: 'const style = !shouldUseNarrowLayout ? styles.ph8 : styles.ph5;',
errors: [{messageId: 'layoutSpacingConditionalKnown', data: {test: '!shouldUseNarrowLayout', first: 'ph8', second: 'ph5', replacement: 'cardPaddingHorizontal'}}],
},
{
code: 'const style = isLargeScreenWidth ? styles.ph8 : styles.ph5;',
errors: [{messageId: 'layoutSpacingConditionalKnown', data: {test: 'isLargeScreenWidth', first: 'ph8', second: 'ph5', replacement: 'cardPaddingHorizontal'}}],
},
{
code: 'const style = layout.shouldUseNarrowLayout ? styles.ph5 : styles.ph8;',
errors: [{messageId: 'layoutSpacingConditionalKnown'}],
},
{
code: 'const jsx = <View style={[styles.flexRow, shouldUseNarrowLayout ? styles.ph5 : styles.ph8]} />;',
errors: [{messageId: 'layoutSpacingConditionalKnown'}],
},
{
code: 'const style = shouldUseNarrowLayout ? styles.pl5 : styles.pl8;',
errors: [{messageId: 'layoutSpacingConditionalKnown', data: {test: 'shouldUseNarrowLayout', first: 'pl5', second: 'pl8', replacement: 'cardPaddingLeft'}}],
},
{
code: 'const style = shouldUseNarrowLayout ? styles.mh5 : styles.mh8;',
errors: [{messageId: 'layoutSpacingConditional', data: {test: 'shouldUseNarrowLayout', first: 'mh5', second: 'mh8'}}],
},
{
code: 'const style = isLargeScreenWidth ? styles.mr8 : styles.mr5;',
errors: [{messageId: 'layoutSpacingConditional'}],
},
{
code: 'const padding = shouldUseNarrowLayout ? styles.ph5.paddingHorizontal : styles.ph8.paddingHorizontal;',
errors: [{messageId: 'layoutSpacingConditionalKnown'}],
},
],
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {RuleTester} from 'eslint';

// eslint-disable-next-line import/extensions -- Node resolves this file directly when the oxlint-migration harness imports it, so the extension is required
import {create, meta} from '../require-a11y-disable-justification.js';

const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
parserOptions: {ecmaFeatures: {jsx: true}},
},
});

const ISSUE = 'https://github.com/Expensify/App/issues/12345';

ruleTester.run(
'require-a11y-disable-justification',
{create, meta},
{
valid: [
{code: `// eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors -- ${ISSUE}\nconst element = <View onPress={onPress} />;`},
{
code: '// eslint-disable-next-line react-native-a11y/has-valid-accessibility-ignores-invalid-hints -- https://github.com/FormidableLabs/eslint-plugin-react-native-a11y/pull/78\nconst element = <View onPress={onPress} />;',
},
{
code: '// eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors -- decorative wrapper, the child owns the label\nconst element = <View onPress={onPress} />;',
},
{
code: '/* eslint-disable react-native-a11y/has-valid-accessibility-descriptors\n * the row below is announced by its parent list item\n */\nconst element = <View onPress={onPress} />;',
},
{code: '// eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors -- https://example.com/1\nconst element = <View onPress={onPress} />;'},
{code: '// eslint-disable-next-line no-console\nconsole.log(1);'},
{code: 'const element = <View onPress={onPress} />;'},
],
invalid: [
{
code: '// eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors\nconst element = <View onPress={onPress} />;',
errors: [{messageId: 'missingIssueOrRationale'}],
},
{
code: '// eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors -- legacy\nconst element = <View onPress={onPress} />;',
errors: [{messageId: 'missingIssueOrRationale'}],
},
{
code: '/* eslint-disable react-native-a11y/has-valid-accessibility-ignores-invalid-hints */\nconst element = <View onPress={onPress} />;',
errors: [{messageId: 'missingIssueOrRationale'}],
},
{
code: '// eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors\nconst first = <View onPress={onPress} />;\n// eslint-disable-next-line react-native-a11y/has-valid-accessibility-ignores-invalid-hints\nconst second = <View onPress={onPress} />;',
errors: [{messageId: 'missingIssueOrRationale'}, {messageId: 'missingIssueOrRationale'}],
},
],
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {RuleTester} from 'eslint';

// eslint-disable-next-line import/extensions -- Node resolves this file directly when the oxlint-migration harness imports it, so the extension is required
import {create, meta} from '../require-live-region-for-status-updates.js';

const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
parserOptions: {ecmaFeatures: {jsx: true}},
},
});

ruleTester.run(
'require-live-region-for-status-updates',
{create, meta},
{
valid: [
{code: 'const element = <View role="alert" accessibilityLiveRegion="polite" />;'},
{code: 'const element = <View accessibilityRole="status" accessibilityLiveRegion="assertive" />;'},
{code: 'const element = <View role="button" />;'},
{code: 'const element = <View accessibilityRole={CONST.ROLE.PRESENTATION} />;'},
{code: 'const element = <View role={dynamicRole} accessibilityLiveRegion="polite" />;'},
{code: 'const element = <View accessibilityRole={CONST.ROLE.ALERT} accessibilityLiveRegion="polite" />;'},
],
invalid: [
{
code: 'const element = <View role="alert" />;',
errors: [{messageId: 'missingLiveRegion'}],
},
{
code: 'const element = <View accessibilityRole={CONST.ROLE.STATUS} />;',
errors: [{messageId: 'missingLiveRegion'}],
},
{
code: 'const element = <View role={hasError ? "alert" : "button"} />;',
errors: [{messageId: 'missingLiveRegion'}],
},
{
code: 'const element = <View accessibilityLiveRegion="polite" />;',
errors: [{messageId: 'missingStatusRole'}],
},
{
code: 'const element = <View role="button" accessibilityLiveRegion="assertive" />;',
errors: [{messageId: 'missingStatusRole'}],
},
{
code: 'const element = <View role="status" accessibilityLiveRegion="none" />;',
errors: [{messageId: 'invalidLiveRegion'}],
},
],
},
);
Loading
Loading