diff --git a/README.md b/README.md index 5a3298c14..74b4aa25c 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,10 @@ Linters which are not language-specific: | Kotlin | [ktfmt] | [ktlint] | | Markdown | [Prettier] | [Vale] | | Pkl | [pkl] | | +| PowerShell | | [PSScriptAnalyzer] | | Protocol Buffer | [buf] | [buf lint] | | Python | [ruff] | [bandit], [flake8], [pydoclint], [pylint], [ruff], [ty] | -| QML | [qmlformat] | [qmllint] | +| QML | [qmlformat] | [qmllint] | | Ruby | | [RuboCop], [Standard] | | Rust | [rustfmt] | [clippy] | | SQL | [prettier-plugin-sql] | | @@ -115,6 +116,7 @@ Linters which are not language-specific: [vale]: https://vale.sh/ [yamlfmt]: https://github.com/google/yamlfmt [yamllint]: https://yamllint.readthedocs.io/en/stable/ +[psscriptanalyzer]: https://github.com/PowerShell/PSScriptAnalyzer [rustfmt]: https://rust-lang.github.io/rustfmt [stylelint]: https://stylelint.io [clippy]: https://github.com/rust-lang/rust-clippy diff --git a/examples/powershell/.bazelrc b/examples/powershell/.bazelrc new file mode 100644 index 000000000..69bd17c56 --- /dev/null +++ b/examples/powershell/.bazelrc @@ -0,0 +1 @@ +build:lint --aspects=//tools/lint:linters.bzl%ps_script_analyzer diff --git a/examples/powershell/BUILD b/examples/powershell/BUILD new file mode 100644 index 000000000..0244eb780 --- /dev/null +++ b/examples/powershell/BUILD @@ -0,0 +1,3 @@ +package(default_visibility = ["//visibility:public"]) + +exports_files(["PSScriptAnalyzerSettings.psd1"]) diff --git a/examples/powershell/MODULE.aspect b/examples/powershell/MODULE.aspect new file mode 100644 index 000000000..e8df5dee8 --- /dev/null +++ b/examples/powershell/MODULE.aspect @@ -0,0 +1,9 @@ +# Aspect Extension setup for CLI "lint" command. +# This example is in rules_lint, so we use a local dependency. +# See https://github.com/bazel-starters/python/blob/main/MODULE.aspect +# for a real-world example. +axl_local_dep( + name = "rules_lint", + path = "../..", + auto_use_tasks = True, +) diff --git a/examples/powershell/MODULE.bazel b/examples/powershell/MODULE.bazel new file mode 100644 index 000000000..2637bca36 --- /dev/null +++ b/examples/powershell/MODULE.bazel @@ -0,0 +1,10 @@ +"Bazel dependencies for PowerShell linting example" + +bazel_dep(name = "aspect_rules_lint") +local_path_override( + module_name = "aspect_rules_lint", + path = "../..", +) + +repos = use_extension("//tools:repositories.bzl", "repos") +use_repo(repos, "converttosarif", "psscriptanalyzer", "pwsh") diff --git a/examples/powershell/PSScriptAnalyzerSettings.psd1 b/examples/powershell/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 000000000..cbb8899a4 --- /dev/null +++ b/examples/powershell/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,3 @@ +@{ + Severity = @('Error', 'Warning', 'Information') +} diff --git a/examples/powershell/README.md b/examples/powershell/README.md new file mode 100644 index 000000000..a11578bfe --- /dev/null +++ b/examples/powershell/README.md @@ -0,0 +1,58 @@ +# PowerShell Linting with PSScriptAnalyzer + +This example demonstrates running [PSScriptAnalyzer](https://github.com/PowerShell/PSScriptAnalyzer) +as a Bazel lint aspect via `aspect_rules_lint`. + +## Prerequisites + +PSScriptAnalyzer has no Bazel ruleset, so you supply three archives via a module extension +in `tools/repositories.bzl` (bzlmod doesn't allow `http_archive` directly in `MODULE.bazel`): + +- `pwsh` — [PowerShell releases](https://github.com/PowerShell/PowerShell/releases) (`linux-x64.tar.gz`) +- `psscriptanalyzer` — [PowerShell Gallery](https://www.powershellgallery.com/packages/PSScriptAnalyzer) +- `converttosarif` — [PowerShell Gallery](https://www.powershellgallery.com/packages/ConvertToSARIF) + +For the sha256: download the `.nupkg` and run `sha256sum` on it (the Gallery URL redirects, so +`curl -L -o pkg.nupkg "" && sha256sum pkg.nupkg`). + +## Setup + +See `tools/repositories.bzl` for the `http_archive` declarations and `tools/lint/linters.bzl` +for the aspect instantiation. + +Key points: + +- `type = "zip"` is required for Gallery packages (the redirect URL has no file extension) +- `patch_cmds = ["chmod +x pwsh"]` restores the execute bit stripped by `http_archive` +- Pass a `PSScriptAnalyzerSettings.psd1` label as `config` to customise rules (optional) + +Reference the extension from `MODULE.bazel`: + +```starlark +repos = use_extension("//tools:repositories.bzl", "repos") +use_repo(repos, "converttosarif", "psscriptanalyzer", "pwsh") +``` + +Register the aspect in `.bazelrc`: + +``` +build:lint --aspects=//tools/lint:linters.bzl%ps_script_analyzer +``` + +Opt PowerShell files into linting with a tagged filegroup (only `.ps1` and `.psm1` are linted): + +```starlark +filegroup( + name = "scripts", + srcs = glob(["**/*.ps1"]), + tags = ["lint-with-psscriptanalyzer"], +) +``` + +## Running + +```bash +bazel build --config=lint --output_groups=rules_lint_human //src:all +bazel build --config=lint --output_groups=rules_lint_machine //src:all +bazel build --config=lint --@aspect_rules_lint//lint:fail_on_violation //src:all +``` diff --git a/examples/powershell/src/BUILD b/examples/powershell/src/BUILD new file mode 100644 index 000000000..0ea8c94a5 --- /dev/null +++ b/examples/powershell/src/BUILD @@ -0,0 +1,29 @@ +load("//tools/lint:linters.bzl", "ps_script_analyzer_test") + +package(default_visibility = ["//visibility:public"]) + +filegroup( + name = "scripts", + srcs = glob(["**/*.ps1"]), + tags = ["lint-with-psscriptanalyzer"], +) + +filegroup( + name = "clean_scripts", + srcs = ["hello.ps1"], + tags = ["lint-with-psscriptanalyzer"], +) + +# bad.ps1 triggers violations — expect exit code 1 +ps_script_analyzer_test( + name = "ps_script_analyzer_violations_test", + srcs = [":scripts"], + expected_exit_code = 1, +) + +# hello.ps1 is clean — expect exit code 0, empty output, empty SARIF +ps_script_analyzer_test( + name = "ps_script_analyzer_clean_test", + srcs = [":clean_scripts"], + expected_exit_code = 0, +) diff --git a/examples/powershell/src/bad.ps1 b/examples/powershell/src/bad.ps1 new file mode 100644 index 000000000..2bbd48bd7 --- /dev/null +++ b/examples/powershell/src/bad.ps1 @@ -0,0 +1,3 @@ +Write-Host "Starting script" +$unused = "never read" +ls C:\Windows diff --git a/examples/powershell/src/hello.ps1 b/examples/powershell/src/hello.ps1 new file mode 100644 index 000000000..8538312fd --- /dev/null +++ b/examples/powershell/src/hello.ps1 @@ -0,0 +1,6 @@ +function Get-Greeting { + param([string]$Name = "World") + return "Hello, $Name!" +} + +Write-Output (Get-Greeting) diff --git a/examples/powershell/tools/BUILD b/examples/powershell/tools/BUILD new file mode 100644 index 000000000..9d9df5200 --- /dev/null +++ b/examples/powershell/tools/BUILD @@ -0,0 +1 @@ +# Makes tools/ a Bazel package so MODULE.bazel can reference //tools:repositories.bzl diff --git a/examples/powershell/tools/lint/BUILD b/examples/powershell/tools/lint/BUILD new file mode 100644 index 000000000..58e0e9a74 --- /dev/null +++ b/examples/powershell/tools/lint/BUILD @@ -0,0 +1 @@ +package(default_visibility = ["//:__subpackages__"]) diff --git a/examples/powershell/tools/lint/linters.bzl b/examples/powershell/tools/lint/linters.bzl new file mode 100644 index 000000000..c679d3d20 --- /dev/null +++ b/examples/powershell/tools/lint/linters.bzl @@ -0,0 +1,13 @@ +"""PSScriptAnalyzer linter aspect for the PowerShell example workspace.""" + +load("@aspect_rules_lint//lint:lint_test.bzl", "lint_test") +load("@aspect_rules_lint//lint:ps_script_analyzer.bzl", "lint_ps_script_analyzer_aspect") + +ps_script_analyzer = lint_ps_script_analyzer_aspect( + binary = Label("@pwsh//:pwsh"), + psscriptanalyzer = Label("@psscriptanalyzer//:files"), + converttosarif = Label("@converttosarif//:files"), + config = Label("//:PSScriptAnalyzerSettings.psd1"), +) + +ps_script_analyzer_test = lint_test(aspect = ps_script_analyzer) diff --git a/examples/powershell/tools/repositories.bzl b/examples/powershell/tools/repositories.bzl new file mode 100644 index 000000000..3274f349e --- /dev/null +++ b/examples/powershell/tools/repositories.bzl @@ -0,0 +1,36 @@ +"""Module extension that declares external tool archives for the PowerShell linting example.""" + +load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") + +def _repos_impl(_mctx): + http_archive( + name = "pwsh", + url = "https://github.com/PowerShell/PowerShell/releases/download/v7.6.1/powershell-7.6.1-linux-x64.tar.gz", + sha256 = "dfc94229767921603f7c3e1cb1ac5aa931448af7496ccf657723b6278057c415", + build_file_content = """exports_files(["pwsh"])""", + patch_cmds = ["chmod +x pwsh"], + ) + http_archive( + name = "psscriptanalyzer", + url = "https://www.powershellgallery.com/api/v2/package/PSScriptAnalyzer/1.25.0", + sha256 = "14e634c828eb98efb9f40b2918ba90f139ed5eccdf663a2a747736d996995d60", + type = "zip", + build_file_content = """ +package(default_visibility = ["//visibility:public"]) +filegroup(name = "files", srcs = glob(["**"])) +""", + ) + http_archive( + name = "converttosarif", + url = "https://www.powershellgallery.com/api/v2/package/ConvertToSARIF/1.0.0", + sha256 = "b1bdf60f029f12284dd78b2c2edc34705d4475079b4cb3d50d669da464bf3d35", + type = "zip", + build_file_content = """ +package(default_visibility = ["//visibility:public"]) +filegroup(name = "files", srcs = glob(["**"])) +""", + ) + +repos = module_extension( + implementation = _repos_impl, +) diff --git a/lint/BUILD.bazel b/lint/BUILD.bazel index c219f596d..4878f1d2b 100644 --- a/lint/BUILD.bazel +++ b/lint/BUILD.bazel @@ -6,7 +6,10 @@ load("//lint/private:lint_aspect.bzl", "lint_options") package(default_visibility = ["//visibility:public"]) -exports_files(glob(["*.bzl"]) + ["lint_test.sh"]) +exports_files(glob(["*.bzl"]) + [ + "lint_test.sh", + "ps_script_analyzer_wrapper.ps1", +]) # Aliases for the built-in linters alias( @@ -183,6 +186,12 @@ bzl_library( deps = ["//lint/private:lint_aspect"], ) +bzl_library( + name = "ps_script_analyzer", + srcs = ["ps_script_analyzer.bzl"], + deps = ["//lint/private:lint_aspect"], +) + bzl_library( name = "keep_sorted", srcs = ["keep_sorted.bzl"], diff --git a/lint/ps_script_analyzer.bzl b/lint/ps_script_analyzer.bzl new file mode 100644 index 000000000..15c63283e --- /dev/null +++ b/lint/ps_script_analyzer.bzl @@ -0,0 +1,167 @@ +"""API for declaring a PSScriptAnalyzer lint aspect that visits filegroups tagged +lint-with-psscriptanalyzer (or any rule kinds provided by the caller). + +Typical usage in tools/lint/linters.bzl: + +```starlark +load("@aspect_rules_lint//lint:ps_script_analyzer.bzl", "lint_ps_script_analyzer_aspect") + +ps_script_analyzer = lint_ps_script_analyzer_aspect( + binary = Label("@pwsh//:pwsh"), + psscriptanalyzer = Label("@psscriptanalyzer//:files"), + converttosarif = Label("@converttosarif//:files"), + config = Label("//:PSScriptAnalyzerSettings.psd1"), +) +``` + +Opt PowerShell sources into linting by tagging a filegroup: + +```starlark +filegroup( + name = "scripts", + srcs = glob(["**/*.ps1"]), + tags = ["lint-with-psscriptanalyzer"], +) +``` +""" + +load("//lint/private:lint_aspect.bzl", "LintOptionsInfo", "filter_srcs", "noop_lint_action", "output_files", "should_visit") + +_MNEMONIC = "AspectRulesLintPSScriptAnalyzer" + +_PS_EXTENSIONS = (".ps1", ".psm1") + +def _ps_files(files): + return [f for f in files if f.basename.endswith(_PS_EXTENSIONS)] + +def _ps_script_analyzer_aspect_impl(target, ctx): + if not should_visit(ctx.rule, ctx.attr._rule_kinds, ctx.attr._filegroup_tags): + return [] + + files_to_lint = _ps_files(filter_srcs(ctx.rule)) + outputs, info = output_files(_MNEMONIC, target, ctx) + + if len(files_to_lint) == 0: + noop_lint_action(ctx, outputs) + return [info] + + wrapper = ctx.file._wrapper + pwsh = ctx.file._pwsh + pssa_files = ctx.files._psscriptanalyzer + sarif_files = ctx.files._converttosarif + + pssa_matches = [f for f in pssa_files if f.basename == "PSScriptAnalyzer.psd1"] + if not pssa_matches: + fail("PSScriptAnalyzer.psd1 not found in the psscriptanalyzer filegroup. " + + "Ensure the http_archive for @psscriptanalyzer points to a PSScriptAnalyzer nupkg.") + pssa_psd1 = pssa_matches[0] + + sarif_matches = [f for f in sarif_files if f.basename == "ConvertToSARIF.psd1"] + if not sarif_matches: + fail("ConvertToSARIF.psd1 not found in the converttosarif filegroup. " + + "Ensure the http_archive for @converttosarif points to a ConvertToSARIF nupkg.") + sarif_psd1 = sarif_matches[0] + + args = ctx.actions.args() + args.add_all(files_to_lint) + + # Use $PWD/ to make psd1 paths absolute: Import-Module doesn't recognise + # relative paths that don't start with ./ or / (e.g. "external/repo/foo.psd1" + # is treated as a module name search, not a file path). + cmd = ( + "{pwsh} -NonInteractive -File {wrapper}" + + " -PssaPsd1 $PWD/{pssa} -SarifPsd1 $PWD/{sarif}" + + " -OutFile {outfile} -SarifFile {sariffile}" + ).format( + pwsh = pwsh.path, + wrapper = wrapper.path, + pssa = pssa_psd1.path, + sarif = sarif_psd1.path, + outfile = outputs.human.out.path, + sariffile = outputs.machine.out.path, + ) + + action_outputs = [outputs.human.out, outputs.machine.out] + + if outputs.human.exit_code: + cmd += " -HumanExitCodeFile {hec} -MachineExitCodeFile {mec}".format( + hec = outputs.human.exit_code.path, + mec = outputs.machine.exit_code.path, + ) + action_outputs += [outputs.human.exit_code, outputs.machine.exit_code] + + if ctx.file._config_file: + cmd += " -Settings {settings}".format(settings = ctx.file._config_file.path) + + cmd += " $@" + + action_inputs = files_to_lint + pssa_files + sarif_files + [wrapper] + if ctx.file._config_file: + action_inputs = action_inputs + [ctx.file._config_file] + + ctx.actions.run_shell( + inputs = action_inputs, + outputs = action_outputs, + arguments = [args], + command = cmd, + tools = [pwsh], + mnemonic = _MNEMONIC, + progress_message = "Linting %{label} with PSScriptAnalyzer", + ) + + return [info] + +def lint_ps_script_analyzer_aspect( + binary, + psscriptanalyzer, + converttosarif, + config = None, + rule_kinds = [], + filegroup_tags = ["lint-with-psscriptanalyzer"]): + """A factory function to create a linter aspect for PSScriptAnalyzer. + + Args: + binary: label of the pwsh executable (e.g. Label("@pwsh//:pwsh")) + psscriptanalyzer: label of the PSScriptAnalyzer filegroup from http_archive + converttosarif: label of the ConvertToSARIF filegroup from http_archive + config: optional label of a .psd1 settings file + rule_kinds: Bazel rule kinds to lint (default: none, use filegroup_tags instead) + filegroup_tags: filegroup tags that opt targets into linting + """ + return aspect( + implementation = _ps_script_analyzer_aspect_impl, + attrs = { + "_options": attr.label( + default = "//lint:options", + providers = [LintOptionsInfo], + ), + "_pwsh": attr.label( + default = binary, + allow_single_file = True, + cfg = "exec", + ), + "_psscriptanalyzer": attr.label( + default = psscriptanalyzer, + allow_files = True, + ), + "_converttosarif": attr.label( + default = converttosarif, + allow_files = True, + ), + "_config_file": attr.label( + default = config, + allow_single_file = True, + ), + "_wrapper": attr.label( + default = "@aspect_rules_lint//lint:ps_script_analyzer_wrapper.ps1", + allow_single_file = True, + ), + "_rule_kinds": attr.string_list( + default = rule_kinds, + ), + "_filegroup_tags": attr.string_list( + default = filegroup_tags, + ), + }, + toolchains = [], + ) diff --git a/lint/ps_script_analyzer_wrapper.ps1 b/lint/ps_script_analyzer_wrapper.ps1 new file mode 100644 index 000000000..787c3b886 --- /dev/null +++ b/lint/ps_script_analyzer_wrapper.ps1 @@ -0,0 +1,37 @@ +[CmdletBinding(PositionalBinding=$false)] +param( + [Parameter(Mandatory)][string]$PssaPsd1, + [Parameter(Mandatory)][string]$SarifPsd1, + [Parameter(Mandatory)][string]$OutFile, + [Parameter(Mandatory)][string]$SarifFile, + [string]$HumanExitCodeFile, + [string]$MachineExitCodeFile, + [string]$Settings, + [Parameter(ValueFromRemainingArguments)][string[]]$Files +) + +# Load modules by explicit manifest path -- PSModulePath directory-level approach +# fails in Bazel sandbox because PowerShell expects parent-of-module-dir entries. +Import-Module $PssaPsd1 -ErrorAction Stop +Import-Module $SarifPsd1 -ErrorAction Stop + +$results = @(foreach ($file in $Files) { + $invokeArgs = @{ Path = $file } + if ($Settings) { $invokeArgs['Settings'] = $Settings } + Invoke-ScriptAnalyzer @invokeArgs +}) + +$results | Format-Table -AutoSize RuleName, Severity, ScriptName, Line, Message | + Out-String | Out-File -FilePath $OutFile -Encoding utf8 + +$results | ConvertTo-SARIF -FilePath $SarifFile + +$exitCode = if ($results.Count -gt 0) { 1 } else { 0 } + +if ($HumanExitCodeFile -or $MachineExitCodeFile) { + if ($HumanExitCodeFile) { $exitCode | Out-File -FilePath $HumanExitCodeFile -Encoding utf8 -NoNewline } + if ($MachineExitCodeFile) { $exitCode | Out-File -FilePath $MachineExitCodeFile -Encoding utf8 -NoNewline } + exit 0 +} else { + exit $exitCode +}