From 1c6a457758233d6b5a217d5879d32efa3d516164 Mon Sep 17 00:00:00 2001 From: Pol Date: Wed, 29 Jul 2020 11:05:35 +0300 Subject: [PATCH] feat: quickFix for non stable Id --- packages/language-server/src/quick-fix.ts | 73 ++++++ packages/language-server/src/server.ts | 81 +++++- .../src/xml-view-diagnostics.ts | 17 +- packages/xml-views-validation/api.d.ts | 4 + packages/xml-views-validation/src/api.ts | 4 +- .../src/validators/document/non-stable-id.ts | 235 ++++++++++++++++++ .../src/validators/elements/non-stable-id.ts | 125 ---------- .../xml-views-validation/test/test-utils.ts | 4 +- .../non-stable-id-spec.ts | 10 +- yarn.lock | 8 + 10 files changed, 427 insertions(+), 134 deletions(-) create mode 100644 packages/language-server/src/quick-fix.ts create mode 100644 packages/xml-views-validation/src/validators/document/non-stable-id.ts delete mode 100644 packages/xml-views-validation/src/validators/elements/non-stable-id.ts rename packages/xml-views-validation/test/validators/{element => document}/non-stable-id-spec.ts (95%) diff --git a/packages/language-server/src/quick-fix.ts b/packages/language-server/src/quick-fix.ts new file mode 100644 index 000000000..fb201ce92 --- /dev/null +++ b/packages/language-server/src/quick-fix.ts @@ -0,0 +1,73 @@ +import { + Diagnostic, + CodeAction, + Command, + CodeActionKind, +} from "vscode-languageserver"; +import { find, isEqual } from "lodash"; +import { Range, TextDocument } from "vscode-languageserver-types"; + +type docUri = string; +type extendedDiagnostic = Diagnostic & { + quickFixIdSuggestion?: string; + quickFixIdRange?: Range; +}; +type DiagnosticData = Record; +const diagnosticData: DiagnosticData = Object.create(null); + +export function updateDiagnosticData( + docUri: string, + diagnostic: Diagnostic[] +): void { + diagnosticData[docUri] = diagnostic; +} + +export function getFullDiagnostic( + docUri: string, + diagnostic: Diagnostic +): extendedDiagnostic | undefined { + return find( + diagnosticData[docUri], + (_) => + isEqual(_.range, diagnostic.range) && _.message === diagnostic.message + ); +} + +export function getCodeActionForDiagnostic( + docUri: string, + diagnostic: Diagnostic +): CodeAction | undefined { + switch (diagnostic.code) { + case 666: { + // non stable id + return getCodeActionForQuickFixId(docUri, diagnostic); + } + default: + return undefined; + } +} + +function getCodeActionForQuickFixId( + docUri: string, + nonStableIdDiagnostic: Diagnostic +): CodeAction | undefined { + const fullDiagnostic = getFullDiagnostic(docUri, nonStableIdDiagnostic); + + if (fullDiagnostic === undefined) { + return undefined; + } + + const quickFixIDSuggestion = fullDiagnostic.quickFixIdSuggestion; + const title = "QuickFix Stable ID"; + return CodeAction.create( + title, + Command.create( + title, + "stableIdQuickFix", + docUri, + fullDiagnostic.quickFixIdRange, + quickFixIDSuggestion + ), + CodeActionKind.QuickFix + ); +} diff --git a/packages/language-server/src/server.ts b/packages/language-server/src/server.ts index 6c65d8b63..61061ff41 100644 --- a/packages/language-server/src/server.ts +++ b/packages/language-server/src/server.ts @@ -1,5 +1,5 @@ /* istanbul ignore file */ -import { forEach } from "lodash"; +import { forEach, filter, find } from "lodash"; import { createConnection, TextDocuments, @@ -10,6 +10,17 @@ import { InitializeParams, Hover, DidChangeConfigurationNotification, + Diagnostic, + CodeAction, + Command, + CodeActionKind, + TextDocumentEdit, + TextEdit, + Position, + CodeActionParams, + TextDocumentIdentifier, + Range, + CodeActionContext, } from "vscode-languageserver"; import { URI } from "vscode-uri"; import { TextDocument } from "vscode-languageserver-textdocument"; @@ -33,6 +44,7 @@ import { initializeManifestData, updateManifestData, } from "./manifest-handling"; +import { getCodeActionForDiagnostic, updateDiagnosticData } from "./quick-fix"; const connection = createConnection(ProposedFeatures.all); const documents = new TextDocuments(TextDocument); @@ -67,6 +79,10 @@ connection.onInitialize((params: InitializeParams) => { triggerCharacters: ['"', "'", ":", "<"], }, hoverProvider: true, + codeActionProvider: true, + executeCommandProvider: { + commands: ["stableIdQuickFix"], + }, }, }; }); @@ -160,10 +176,53 @@ documents.onDidChangeContent(async (changeEvent) => { ui5Model, flexEnabled, }); + + updateDiagnosticData(changeEvent.document.uri, diagnostics); connection.sendDiagnostics({ uri: changeEvent.document.uri, diagnostics }); } }); +connection.onCodeAction((params) => { + const docUri = params.textDocument.uri; + let codeActions: CodeAction[] = []; + const textDocument = documents.get(docUri); + if (textDocument === undefined) { + return undefined; + } + + const diagnostics = params.context.diagnostics; + forEach(diagnostics, (_) => { + const codeAction = getCodeActionForDiagnostic(docUri, _); + if (codeAction !== undefined) { + codeActions.push(codeAction); + } + }); + + return codeActions; +}); + +connection.onExecuteCommand(async (params) => { + if (params.arguments === undefined) { + return; + } + + const textDocument = documents.get(params.arguments[0]); + if (textDocument === undefined) { + return; + } + params.command !== "stableIdQuickFix"; + + switch (params.command) { + case "stableIdQuickFix": + executeQuickFixIdCommand({ + textDocument, + quickFixRange: params.arguments[1], + quickFixIDSuggestion: params.arguments[2], + }); + return; + } +}); + function ensureDocumentSettingsUpdated(resource: string): void { // There are 2 flows for settings, depending on the client capabilities: // 1. The client doesn't support workspace/document-level settings (workspace/configuration request). @@ -211,3 +270,23 @@ connection.listen(); function isXMLView(uri: string): boolean { return /(view|fragment)\.xml$/.test(uri); } + +function executeQuickFixIdCommand(opts: { + textDocument: TextDocument; + quickFixRange: Range; + quickFixIDSuggestion: string; +}) { + connection.workspace.applyEdit({ + documentChanges: [ + TextDocumentEdit.create( + { uri: opts.textDocument.uri, version: opts.textDocument.version }, + [ + TextEdit.replace( + opts.quickFixRange, + `id="${opts.quickFixIDSuggestion}" ` + ), + ] + ), + ], + }); +} diff --git a/packages/language-server/src/xml-view-diagnostics.ts b/packages/language-server/src/xml-view-diagnostics.ts index 1c494e9bb..ee4d21532 100644 --- a/packages/language-server/src/xml-view-diagnostics.ts +++ b/packages/language-server/src/xml-view-diagnostics.ts @@ -1,11 +1,14 @@ -import { map } from "lodash"; +import { map, filter } from "lodash"; import { assertNever } from "assert-never"; import { Diagnostic, DiagnosticSeverity, DiagnosticTag, Range as LSPRange, + CodeAction, + CodeActionKind, } from "vscode-languageserver-types"; +import { WorkspaceEdit } from "vscode"; import { TextDocument } from "vscode-languageserver-textdocument"; import { DocumentCstNode, parse } from "@xml-tools/parser"; import { buildAst } from "@xml-tools/ast"; @@ -16,6 +19,7 @@ import { UI5XMLViewIssue, validateXMLView, XMLViewIssueSeverity, + NonStableIDIssue, } from "@ui5-language-assistant/xml-views-validation"; export function getXMLViewDiagnostics(opts: { @@ -46,7 +50,6 @@ function validationIssuesToLspDiagnostics( source: "UI5 Language Assistant", message: currIssue.message, }; - const issueKind = currIssue.kind; switch (issueKind) { case "InvalidBooleanValue": @@ -56,9 +59,19 @@ function validationIssuesToLspDiagnostics( case "UnknownTagName": case "InvalidAggregationCardinality": case "InvalidAggregationType": + return { + ...commonDiagnosticPros, + }; case "NonStableIDIssue": return { ...commonDiagnosticPros, + quickFixIdSuggestion: currIssue.quickFixIdSuggestion, + quickFixIdRange: offsetRangeToLSPRange( + // @ts-expect-error - NonStableIDIssue always contains quickFixIdRange + currIssue.quickFixIdRange, + document + ), + code: 666, }; case "UseOfDeprecatedClass": case "UseOfDeprecatedProperty": diff --git a/packages/xml-views-validation/api.d.ts b/packages/xml-views-validation/api.d.ts index e0efdf957..f5ec7233a 100644 --- a/packages/xml-views-validation/api.d.ts +++ b/packages/xml-views-validation/api.d.ts @@ -14,6 +14,8 @@ export interface BaseUI5XMLViewIssue { message: string; severity: "hint" | "info" | "warn" | "error"; offsetRange: OffsetRange; + quickFixIdSuggestion?: string; + quickFixIdRange?: OffsetRange; } export interface OffsetRange { @@ -96,4 +98,6 @@ export interface NonUniqueIDIssue extends BaseUI5XMLViewIssue { export interface NonStableIDIssue extends BaseUI5XMLViewIssue { kind: "NonStableIDIssue"; + quickFixIdSuggestion: string; + quickFixIdRange: OffsetRange; } diff --git a/packages/xml-views-validation/src/api.ts b/packages/xml-views-validation/src/api.ts index 7cec023a1..ef58151a0 100644 --- a/packages/xml-views-validation/src/api.ts +++ b/packages/xml-views-validation/src/api.ts @@ -4,7 +4,7 @@ import { XMLDocument } from "@xml-tools/ast"; import { UI5XMLViewIssue } from "../api"; import { validateXMLView as validateXMLViewImpl } from "./validate-xml-views"; import { allValidators } from "./validators"; -import { validateNonStableId } from "./validators/elements/non-stable-id"; +import { validateNonStableId } from "./validators/document/non-stable-id"; export function validateXMLView(opts: { model: UI5SemanticModel; @@ -13,7 +13,7 @@ export function validateXMLView(opts: { }): UI5XMLViewIssue[] { const actualValidators = cloneDeep(allValidators); if (opts.flexEnabled) { - actualValidators.element.push(validateNonStableId); + actualValidators.document.push(validateNonStableId); } return validateXMLViewImpl({ validators: actualValidators, ...opts }); diff --git a/packages/xml-views-validation/src/validators/document/non-stable-id.ts b/packages/xml-views-validation/src/validators/document/non-stable-id.ts new file mode 100644 index 000000000..a3292ad12 --- /dev/null +++ b/packages/xml-views-validation/src/validators/document/non-stable-id.ts @@ -0,0 +1,235 @@ +import { + some, + includes, + map, + isSafeInteger, + isNaN, + toString, + find, + replace, + isEmpty, +} from "lodash"; +import { + XMLElement, + XMLDocument, + accept, + XMLAstVisitor, + XMLToken, + XMLAttribute, +} from "@xml-tools/ast"; +import { resolveXMLNS } from "@ui5-language-assistant/logic-utils"; +import { NonStableIDIssue, OffsetRange } from "../../../api"; +import { NON_STABLE_ID, getMessage } from "../../utils/messages"; +import { + isPossibleCustomClass, + isKnownUI5Class, +} from "../../utils/ui5-classes"; +import { CORE_NS } from "../../utils/special-namespaces"; +import { UI5SemanticModel } from "@ui5-language-assistant/semantic-model-types"; + +type NonStableIDElement = XMLElement & { + name: string; + syntax: { openName: XMLToken }; +}; + +const ID_PREFFIX_PATTERN_SUGGESTION = "_IDEGen"; +const ID_PREFIX_PATTERN = "^_IDEGen"; +const ID_SUFFIX_PATTERN = "(\\d+$)?"; + +export function validateNonStableId( + xmlDoc: XMLDocument, + model: UI5SemanticModel +): NonStableIDIssue[] { + const missingIdElements = new NonStableIdCollectorVisitor(model); + accept(xmlDoc, missingIdElements); + + const nonStableIdElementsCollector = missingIdElements.nonStableIdElements; + if (isEmpty(nonStableIdElementsCollector)) { + return []; + } + + const biggestIdsCollector = new IdsByElementNameCollectorVisitor(); + accept(xmlDoc, biggestIdsCollector); + const biggestIdNumbersOfXMLElements = + biggestIdsCollector.biggestIdsOfElements; + + const allNonStableIdIsuues: NonStableIDIssue[] = buildIssuesForElements( + nonStableIdElementsCollector, + biggestIdNumbersOfXMLElements + ); + + return allNonStableIdIsuues; +} + +function buildIssuesForElements( + nonStableIdElements: NonStableIDElement[], + biggestIdsOflements: Record +): NonStableIDIssue[] { + const issuesForElements = map(nonStableIdElements, (currentElement) => { + const nonStableIDIssue: NonStableIDIssue = { + kind: "NonStableIDIssue", + quickFixIdSuggestion: getQuickFixIdSuggestion( + currentElement, + biggestIdsOflements + ), + quickFixIdRange: getQuickFixIdRange(currentElement), + message: getMessage(NON_STABLE_ID, currentElement.name), + severity: "error", + offsetRange: { + start: currentElement.syntax.openName.startOffset, + end: currentElement.syntax.openName.endOffset, + }, + }; + + return nonStableIDIssue; + }); + + return issuesForElements; +} + +function getQuickFixIdRange(xmlElement: NonStableIDElement): OffsetRange { + const idAttrib = find(xmlElement.attributes, (attrib) => attrib.key === "id"); + if (idAttrib !== undefined) { + return { + start: idAttrib.position.startOffset, + end: idAttrib.position.endOffset + 1, + }; + } + + return { + start: xmlElement.syntax.openName.endOffset + 2, + end: xmlElement.syntax.openName.endOffset + 1, + }; +} + +class NonStableIdCollectorVisitor implements XMLAstVisitor { + public nonStableIdElements: NonStableIDElement[] = []; + + constructor(private model: UI5SemanticModel) {} + + visitXMLElement(xmlElement: XMLElement) { + if ( + xmlElement.name !== null && + xmlElement.syntax.openName !== undefined && + !isWhiteListedTag(xmlElement) && + (isKnownUI5Class(xmlElement, this.model) || + // @ts-expect-error - we already checked that xmlElement.name is not null + isPossibleCustomClass(xmlElement)) && + !hasNonAdaptableMetaData(xmlElement) && + !hasNonAdaptableTreeMetaData(xmlElement) && + !isElementWithStableID(xmlElement) + ) { + // @ts-expect-error - TSC does not understand: `xmlElement.syntax.openName !== undefined` is a type guard + this.nonStableIdElements.push(xmlElement); + } + } +} + +class IdsByElementNameCollectorVisitor implements XMLAstVisitor { + public biggestIdsOfElements: Record = Object.create(null); + + visitXMLAttribute(xmlAttribute: XMLAttribute) { + const parentName = xmlAttribute.parent.name; + const elementIdPattern = new RegExp( + ID_PREFIX_PATTERN + parentName + ID_SUFFIX_PATTERN + ); + + if ( + xmlAttribute.key === "id" && + xmlAttribute.value !== null && + parentName !== null && + elementIdPattern.test(xmlAttribute.value) + ) { + const matchSuffix = /\d+$/.exec(xmlAttribute.value); + const suffix = matchSuffix ? parseInt(matchSuffix[0]) : 0; + if ( + this.biggestIdsOfElements[parentName] === undefined || + suffix > this.biggestIdsOfElements[parentName] + ) { + this.biggestIdsOfElements[parentName] = suffix; + } + } + } +} + +function getQuickFixIdSuggestion( + xmlElement: NonStableIDElement, + biggestIdsOfElements: Record +): string { + const biggestIdForElement = biggestIdsOfElements[xmlElement.name]; + const quickFixIdSuffix = biggestIdForElement + ? toString(biggestIdForElement + 1) + : ""; + const quickFixIdSuggestion = `${ID_PREFFIX_PATTERN_SUGGESTION}${xmlElement.name}${quickFixIdSuffix}`; + + return quickFixIdSuggestion; +} + +function isWhiteListedTag(xmlElement: XMLElement): boolean { + const rootWhiteListedExceptions: Record = { + "sap.ui.core.mvc": ["View"], + "sap.ui.core": ["View", "FragmentDefinition"], + }; + + // The class is in the root level + if (xmlElement.parent.type === "XMLDocument") { + const resolvedXMLNS = resolveXMLNS(xmlElement); + // @ts-expect-error - it's fine to use undefined in member access + const exceptionsForResolvedXMLNS = rootWhiteListedExceptions[resolvedXMLNS]; + if (includes(exceptionsForResolvedXMLNS, xmlElement.name)) { + return true; + } + } + + const coreNsWhiteListedExceptions = [ + "Fragment", + "CustomData", + "ExtensionPoint", + ]; + + const isCoreNsWhiteListed = + resolveXMLNS(xmlElement) === CORE_NS && + includes(coreNsWhiteListedExceptions, xmlElement.name); + return isCoreNsWhiteListed; +} + +function hasNonAdaptableMetaData(xmlElement: XMLElement): boolean { + return some( + xmlElement.attributes, + (attribute) => + attribute.key === "sap.ui.dt:designtime" && + attribute.value === "not-adaptable" + ); +} + +function hasNonAdaptableTreeMetaData(xmlElement: XMLElement): boolean { + let currElement = xmlElement; + while (currElement.parent.type !== "XMLDocument") { + const hasNonAdaptableTreeMetaData = some( + currElement.attributes, + (attribute) => + //TODO - inspect if we need to properly resolve the attribute "NS" / use plain string matcher + attribute.key === "sap.ui.dt:designtime" && + attribute.value === "not-adaptable-tree" + ); + + if (hasNonAdaptableTreeMetaData) { + return true; + } + + currElement = currElement.parent; + } + + return false; +} + +function isElementWithStableID(xmlElement: XMLElement): boolean { + return some( + xmlElement.attributes, + (attribute) => + attribute.key === "id" && + attribute.value !== null && + // Contains a single non ws character + /\S/.test(attribute.value) + ); +} diff --git a/packages/xml-views-validation/src/validators/elements/non-stable-id.ts b/packages/xml-views-validation/src/validators/elements/non-stable-id.ts deleted file mode 100644 index 070105545..000000000 --- a/packages/xml-views-validation/src/validators/elements/non-stable-id.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { some, includes } from "lodash"; -import { XMLElement } from "@xml-tools/ast"; -import { resolveXMLNS } from "@ui5-language-assistant/logic-utils"; -import { UI5SemanticModel } from "@ui5-language-assistant/semantic-model-types"; -import { NonStableIDIssue } from "../../../api"; -import { NON_STABLE_ID, getMessage } from "../../utils/messages"; -import { - isPossibleCustomClass, - isKnownUI5Class, -} from "../../utils/ui5-classes"; -import { CORE_NS } from "../../utils/special-namespaces"; - -export function validateNonStableId( - xmlElement: XMLElement, - model: UI5SemanticModel -): NonStableIDIssue[] { - // Can't give an error if there is no position or name - if (xmlElement.name === null || xmlElement.syntax.openName === undefined) { - return []; - } - - if (isWhiteListedTag(xmlElement)) { - return []; - } - - if ( - // @ts-expect-error - we already checked that xmlElement.name is not null - !isPossibleCustomClass(xmlElement) && - !isKnownUI5Class(xmlElement, model) - ) { - return []; - } - - if ( - hasNonAdaptableMetaData(xmlElement) || - hasNonAdaptableTreeMetaData(xmlElement) - ) { - return []; - } - - if (isElementWithStableID(xmlElement)) { - return []; - } - - const nonStableIDIssue: NonStableIDIssue = { - kind: "NonStableIDIssue", - message: getMessage(NON_STABLE_ID, xmlElement.name), - severity: "error", - offsetRange: { - start: xmlElement.syntax.openName.startOffset, - end: xmlElement.syntax.openName.endOffset, - }, - }; - - return [nonStableIDIssue]; -} - -function isWhiteListedTag(xmlElement: XMLElement): boolean { - const rootWhiteListedExceptions: Record = { - "sap.ui.core.mvc": ["View"], - "sap.ui.core": ["View", "FragmentDefinition"], - }; - - // The class is in the root level - if (xmlElement.parent.type === "XMLDocument") { - const resolvedXMLNS = resolveXMLNS(xmlElement); - // @ts-expect-error - it's fine to use undefined in member access - const exceptionsForResolvedXMLNS = rootWhiteListedExceptions[resolvedXMLNS]; - if (includes(exceptionsForResolvedXMLNS, xmlElement.name)) { - return true; - } - } - - const coreNsWhiteListedExceptions = [ - "Fragment", - "CustomData", - "ExtensionPoint", - ]; - - const isCoreNsWhiteListed = - resolveXMLNS(xmlElement) === CORE_NS && - includes(coreNsWhiteListedExceptions, xmlElement.name); - return isCoreNsWhiteListed; -} - -function hasNonAdaptableMetaData(xmlElement: XMLElement): boolean { - return some( - xmlElement.attributes, - (attribute) => - attribute.key === "sap.ui.dt:designtime" && - attribute.value === "not-adaptable" - ); -} - -function hasNonAdaptableTreeMetaData(xmlElement: XMLElement): boolean { - let currElement = xmlElement; - while (currElement.parent.type !== "XMLDocument") { - const hasNonAdaptableTreeMetaData = some( - currElement.attributes, - (attribute) => - //TODO - inspect if we need to properly resolve the attribute "NS" / use plain string matcher - attribute.key === "sap.ui.dt:designtime" && - attribute.value === "not-adaptable-tree" - ); - - if (hasNonAdaptableTreeMetaData) { - return true; - } - - currElement = currElement.parent; - } - - return false; -} - -function isElementWithStableID(xmlElement: XMLElement): boolean { - return some( - xmlElement.attributes, - (attribute) => - attribute.key === "id" && - attribute.value !== null && - // Contains a single non ws character - /\S/.test(attribute.value) - ); -} diff --git a/packages/xml-views-validation/test/test-utils.ts b/packages/xml-views-validation/test/test-utils.ts index ced081192..8205c24e8 100644 --- a/packages/xml-views-validation/test/test-utils.ts +++ b/packages/xml-views-validation/test/test-utils.ts @@ -106,7 +106,8 @@ export function assertSingleIssue( kind: string, severity: string, xmlSnippet: string, - message: string + message: string, + extraProps: Object = {} ): void { testValidationsScenario({ model: model, @@ -119,6 +120,7 @@ export function assertSingleIssue( message: message, offsetRange: computeExpectedRange(xmlSnippet), severity: severity, + ...extraProps, }, ]); }, diff --git a/packages/xml-views-validation/test/validators/element/non-stable-id-spec.ts b/packages/xml-views-validation/test/validators/document/non-stable-id-spec.ts similarity index 95% rename from packages/xml-views-validation/test/validators/element/non-stable-id-spec.ts rename to packages/xml-views-validation/test/validators/document/non-stable-id-spec.ts index bbfa31115..862b51371 100644 --- a/packages/xml-views-validation/test/validators/element/non-stable-id-spec.ts +++ b/packages/xml-views-validation/test/validators/document/non-stable-id-spec.ts @@ -6,8 +6,12 @@ import { getMessage, NON_STABLE_ID } from "../../../src/utils/messages"; import { assertNoIssues as assertNoIssuesBase, assertSingleIssue as assertSingleIssueBase, + testValidationsScenario, + computeExpectedRange, } from "../../test-utils"; -import { validateNonStableId } from "../../../src/validators/elements/non-stable-id"; +import { validateNonStableId } from "../../../src/validators/document/non-stable-id"; +import { UI5Validators } from "src/validate-xml-views"; +import { expect } from "chai"; describe("the use of non stable id validation", () => { let ui5SemanticModel: UI5SemanticModel; @@ -26,7 +30,7 @@ describe("the use of non stable id validation", () => { assertSingleIssueBase, ui5SemanticModel, { - element: [validateNonStableId], + document: [validateNonStableId], }, "NonStableIDIssue", "error" @@ -124,7 +128,7 @@ describe("the use of non stable id validation", () => { let assertNoIssues: (xmlSnippet: string) => void; before(() => { assertNoIssues = partial(assertNoIssuesBase, ui5SemanticModel, { - element: [validateNonStableId], + document: [validateNonStableId], }); }); diff --git a/yarn.lock b/yarn.lock index 5a1e47424..74ac22375 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3053,6 +3053,14 @@ deep-eql@^3.0.1: dependencies: type-detect "^4.0.0" +deep-equal-in-any-order@1.0.27: + version "1.0.27" + resolved "https://registry.yarnpkg.com/deep-equal-in-any-order/-/deep-equal-in-any-order-1.0.27.tgz#607c4250368f3f95dddb07cc768ef5428132c16e" + integrity sha512-yxZ1zYvaiZidcpprBq5hZdbiWNzNXQtcDjc10R2iMfoCgYx6oIdwkPrIaXbcvZkk6spsJvG/C1nRyVTcI748Jg== + dependencies: + lodash.mapvalues "^4.6.0" + sort-any "^1.1.21" + deep-equal-in-any-order@^1.0.24: version "1.0.28" resolved "https://registry.yarnpkg.com/deep-equal-in-any-order/-/deep-equal-in-any-order-1.0.28.tgz#2b002827e03b9e6b048692baf7faa0a07d3dd1d8"