diff --git a/packages/code-analyzer-core/package.json b/packages/code-analyzer-core/package.json index e67acff0..8ffde631 100644 --- a/packages/code-analyzer-core/package.json +++ b/packages/code-analyzer-core/package.json @@ -1,7 +1,7 @@ { "name": "@salesforce/code-analyzer-core", "description": "Core Package for the Salesforce Code Analyzer", - "version": "0.38.1", + "version": "0.39.0-SNAPSHOT", "author": "The Salesforce Code Analyzer Team", "license": "BSD-3-Clause", "homepage": "https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/overview", @@ -72,4 +72,4 @@ "!src/index.ts" ] } -} \ No newline at end of file +} diff --git a/packages/code-analyzer-core/src/code-analyzer.ts b/packages/code-analyzer-core/src/code-analyzer.ts index 1268d68c..be3b7a1d 100644 --- a/packages/code-analyzer-core/src/code-analyzer.ts +++ b/packages/code-analyzer-core/src/code-analyzer.ts @@ -326,14 +326,18 @@ export class CodeAnalyzer { const runPromises: Promise[] = ruleSelection.getEngineNames().map(async (engineName) => { const workingFolder: string = await this.tempFolder.makeSubfolder(runWorkingFolderName, engineName); + if (this.config.getPreserveAllWorkingFolders()) { + this.tempFolder.markToBeKept(runWorkingFolderName, engineName); + } const engineRunOptions: engApi.RunOptions = { logFolder: this.config.getLogFolder(), workingFolder: workingFolder, workspace: engApiWorkspace }; const errorCallback: () => void = () => { + // istanbul ignore else if (!this.tempFolder.isKept(runWorkingFolderName, engineName)) { - this.emitLogEvent(LogLevel.Debug, getMessage('EngineWorkingFolderKept', engineName, workingFolder)); + this.emitLogEvent(LogLevel.Debug, getMessage('EngineWorkingFolderKeptDueToError', engineName, workingFolder)); this.tempFolder.markToBeKept(runWorkingFolderName, engineName); } }; @@ -341,6 +345,9 @@ export class CodeAnalyzer { await this.tempFolder.removeIfNotKept(runWorkingFolderName, engineName); return results; }); + if (this.config.getPreserveAllWorkingFolders()) { + this.emitLogEvent(LogLevel.Debug, getMessage('AllWorkingFoldersKept', await this.tempFolder.getPath(runWorkingFolderName))); + } const engineRunResultsList: EngineRunResults[] = await Promise.all(runPromises); await this.tempFolder.removeIfNotKept(runWorkingFolderName); @@ -377,6 +384,11 @@ export class CodeAnalyzer { const rulePromises: Promise[] = this.getEngineNames().map(async (engineName) => { const workingFolder: string = await this.tempFolder.makeSubfolder(rulesWorkingFolderName, engineName); + + if (this.config.getPreserveAllWorkingFolders()) { + this.tempFolder.markToBeKept(rulesWorkingFolderName, engineName) + } + const describeOptions: engApi.DescribeOptions = { workspace: engApiWorkspace, workingFolder: workingFolder, @@ -384,7 +396,7 @@ export class CodeAnalyzer { }; const errorCallback: () => void = () => { if (!this.tempFolder.isKept(rulesWorkingFolderName, engineName)) { - this.emitLogEvent(LogLevel.Debug, getMessage('EngineWorkingFolderKept', engineName, workingFolder)); + this.emitLogEvent(LogLevel.Debug, getMessage('EngineWorkingFolderKeptDueToError', engineName, workingFolder)); this.tempFolder.markToBeKept(rulesWorkingFolderName, engineName); } }; @@ -393,6 +405,10 @@ export class CodeAnalyzer { return rules; }); + if (this.config.getPreserveAllWorkingFolders()) { + this.emitLogEvent(LogLevel.Debug, getMessage('AllWorkingFoldersKept', await this.tempFolder.getPath(rulesWorkingFolderName))); + } + this.rulesCache.set(cacheKey, (await Promise.all(rulePromises)).flat()); await this.tempFolder.removeIfNotKept(rulesWorkingFolderName); diff --git a/packages/code-analyzer-core/src/config.ts b/packages/code-analyzer-core/src/config.ts index af922281..5aae83a9 100644 --- a/packages/code-analyzer-core/src/config.ts +++ b/packages/code-analyzer-core/src/config.ts @@ -14,6 +14,7 @@ export const FIELDS = { LOG_FOLDER: 'log_folder', LOG_LEVEL: 'log_level', CUSTOM_ENGINE_PLUGIN_MODULES: 'custom_engine_plugin_modules', // Hidden + PRESERVE_ALL_WORKING_FOLDERS: 'preserve_all_working_folders', // Hidden RULES: 'rules', ENGINES: 'engines', SEVERITY: 'severity', @@ -41,6 +42,7 @@ type TopLevelConfig = { log_level: LogLevel rules: Record engines: Record + preserve_all_working_folders: boolean // INTERNAL USE ONLY custom_engine_plugin_modules: string[] // INTERNAL USE ONLY } @@ -51,6 +53,7 @@ export const DEFAULT_CONFIG: TopLevelConfig = { log_level: LogLevel.Debug, rules: {}, engines: {}, + preserve_all_working_folders: false, // INTERNAL USE ONLY custom_engine_plugin_modules: [], // INTERNAL USE ONLY }; @@ -136,7 +139,7 @@ export class CodeAnalyzerConfig { configRoot = !rawConfig.config_root ? (configRoot ?? process.cwd()) : validateAbsoluteFolder(rawConfig.config_root, FIELDS.CONFIG_ROOT); const configExtractor: engApi.ConfigValueExtractor = new engApi.ConfigValueExtractor(rawConfig, '', configRoot); - configExtractor.addKeysThatBypassValidation([FIELDS.CUSTOM_ENGINE_PLUGIN_MODULES]); // Because custom_engine_plugin_modules is currently hidden + configExtractor.addKeysThatBypassValidation([FIELDS.CUSTOM_ENGINE_PLUGIN_MODULES, FIELDS.PRESERVE_ALL_WORKING_FOLDERS]); // Hidden fields bypass validation configExtractor.validateContainsOnlySpecifiedKeys([FIELDS.CONFIG_ROOT, FIELDS.LOG_FOLDER, FIELDS.LOG_LEVEL ,FIELDS.RULES, FIELDS.ENGINES]); const config: TopLevelConfig = { config_root: configRoot, @@ -145,6 +148,7 @@ export class CodeAnalyzerConfig { custom_engine_plugin_modules: configExtractor.extractArray(FIELDS.CUSTOM_ENGINE_PLUGIN_MODULES, engApi.ValueValidator.validateString, DEFAULT_CONFIG.custom_engine_plugin_modules)!, + preserve_all_working_folders: configExtractor.extractBoolean(FIELDS.PRESERVE_ALL_WORKING_FOLDERS, DEFAULT_CONFIG.preserve_all_working_folders)!, rules: extractRulesValue(configExtractor), engines: extractEnginesValue(configExtractor) } @@ -226,6 +230,15 @@ export class CodeAnalyzerConfig { return this.config.custom_engine_plugin_modules; } + /** + * Returns a boolean indicating whether working folders are always preserved. + * If false, then the working folders are deleted unless the engine issues an error. + * If true, then the working folder for each engine remains, even if the engine does not issue an error. + */ + public getPreserveAllWorkingFolders(): boolean { + return this.config.preserve_all_working_folders; + } + /** * Returns a {@link RuleOverrides} instance containing the user specified overrides for all rules associated with the specified engine * @param engineName name of the engine diff --git a/packages/code-analyzer-core/src/messages.ts b/packages/code-analyzer-core/src/messages.ts index 8f2ad2e5..e16c4091 100644 --- a/packages/code-analyzer-core/src/messages.ts +++ b/packages/code-analyzer-core/src/messages.ts @@ -202,7 +202,10 @@ const MESSAGE_CATALOG : MessageCatalog = { EngineReturnedViolationWithCodeLocationWithEndColumnBeforeStartColumnOnSameLine: `Engine failure. The engine '%s' returned a violation for rule '%s' that contains a code location with the endLine equal to the startLine and the endColumn %d before the startColumn %d.`, - EngineWorkingFolderKept: + AllWorkingFoldersKept: + `Since preserve_all_working_folders config setting is true, all temporary working folders in %s have been kept.`, + + EngineWorkingFolderKeptDueToError: `Since the engine '%s' emitted an error, the following temporary working folder will not be removed: %s` } diff --git a/packages/code-analyzer-core/test/code-analyzer.test.ts b/packages/code-analyzer-core/test/code-analyzer.test.ts index 58f124ce..2fde6755 100644 --- a/packages/code-analyzer-core/test/code-analyzer.test.ts +++ b/packages/code-analyzer-core/test/code-analyzer.test.ts @@ -201,14 +201,18 @@ describe("Tests for the run method of CodeAnalyzer", () => { return codeAnalyzer; } - beforeEach(async () => { - codeAnalyzer = createCodeAnalyzer(); + async function setupCodeAnalyzerWithStubs(config: CodeAnalyzerConfig = CodeAnalyzerConfig.withDefaults()): Promise { + codeAnalyzer = createCodeAnalyzer(config); sampleRunOptions = {workspace: await codeAnalyzer.createWorkspace([__dirname])}; const stubPlugin: stubs.StubEnginePlugin = new stubs.StubEnginePlugin(); await codeAnalyzer.addEnginePlugin(stubPlugin); stubEngine1 = stubPlugin.getCreatedEngine('stubEngine1') as stubs.StubEngine1; stubEngine2 = stubPlugin.getCreatedEngine('stubEngine2') as stubs.StubEngine2; selection = await codeAnalyzer.selectRules([]); + } + + beforeEach(async () => { + await setupCodeAnalyzerWithStubs(); }); it("When run options contains workspace with targets, then they are passed to each engine successfully", async () => { @@ -861,6 +865,49 @@ describe("Tests for the run method of CodeAnalyzer", () => { expect(fileSystem.files).not.toContain(expectedRunWorkingFolderForStubEngine3); }); + + it("When running rules, if the top-level preserve_all_working_folders flag is true, all run working folders are preserved and a log is issued", async () => { + await setupCodeAnalyzerWithStubs(CodeAnalyzerConfig.fromObject({ + preserve_all_working_folders: true + })); + + const logEvents: LogEvent[] = []; + codeAnalyzer.onEvent(EventType.LogEvent, (event: LogEvent) => logEvents.push(event)); + + await codeAnalyzer.run(selection, sampleRunOptions); + + const expectedRunWorkingFolderRoot: string = path.join(os.tmpdir(),'code-analyzer-0','run-' + clock.formatToDateTimeString()); + const expectedRunWorkingFolderForStubEngine1: string = path.join(expectedRunWorkingFolderRoot, 'stubEngine1'); + const expectedRunWorkingFolderForStubEngine2: string = path.join(expectedRunWorkingFolderRoot, 'stubEngine2'); + const expectedRunWorkingFolderForStubEngine3: string = path.join(expectedRunWorkingFolderRoot, 'stubEngine3'); + + // First confirm that the root folder and all 3 engines run working folders were created + const createdFolders: string[] = fileSystem.mkdirCallHistory.map(args => args.absPath.toString()); + expect(createdFolders).toContain(expectedRunWorkingFolderRoot); + expect(createdFolders).toContain(expectedRunWorkingFolderForStubEngine1); + expect(createdFolders).toContain(expectedRunWorkingFolderForStubEngine2); + expect(createdFolders).toContain(expectedRunWorkingFolderForStubEngine3); + + // Confirm that the root folder and all 3 engines run working folders were removed (because none of them errored during run) + const removedFolders: string[] = fileSystem.rmCallHistory.map(args => args.absPath.toString()); + expect(removedFolders).not.toContain(expectedRunWorkingFolderRoot); + expect(removedFolders).not.toContain(expectedRunWorkingFolderForStubEngine1); + expect(removedFolders).not.toContain(expectedRunWorkingFolderForStubEngine2); + expect(removedFolders).not.toContain(expectedRunWorkingFolderForStubEngine3); + + // Verify end result + expect(fileSystem.files).toContain(expectedRunWorkingFolderRoot); + expect(fileSystem.files).toContain(expectedRunWorkingFolderForStubEngine1); + expect(fileSystem.files).toContain(expectedRunWorkingFolderForStubEngine2); + expect(fileSystem.files).toContain(expectedRunWorkingFolderForStubEngine3); + + // Verify log lines + const relevantLogMsgs: string[] = logEvents.filter(e => e.logLevel === LogLevel.Debug && + e.message.includes('all temporary working folders in')).map(e => e.message); + + expect(relevantLogMsgs.filter(m => m.includes(expectedRunWorkingFolderRoot))).toHaveLength(1); + }) + it("When running rules, if an engine issues an error, then we preserve that run working folder and issue a log pointing to it", async () => { codeAnalyzer = createCodeAnalyzer(); const logEvents: LogEvent[] = []; diff --git a/packages/code-analyzer-core/test/config.test.ts b/packages/code-analyzer-core/test/config.test.ts index b99a7be8..abd1f1ee 100644 --- a/packages/code-analyzer-core/test/config.test.ts +++ b/packages/code-analyzer-core/test/config.test.ts @@ -19,6 +19,7 @@ describe("Tests for creating and accessing configuration values", () => { expect(conf.getLogFolder()).toEqual(os.tmpdir()); expect(conf.getLogLevel()).toEqual(LogLevel.Debug); expect(conf.getCustomEnginePluginModules()).toEqual([]); + expect(conf.getPreserveAllWorkingFolders()).toEqual(false); expect(conf.getRuleOverridesFor("stubEngine1")).toEqual({}); expect(conf.getEngineOverridesFor("stubEngine1")).toEqual({}); expect(conf.getRuleOverridesFor("stubEngine2")).toEqual({}); @@ -81,6 +82,7 @@ describe("Tests for creating and accessing configuration values", () => { const conf: CodeAnalyzerConfig = CodeAnalyzerConfig.fromFile(path.join(TEST_DATA_DIR, 'sample-config-02.Yml')); expect(conf.getLogFolder()).toEqual(os.tmpdir()); expect(conf.getCustomEnginePluginModules()).toEqual(['dummy_plugin_module_path']); + expect(conf.getPreserveAllWorkingFolders()).toEqual(true); expect(conf.getRuleOverridesFor('stubEngine1')).toEqual({}); expect(conf.getRuleOverridesFor('stubEngine2')).toEqual({ stub2RuleC: { @@ -103,6 +105,7 @@ describe("Tests for creating and accessing configuration values", () => { const conf: CodeAnalyzerConfig = CodeAnalyzerConfig.fromFile(path.join(TEST_DATA_DIR, 'sample-config-03.json')); expect(conf.getLogFolder()).toEqual(path.join(TEST_DATA_DIR, 'sampleLogFolder')); expect(conf.getCustomEnginePluginModules()).toEqual([]); + expect(conf.getPreserveAllWorkingFolders()).toEqual(false); expect(conf.getRuleOverridesFor('stubEngine1')).toEqual({}); expect(conf.getRuleOverridesFor('stubEngine2')).toEqual({}); expect(conf.getEngineOverridesFor('stubEngine1')).toEqual({}); @@ -292,6 +295,17 @@ describe("Tests for creating and accessing configuration values", () => { getMessageFromCatalog(SHARED_MESSAGE_CATALOG, 'ConfigValueMustBeOfType','custom_engine_plugin_modules', 'array', 'string')); }); + it("When preserve_all_working_folders is not a boolean, then throw an error", () => { + expect(() => CodeAnalyzerConfig.fromObject({preserve_all_working_folders: 3})).toThrow( + getMessageFromCatalog(SHARED_MESSAGE_CATALOG, 'ConfigValueMustBeOfType','preserve_all_working_folders', 'boolean', 'number')); + + expect(() => CodeAnalyzerConfig.fromObject({preserve_all_working_folders: 'abcd'})).toThrow( + getMessageFromCatalog(SHARED_MESSAGE_CATALOG, 'ConfigValueMustBeOfType','preserve_all_working_folders', 'boolean', 'string')); + + expect(() => CodeAnalyzerConfig.fromObject({preserve_all_working_folders: 'true'})).toThrow( + getMessageFromCatalog(SHARED_MESSAGE_CATALOG, 'ConfigValueMustBeOfType','preserve_all_working_folders', 'boolean', 'string')); + }) + it("When supplied config_root path is a valid absolute path, then we use it", () => { const configRootValue: string = path.join(TEST_DATA_DIR, 'sampleWorkspace'); const conf: CodeAnalyzerConfig = CodeAnalyzerConfig.fromObject({config_root: configRootValue}); diff --git a/packages/code-analyzer-core/test/rule-selection.test.ts b/packages/code-analyzer-core/test/rule-selection.test.ts index d5074014..fa1c8ab7 100644 --- a/packages/code-analyzer-core/test/rule-selection.test.ts +++ b/packages/code-analyzer-core/test/rule-selection.test.ts @@ -641,6 +641,47 @@ describe('Tests for selecting rules', () => { expect(relevantLogMsgs.filter(m => m.endsWith(expectedRulesWorkingFolderForStubEngine2))).toHaveLength(1); }); + it("When selecting rules, if preserve_all_working_folders is true, then all working folders are kept regardless of failures and the root is logged", async () => { + await setupCodeAnalyzerWithStubPlugin(CodeAnalyzerConfig.fromObject({ + preserve_all_working_folders: true + })); + + const logEvents: LogEvent[] = []; + codeAnalyzer.onEvent(EventType.LogEvent, (event: LogEvent) => logEvents.push(event)); + + await codeAnalyzer.selectRules(['all']); + + const expectedRulesWorkingFolderRoot: string = path.join(os.tmpdir(),'code-analyzer-0','rules-' + clock.formatToDateTimeString()); + const expectedRulesWorkingFolderForStubEngine1: string = path.join(expectedRulesWorkingFolderRoot, 'stubEngine1'); + const expectedRulesWorkingFolderForStubEngine2: string = path.join(expectedRulesWorkingFolderRoot, 'stubEngine2'); + const expectedRulesWorkingFolderForStubEngine3: string = path.join(expectedRulesWorkingFolderRoot, 'stubEngine3'); + + // First confirm that the root folder and all 3 engine rule working folders were created + const createdFolders: string[] = fileSystem.mkdirCallHistory.map(args => args.absPath.toString()); + expect(createdFolders).toContain(expectedRulesWorkingFolderRoot); + expect(createdFolders).toContain(expectedRulesWorkingFolderForStubEngine1); + expect(createdFolders).toContain(expectedRulesWorkingFolderForStubEngine2); + expect(createdFolders).toContain(expectedRulesWorkingFolderForStubEngine3); + + // Next confirm that neither the root folder nor the subfolders were removed. + const removedFolders: string[] = fileSystem.rmCallHistory.map(args => args.absPath.toString()); + expect(removedFolders).not.toContain(expectedRulesWorkingFolderRoot); + expect(removedFolders).not.toContain(expectedRulesWorkingFolderForStubEngine1); + expect(removedFolders).not.toContain(expectedRulesWorkingFolderForStubEngine2); + expect(removedFolders).not.toContain(expectedRulesWorkingFolderForStubEngine3); + + // Verify end result + expect(fileSystem.files).toContain(expectedRulesWorkingFolderRoot); + expect(fileSystem.files).toContain(expectedRulesWorkingFolderForStubEngine1); + expect(fileSystem.files).toContain(expectedRulesWorkingFolderForStubEngine2); + expect(fileSystem.files).toContain(expectedRulesWorkingFolderForStubEngine3); + + // Verify log lines + const relevantLogMsgs: string[] = logEvents.filter(e => e.logLevel === LogLevel.Debug && + e.message.includes('all temporary working folders in')).map(e => e.message); + expect(relevantLogMsgs.filter(m => m.includes(expectedRulesWorkingFolderRoot))).toHaveLength(1); + }); + it("When selecting rules, if no engine errors, then we fully remove the rules working folder", async () => { codeAnalyzer = createCodeAnalyzer(); await codeAnalyzer.addEnginePlugin(new stubs.EmptyTagEnginePlugin()); diff --git a/packages/code-analyzer-core/test/test-data/sample-config-02.Yml b/packages/code-analyzer-core/test/test-data/sample-config-02.Yml index fcdefaa6..e931d11d 100644 --- a/packages/code-analyzer-core/test/test-data/sample-config-02.Yml +++ b/packages/code-analyzer-core/test/test-data/sample-config-02.Yml @@ -1,5 +1,5 @@ custom_engine_plugin_modules: ["dummy_plugin_module_path"] - +preserve_all_working_folders: true rules: stubEngine2: stub2RuleC: @@ -10,4 +10,4 @@ engines: miscSetting1: true miscSetting2: miscSetting2A: 3 - miscSetting2B: ["hello", "world"] \ No newline at end of file + miscSetting2B: ["hello", "world"]