Skip to content
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

feat(eslint-plugin): [prefer-optional-chain] handle cases where the first operands are unrelated to the rest of the chain and add type info #6397

Merged
merged 32 commits into from
Jul 7, 2023

Conversation

bradzacher
Copy link
Member

@bradzacher bradzacher commented Jan 31, 2023

PR Checklist

Overview

This is a big refactor of the lint rule so that it can be MUCH more robust and handle more cases.

The new algorithm is based off of flattening the logical expression to an array and then iterating over it. This new algorithm allows us to handle cases like this:

if (unrelated && foo && foo.bar) {...}

The new algorithm also includes a beefy recursive node comparison function which clearly compares two nodes semantically, instead of comparing them loosely based on source code text. This should lead to fewer weird cases, and also allows us to handle cases like this:

if (foo.bar && foo?.bar.baz) {...}
if (foo?.bar && foo.bar.baz) {...}

The TL;DR of the algorithm:

  1. Convert the logical chain to a list of logical operators in order
    foo && foo.bar && foo.bar.baz --> [foo, foo.bar, foo.bar.baz]
  2. Classify each operand based on whether or not it's in a form that does a "nullish comparison" that can be converted to optional chain.
    foo != null && foo.bar !== undefined && foo.bar.baz --> [foo<NotEqualNullOrUndefined>, foo.bar<NotStrictEqualUndefined>, foo.bar.baz<Boolean>]
  3. Further analyse the chain looking for pairs like foo !== null && foo !== undefined and group those as one operand
  4. Analyse the chain looking for contiguous sub-chains of "subset comparisons" (eg foo.bar is a subset of foo.bar.baz.bam, but not of foo.baz)
  5. When a sub-chain ends, report on it.

TODO:

@typescript-eslint
Copy link
Contributor

Thanks for the PR, @bradzacher!

typescript-eslint is a 100% community driven project, and we are incredibly grateful that you are contributing to that community.

The core maintainers work on this in their personal time, so please understand that it may not be possible for them to review your work immediately.

Thanks again!


🙏 Please, if you or your company is finding typescript-eslint valuable, help us sustain the project by sponsoring it transparently on https://opencollective.com/typescript-eslint.

@nx-cloud
Copy link

nx-cloud bot commented Jan 31, 2023

☁️ Nx Cloud Report

CI is running/has finished running commands for commit 3c387a9. As they complete they will appear below. Click to see the status, the terminal output, and the build insights.

📂 See all runs for this branch


✅ Successfully ran 47 targets

Sent with 💌 from NxCloud.

@netlify
Copy link

netlify bot commented Jan 31, 2023

Deploy Preview for typescript-eslint ready!

Name Link
🔨 Latest commit 4c36891
🔍 Latest deploy log https://app.netlify.com/sites/typescript-eslint/deploys/64a7905efc36a000084beac7
😎 Deploy Preview https://deploy-preview-6397--typescript-eslint.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site configuration.

@bradzacher
Copy link
Member Author

This is now "check complete" - all existing tests are passing as are the new tests.
Need to rebuild the fixer logic though.

This is going to be a big chunk of the logic to figure out the correct places to put an optional chain 🤔

@bradzacher bradzacher added the enhancement: new plugin rule New rule request for eslint-plugin label Apr 17, 2023
@bradzacher bradzacher changed the base branch from main to v6 April 17, 2023 04:42
@nx-cloud
Copy link

nx-cloud bot commented Apr 17, 2023

☁️ Nx Cloud Report

CI is running/has finished running commands for commit 4c36891. As they complete they will appear below. Click to see the status, the terminal output, and the build insights.

📂 See all runs for this branch


✅ Successfully ran 31 targets

Sent with 💌 from NxCloud.

@bradzacher
Copy link
Member Author

@typescript-eslint/triage-team - this is functionally complete in terms of raw logic.
However it is missing the fixer logic because:

  1. frankly it's not a small piece of work to re-implement it.
  2. it's the bit that made the rule unsafe to begin with (x != null && x.y returns false | T, but x?.y returns T | undefined)

In some future state I can definitely see us including:

  • an autofixer if we detect it's safe (eg if it doesn't change the types), or
  • a suggestion fixer in other cases

But I don't know if it's worth "holding up" this change to re-implement. Happy to do it if we think it's necessary, if not then we can review and land this as is.

@JoshuaKGoldberg
Copy link
Member

JoshuaKGoldberg commented Apr 17, 2023

😞 I do dislike removing functionality. Maybe we could put this out as an experimental rule without deleting the existing one?

@bradzacher
Copy link
Member Author

Maybe we could put this out as an experimental rule without deleting the existing one?

That would create a worse state, IMO - as we would have two nearly identical rules that are mostly differentiated by having a fixer or not. What would be the value prop for a user to switch to test the rule? We'd need a breaking change to remove the experimental rule name.

Also "experimental" implies instability or that it may not be adopted - but the rule is perfectly stable (passes all the existing tests, and more) and will be adopted - it just doesn't have the previously available (unsafe) fixer.

