Skip to content

Commit

Permalink
feat: add command dev audit messages
Browse files Browse the repository at this point in the history
@W-11940230@
  • Loading branch information
peternhale committed Nov 8, 2022
1 parent 919ad0f commit 179d221
Show file tree
Hide file tree
Showing 4 changed files with 469 additions and 0 deletions.
11 changes: 11 additions & 0 deletions command-snapshot.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
[
{
"command": "dev:audit:messages",
"plugin": "@salesforce/plugin-dev",
"flags": [
"json",
"messages-dir",
"project-dir",
"source-dir"
],
"alias": []
},
{
"command": "dev:configure:repo",
"plugin": "@salesforce/plugin-dev",
Expand Down
73 changes: 73 additions & 0 deletions messages/audit.messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# summary

Audit messages in a plugin's messages directory to locate unused messages and missing messages that have references in source code.

# description

Audit messages in a plugin's messages directory to locate unused messages and missing messages that have references in source code.

# examples

sf dev audit messages
sf dev audit messages --json
sf dev audit messages --messages-dir ./messages --source-dir ./src

# flags.project-dir.summary

Location project where messages are to be audited.

# flags.project-dir.description

The project directory.

# flags.messages-dir.summary

Location of the message bundle directory.

# flags.messages-dir.description

The directory that holds the message bundle files. The default is the messages directory in the current working directory.

# flags.source-dir.summary

Location of the plugin's source code.

# flags.source-dir.description

The directory that holds the plugin's source code. The default is the src directory in the current working directory.

# noUnusedMessagesFound

No unused messages found

# unusedMessagesFound

Unused messages found

# noMissingMessagesFound

No missing messages found

# missingMessagesExplanation

The following entries are message references that do not have corresponding message definitions.

# missingMessagesNonLiteralWarning

An asterisk (*) indicates that the message reference is either a variable or function.
This means that the message reference is not a literal string and cannot be audited.
The section 'Unused Messages' may include messages that are referenced by variables or functions.
Accessing a message by a variable or function can also cause entire message bundles to be flagged as unused.
Check the references manually to determine which messages are consumed by variables or functions.

# missingMessagesFound

Missing messages found

# unusedBundlesFound

Unused bundles found

# noUnusedBundlesFound

No unused bundles found
260 changes: 260 additions & 0 deletions src/commands/dev/audit/messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
/*
* Copyright (c) 2022, 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 fs from 'fs';
import { join, parse, relative, resolve } from 'path';
import { Logger, Messages } from '@salesforce/core';
import { Flags, SfCommand } from '@salesforce/sf-plugins-core';
import { Duration, ThrottledPromiseAll } from '@salesforce/kit';

export type AuditResults = {
unusedBundles: string[];
messageState: Record<string, { found: boolean; files: string[] }>;
missing: Record<string, { isLiteral: boolean; missing: boolean; files: string[] }>;
};

Messages.importMessagesDirectory(__dirname);
const messages = Messages.load('@salesforce/plugin-dev', 'audit.messages', [
'summary',
'description',
'examples',
'flags.messages-dir.summary',
'flags.messages-dir.description',
'flags.project-dir.summary',
'flags.project-dir.description',
'flags.source-dir.summary',
'flags.source-dir.description',
'missingMessagesExplanation',
'missingMessagesNonLiteralWarning',
'missingMessagesFound',
'noMissingMessagesFound',
'noUnusedBundlesFound',
'noUnusedMessagesFound',
'unusedBundlesFound',
'unusedMessagesFound',
]);

export default class AuditMessages extends SfCommand<AuditResults> {
public static summary = messages.getMessage('summary');
public static description = messages.getMessage('description');
public static examples = messages.getMessages('examples');

public static flags = {
'project-dir': Flags.directory({
summary: messages.getMessage('flags.project-dir.summary'),
char: 'p',
description: messages.getMessage('flags.project-dir.description'),
default: './',
}),
'messages-dir': Flags.directory({
summary: messages.getMessage('flags.messages-dir.summary'),
char: 'm',
description: messages.getMessage('flags.messages-dir.description'),
default: './messages',
}),
'source-dir': Flags.directory({
summary: messages.getMessage('flags.source-dir.summary'),

char: 's',
description: messages.getMessage('flags.source-dir.description'),
default: './src',
}),
};
private flags: { 'source-dir': string; 'messages-dir': string };
private messagesDirPath: string;
private bundles: string[] = [];
private sourceDirPath: string;
private source: Map<string, string> = new Map();
private auditResults: AuditResults = { unusedBundles: [], messageState: {}, missing: {} };
private package: string;
private projectDir: string;
private logger: Logger;

public async run(): Promise<AuditResults> {
this.logger = Logger.childFromRoot(this.constructor.name);
const { flags } = await this.parse(AuditMessages);
this.flags = flags;
await this.validateFlags();
await this.loadMessages();
await this.loadSource();
this.auditMessages();
this.displayResults();
return this.auditResults;
}

private async validateFlags(): Promise<void> {
this.projectDir = resolve(this.flags['project-dir'] as string);
this.logger.debug('Loading project directory: %s', this.projectDir);
const { name } = JSON.parse(await fs.promises.readFile(resolve(this.projectDir, 'package.json'), 'utf8')) as {
name: string;
};
this.logger.debug('Loaded package name: %s', name);
this.package = name;
}

private async loadMessages(): Promise<void> {
this.messagesDirPath = resolve(this.projectDir, this.flags['messages-dir']);
this.logger.debug('Loading messages from %s', this.messagesDirPath);
const messagesDir = await fs.promises.readdir(this.messagesDirPath, { withFileTypes: true });
Messages.importMessagesDirectory(this.messagesDirPath);
this.bundles = messagesDir.filter((entry) => entry.isFile()).map((entry) => entry.name);
}

private async loadSource(): Promise<void> {
this.sourceDirPath = resolve(this.projectDir, this.flags['source-dir']);
this.logger.debug('Loading source from %s', this.sourceDirPath);
const throttledPromise = new ThrottledPromiseAll<string, void>({ concurrency: 10, timeout: Duration.minutes(5) });
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const fileProducer = async (file: string, producer: ThrottledPromiseAll<string, void>): Promise<void> => {
this.logger.trace('Loading file %s', file);
const fileContents = await fs.promises.readFile(file, 'utf8');
const contents = fileContents.replace(/\n/g, ' ').replace(/\s{2,}/g, '');
this.source.set(relative(this.projectDir, file), contents);
};

const dirHandler = async (dir: string, producer: ThrottledPromiseAll<string, void>): Promise<void> => {
this.logger.debug('Loading directory %s', dir);
const contents = await fs.promises.readdir(dir, { withFileTypes: true });
producer.add(
contents.filter((entry) => entry.isDirectory()).map((entry) => join(dir, entry.name)),
dirHandler
);

producer.add(
contents
.filter((entry) => entry.isFile() && entry.name.match(/\.(?:ts|js)$/))
.map((entry) => join(dir, entry.name)),
fileProducer
);
};
throttledPromise.add(this.sourceDirPath, dirHandler);
await throttledPromise.all();
}

private displayResults(): void {
this.log();
if (this.auditResults.unusedBundles.length === 0) {
this.styledHeader(messages.getMessage('noUnusedBundlesFound'));
} else {
this.styledHeader(messages.getMessage('unusedBundlesFound'));
this.table(
this.auditResults.unusedBundles.sort().map((Bundle) => ({ Bundle })),
{ Bundle: { header: 'Bundle' } }
);
}
const unusedMessages = [...Object.entries(this.auditResults.messageState)]
.filter(([, { found }]) => !found)
.map(([key]) => {
const [Bundle, Name] = key.split(':');
return { Bundle, Name };
})
.filter((unused) => !this.auditResults.unusedBundles.includes(unused.Bundle))
.sort((a, b) => {
return a.Bundle.localeCompare(b.Bundle) || a.Name.localeCompare(b.Name);
});
this.log();
if (unusedMessages.length === 0) {
this.styledHeader(messages.getMessage('noUnusedMessagesFound'));
} else {
this.styledHeader(messages.getMessage('unusedMessagesFound'));
this.table(unusedMessages, { Bundle: { header: 'Bundle' }, Name: { header: 'Name' } });
}
const hasNonLiteralReferences = Object.values(this.auditResults.missing).some((missing) => !missing.isLiteral);
const missingMessages = [...Object.entries(this.auditResults.missing)]
.filter(([, { missing }]) => missing)
.sort((a, b) => {
const [akey] = a[0];
const [bkey] = b[0];
return akey.localeCompare(bkey);
})
.map((entry) => {
const [key, { isLiteral, files }] = entry;
return { Name: key, isLiteral: isLiteral ? '' : '*', Files: files.sort().join('\n') };
});
this.log();
if (missingMessages.length === 0) {
this.styledHeader(messages.getMessage('noMissingMessagesFound'));
} else {
this.styledHeader(messages.getMessage('missingMessagesFound'));
this.info(messages.getMessage('missingMessagesExplanation'));
if (hasNonLiteralReferences) {
this.log();
this.warn(messages.getMessage('missingMessagesNonLiteralWarning'));
this.log();
}
this.table(
missingMessages,
{ Name: { header: 'Name' }, isLiteral: { header: '*' }, Files: { header: 'Files' } },
{ 'no-truncate': true }
);
}
}

private auditMessages(): void {
this.bundles.forEach((bundleName) => {
const bundle: Messages<string> = Messages.loadMessages(this.package, parse(bundleName).name);
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const keys: string[] = [...bundle.messages.keys()] as string[];
// audits bundle for unused keys
const loadBundleRegex = new RegExp(`Messages.load(Messages)?\\(.*?${parse(bundleName).name}.*?\\)`);
keys.forEach((key) => {
const messageKeyRegex = `\\.(?:getMessage|getMessages|getMessageWithMap|createError|createWarn|createInfo)\\(['"]?(${key})['"]?.*?\\)`;
const re = new RegExp(messageKeyRegex, 'g');
const keyFound: { found: boolean; files: string[] } = { found: false, files: [] };
[...this.source.entries()]
.filter(([, contents]) => loadBundleRegex.test(contents))
.forEach(([file, contents]) => {
const matches = [...contents.matchAll(re)];
matches.forEach(() => {
keyFound.found = true;
keyFound.files.push(file);
});
});

this.auditResults.messageState = Object.assign(this.auditResults.messageState, {
[`${bundleName}:${key}`]: keyFound,
});
});
// audits bundle this is not used
const allMessagesNotFound = [...Object.entries(this.auditResults.messageState)]
.filter(([key]) => {
const [bundlePart] = key.split(':');
return bundlePart === bundleName;
})
.every(([, { found }]) => !found);
if (allMessagesNotFound) {
this.auditResults.unusedBundles.push(bundleName);
}

// audits source for missing messages
[...this.source.entries()]
.filter(([, contents]) => loadBundleRegex.test(contents))
.forEach(([file, contents]) => {
const reString =
'\\.(?:getMessage|getMessages|getMessageWithMap|createError|createWarn|createInfo)\\((.*?)\\)';
const re = new RegExp(reString, 'g');
const matches = [...contents.matchAll(re)];
matches
.filter((m) => m?.[1])
.forEach((match) => {
const params = match[1].split(',');
const isLiteral = /^['"]/.test(params[0]);
const key = params[0].replace(/['"]/g, '');
const missingKey = this.auditResults.missing[key] || { isLiteral, missing: true, files: [] };
if (keys.includes(key)) {
missingKey.missing = false;
}
if (!missingKey.files.includes(file)) {
missingKey.files.push(file);
}
this.auditResults.missing = Object.assign(this.auditResults.missing, { [key]: missingKey });
});
});
});
}
}
Loading

0 comments on commit 179d221

Please sign in to comment.