Skip to content

Commit

Permalink
fix: offer suggestions for unresolved metadata types
Browse files Browse the repository at this point in the history
  • Loading branch information
iowillhoit committed Apr 27, 2023
1 parent 67a24fd commit bceba7f
Show file tree
Hide file tree
Showing 12 changed files with 472 additions and 5 deletions.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@salesforce/kit": "^1.9.2",
"@salesforce/ts-types": "^1.7.2",
"archiver": "^5.3.1",
"fast-levenshtein": "^3.0.0",
"fast-xml-parser": "^4.1.4",
"got": "^11.8.6",
"graceful-fs": "^4.2.11",
Expand All @@ -47,6 +48,7 @@
"@salesforce/ts-sinon": "^1.4.6",
"@types/archiver": "^5.3.1",
"@types/deep-equal-in-any-order": "^1.0.1",
"@types/fast-levenshtein": "^0.0.2",
"@types/mime": "2.0.3",
"@types/minimatch": "^5.1.2",
"@types/proxy-from-env": "^1.0.1",
Expand Down Expand Up @@ -187,4 +189,4 @@
"output": []
}
}
}
}
32 changes: 31 additions & 1 deletion src/registry/registryAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import { Messages, SfError } from '@salesforce/core';
import * as Levenshtein from 'fast-levenshtein';
import { registry as defaultRegistry } from './registry';
import { MetadataRegistry, MetadataType } from './types';

Expand Down Expand Up @@ -60,12 +61,41 @@ export class RegistryAccess {
* @returns The corresponding metadata type object
*/
public getTypeBySuffix(suffix: string): MetadataType | undefined {
if (this.registry.suffixes?.[suffix]) {
if (this.registry.suffixes[suffix]) {
const typeId = this.registry.suffixes[suffix];
return this.getTypeByName(typeId);
}
}

/**
* Find similar metadata type matches by its file suffix
*
* @param suffix - File suffix of the metadata type
* @returns An array of similar suffix and metadata type matches
*/
public guessTypeBySuffix(
suffix: string
): Array<{ suffixGuess: string; metadataTypeGuess: MetadataType }> | undefined {
const registryKeys = Object.keys(this.registry.suffixes);

const scores = registryKeys.map((registryKey) => ({ registryKey, score: Levenshtein.get(suffix, registryKey) }));
const sortedScores = scores.sort((a, b) => a.score - b.score);
const lowestScore = sortedScores[0].score;
// Levenshtein uses positive integers for scores, find all scores that match the lowest score
const guesses = sortedScores.filter((score) => score.score === lowestScore);

if (guesses.length > 0) {
return guesses.map((guess) => {
const typeId = this.registry.suffixes[guess.registryKey];
const metadataType = this.getTypeByName(typeId);
return {
suffixGuess: guess.registryKey,
metadataTypeGuess: metadataType,
};
});
}
}

/**
* Searches for the first metadata type in the registry that returns `true`
* for the given predicate function.
Expand Down
2 changes: 1 addition & 1 deletion src/registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/
export interface MetadataRegistry {
types: TypeIndex;
suffixes?: SuffixIndex;
suffixes: SuffixIndex;
strictDirectoryNames: {
[directoryName: string]: string;
};
Expand Down
87 changes: 85 additions & 2 deletions src/resolve/metadataResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import { basename, dirname, join, sep } from 'path';
import { Lifecycle, Messages, SfError } from '@salesforce/core';
import { Lifecycle, Messages, SfError, Logger } from '@salesforce/core';
import { extName, parentName, parseMetadataXml } from '../utils';
import { MetadataType, RegistryAccess } from '../registry';
import { ComponentSet } from '../collections';
Expand All @@ -25,6 +25,7 @@ const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sd
*/
export class MetadataResolver {
public forceIgnoredPaths: Set<string>;
protected logger: Logger;
private forceIgnore?: ForceIgnore;
private sourceAdapterFactory: SourceAdapterFactory;
private folderContentTypeDirNames?: string[];
Expand All @@ -38,6 +39,7 @@ export class MetadataResolver {
private tree: TreeContainer = new NodeFSTreeContainer(),
private useFsForceIgnore = true
) {
this.logger = Logger.childFromRoot(this.constructor.name);
this.sourceAdapterFactory = new SourceAdapterFactory(this.registry, tree);
this.forceIgnoredPaths = new Set<string>();
}
Expand Down Expand Up @@ -145,7 +147,19 @@ export class MetadataResolver {
function: 'resolveComponent',
path: fsPath,
});
throw new SfError(messages.getMessage('error_could_not_infer_type', [fsPath]), 'TypeInferenceError');

// If a file ends with .xml and is not a metadata type, it is likely a package manifest
// In the past, these were "resolved" as EmailServicesFunction. See note on "attempt 3" in resolveType() below.
if (fsPath.endsWith('.xml') && !fsPath.endsWith(META_XML_SUFFIX)) {
this.logger.debug(`Could not resolve type for ${fsPath}. It is likely a package manifest. Moving on.`);
return undefined;
}

// The metadata type could not be inferred
// Attempt to guess the type and throw an error with actions
const actions = this.getSuggestionsForUnresolvedTypes(fsPath);

throw new SfError(messages.getMessage('error_could_not_infer_type', [fsPath]), 'TypeInferenceError', actions);
}

private resolveTypeFromStrictFolder(fsPath: string): MetadataType | undefined {
Expand Down Expand Up @@ -202,6 +216,15 @@ export class MetadataResolver {
// attempt 3 - try treating the file extension name as a suffix
if (!resolvedType) {
resolvedType = this.registry.getTypeBySuffix(extName(fsPath));

// Metadata types with `strictDirectoryName` should have been caught in "attempt 1".
// If the metadata returned from this lookup has a `strictDirectoryName`, something is wrong.
// It is likely that the metadata file is misspelled or has the wrong suffix.
// A common occurrence is that a misspelled metadata file will fall back to
// `EmailServicesFunction` because that is the default for the `.xml` suffix
if (resolvedType?.strictDirectoryName === true) {
resolvedType = undefined;
}
}

// attempt 4 - try treating the content as metadata
Expand All @@ -215,6 +238,66 @@ export class MetadataResolver {
return resolvedType;
}

/**
* Attempt to find similar types for types that could not be inferred
* To be used after executing the resolveType() method
*
* @param fsPath
* @returns an array of suggestions
*/
private getSuggestionsForUnresolvedTypes(fsPath: string): string[] {
const actions = [];

const parsedMetaXml = parseMetadataXml(fsPath);

// Analogous to "attempt 2" above
// Attempt to guess the metadata suffix by finding a close match
if (parsedMetaXml?.suffix) {
const results = this.registry.guessTypeBySuffix(parsedMetaXml.suffix);

if (results) {
actions.push(
`A search for the ".${parsedMetaXml.suffix}-meta.xml" metadata suffix found the following close match${
results.length > 1 ? 'es' : ''
}:`
);
results.forEach((result) => {
actions.push(
`- Did you mean ".${result.suffixGuess}-meta.xml" instead for the "${result.metadataTypeGuess.name}" metadata type?`
);
});
// check the file name, check the extension, check the folder and here is the registry
}
}

// Analogous to "attempt 3" above
// Attempt to guess the filename suffix by finding a close match
if (actions.length === 0 && !fsPath.includes('-meta.xml')) {
const fileExtension = extName(fsPath);
const results = this.registry.guessTypeBySuffix(fileExtension);
if (results) {
actions.push(
`A search for the ".${fileExtension}" filename suffix found the following close match${
results.length > 1 ? 'es' : ''
}:`
);
results.forEach((result) => {
actions.push(
`- Did you mean ".${result.suffixGuess}" instead for the "${result.metadataTypeGuess.name}" metadata type?`
);
});
}
}

if (actions.length > 0) {
actions.push(
'\nAdditional suggestions:\nConfirm the file name, extension, and directory names are correct. Validate against the registry at:\nhttps://github.com/forcedotcom/source-deploy-retrieve/blob/main/src/registry/metadataRegistry.json'
);
}

return actions;
}

/**
* Whether or not a directory that represents a single component should be resolved as one,
* or if it should be walked for additional components.
Expand Down
87 changes: 87 additions & 0 deletions test/nuts/suggestType/suggestType.nut.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright (c) 2020, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import * as path from 'path';
import { TestSession } from '@salesforce/cli-plugins-testkit';
import { expect } from 'chai';
import { SfError } from '@salesforce/core';
import { ComponentSetBuilder } from '../../../src';

describe('suggest types', () => {
let session: TestSession;

before(async () => {
session = await TestSession.create({
project: {
sourceDir: path.join('test', 'nuts', 'suggestType', 'testProj'),
},
devhubAuthStrategy: 'NONE',
});
});

after(async () => {
await session?.clean();
});

it('it offers a suggestions on an invalid type', async () => {
try {
await ComponentSetBuilder.build({
sourcepath: [path.join(session.project.dir, 'force-app', 'main', 'default', 'objects')],
});
throw new Error('This test should have thrown');
} catch (err) {
const error = err as SfError;
expect(error.name).to.equal('TypeInferenceError');
expect(error.actions).to.include(
'A search for the ".objct-meta.xml" metadata suffix found the following close match:'
);
expect(error.actions).to.include(
'- Did you mean ".object-meta.xml" instead for the "CustomObject" metadata type?'
);
}
});

it('it offers a suggestions on a incorrect casing', async () => {
try {
await ComponentSetBuilder.build({
sourcepath: [path.join(session.project.dir, 'force-app', 'main', 'default', 'layouts')],
});
throw new Error('This test should have thrown');
} catch (err) {
const error = err as SfError;
expect(error.name).to.equal('TypeInferenceError');
expect(error.actions).to.include(
'A search for the ".Layout-meta.xml" metadata suffix found the following close match:'
);
expect(error.actions).to.include('- Did you mean ".layout-meta.xml" instead for the "Layout" metadata type?');
}
});

it('it offers multiple suggestions if Levenshtein distance is the same', async () => {
try {
await ComponentSetBuilder.build({
sourcepath: [path.join(session.project.dir, 'force-app', 'main', 'default', 'tabs')],
});
throw new Error('This test should have thrown');
} catch (err) {
const error = err as SfError;
expect(error.name).to.equal('TypeInferenceError');
expect(error.actions).to.include(
'A search for the ".tabsss-meta.xml" metadata suffix found the following close matches:'
);
expect(error.actions).to.include(
'- Did you mean ".labels-meta.xml" instead for the "CustomLabels" metadata type?'
);
expect(error.actions).to.include('- Did you mean ".tab-meta.xml" instead for the "CustomTab" metadata type?');
}
});

it('it ignores package manifest files', async () => {
const cs = await ComponentSetBuilder.build({ sourcepath: [path.join(session.project.dir, 'package-manifest')] });
expect(cs['components'].size).to.equal(0);
});
});
13 changes: 13 additions & 0 deletions test/nuts/suggestType/testProj/config/project-scratch-def.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"orgName": "ewillhoit company",
"edition": "Developer",
"features": ["EnableSetPasswordInApi"],
"settings": {
"lightningExperienceSettings": {
"enableS1DesktopEnabled": true
},
"mobileSettings": {
"enableS1EncryptedStoragePref2": false
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<Layout xmlns="http://soap.sforce.com/2006/04/metadata">
<layoutSections>
<customLabel>false</customLabel>
<detailHeading>false</detailHeading>
<editHeading>true</editHeading>
<label>Information</label>
<layoutColumns>
<layoutItems>
<behavior>Required</behavior>
<field>Name</field>
</layoutItems>
</layoutColumns>
<layoutColumns>
<layoutItems>
<behavior>Edit</behavior>
<field>OwnerId</field>
</layoutItems>
</layoutColumns>
<style>TwoColumnsTopToBottom</style>
</layoutSections>
<layoutSections>
<customLabel>false</customLabel>
<detailHeading>false</detailHeading>
<editHeading>true</editHeading>
<label>System Information</label>
<layoutColumns>
<layoutItems>
<behavior>Readonly</behavior>
<field>CreatedById</field>
</layoutItems>
</layoutColumns>
<layoutColumns>
<layoutItems>
<behavior>Readonly</behavior>
<field>LastModifiedById</field>
</layoutItems>
</layoutColumns>
<style>TwoColumnsTopToBottom</style>
</layoutSections>
<layoutSections>
<customLabel>false</customLabel>
<detailHeading>false</detailHeading>
<editHeading>true</editHeading>
<layoutColumns/>
<layoutColumns/>
<layoutColumns/>
<style>CustomLinks</style>
</layoutSections>
<showEmailCheckbox>false</showEmailCheckbox>
<showHighlightsPanel>false</showHighlightsPanel>
<showInteractionLogPanel>false</showInteractionLogPanel>
<showRunAssignmentRulesCheckbox>false</showRunAssignmentRulesCheckbox>
<showSubmitAndAttachButton>false</showSubmitAndAttachButton>
</Layout>
Loading

2 comments on commit bceba7f

@svc-cli-bot
Copy link
Collaborator

Choose a reason for hiding this comment

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

Benchmark

Benchmark suite Current: bceba7f Previous: 8d18a18 Ratio
eda-componentSetCreate-linux 228 ms 207 ms 1.10
eda-sourceToMdapi-linux 6124 ms 5509 ms 1.11
eda-sourceToZip-linux 4695 ms 5141 ms 0.91
eda-mdapiToSource-linux 4296 ms 3679 ms 1.17
lotsOfClasses-componentSetCreate-linux 449 ms 419 ms 1.07
lotsOfClasses-sourceToMdapi-linux 8956 ms 7706 ms 1.16
lotsOfClasses-sourceToZip-linux 7052 ms 5925 ms 1.19
lotsOfClasses-mdapiToSource-linux 4794 ms 4055 ms 1.18
lotsOfClassesOneDir-componentSetCreate-linux 784 ms 707 ms 1.11
lotsOfClassesOneDir-sourceToMdapi-linux 11965 ms 10718 ms 1.12
lotsOfClassesOneDir-sourceToZip-linux 10676 ms 8979 ms 1.19
lotsOfClassesOneDir-mdapiToSource-linux 9197 ms 7179 ms 1.28

This comment was automatically generated by workflow using github-action-benchmark.

@svc-cli-bot
Copy link
Collaborator

Choose a reason for hiding this comment

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

Benchmark

Benchmark suite Current: bceba7f Previous: 8d18a18 Ratio
eda-componentSetCreate-win32 387 ms 397 ms 0.97
eda-sourceToMdapi-win32 7802 ms 8512 ms 0.92
eda-sourceToZip-win32 6294 ms 6272 ms 1.00
eda-mdapiToSource-win32 7130 ms 7626 ms 0.93
lotsOfClasses-componentSetCreate-win32 803 ms 828 ms 0.97
lotsOfClasses-sourceToMdapi-win32 10541 ms 11493 ms 0.92
lotsOfClasses-sourceToZip-win32 7504 ms 7814 ms 0.96
lotsOfClasses-mdapiToSource-win32 8863 ms 9307 ms 0.95
lotsOfClassesOneDir-componentSetCreate-win32 1451 ms 1555 ms 0.93
lotsOfClassesOneDir-sourceToMdapi-win32 17294 ms 19120 ms 0.90
lotsOfClassesOneDir-sourceToZip-win32 12202 ms 12797 ms 0.95
lotsOfClassesOneDir-mdapiToSource-win32 15364 ms 16771 ms 0.92

This comment was automatically generated by workflow using github-action-benchmark.

Please sign in to comment.