@JoshuaKGoldberg
Copy link
Member

Yeah that's a good point.

Heh, I'm torn here. Expecting previously (perhaps slightly incorrect) satisfied users to be annoyed by the lack of autofixer.

Given that the autofixer today is unsafe, I think it's reasonable for us to say "we've rewritten and removed it for safety - welcoming community PRs to add it back".

@omril1
Copy link
Contributor

omril1 commented Apr 17, 2023

It's really sad to see the fixer being removed, my team absolutely loved it when I turned the rule on since we worked on a code-base full of partial objects (and different patterns to deal with them).
I've never even once saw a case of false positive fix of the type object | false.
I did see a few false positives returning undefined instead of null (from nullish objects) but they only affected tests asserting on null, the real code had falsy checks catching both.

@JoshuaKGoldberg
Copy link
Member

...maybe we could expose the old one under a name like "deprecated"?

@bradzacher
Copy link
Member Author

I've never even once saw a case of false positive fix of the type object | false.

Also applies to 0 | object and '' | object.
It's rare, for sure, but we've had a few users report it - which means it's not as rare as you might hope in the wild.

The bigger issue is the conversion of return types - x != null && x.y returns false | T, whereas x?.y returns undefined | T. In a lot of cases this distinction isn't going to matter - eg in boolean locations like if or logical expressions, or in expression statements. But there are places where this can matter and the autofix will cause errors. Again we've had some reports of this!

Honestly I'd be happy with having a flag like allowPotentiallyUnsafeFixesThatModifyTheReturnTypeIKnowWhatImDoing which enables people to opt-in to an unsafe fixer - the issue is that it's just a bigger chunk of work to build the fixer.

@bradzacher bradzacher changed the title feat(eslint-plugin): [prefer-optional-chain] handle cases where the first operands are unrelated to the rest of the chain feat(eslint-plugin): [prefer-optional-chain] handle cases where the first operands are unrelated to the rest of the chain and add type info Apr 23, 2023
.cspell.json Outdated Show resolved Hide resolved
@bradzacher
Copy link
Member Author

Fun edge case I just discovered:

declare const foo: { bar: number } | null;
foo === null || foo.bar;
foo !== null && !foo.bar;

In the first case foo.bar is seen by my code as a "truthy boolean" check - which isn't a valid operand in an "OR" chain.
Similarly in the second case !foo.bar is seen as a "falsy boolean" check - which isn't a valid operand in an "AND" chain.

Which means that my code will ignore it and try not to include it in the chain to report and fix.

Will need to add special case logic to handle this as it's obviously intended to be chained together.


This also opens up another question about how this rule functions (that we can probably punt on for now, but worth mentioning).
Currently the rule will ignore operands with "invalid" checks. For example:

foo && foo.bar && foo.bar.baz === 1
// rule will currently fix to
foo?.bar && foo.bar.baz === 1

Should the rule inspect through these "invalid" checks? Or should it ignore them entirely?
I think there's a subset of them that it should include

  • !==/===/!=/== comparing against anything
  • not >/>=/</<= because TS will hard error if one of the operands is nullish.
  • not typeof because foo && typeof foo.bar will return undefined | typeof foo.bar whereas typeof foo?.bar will return typeof foo.bar | 'undefined' - which is VERY different

@JoshuaKGoldberg
Copy link
Member

Should the rule inspect through these "invalid" checks? Or should it ignore them entirely?

...hmm. My instinct is to ignore "invalid" checks as an odd edge case. If users complain we can add it as a bugfix later on - but if the code is roughly not valid, I'd hesitate to feel confident in making changes.

@bradzacher bradzacher marked this pull request as ready for review June 24, 2023 04:13
@bradzacher
Copy link
Member Author

bradzacher commented Jun 24, 2023

Note: I hate the tests with a passion because it's impossible for anyone to understand them as written without checking out and running the test 😢
However without utilities it's really hard to write and maintain the 700 cases with lots of repeated code.
Sigh this is a big pet peeve I have with ESLint rule tests. There has to be a better way...

Snapshot output would make it much simpler to review as you can see the expected reports and fixes properly rather than doing it opaquely.

The test coverage should show how much is covered overall - which should be all of the code!

Copy link
Member

@JoshuaKGoldberg JoshuaKGoldberg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really impressive code. Seriously awesome PR @bradzacher!

I've gone over it a few times and think I generally understand it. Particular ups on the approach of separating out the collection and analysis bits.

I say let's get this in for v6!

Comment on lines +722 to +723
// type parameters are considered
'foo.bar<a>() && foo.bar<a, b>().baz;',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

type parameters were renamed to type arguments in this context, right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

},
],
},
// type parameters are considered
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same

@bradzacher bradzacher merged commit 02a37c4 into v6 Jul 7, 2023
@bradzacher bradzacher deleted the 6332-refactor-prefer-optional-chain branch July 7, 2023 04:28
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Jul 15, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
breaking change This change will require a new major version to be released enhancement: new plugin rule New rule request for eslint-plugin
Projects
None yet
4 participants