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

chore: extract a result sorting function #737

Merged
merged 1 commit into from
Nov 20, 2019
Merged

chore: extract a result sorting function #737

merged 1 commit into from
Nov 20, 2019

Conversation

nulltoken
Copy link
Contributor

@nulltoken nulltoken commented Nov 5, 2019

@nulltoken nulltoken changed the title [WIP] spike: implement a resultComparer chore: extract a result sorting function Nov 12, 2019
@nulltoken
Copy link
Contributor Author

@P0lip Ready for review

return [...results].sort((resultA, resultB) => {
const diff = resultA.range.start.line - resultB.range.start.line;
const safeCastToString = (code: string | number | undefined): string => {
if (code === undefined) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@P0lip Are there any use case where code is either undefined or a number? Wouldn't that be the case, as 5.0 is around the corner, could we make this a non nullable string?

Copy link
Contributor

Choose a reason for hiding this comment

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

AFAIK our own code includes code in each diagnostic result, but that doesn't necessarily need to be the case when it comes to end-user code.
Moreover, if we wanted to make it required, we'd need to make the change over here https://github.com/stoplightio/types/blob/master/src/diagnostics.ts#L63, as that's is the interface SpectralDiagnostic inherits from, or, alternatively, we could make code required over here https://github.com/stoplightio/spectral/blob/develop/src/types/spectral.ts#L42#L44.
Either way, code is rather an additional metadata, not something really required, so I'd keep it optional.

Copy link
Contributor

Choose a reason for hiding this comment

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

and yeah, we should not be throwing either.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

and yeah, we should not be throwing either.

Makes sense. Pushed another proposal.

return code;
};

const ARTIFICIAL_ROOT = 'root';
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's have a symbol here or something really unique. Root can be a potential source

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@P0lip Fixed. However, I can't help but feeling slightly displeased with result. The toString() call isn't really self explicit with regards to the uniqueness intent.

Moreover, although quite unlikely, nothing prevents a caller from feeding source with a string containing Symbol(root).

Thoughts?

Copy link
Contributor

Choose a reason for hiding this comment

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

Let safeSource return a symbol or string. If there is a symbol returned, shift the item to the left (return -1).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@P0lip I went a different way. Do you like it better?

export const sortResults = (results: IRuleResult[]) => {
return [...results].sort((resultA, resultB) => {
const diff = resultA.range.start.line - resultB.range.start.line;
const compareCode = (one: string | number | undefined, another: string | number | undefined): number => {
Copy link
Contributor

@P0lip P0lip Nov 18, 2019

Choose a reason for hiding this comment

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

Suggested change
const compareCode = (one: string | number | undefined, another: string | number | undefined): number => {
const compareCode = (left: IDiagnostic['code'], right: IDiagnostic['code']): -1 | 0 | 1 => {

Not sure why, but I like left and right as names for such comparison functions. 😆
or codeA and codeB
Feel free to leave it as is though.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@P0lip Fixed

return one.localeCompare(another);
};

const normalize = (value: number): -1 | 0 | 1 => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
const normalize = (value: number): -1 | 0 | 1 => {
const normalize = (value: number): -1 | 0 | 1 => Math.max(-1, Math.min(1, Math.floor(value))) as -1 | 0 | 1;

Math.floor is optional, but it's meant to protect us from returning other values than integers.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@P0lip Hmmm. TBH, I do believe that this proposal is less easier to parse than the original (boring) code. I tend to avoid those one-liner smart constructs for readability purposes.

However, if you really want me to update the code, I'll do it, of course.

Copy link
Contributor

Choose a reason for hiding this comment

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

Nah, I don't really mind. :)

return 1;
}

return one.toString().localeCompare(another.toString(), undefined, { numeric: true });
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
return one.toString().localeCompare(another.toString(), undefined, { numeric: true });
return String(one).localeCompare(String(another), void 0, { numeric: true });

String is a slightly safer alternative. String.prototype.toString or Number.prototype.toString can always be monkey-patched in JS 😷

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed

end: { line: 99, character: 99 },
},
message: '99',
severity: 99,
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
severity: 99,
severity: DiagnosticSeverity.Error, // or any other level, it's irrelevant

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed


export const deduplicateResults = (results: IRuleResult[]) => {
const seen: Dictionary<Dictionary<string, string>, symbol> = {};
const sorted = results.sort(resultsComparer);
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess you don't want to mutate results.

Suggested change
const sorted = results.sort(resultsComparer);
const sorted = [...results].sort(resultsComparer);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@P0lip Dammit! Thanks for this.

Copy link
Contributor

@P0lip P0lip left a comment

Choose a reason for hiding this comment

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

Looking good in general! Appreciate your work a lot. Thanks.


export const deduplicateResults = (results: IRuleResult[]) => {
const seen: Dictionary<Dictionary<string, string>, symbol> = {};
const sorted = results.sort(resultsComparer);
Copy link
Contributor

Choose a reason for hiding this comment

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

I suspect you don't intent to mutate results.

Suggested change
const sorted = results.sort(resultsComparer);
const sorted = [...results].sort(resultsComparer);

Copy link
Contributor

Choose a reason for hiding this comment

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

You might need to update some other places in the code to reflect the above change.
I bet certain tests will fail, i.e src/cli/services/__tests__/linter.test.ts

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I bet certain tests will fail, i.e src/cli/services/tests/linter.test.ts

I was expecting them to also fail as well, but the CI server doesn't complain yet. It's possible the current harness doesn't contain enough different broken rules that hit the same line and char range for the "wrongly ordered" results to appear.


const ARTIFICIAL_ROOT = Symbol('root');
const howMany = results.length;
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
const howMany = results.length;
const totalResults = results.length;

sounds a tiny bit better to me, but I'm terrible at naming.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed


return diff;
});
export const resultsComparer = (resultA: IRuleResult, resultB: IRuleResult): -1 | 0 | 1 => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Now that I think of it, I believe we could name it in the way other functions are named in this file.
compareResults What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed

filtered.push(sorted[i]);
}

if (resultsComparer(sorted[howMany - 1], filtered[filtered.length - 1]) !== 0) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Technically there is a possibility of out of bounds access here (in case of filtered array).
Let's avoid it.

Suggested change
if (resultsComparer(sorted[howMany - 1], filtered[filtered.length - 1]) !== 0) {
if (filtered.length > 0 && resultsComparer(sorted[howMany - 1], filtered[filtered.length - 1]) !== 0) {

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmmm. It's possible I'm lacking a test case here. Let me see how I can put that under the light.

Copy link
Contributor Author

@nulltoken nulltoken Nov 19, 2019

Choose a reason for hiding this comment

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

@P0lip There was a bug. And thanks to you I've found it. Added a test case and changed the algo to make it backward looking (rather than forward looking). It also brings the slight benefit of making the whole thing easier to understand (and hopefully review).

return diff;
});
export const resultsComparer = (resultA: IRuleResult, resultB: IRuleResult): -1 | 0 | 1 => {
const diffSource = compareSource(resultA.source, resultB.source);
Copy link
Contributor

Choose a reason for hiding this comment

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

Also, how do you feel about having a sort of loop here?
The performance difference will be negliable, but the code will look nicer, IMHO.

return normalize([
  compareSource(resultA.source, resultB.source),
  resultA.range.start.line - resultB.range.start.line,
  resultA.range.start.character - resultB.range.start.character,
  compareCode(resultA.code, resultB.code),
  resultA.path.join().localeCompare(resultB.path.join()),
].find(diff => diff !== 0) ?? 0); // ?? 0 just in case all previous calls returned 0

Didn't test it at all, but it should work.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@P0lip That should work. But I feel a bit wary about this kind of code. It's usually very painful to debug/troubleshoot when it doesn't behave as expected.

For some reasons that reminds me of the jest exceptArrayContaining/ObjectContaining pattern. This is really elegant when you read it, but turns into a gigantic mess when the expectations have changed and you're trying to find what values you forgot to update in the test cases.

As usual, if you feel more at ease with this construct, I'll update the code to meet your expectations.

return one.toString().localeCompare(another.toString(), undefined, { numeric: true });
};

const compareSource = (one: string | undefined, another: string | undefined): number => {
Copy link
Contributor

Choose a reason for hiding this comment

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

I like it.

@nulltoken
Copy link
Contributor Author

@P0lip Thanks for this very thorough review. I think I've dealt with (or discussed) every comment.

Fixup commits have been pushed to make it easier for you to see the introduced changes.

Thoughts?

P0lip
P0lip previously approved these changes Nov 19, 2019
Copy link
Contributor

@P0lip P0lip left a comment

Choose a reason for hiding this comment

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

The very 2 last comments on tests.
Besides these, it's good to go

});
});

describe('resultsComparer', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
describe('resultsComparer', () => {
describe('compareResults', () => {

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed

temp = [...shuffled];

shuffled = temp.sort((_a, _b) => {
return Math.random() * 10 - 5;
Copy link
Contributor

Choose a reason for hiding this comment

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

I get the idea, but in general tests should be deterministic, so could you execute that and paste the output into some JSON file and load it here in tests?
Alternatively, you could provide an array of indices:

const indices = [-1, 0, 1, 1, -1, 0]; // etc

shuffled = temp.sort((_a, _b, i) => indices[i]);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

in general tests should be deterministic,

You're right. But I was really willing to keep the list of results in an sorted way to keep the test as maintainable as possible.

Your indices based proposal is a great idea that allows to do just that. 🎉 (Sadly the sort callback doesn't accept indices. I worked around that)

Fixed.

shuffled = temp.sort((_a, _b) => {
return Math.random() * 10 - 5;
});
} while (shuffled[0].code === results[0].code);
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this loop actually needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No longer needed now that the test is deterministic.

return one.localeCompare(another);
};

const normalize = (value: number): -1 | 0 | 1 => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nah, I don't really mind. :)

Copy link
Contributor

@P0lip P0lip left a comment

Choose a reason for hiding this comment

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

Thank you!

@P0lip P0lip merged commit e327fcc into stoplightio:develop Nov 20, 2019
@nulltoken nulltoken deleted the ntk/comparer branch March 5, 2020 07:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants