Skip to content

Commit a66f50d

Browse files
Sm/levenshtein-missing-types (#1374)
* feat: did you mean when types aren't in Registry * refactor: nicer output formatting * test: skip a test * refactor: remove unused handler * refactor: complexity, simplified naming --------- Co-authored-by: Willie Ruemmele <[email protected]>
1 parent 7870179 commit a66f50d

File tree

9 files changed

+452
-361
lines changed

9 files changed

+452
-361
lines changed

messages/sdr.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,3 +190,16 @@ If the type is available via Metadata API but not in the registry
190190

191191
- Open an issue <https://github.com/forcedotcom/cli/issues>
192192
- Add the type via PR. Instructions: <https://github.com/forcedotcom/source-deploy-retrieve/blob/main/contributing/metadata.md>
193+
194+
# type_name_suggestions
195+
196+
Confirm the metadata type name is correct. Validate against the registry at:
197+
<https://github.com/forcedotcom/source-deploy-retrieve/blob/main/src/registry/metadataRegistry.json>
198+
199+
If the type is not listed in the registry, check that it has Metadata API support via the Metadata Coverage Report:
200+
<https://developer.salesforce.com/docs/metadata-coverage>
201+
202+
If the type is available via Metadata API but not in the registry
203+
204+
- Open an issue <https://github.com/forcedotcom/cli/issues>
205+
- Add the type via PR. Instructions: <https://github.com/forcedotcom/source-deploy-retrieve/blob/main/contributing/metadata.md>

package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"node": ">=18.0.0"
2626
},
2727
"dependencies": {
28-
"@salesforce/core": "^8.2.1",
28+
"@salesforce/core": "^8.2.3",
2929
"@salesforce/kit": "^3.1.6",
3030
"@salesforce/ts-types": "^2.0.10",
3131
"fast-levenshtein": "^3.0.0",
@@ -39,9 +39,9 @@
3939
"proxy-agent": "^6.4.0"
4040
},
4141
"devDependencies": {
42-
"@jsforce/jsforce-node": "^3.3.2",
42+
"@jsforce/jsforce-node": "^3.3.3",
4343
"@salesforce/cli-plugins-testkit": "^5.3.20",
44-
"@salesforce/dev-scripts": "^10.2.2",
44+
"@salesforce/dev-scripts": "^10.2.5",
4545
"@types/deep-equal-in-any-order": "^1.0.1",
4646
"@types/fast-levenshtein": "^0.0.4",
4747
"@types/graceful-fs": "^4.1.9",
@@ -54,7 +54,7 @@
5454
"mocha-snap": "^5.0.0",
5555
"ts-node": "^10.9.2",
5656
"ts-patch": "^3.2.1",
57-
"typescript": "^5.5.3"
57+
"typescript": "^5.5.4"
5858
},
5959
"scripts": {
6060
"build": "wireit",

src/collections/componentSetBuilder.ts

Lines changed: 107 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -70,137 +70,132 @@ export class ComponentSetBuilder {
7070
* @param options: options for creating a ComponentSet
7171
*/
7272

73-
// eslint-disable-next-line complexity
7473
public static async build(options: ComponentSetOptions): Promise<ComponentSet> {
7574
const logger = Logger.childFromRoot('componentSetBuilder');
7675
let componentSet: ComponentSet | undefined;
7776

78-
const { sourcepath, manifest, metadata, packagenames, apiversion, sourceapiversion, org, projectDir } = options;
79-
const registryAccess = new RegistryAccess(undefined, projectDir);
80-
81-
try {
82-
if (sourcepath) {
83-
logger.debug(`Building ComponentSet from sourcepath: ${sourcepath.join(', ')}`);
84-
const fsPaths = sourcepath.map(validateAndResolvePath);
85-
componentSet = ComponentSet.fromSource({
86-
fsPaths,
87-
registry: registryAccess,
88-
});
89-
}
77+
const { sourcepath, manifest, metadata, packagenames, org } = options;
78+
const registry = new RegistryAccess(undefined, options.projectDir);
9079

91-
// Return empty ComponentSet and use packageNames in the connection via `.retrieve` options
92-
if (packagenames) {
93-
logger.debug(`Building ComponentSet for packagenames: ${packagenames.toString()}`);
94-
componentSet ??= new ComponentSet(undefined, registryAccess);
95-
}
80+
if (sourcepath) {
81+
logger.debug(`Building ComponentSet from sourcepath: ${sourcepath.join(', ')}`);
82+
const fsPaths = sourcepath.map(validateAndResolvePath);
83+
componentSet = ComponentSet.fromSource({
84+
fsPaths,
85+
registry,
86+
});
87+
}
9688

97-
// Resolve manifest with source in package directories.
98-
if (manifest) {
99-
logger.debug(`Building ComponentSet from manifest: ${manifest.manifestPath}`);
100-
assertFileExists(manifest.manifestPath);
101-
102-
logger.debug(`Searching in packageDir: ${manifest.directoryPaths.join(', ')} for matching metadata`);
103-
componentSet = await ComponentSet.fromManifest({
104-
manifestPath: manifest.manifestPath,
105-
resolveSourcePaths: manifest.directoryPaths,
106-
forceAddWildcards: true,
107-
destructivePre: manifest.destructiveChangesPre,
108-
destructivePost: manifest.destructiveChangesPost,
109-
registry: registryAccess,
110-
});
111-
}
89+
// Return empty ComponentSet and use packageNames in the connection via `.retrieve` options
90+
if (packagenames) {
91+
logger.debug(`Building ComponentSet for packagenames: ${packagenames.toString()}`);
92+
componentSet ??= new ComponentSet(undefined, registry);
93+
}
11294

113-
// Resolve metadata entries with source in package directories.
114-
if (metadata) {
115-
logger.debug(`Building ComponentSet from metadata: ${metadata.metadataEntries.toString()}`);
116-
const directoryPaths = metadata.directoryPaths;
117-
componentSet ??= new ComponentSet(undefined, registryAccess);
118-
const componentSetFilter = new ComponentSet(undefined, registryAccess);
119-
120-
// Build a Set of metadata entries
121-
metadata.metadataEntries
122-
.map(entryToTypeAndName(registryAccess))
123-
.flatMap(typeAndNameToMetadataComponents({ directoryPaths, registry: registryAccess }))
124-
.map(addToComponentSet(componentSet))
125-
.map(addToComponentSet(componentSetFilter));
126-
127-
logger.debug(`Searching for matching metadata in directories: ${directoryPaths.join(', ')}`);
128-
129-
// add destructive changes if defined. Because these are deletes, all entries
130-
// are resolved to SourceComponents
131-
if (metadata.destructiveEntriesPre) {
132-
metadata.destructiveEntriesPre
133-
.map(entryToTypeAndName(registryAccess))
134-
.map(assertNoWildcardInDestructiveEntries)
135-
.flatMap(typeAndNameToMetadataComponents({ directoryPaths, registry: registryAccess }))
136-
.map((mdComponent) => new SourceComponent({ type: mdComponent.type, name: mdComponent.fullName }))
137-
.map(addToComponentSet(componentSet, DestructiveChangesType.PRE));
138-
}
139-
if (metadata.destructiveEntriesPost) {
140-
metadata.destructiveEntriesPost
141-
.map(entryToTypeAndName(registryAccess))
142-
.map(assertNoWildcardInDestructiveEntries)
143-
.flatMap(typeAndNameToMetadataComponents({ directoryPaths, registry: registryAccess }))
144-
.map((mdComponent) => new SourceComponent({ type: mdComponent.type, name: mdComponent.fullName }))
145-
.map(addToComponentSet(componentSet, DestructiveChangesType.POST));
146-
}
95+
// Resolve manifest with source in package directories.
96+
if (manifest) {
97+
logger.debug(`Building ComponentSet from manifest: ${manifest.manifestPath}`);
98+
assertFileExists(manifest.manifestPath);
99+
100+
logger.debug(`Searching in packageDir: ${manifest.directoryPaths.join(', ')} for matching metadata`);
101+
componentSet = await ComponentSet.fromManifest({
102+
manifestPath: manifest.manifestPath,
103+
resolveSourcePaths: manifest.directoryPaths,
104+
forceAddWildcards: true,
105+
destructivePre: manifest.destructiveChangesPre,
106+
destructivePost: manifest.destructiveChangesPost,
107+
registry,
108+
});
109+
}
147110

148-
const resolvedComponents = ComponentSet.fromSource({
149-
fsPaths: directoryPaths,
150-
include: componentSetFilter,
151-
registry: registryAccess,
152-
});
153-
154-
if (resolvedComponents.forceIgnoredPaths) {
155-
// if useFsForceIgnore = true, then we won't be able to resolve a forceignored path,
156-
// which we need to do to get the ignored source component
157-
const resolver = new MetadataResolver(registryAccess, undefined, false);
158-
159-
for (const ignoredPath of resolvedComponents.forceIgnoredPaths ?? []) {
160-
resolver.getComponentsFromPath(ignoredPath).map((ignored) => {
161-
componentSet = componentSet?.filter(
162-
(resolved) => !(resolved.fullName === ignored.name && resolved.type === ignored.type)
163-
);
164-
});
165-
}
166-
componentSet.forceIgnoredPaths = resolvedComponents.forceIgnoredPaths;
167-
}
111+
// Resolve metadata entries with source in package directories.
112+
if (metadata) {
113+
logger.debug(`Building ComponentSet from metadata: ${metadata.metadataEntries.toString()}`);
114+
const directoryPaths = metadata.directoryPaths;
115+
componentSet ??= new ComponentSet(undefined, registry);
116+
const componentSetFilter = new ComponentSet(undefined, registry);
117+
118+
// Build a Set of metadata entries
119+
metadata.metadataEntries
120+
.map(entryToTypeAndName(registry))
121+
.flatMap(typeAndNameToMetadataComponents({ directoryPaths, registry }))
122+
.map(addToComponentSet(componentSet))
123+
.map(addToComponentSet(componentSetFilter));
124+
125+
logger.debug(`Searching for matching metadata in directories: ${directoryPaths.join(', ')}`);
126+
127+
// add destructive changes if defined. Because these are deletes, all entries
128+
// are resolved to SourceComponents
129+
if (metadata.destructiveEntriesPre) {
130+
metadata.destructiveEntriesPre
131+
.map(entryToTypeAndName(registry))
132+
.map(assertNoWildcardInDestructiveEntries)
133+
.flatMap(typeAndNameToMetadataComponents({ directoryPaths, registry }))
134+
.map((mdComponent) => new SourceComponent({ type: mdComponent.type, name: mdComponent.fullName }))
135+
.map(addToComponentSet(componentSet, DestructiveChangesType.PRE));
136+
}
137+
if (metadata.destructiveEntriesPost) {
138+
metadata.destructiveEntriesPost
139+
.map(entryToTypeAndName(registry))
140+
.map(assertNoWildcardInDestructiveEntries)
141+
.flatMap(typeAndNameToMetadataComponents({ directoryPaths, registry }))
142+
.map((mdComponent) => new SourceComponent({ type: mdComponent.type, name: mdComponent.fullName }))
143+
.map(addToComponentSet(componentSet, DestructiveChangesType.POST));
144+
}
168145

169-
resolvedComponents.toArray().map(addToComponentSet(componentSet));
146+
const resolvedComponents = ComponentSet.fromSource({
147+
fsPaths: directoryPaths,
148+
include: componentSetFilter,
149+
registry,
150+
});
151+
152+
if (resolvedComponents.forceIgnoredPaths) {
153+
// if useFsForceIgnore = true, then we won't be able to resolve a forceignored path,
154+
// which we need to do to get the ignored source component
155+
const resolver = new MetadataResolver(registry, undefined, false);
156+
157+
for (const ignoredPath of resolvedComponents.forceIgnoredPaths ?? []) {
158+
resolver.getComponentsFromPath(ignoredPath).map((ignored) => {
159+
componentSet = componentSet?.filter(
160+
(resolved) => !(resolved.fullName === ignored.name && resolved.type === ignored.type)
161+
);
162+
});
163+
}
164+
componentSet.forceIgnoredPaths = resolvedComponents.forceIgnoredPaths;
170165
}
171166

172-
// Resolve metadata entries with an org connection
173-
if (org) {
174-
componentSet ??= new ComponentSet(undefined, registryAccess);
167+
resolvedComponents.toArray().map(addToComponentSet(componentSet));
168+
}
175169

176-
logger.debug(
177-
`Building ComponentSet from targetUsername: ${org.username} ${
178-
metadata ? `filtered by metadata: ${metadata.metadataEntries.toString()}` : ''
179-
}`
180-
);
170+
// Resolve metadata entries with an org connection
171+
if (org) {
172+
componentSet ??= new ComponentSet(undefined, registry);
181173

182-
const mdMap = metadata
183-
? buildMapFromComponents(metadata.metadataEntries.map(entryToTypeAndName(registryAccess)))
184-
: (new Map() as MetadataMap);
174+
logger.debug(
175+
`Building ComponentSet from targetUsername: ${org.username} ${
176+
metadata ? `filtered by metadata: ${metadata.metadataEntries.toString()}` : ''
177+
}`
178+
);
185179

186-
const fromConnection = await ComponentSet.fromConnection({
187-
usernameOrConnection: (await StateAggregator.getInstance()).aliases.getUsername(org.username) ?? org.username,
188-
componentFilter: getOrgComponentFilter(org, mdMap, metadata),
189-
metadataTypes: mdMap.size ? Array.from(mdMap.keys()) : undefined,
190-
registry: registryAccess,
191-
});
180+
const mdMap = metadata
181+
? buildMapFromComponents(metadata.metadataEntries.map(entryToTypeAndName(registry)))
182+
: (new Map() as MetadataMap);
192183

193-
fromConnection.toArray().map(addToComponentSet(componentSet));
194-
}
195-
} catch (e) {
196-
return componentSetBuilderErrorHandler(e);
184+
const fromConnection = await ComponentSet.fromConnection({
185+
usernameOrConnection: (await StateAggregator.getInstance()).aliases.getUsername(org.username) ?? org.username,
186+
componentFilter: getOrgComponentFilter(org, mdMap, metadata),
187+
metadataTypes: mdMap.size ? Array.from(mdMap.keys()) : undefined,
188+
registry,
189+
});
190+
191+
fromConnection.toArray().map(addToComponentSet(componentSet));
197192
}
198193

199194
// there should have been a componentSet created by this point.
200195
componentSet = assertComponentSetIsNotUndefined(componentSet);
201-
componentSet.apiVersion ??= apiversion;
202-
componentSet.sourceApiVersion ??= sourceapiversion;
203-
componentSet.projectDirectory = projectDir;
196+
componentSet.apiVersion ??= options.apiversion;
197+
componentSet.sourceApiVersion ??= options.sourceapiversion;
198+
componentSet.projectDirectory = options.projectDir;
204199

205200
logComponents(logger, componentSet);
206201
return componentSet;
@@ -214,17 +209,6 @@ const addToComponentSet =
214209
return cmp;
215210
};
216211

217-
const componentSetBuilderErrorHandler = (e: unknown): never => {
218-
if (e instanceof Error && e.message.includes('Missing metadata type definition in registry for id')) {
219-
// to remain generic to catch missing metadata types regardless of parameters, split on '
220-
// example message : Missing metadata type definition in registry for id 'NonExistentType'
221-
const issueType = e.message.split("'")[1];
222-
throw new SfError(`The specified metadata type is unsupported: [${issueType}]`);
223-
} else {
224-
throw e;
225-
}
226-
};
227-
228212
const validateAndResolvePath = (filepath: string): string => path.resolve(assertFileExists(filepath));
229213

230214
const assertFileExists = (filepath: string): string => {

src/registry/levenshtein.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* Copyright (c) 2023, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import { Messages } from '@salesforce/core/messages';
9+
import * as Levenshtein from 'fast-levenshtein';
10+
import { MetadataRegistry } from './types';
11+
12+
Messages.importMessagesDirectory(__dirname);
13+
const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr');
14+
15+
/** "did you mean" for Metadata type names */
16+
export const getTypeSuggestions = (registry: MetadataRegistry, typeName: string): string[] => {
17+
const scores = getScores(
18+
Object.values(registry.types).map((t) => t.name),
19+
typeName
20+
);
21+
22+
const guesses = getLowestScores(scores).map((guess) => guess.registryKey);
23+
return [
24+
...(guesses.length
25+
? [
26+
`Did you mean one of the following types? [${guesses.join(',')}]`,
27+
'', // Add a blank line for better readability
28+
]
29+
: []),
30+
messages.getMessage('type_name_suggestions'),
31+
];
32+
};
33+
34+
export const getSuffixGuesses = (suffixes: string[], input: string): string[] => {
35+
const scores = getScores(suffixes, input);
36+
return getLowestScores(scores).map((g) => g.registryKey);
37+
};
38+
39+
type LevenshteinScore = {
40+
registryKey: string;
41+
score: number;
42+
};
43+
44+
const getScores = (choices: string[], input: string): LevenshteinScore[] =>
45+
choices.map((registryKey) => ({
46+
registryKey,
47+
score: Levenshtein.get(input, registryKey, { useCollator: true }),
48+
}));
49+
50+
/** Levenshtein uses positive integers for scores, find all scores that match the lowest score */
51+
const getLowestScores = (scores: LevenshteinScore[]): LevenshteinScore[] => {
52+
const sortedScores = scores.sort(levenshteinSorter);
53+
const lowestScore = scores[0].score;
54+
return sortedScores.filter((score) => score.score === lowestScore);
55+
};
56+
57+
const levenshteinSorter = (a: LevenshteinScore, b: LevenshteinScore): number => a.score - b.score;

0 commit comments

Comments
 (0)