diff --git a/Chocolatey.Cake.Recipe/Content/analyzing.cake b/Chocolatey.Cake.Recipe/Content/analyzing.cake index fcdac68..181c920 100644 --- a/Chocolatey.Cake.Recipe/Content/analyzing.cake +++ b/Chocolatey.Cake.Recipe/Content/analyzing.cake @@ -123,4 +123,5 @@ BuildParameters.Tasks.AnalyzeTask = Task("Analyze") .IsDependentOn("InspectCode") .IsDependentOn("Run-DotNetFormatCheck") .IsDependentOn("CreateIssuesReport") + .IsDependentOn("Run-PSScriptAnalyzer") .WithCriteria(() => BuildParameters.ShouldRunAnalyze, "Skipping because running analysis tasks is not enabled"); diff --git a/Chocolatey.Cake.Recipe/Content/formatting-settings.psd1 b/Chocolatey.Cake.Recipe/Content/formatting-settings.psd1 new file mode 100644 index 0000000..0e23cf7 --- /dev/null +++ b/Chocolatey.Cake.Recipe/Content/formatting-settings.psd1 @@ -0,0 +1,70 @@ +@{ + IncludeRules = @( + 'PSUseBOMForUnicodeEncodedFile', + 'PSMisleadingBacktick', + 'PSAvoidUsingCmdletAliases', + 'PSAvoidTrailingWhitespace', + 'PSAvoidSemicolonsAsLineTerminators', + 'PSUseCorrectCasing', + 'PSPlaceOpenBrace', + 'PSPlaceCloseBrace', + 'PSAlignAssignmentStatement', + 'PSUseConsistentWhitespace', + 'PSUseConsistentIndentation' + ) + + Rules = @{ + + <# + PSAvoidUsingCmdletAliases = @{ + 'allowlist' = @('') + }#> + + PSAvoidSemicolonsAsLineTerminators = @{ + Enable = $true + } + + PSUseCorrectCasing = @{ + Enable = $true + } + + PSPlaceOpenBrace = @{ + Enable = $true + OnSameLine = $true + NewLineAfter = $true + IgnoreOneLineBlock = $false + } + + PSPlaceCloseBrace = @{ + Enable = $true + NewLineAfter = $true + IgnoreOneLineBlock = $false + NoEmptyLineBefore = $true + } + + PSAlignAssignmentStatement = @{ + Enable = $true + CheckHashtable = $true + } + + PSUseConsistentIndentation = @{ + Enable = $true + Kind = 'space' + PipelineIndentation = 'IncreaseIndentationForFirstPipeline' + IndentationSize = 4 + } + + PSUseConsistentWhitespace = @{ + Enable = $true + CheckInnerBrace = $true + CheckOpenBrace = $true + CheckOpenParen = $true + CheckOperator = $true + CheckPipe = $true + CheckPipeForRedundantWhitespace = $false + CheckSeparator = $true + CheckParameter = $false + IgnoreAssignmentOperatorInsideHashTable = $true + } + } +} \ No newline at end of file diff --git a/Chocolatey.Cake.Recipe/Content/install-module.ps1 b/Chocolatey.Cake.Recipe/Content/install-module.ps1 new file mode 100644 index 0000000..745f412 --- /dev/null +++ b/Chocolatey.Cake.Recipe/Content/install-module.ps1 @@ -0,0 +1,33 @@ +# Copyright © 2023 Chocolatey Software, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +[cmdletBinding()] +Param( + [Parameter()] + [String] + $ModuleName, + + [Parameter()] + [String] + $RequiredVersion +) + +if (Get-Module -ListAvailable -Name $ModuleName) { + Write-Host "The $ModuleName PowerShell Module is already installed." +} +else { + Write-Host "Install Module $ModuleName..." + Install-Module -Name $ModuleName -RequiredVersion $RequiredVersion -Force +} \ No newline at end of file diff --git a/Chocolatey.Cake.Recipe/Content/parameters.cake b/Chocolatey.Cake.Recipe/Content/parameters.cake index d3439d2..6495a7d 100644 --- a/Chocolatey.Cake.Recipe/Content/parameters.cake +++ b/Chocolatey.Cake.Recipe/Content/parameters.cake @@ -104,6 +104,7 @@ public static class BuildParameters public static Func GetFilesToObfuscate { get; private set; } public static Func GetFilesToSign { get; private set; } public static Func> GetILMergeConfigs { get; private set; } + public static Func> GetPSScriptAnalyzerSettings { get; private set; } public static Func GetMsisToSign { get; private set; } public static Func GetProjectsToPack { get; private set; } public static Func GetScriptsToSign { get; private set; } @@ -181,6 +182,7 @@ public static class BuildParameters public static bool ShouldRunTests { get; private set ;} public static bool ShouldRunTransifex { get; set; } public static bool ShouldRunxUnit { get; private set; } + public static bool ShouldRunPSScriptAnalyzer { get; private set; } public static bool ShouldStrongNameOutputAssemblies { get; private set; } public static bool ShouldStrongNameSignDependentAssemblies { get; private set; } public static SlackCredentials Slack { get; private set; } @@ -313,6 +315,7 @@ public static class BuildParameters context.Information("ShouldRunTests: {0}", BuildParameters.ShouldRunTests); context.Information("ShouldRunTransifex: {0}", BuildParameters.ShouldRunTransifex); context.Information("ShouldRunxUnit: {0}", BuildParameters.ShouldRunxUnit); + context.Information("ShouldRunPSScriptAnalyzer: {0}", BuildParameters.ShouldRunPSScriptAnalyzer); context.Information("ShouldStrongNameOutputAssemblies: {0}", BuildParameters.ShouldStrongNameOutputAssemblies); context.Information("ShouldStrongNameSignDependentAssemblies: {0}", BuildParameters.ShouldStrongNameSignDependentAssemblies); context.Information("SolutionDirectoryPath: {0}", context.MakeAbsolute((DirectoryPath)SolutionDirectoryPath)); @@ -352,6 +355,7 @@ public static class BuildParameters Func getFilesToObfuscate = null, Func getFilesToSign = null, Func> getILMergeConfigs = null, + Func> getPSScriptAnalyzerSettings = null, Func getMsisToSign = null, Func getProjectsToPack = null, Func getScriptsToSign = null, @@ -418,6 +422,7 @@ public static class BuildParameters bool shouldRunTests = true, bool? shouldRunTransifex = null, bool shouldRunxUnit = true, + bool shouldRunPSScriptAnalyzer = true, bool shouldStrongNameOutputAssemblies = true, bool shouldStrongNameSignDependentAssemblies = true, Func slackMessageArguments = null, @@ -482,6 +487,7 @@ public static class BuildParameters GetFilesToObfuscate = getFilesToObfuscate; GetFilesToSign = getFilesToSign; GetILMergeConfigs = getILMergeConfigs; + GetPSScriptAnalyzerSettings = getPSScriptAnalyzerSettings; GetMsisToSign = getMsisToSign; GetProjectsToPack = getProjectsToPack; GetScriptsToSign = getScriptsToSign; @@ -738,6 +744,13 @@ public static class BuildParameters ShouldRunOpenCover = context.Argument("shouldRunOpenCover"); } + ShouldRunPSScriptAnalyzer = shouldRunPSScriptAnalyzer; + + if (context.HasArgument("shouldRunPSScriptAnalyzer")) + { + ShouldRunPSScriptAnalyzer = context.Argument("shouldRunPSScriptAnalyzer"); + } + ShouldRunReportGenerator = shouldRunReportGenerator; if (context.HasArgument("shouldRunReportGenerator")) @@ -772,7 +785,7 @@ public static class BuildParameters } ShouldRunxUnit = shouldRunxUnit; - + if (context.HasArgument("shouldRunxUnit")) { ShouldRunxUnit = context.Argument("shouldRunxUnit"); diff --git a/Chocolatey.Cake.Recipe/Content/paths.cake b/Chocolatey.Cake.Recipe/Content/paths.cake index 33940ab..d718a1b 100644 --- a/Chocolatey.Cake.Recipe/Content/paths.cake +++ b/Chocolatey.Cake.Recipe/Content/paths.cake @@ -38,6 +38,7 @@ public class BuildPaths var testResultsDirectory = buildDirectoryPath + "/TestResults"; var inspectCodeResultsDirectory = testResultsDirectory + "/InspectCode"; + var scriptAnalyzerResultsDirectory = testResultsDirectory + "/PSScriptAnalyzer"; var NUnitTestResultsDirectory = testResultsDirectory + "/NUnit"; var xUnitTestResultsDirectory = testResultsDirectory + "/xUnit"; @@ -75,6 +76,7 @@ public class BuildPaths nugetNuspecDirectory, testResultsDirectory, inspectCodeResultsDirectory, + scriptAnalyzerResultsDirectory, NUnitTestResultsDirectory, xUnitTestResultsDirectory, testCoverageDirectory, @@ -165,6 +167,7 @@ public class BuildDirectories public DirectoryPath NuGetNuspecDirectory { get; private set; } public DirectoryPath TestResults { get; private set; } public DirectoryPath InspectCodeTestResults { get; private set; } + public DirectoryPath PSScriptAnalyzerResults { get; private set; } public DirectoryPath NUnitTestResults { get; private set; } public DirectoryPath xUnitTestResults { get; private set; } public DirectoryPath TestCoverage { get; private set; } @@ -188,6 +191,7 @@ public class BuildDirectories DirectoryPath nugetNuspecDirectory, DirectoryPath testResults, DirectoryPath inspectCodeTestResults, + DirectoryPath scriptAnalyzerResults, DirectoryPath nunitTestResults, DirectoryPath xunitTestResults, DirectoryPath testCoverage, @@ -210,6 +214,7 @@ public class BuildDirectories NuGetNuspecDirectory = nugetNuspecDirectory; TestResults = testResults; InspectCodeTestResults = inspectCodeTestResults; + PSScriptAnalyzerResults = scriptAnalyzerResults; NUnitTestResults = nunitTestResults; xUnitTestResults = xunitTestResults; TestCoverage = testCoverage; diff --git a/Chocolatey.Cake.Recipe/Content/psscriptanalyzer.cake b/Chocolatey.Cake.Recipe/Content/psscriptanalyzer.cake new file mode 100644 index 0000000..7e618eb --- /dev/null +++ b/Chocolatey.Cake.Recipe/Content/psscriptanalyzer.cake @@ -0,0 +1,123 @@ +// Copyright © 2023 Chocolatey Software, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +BuildParameters.Tasks.PSScriptAnalyzerTask = Task("Run-PSScriptAnalyzer") + .WithCriteria(() => BuildParameters.ShouldRunPSScriptAnalyzer, "Skipping because PSScriptAnalyzer is not enabled") + .WithCriteria(() => BuildParameters.ShouldRunAnalyze, "Skipping because running analysis tasks is not enabled") + .Does(() => RequirePSModule("PSScriptAnalyzer", "1.21.0", () => + RequirePSModule("ConvertToSARIF", "1.0.0", () => { + var powerShellAnalysisScript = GetFiles("./tools/Chocolatey.Cake.Recipe*/Content/run-psscriptanalyzer.ps1").FirstOrDefault(); + + if (powerShellAnalysisScript == null) + { + Warning("Unable to find PowerShell Analysis script, so unable to run analysis."); + return; + } + + var outputFolder = MakeAbsolute(BuildParameters.Paths.Directories.PSScriptAnalyzerResults).FullPath; + EnsureDirectoryExists(outputFolder); + + if (BuildParameters.GetPSScriptAnalyzerSettings != null) + { + foreach (var PSScriptAnalyzerSetting in BuildParameters.GetPSScriptAnalyzerSettings()) + { + Information(string.Format("Running PSScriptAnalyzer {0}", PSScriptAnalyzerSetting.Name)); + + StartPowershellFile(MakeAbsolute(powerShellAnalysisScript), new PowershellSettings() + .WithModule("PSScriptAnalyzer") + .WithModule("ConvertToSARIF") + .WithModule("Microsoft.PowerShell.Management") + .WithModule("Microsoft.PowerShell.Utility") + .SetFormatOutput(false) + .SetLogOutput(true) + .OutputToAppConsole(true) + .WithArguments(args => { + args.AppendQuoted("AnalyzePath", PSScriptAnalyzerSetting.AnalysisPath.ToString()) + .AppendQuoted("SettingsPath", PSScriptAnalyzerSetting.SettingsPath.ToString()) + .AppendQuoted("OutputPath", outputFolder) + .AppendArray("ExcludePaths", PSScriptAnalyzerSetting.ExcludePaths); + })); + } + } + else + { + Information("There are no PSScriptAnalyzer Settings defined for this build, running with default format checking settings."); + + var settingsFile = GetFiles("./tools/Chocolatey.Cake.Recipe*/Content/formatting-settings.psd1").FirstOrDefault(); + + if (settingsFile == null) + { + Warning("Unable to find PowerShell Analysis settings, so unable to run analysis."); + return; + } + + var pwshSettings = new PowershellSettings() + .WithModule("PSScriptAnalyzer") + .WithModule("ConvertToSARIF") + .WithModule("Microsoft.PowerShell.Management") + .WithModule("Microsoft.PowerShell.Utility") + .SetFormatOutput(false) + .SetLogOutput(true) + .OutputToAppConsole(true) + .WithArguments(args => { + args.AppendQuoted("AnalyzePath", BuildParameters.RootDirectoryPath.ToString()) + .AppendQuoted("SettingsPath", settingsFile.ToString()) + .AppendQuoted("OutputPath", outputFolder) + .AppendArray("ExcludePaths", ToolSettings.PSScriptAnalyzerExcludePaths); + }); + + pwshSettings.ExceptionOnScriptError = false; + + var resultCollection = StartPowershellFile(MakeAbsolute(powerShellAnalysisScript), pwshSettings); + var returnCode = int.Parse(resultCollection[0].BaseObject.ToString()); + + Information("Result: {0}", returnCode); + + // NOTE: Ideally, we would have this throw an exception, however, during testing, invoking PSScriptAnalyzer + // sometimes caused random "The term 'Get-Command' is not recognized as the name of a cmdlet, function, script + // file, or operable program." errors, which meant that we can't rely on this returnCode. For now, we + // only want PSScriptAnalyzer to warn on violations, so we don't need to worry about this just now. + //if (returnCode != 0) + //{ + // throw new ApplicationException("Script failed to execute"); + //} + } + }) + ) +); + +public class PSScriptAnalyzerSettings +{ + public FilePath AnalysisPath { get; set; } + public FilePath SettingsPath { get; set; } + public List ExcludePaths { get; set; } + public string Name { get; set; } + + public PSScriptAnalyzerSettings() + { + Name = "Unnamed"; + } + + public PSScriptAnalyzerSettings(FilePath analysisPath, + FilePath settingsPath, + List excludePaths = null, + string name = "Unnamed") + { + AnalysisPath = analysisPath; + SettingsPath = settingsPath; + ExcludePaths = excludePaths; + Name = name; + } +} \ No newline at end of file diff --git a/Chocolatey.Cake.Recipe/Content/run-psscriptanalyzer.ps1 b/Chocolatey.Cake.Recipe/Content/run-psscriptanalyzer.ps1 new file mode 100644 index 0000000..fb32857 --- /dev/null +++ b/Chocolatey.Cake.Recipe/Content/run-psscriptanalyzer.ps1 @@ -0,0 +1,120 @@ +# Copyright © 2023 Chocolatey Software, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +[cmdletBinding()] +Param( + [Parameter()] + [String] + $AnalyzePath, + + [Parameter()] + [String] + $SettingsPath, + + [Parameter()] + [String] + $OutputPath, + + [Parameter()] + [String[]] + $ExcludePaths +) + +#Requires -Modules PSScriptAnalyzer, ConvertToSARIF + +Push-Location -Path $AnalyzePath + +try { + if ($PSBoundParameters.ContainsKey('ExcludePaths')) { + $ExcludePaths = $ExcludePaths | Where-Object { Test-Path $_ } | ForEach-Object { (Resolve-Path -Path $_).Path } + } +} +finally { + Pop-Location +} + +$scripts = Get-ChildItem -Path $AnalyzePath -Filter "*.ps1" -Recurse | ForEach-Object { + $found = $false + if ($PSBoundParameters.ContainsKey('ExcludePaths')) { + foreach ($path in $ExcludePaths) { + if ($_.FullName.StartsWith($path)) { + $found = $true + } + } + } + if (-not $found) { + $_ + } +} + +$modules = Get-ChildItem -Path $AnalyzePath -Filter "*.psm1" -Recurse | ForEach-Object { + $found = $false + if ($PSBoundParameters.ContainsKey('ExcludePaths')) { + foreach ($path in $ExcludePaths) { + if ($_.FullName.StartsWith($path)) { + $found = $true + } + } + } + if (-not $found) { + $_ + } +} + +Write-Output "Analyzing module files..." + +$records = Start-Job -ArgumentList $modules, $SettingsPath { + Param( + $modules, + $SettingsPath + ) + $modules | Invoke-ScriptAnalyzer -Settings $SettingsPath | Select-Object RuleName, ScriptPath, Line, Message +} | Wait-Job | Receive-Job + +if (-not ($null -EQ $records)) { + Write-Output "Violations found in Module Files..." + $records | Format-List | Out-String + + Write-Output $OutputPath + + Write-Output "Writing violations to output file..." + $records | ConvertTo-SARIF -FilePath "$OutputPath\modules.sarif" +} +else { + Write-Output "No rule violations found in Module Files." +} + +Write-Output "Analyzing script files..." + +$records = Start-Job -ArgumentList $Scripts, $SettingsPath { + Param( + $Scripts, + $SettingsPath + ) + $Scripts | Invoke-ScriptAnalyzer -Settings $SettingsPath | Select-Object RuleName, ScriptPath, Line, Message +} | Wait-Job | Receive-Job + +if (-not ($null -EQ $records)) { + Write-Output "Violations found in Script Files..." + $records | Format-List | Out-String + + Write-Output "Writing violations to output file..." + $records | ConvertTo-SARIF -FilePath "$OutputPath\scripts.sarif" +} +else { + Write-Output "No rule violations found in Script Files." +} + +Write-Output "Analyzing complete." \ No newline at end of file diff --git a/Chocolatey.Cake.Recipe/Content/tasks.cake b/Chocolatey.Cake.Recipe/Content/tasks.cake index 5c618cb..45242c8 100644 --- a/Chocolatey.Cake.Recipe/Content/tasks.cake +++ b/Chocolatey.Cake.Recipe/Content/tasks.cake @@ -39,6 +39,7 @@ public class BuildTasks public CakeTaskBuilder DotNetFormatCheckTask { get; set; } public CakeTaskBuilder DotNetFormatTask { get; set; } public CakeTaskBuilder AnalyzeTask { get; set; } + public CakeTaskBuilder PSScriptAnalyzerTask { get; set; } // Dependency-Check Tasks public CakeTaskBuilder DependencyCheckTask { get; set; } diff --git a/Chocolatey.Cake.Recipe/Content/tools.cake b/Chocolatey.Cake.Recipe/Content/tools.cake index 58510a7..eac1326 100644 --- a/Chocolatey.Cake.Recipe/Content/tools.cake +++ b/Chocolatey.Cake.Recipe/Content/tools.cake @@ -48,3 +48,15 @@ Action RequireTool = (tool, action) => { action(); }; + +Action RequirePSModule = (module, requiredVersion, action) => { + var powerShellModuleInstallationScript = GetFiles("./tools/Chocolatey.Cake.Recipe*/Content/install-module.ps1").FirstOrDefault(); + + StartPowershellFile(MakeAbsolute(powerShellModuleInstallationScript), args => + { + args.Append("ModuleName", module) + .Append("RequiredVersion", requiredVersion); + }); + + action(); +}; \ No newline at end of file diff --git a/Chocolatey.Cake.Recipe/Content/toolsettings.cake b/Chocolatey.Cake.Recipe/Content/toolsettings.cake index 26c6018..afc768f 100644 --- a/Chocolatey.Cake.Recipe/Content/toolsettings.cake +++ b/Chocolatey.Cake.Recipe/Content/toolsettings.cake @@ -43,6 +43,7 @@ public static class ToolSettings public static string ReportUnitTool { get; private set; } public static string ReSharperReportsTool { get; private set; } public static string ReSharperTools { get; private set; } + public static List PSScriptAnalyzerExcludePaths { get; private set; } public static string SonarQubeTool { get; private set; } public static string StrongNameSignerTool { get; private set; } public static string TestCoverageExcludeByAttribute { get; private set; } @@ -105,6 +106,7 @@ public static class ToolSettings FilePath eazfuscatorToolLocation = null, int? maxCpuCount = null, DirectoryPath outputDirectory = null, + List scriptAnalyzerExcludePaths = null, string testCoverageExcludeByAttribute = null, string testCoverageExcludeByFile = null, string testCoverageFilter = null @@ -118,6 +120,7 @@ public static class ToolSettings EazfuscatorToolLocation = eazfuscatorToolLocation ?? "./lib/Eazfuscator.NET/Eazfuscator.NET.exe"; MaxCpuCount = maxCpuCount ?? 0; OutputDirectory = outputDirectory; + PSScriptAnalyzerExcludePaths = scriptAnalyzerExcludePaths ?? new List { "tools", "code_drop", @"src\*\bin\Debug", @"Source\*\bin\Debug", @"src\*\bin\Release", @"Source\*\bin\Release", @"src\packages", @"Source\packages" }; TestCoverageExcludeByAttribute = testCoverageExcludeByAttribute ?? "*.ExcludeFromCodeCoverage*"; TestCoverageExcludeByFile = testCoverageExcludeByFile ?? "*/*Designer.cs;*/*.g.cs;*/*.g.i.cs"; TestCoverageFilter = testCoverageFilter ?? string.Format("+[{0}*]* +[{1}*]* -[*.tests]* -[*.Tests]*", BuildParameters.Title, BuildParameters.Title.ToLowerInvariant());