diff --git a/PowerForge.PowerShell/Services/ModulePipelineRunner.FormattingSigningDelivery.cs b/PowerForge.PowerShell/Services/ModulePipelineRunner.FormattingSigningDelivery.cs index 50c849c03a..1ee058ff8c 100644 --- a/PowerForge.PowerShell/Services/ModulePipelineRunner.FormattingSigningDelivery.cs +++ b/PowerForge.PowerShell/Services/ModulePipelineRunner.FormattingSigningDelivery.cs @@ -60,29 +60,29 @@ private static FormatterResult[] FormatPowerShellTree( psd1Files = psd1Files.Where(p => !string.Equals(p, manifestPath, StringComparison.OrdinalIgnoreCase)).ToArray(); } - var results = new List(all.Length + 4); + var batches = new List(); // Legacy PSPublishModule defaults did not format standalone PS1 files unless explicitly configured. var standardPs1 = cfg.Standard.FormatCodePS1; if (standardPs1?.Enabled == true && ps1Files.Length > 0) - results.AddRange(pipeline.Run(ps1Files, BuildFormatOptions(standardPs1))); + batches.Add(new FormattingBatch(ps1Files, BuildFormatOptions(standardPs1))); if (cfg.Standard.FormatCodePSM1?.Enabled == true && psm1Files.Length > 0) - results.AddRange(pipeline.Run(psm1Files, BuildFormatOptions(cfg.Standard.FormatCodePSM1))); + batches.Add(new FormattingBatch(psm1Files, BuildFormatOptions(cfg.Standard.FormatCodePSM1))); if (standardPsd1?.Enabled == true && psd1Files.Length > 0) - results.AddRange(pipeline.Run(psd1Files, BuildFormatOptions(standardPsd1))); + batches.Add(new FormattingBatch(psd1Files, BuildFormatOptions(standardPsd1))); if (includeMergeFormatting) { if (cfg.Merge.FormatCodePSM1?.Enabled == true && File.Exists(rootPsm1)) - results.AddRange(pipeline.Run(new[] { rootPsm1 }, BuildFormatOptions(cfg.Merge.FormatCodePSM1))); + batches.Add(new FormattingBatch(new[] { rootPsm1 }, BuildFormatOptions(cfg.Merge.FormatCodePSM1))); if (mergePsd1?.Enabled == true && File.Exists(manifestPath)) - results.AddRange(pipeline.Run(new[] { manifestPath }, BuildFormatOptions(mergePsd1))); + batches.Add(new FormattingBatch(new[] { manifestPath }, BuildFormatOptions(mergePsd1))); } - return results.ToArray(); + return pipeline.RunBatches(batches).ToArray(); static bool IsPowerShellSourceFile(string path) => HasExtension(path, ".ps1", ".psm1", ".psd1"); diff --git a/PowerForge.PowerShell/Services/ModulePipelineRunner.Run.Phases.cs b/PowerForge.PowerShell/Services/ModulePipelineRunner.Run.Phases.cs index 185f401f81..4b9c2be3f4 100644 --- a/PowerForge.PowerShell/Services/ModulePipelineRunner.Run.Phases.cs +++ b/PowerForge.PowerShell/Services/ModulePipelineRunner.Run.Phases.cs @@ -240,6 +240,7 @@ private void ExecuteFormattingAndSigningPhases( if (plan.Formatting is not null && !IsReusingCompiledPowerShellModule(plan)) { var formattingPipeline = new FormattingPipeline(_logger); + using var formatterSession = formattingPipeline.BeginSession(); session.Start(session.FormatStagingStep); try diff --git a/PowerForge.PowerShell/Services/NestedFunctionVisibilityAnalyzer.Binding.cs b/PowerForge.PowerShell/Services/NestedFunctionVisibilityAnalyzer.Binding.cs new file mode 100644 index 0000000000..6113a342e7 --- /dev/null +++ b/PowerForge.PowerShell/Services/NestedFunctionVisibilityAnalyzer.Binding.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Management.Automation.Language; + +namespace PowerForge; + +internal sealed partial class NestedFunctionVisibilityAnalyzer +{ + private readonly Dictionary _commandBindings = new(); + + private StaticBindingResult BindCommandCached(CommandAst command) + { + if (_commandBindings.TryGetValue(command, out var binding)) + return binding; + + binding = StaticParameterBinder.BindCommand(command); + // Incomplete metadata can become available through module discovery later in this analysis. + // Only retain successful bindings, and never share them between analyzed syntax trees. + if (binding.BindingExceptions.Count == 0) + _commandBindings.Add(command, binding); + + return binding; + } +} diff --git a/PowerForge.PowerShell/Services/NestedFunctionVisibilityAnalyzer.Escapes.cs b/PowerForge.PowerShell/Services/NestedFunctionVisibilityAnalyzer.Escapes.cs index fd859b220f..77131a51c1 100644 --- a/PowerForge.PowerShell/Services/NestedFunctionVisibilityAnalyzer.Escapes.cs +++ b/PowerForge.PowerShell/Services/NestedFunctionVisibilityAnalyzer.Escapes.cs @@ -111,7 +111,7 @@ assignment.Left is VariableExpressionAst variable && NormalizeInvocationName(rawConsumerName)); } - private static bool IsFunctionLookupCommand(CommandAst command, string functionName) + private bool IsFunctionLookupCommand(CommandAst command, string functionName) { var commandName = NormalizeInvocationName(command.GetCommandName()); if (string.Equals(commandName, "Get-Command", StringComparison.OrdinalIgnoreCase) || @@ -151,11 +151,11 @@ private static bool IsFunctionLookupCommand(CommandAst command, string functionN "LiteralPath"); } - private static bool GetCommandCanReturnFunction(CommandAst command) + private bool GetCommandCanReturnFunction(CommandAst command) { try { - var binding = StaticParameterBinder.BindCommand(command); + var binding = BindCommandCached(command); if (!binding.BoundParameters.TryGetValue("CommandType", out var result)) return true; @@ -177,7 +177,7 @@ private static bool GetCommandCanReturnFunction(CommandAst command) } } - private static bool BoundLookupCanMatchFunction( + private bool BoundLookupCanMatchFunction( CommandAst command, string functionName, bool providerPath, @@ -186,7 +186,7 @@ private static bool BoundLookupCanMatchFunction( { try { - var binding = StaticParameterBinder.BindCommand(command); + var binding = BindCommandCached(command); foreach (var parameterName in parameterNames) { if (binding.BoundParameters.TryGetValue(parameterName, out var result)) diff --git a/PowerForge.PowerShell/Services/NestedFunctionVisibilityAnalyzer.ExecutionGraph.cs b/PowerForge.PowerShell/Services/NestedFunctionVisibilityAnalyzer.ExecutionGraph.cs new file mode 100644 index 0000000000..a36ee15bc1 --- /dev/null +++ b/PowerForge.PowerShell/Services/NestedFunctionVisibilityAnalyzer.ExecutionGraph.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation.Language; + +namespace PowerForge; + +internal sealed partial class NestedFunctionVisibilityAnalyzer +{ + private static bool IsWithinExecutionScope(CommandAst command, ScriptBlockAst scope) + { + for (Ast? current = command.Parent; current is not null; current = current.Parent) + { + if (ReferenceEquals(current, scope)) + return true; + if (current is FunctionDefinitionAst) + return false; + } + + return false; + } + + private sealed class ScopeExecutionGraph + { + private readonly Dictionary _functionInvocations = new(); + + internal ScopeExecutionGraph(NestedFunctionVisibilityAnalyzer analyzer, ScriptBlockAst scope) + { + var functions = scope.FindAll( + ast => ast is FunctionDefinitionAst, + searchNestedScriptBlocks: true) + .Cast() + .Select(function => new + { + Function = function, + HasContext = analyzer.TryGetPotentialDeclarationContext(function, out var declarationScope, out _), + Scope = declarationScope + }) + .Where(item => item.HasContext && ReferenceEquals(item.Scope, scope)) + .Select(item => item.Function) + .ToArray(); + + FunctionsByName = functions + .GroupBy( + function => NormalizeDeclaredFunctionName(function.Name), + StringComparer.OrdinalIgnoreCase) + .ToDictionary( + group => group.Key, + group => group.ToArray(), + StringComparer.OrdinalIgnoreCase); + + RootInvocations = scope.FindAll( + ast => ast is CommandAst, + searchNestedScriptBlocks: true) + .Cast() + .Where(command => IsWithinExecutionScope(command, scope)) + .Select(command => new InvocationSite(command, FindContainingTrap(command, scope))) + .ToArray(); + + foreach (var function in functions) + { + _functionInvocations[function] = function.Body.FindAll( + ast => ast is CommandAst, + searchNestedScriptBlocks: true) + .Cast() + .Where(command => IsWithinExecutionScope(command, function.Body)) + .Select(command => new InvocationSite(command, trap: null)) + .ToArray(); + } + } + + internal IReadOnlyDictionary FunctionsByName { get; } + internal InvocationSite[] RootInvocations { get; } + + internal InvocationSite[] GetFunctionInvocations(FunctionDefinitionAst function) + { + return _functionInvocations.TryGetValue(function, out var invocations) + ? invocations + : Array.Empty(); + } + } + + private sealed class InvocationSite + { + internal InvocationSite(CommandAst command, TrapStatementAst? trap) + { + Command = command; + Trap = trap; + var commandName = command.GetCommandName(); + if (string.Equals(NormalizeInvocationName(commandName), "Invoke-Expression", StringComparison.OrdinalIgnoreCase) || + string.Equals(commandName, "iex", StringComparison.OrdinalIgnoreCase)) + { + Name = TryGetInvokeExpressionTarget(command); + IsDynamic = string.IsNullOrWhiteSpace(Name); + } + else + { + Name = commandName; + IsDynamic = string.IsNullOrWhiteSpace(Name) && + command.CommandElements.Count > 0 && + command.CommandElements[0] is not ScriptBlockExpressionAst; + } + } + + private static string? TryGetInvokeExpressionTarget(CommandAst command) + { + var arguments = command.CommandElements.Skip(1).ToArray(); + if (arguments.Length != 1 || arguments[0] is not StringConstantExpressionAst literal) + return null; + + var parsed = Parser.ParseInput(literal.Value, out _, out var errors); + if (errors.Length > 0) + return null; + + var commands = parsed.FindAll(ast => ast is CommandAst, searchNestedScriptBlocks: true) + .Cast() + .ToArray(); + return commands.Length == 1 ? commands[0].GetCommandName() : null; + } + + internal CommandAst Command { get; } + internal string? Name { get; } + internal bool IsDynamic { get; } + internal TrapStatementAst? Trap { get; } + } +} diff --git a/PowerForge.PowerShell/Services/NestedFunctionVisibilityAnalyzer.Mutations.cs b/PowerForge.PowerShell/Services/NestedFunctionVisibilityAnalyzer.Mutations.cs index 06cf40c5e6..94c19edbef 100644 --- a/PowerForge.PowerShell/Services/NestedFunctionVisibilityAnalyzer.Mutations.cs +++ b/PowerForge.PowerShell/Services/NestedFunctionVisibilityAnalyzer.Mutations.cs @@ -159,13 +159,13 @@ private static bool IsProviderRemovalCommand(string commandName) string.Equals(commandName, "cli", StringComparison.OrdinalIgnoreCase); } - private static bool TryGetBoundRemovalValue( + private bool TryGetBoundRemovalValue( CommandAst command, IReadOnlyList parameterNames, out object? value) => TryGetBoundRemovalValue(command, parameterNames, out value, out _); - private static bool TryGetBoundRemovalValue( + private bool TryGetBoundRemovalValue( CommandAst command, IReadOnlyList parameterNames, out object? value, @@ -175,7 +175,7 @@ private static bool TryGetBoundRemovalValue( boundParameterName = null; try { - var binding = StaticParameterBinder.BindCommand(command); + var binding = BindCommandCached(command); foreach (var parameterName in parameterNames) { if (binding.BoundParameters.TryGetValue(parameterName, out var result)) @@ -279,11 +279,11 @@ private static bool HasDynamicValue(object? value) return true; } - private static bool IsPrivateAliasDeclaration(CommandAst command) + private bool IsPrivateAliasDeclaration(CommandAst command) { try { - var binding = StaticParameterBinder.BindCommand(command); + var binding = BindCommandCached(command); if (!binding.BoundParameters.TryGetValue("Option", out var result)) return false; diff --git a/PowerForge.PowerShell/Services/NestedFunctionVisibilityAnalyzer.ScriptBlocks.cs b/PowerForge.PowerShell/Services/NestedFunctionVisibilityAnalyzer.ScriptBlocks.cs index e602f9bba3..51b122b12d 100644 --- a/PowerForge.PowerShell/Services/NestedFunctionVisibilityAnalyzer.ScriptBlocks.cs +++ b/PowerForge.PowerShell/Services/NestedFunctionVisibilityAnalyzer.ScriptBlocks.cs @@ -153,7 +153,7 @@ private bool IsPotentiallyShadowedByScriptFunction( } } - private static bool IsKnownSynchronousScriptBlockConsumer( + private bool IsKnownSynchronousScriptBlockConsumer( string invocationName, CommandAst invocation, ScriptBlockExpressionAst scriptBlockExpression) @@ -189,14 +189,14 @@ private static bool IsKnownSynchronousScriptBlockConsumer( return false; } - private static bool IsScriptBlockBoundToAnyParameter( + private bool IsScriptBlockBoundToAnyParameter( CommandAst invocation, ScriptBlockExpressionAst scriptBlockExpression, params string[] parameterNames) { try { - var binding = StaticParameterBinder.BindCommand(invocation); + var binding = BindCommandCached(invocation); foreach (var parameterName in parameterNames) { if (binding.BoundParameters.TryGetValue(parameterName, out var result) && @@ -296,7 +296,7 @@ private bool IsUnshadowedAliasMutation(CommandAst command) !IsPotentiallyShadowedByScriptFunction(command, rawCommandName, commandName); } - private static string? TryGetAuthoredAliasName(CommandAst command) + private string? TryGetAuthoredAliasName(CommandAst command) { var commandName = NormalizeInvocationName(command.GetCommandName()); var aliasCommand = string.Equals(commandName, "Set-Alias", StringComparison.OrdinalIgnoreCase) || @@ -312,7 +312,7 @@ private bool IsUnshadowedAliasMutation(CommandAst command) try { - var binding = StaticParameterBinder.BindCommand(command); + var binding = BindCommandCached(command); if (aliasCommand && binding.BoundParameters.TryGetValue("Name", out var result)) { return TryGetLiteralText(result.Value); @@ -334,7 +334,7 @@ private bool IsUnshadowedAliasMutation(CommandAst command) return null; } - private static bool IsPotentialDynamicAliasDeclaration(CommandAst command) + private bool IsPotentialDynamicAliasDeclaration(CommandAst command) { var commandName = NormalizeInvocationName(command.GetCommandName()); var aliasCommand = string.Equals(commandName, "Set-Alias", StringComparison.OrdinalIgnoreCase) || @@ -353,7 +353,7 @@ private static bool IsPotentialDynamicAliasDeclaration(CommandAst command) try { - var binding = StaticParameterBinder.BindCommand(command); + var binding = BindCommandCached(command); if (binding.BoundParameters.TryGetValue("Path", out var result) || binding.BoundParameters.TryGetValue("LiteralPath", out result)) { @@ -404,11 +404,11 @@ private static bool TryGetAliasProviderName(string? providerPath, out string ali return aliasName.Length > 0; } - private static string? TryGetAuthoredAliasTarget(CommandAst command) + private string? TryGetAuthoredAliasTarget(CommandAst command) { try { - var binding = StaticParameterBinder.BindCommand(command); + var binding = BindCommandCached(command); if (binding.BoundParameters.TryGetValue("Value", out var result)) return TryGetLiteralText(result.Value); } @@ -501,7 +501,7 @@ private bool IsAliasRemovedBeforeInvocation( : null; } - private static bool HasRootAliasScope(CommandAst command) + private bool HasRootAliasScope(CommandAst command) { var authoredScope = TryGetAuthoredAliasScope(command); return string.Equals(authoredScope, "Script", StringComparison.OrdinalIgnoreCase) || @@ -586,11 +586,11 @@ pipeline.Parent is not NamedBlockAst block || .All(statement => statement is FunctionDefinitionAst || statement is TrapStatementAst); } - private static string? TryGetAuthoredAliasScope(CommandAst command) + private string? TryGetAuthoredAliasScope(CommandAst command) { try { - var binding = StaticParameterBinder.BindCommand(command); + var binding = BindCommandCached(command); return binding.BoundParameters.TryGetValue("Scope", out var result) ? TryGetLiteralText(result.Value) : null; @@ -733,11 +733,11 @@ private static bool HasAnyParameter(CommandAst invocation, params string[] param return false; } - private static bool? TryHasAnyBoundParameter(CommandAst invocation, params string[] parameterNames) + private bool? TryHasAnyBoundParameter(CommandAst invocation, params string[] parameterNames) { try { - var binding = StaticParameterBinder.BindCommand(invocation); + var binding = BindCommandCached(invocation); if (binding.BoundParameters.Keys.Any( key => parameterNames.Any(name => string.Equals(name, key, StringComparison.OrdinalIgnoreCase)))) { diff --git a/PowerForge.PowerShell/Services/NestedFunctionVisibilityAnalyzer.cs b/PowerForge.PowerShell/Services/NestedFunctionVisibilityAnalyzer.cs index a78ebe3465..832be13fc2 100644 --- a/PowerForge.PowerShell/Services/NestedFunctionVisibilityAnalyzer.cs +++ b/PowerForge.PowerShell/Services/NestedFunctionVisibilityAnalyzer.cs @@ -606,7 +606,7 @@ latestUnconditional is not null && } } - private static bool IsInsideBoundParameterDefault( + private bool IsInsideBoundParameterDefault( CommandAst command, FunctionDefinitionAst function, CommandAst invocation) @@ -632,14 +632,14 @@ private static bool IsInsideBoundParameterDefault( return false; } - private static bool IsParameterStaticallyBound( + private bool IsParameterStaticallyBound( CommandAst invocation, string parameterName, int parameterIndex) { try { - var binding = StaticParameterBinder.BindCommand(invocation); + var binding = BindCommandCached(invocation); if (binding.BoundParameters.Keys.Any(key => string.Equals(key, parameterName, StringComparison.OrdinalIgnoreCase) || string.Equals(key, parameterIndex.ToString(), StringComparison.Ordinal))) @@ -770,105 +770,4 @@ private static bool IsInvocationTarget(Ast target, CommandAst invocation) return transferStatement?.Extent.StartOffset; } - private sealed class ScopeExecutionGraph - { - private readonly Dictionary _functionInvocations = new(); - - internal ScopeExecutionGraph(NestedFunctionVisibilityAnalyzer analyzer, ScriptBlockAst scope) - { - var functions = scope.FindAll( - ast => ast is FunctionDefinitionAst, - searchNestedScriptBlocks: true) - .Cast() - .Select(function => new - { - Function = function, - HasContext = analyzer.TryGetPotentialDeclarationContext(function, out var declarationScope, out _), - Scope = declarationScope - }) - .Where(item => item.HasContext && ReferenceEquals(item.Scope, scope)) - .Select(item => item.Function) - .ToArray(); - - FunctionsByName = functions - .GroupBy( - function => NormalizeDeclaredFunctionName(function.Name), - StringComparer.OrdinalIgnoreCase) - .ToDictionary( - group => group.Key, - group => group.ToArray(), - StringComparer.OrdinalIgnoreCase); - - RootInvocations = scope.FindAll( - ast => ast is CommandAst, - searchNestedScriptBlocks: true) - .Cast() - .Select(command => new InvocationSite(command, FindContainingTrap(command, scope))) - .ToArray(); - - foreach (var function in functions) - { - _functionInvocations[function] = function.Body.FindAll( - ast => ast is CommandAst, - searchNestedScriptBlocks: true) - .Cast() - .Select(command => new InvocationSite(command, trap: null)) - .ToArray(); - } - } - - internal IReadOnlyDictionary FunctionsByName { get; } - internal InvocationSite[] RootInvocations { get; } - - internal InvocationSite[] GetFunctionInvocations(FunctionDefinitionAst function) - { - return _functionInvocations.TryGetValue(function, out var invocations) - ? invocations - : Array.Empty(); - } - } - - private sealed class InvocationSite - { - internal InvocationSite(CommandAst command, TrapStatementAst? trap) - { - Command = command; - Trap = trap; - var commandName = command.GetCommandName(); - if (string.Equals(NormalizeInvocationName(commandName), "Invoke-Expression", StringComparison.OrdinalIgnoreCase) || - string.Equals(commandName, "iex", StringComparison.OrdinalIgnoreCase)) - { - Name = TryGetInvokeExpressionTarget(command); - IsDynamic = string.IsNullOrWhiteSpace(Name); - } - else - { - Name = commandName; - IsDynamic = string.IsNullOrWhiteSpace(Name) && - command.CommandElements.Count > 0 && - command.CommandElements[0] is not ScriptBlockExpressionAst; - } - } - - private static string? TryGetInvokeExpressionTarget(CommandAst command) - { - var arguments = command.CommandElements.Skip(1).ToArray(); - if (arguments.Length != 1 || arguments[0] is not StringConstantExpressionAst literal) - return null; - - var parsed = Parser.ParseInput(literal.Value, out _, out var errors); - if (errors.Length > 0) - return null; - - var commands = parsed.FindAll(ast => ast is CommandAst, searchNestedScriptBlocks: true) - .Cast() - .ToArray(); - return commands.Length == 1 ? commands[0].GetCommandName() : null; - } - - internal CommandAst Command { get; } - internal string? Name { get; } - internal bool IsDynamic { get; } - internal TrapStatementAst? Trap { get; } - } } diff --git a/PowerForge.Tests/FormattingPipelineTests.cs b/PowerForge.Tests/FormattingPipelineTests.cs new file mode 100644 index 0000000000..4e7514ce34 --- /dev/null +++ b/PowerForge.Tests/FormattingPipelineTests.cs @@ -0,0 +1,81 @@ +using System.Text; + +namespace PowerForge.Tests; + +public sealed class FormattingPipelineTests +{ + [Fact] + public void Run_WithoutPreprocessingStillNormalizesAndReportsFormatterFailure() + { + var root = Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var path = Path.Combine(root, "module.psd1"); + File.WriteAllText(path, "@{\nRootModule='module.psm1'\n}\n", new UTF8Encoding(false)); + try + { + var runner = new RecordingRunner(path); + var pipeline = new FormattingPipeline(new NullLogger(), runner); + + var result = Assert.Single(pipeline.Run(new[] { path }, new FormatOptions())); + + Assert.Single(runner.Requests); + Assert.True(result.Changed); + Assert.StartsWith("Error: formatter rejected input", result.Message); + Assert.Equal(new byte[] { 0xEF, 0xBB, 0xBF }, File.ReadAllBytes(path).Take(3)); + Assert.Contains("\r\n", File.ReadAllText(path)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void Run_EnabledPreprocessingRetainsItsErrorAndRunsFormatting(int option) + { + var root = Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var path = Path.Combine(root, "module.ps1"); + File.WriteAllText(path, "param()\n# comment\n\n\n'hello'\n"); + try + { + var runner = new RecordingRunner(path); + var pipeline = new FormattingPipeline(new NullLogger(), runner); + var options = new FormatOptions + { + RemoveCommentsInParamBlock = option == 0, + RemoveCommentsBeforeParamBlock = option == 1, + RemoveAllEmptyLines = option == 2, + RemoveEmptyLines = option == 3 + }; + + var result = Assert.Single(pipeline.Run(new[] { path }, options)); + + Assert.Equal(2, runner.Requests.Count); + Assert.StartsWith("Error: preprocessing rejected input", result.Message); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + private sealed class RecordingRunner(string path) : IPowerShellRunner + { + internal List Requests { get; } = new(); + + public PowerShellRunResult Run(PowerShellRunRequest request) + { + Requests.Add(request); + var script = File.ReadAllText(request.ScriptPath!); + var output = script.Contains("PRE::", StringComparison.Ordinal) + ? $"PRE::ERROR::{path}::preprocessing rejected input" + : $"ERROR::{path}::formatter rejected input"; + return new PowerShellRunResult(0, output, string.Empty, "stub"); + } + } +} diff --git a/PowerForge.Tests/LineEndingsNormalizerTests.cs b/PowerForge.Tests/LineEndingsNormalizerTests.cs index e029c688df..b2f9f57db6 100644 --- a/PowerForge.Tests/LineEndingsNormalizerTests.cs +++ b/PowerForge.Tests/LineEndingsNormalizerTests.cs @@ -4,6 +4,76 @@ namespace PowerForge.Tests; public sealed class LineEndingsNormalizerTests { + [Theory] + [InlineData(LineEnding.CRLF, true)] + [InlineData(LineEnding.CRLF, false)] + [InlineData(LineEnding.LF, true)] + [InlineData(LineEnding.LF, false)] + public void NormalizeFile_AlreadyNormalized_PreservesBytesAndLastWriteTime(LineEnding ending, bool bom) + { + var path = Path.Combine(Path.GetTempPath(), $"powerforge-lineendings-{Guid.NewGuid():N}.ps1"); + try + { + var newline = ending == LineEnding.CRLF ? "\r\n" : "\n"; + File.WriteAllText(path, "München" + newline + "Δ", new UTF8Encoding(bom)); + File.SetLastWriteTimeUtc(path, new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + var originalTime = File.GetLastWriteTimeUtc(path); + var originalBytes = File.ReadAllBytes(path); + + var result = new LineEndingsNormalizer().NormalizeFile(path, new NormalizationOptions(ending, bom)); + + Assert.False(result.Changed); + Assert.Equal(originalBytes, File.ReadAllBytes(path)); + Assert.Equal(originalTime, File.GetLastWriteTimeUtc(path)); + } + finally + { + File.Delete(path); + } + } + + [Theory] + [InlineData(false, true)] + [InlineData(true, false)] + public void NormalizeFile_ChangesBom_ReportsChanged(bool inputBom, bool outputBom) + { + var path = Path.Combine(Path.GetTempPath(), $"powerforge-lineendings-{Guid.NewGuid():N}.ps1"); + try + { + File.WriteAllText(path, "München\r\nΔ", new UTF8Encoding(inputBom)); + + var result = new LineEndingsNormalizer().NormalizeFile(path, new NormalizationOptions(LineEnding.CRLF, outputBom)); + + Assert.True(result.Changed); + var encoding = new UTF8Encoding(outputBom); + Assert.Equal(encoding.GetPreamble().Concat(encoding.GetBytes("München\r\nΔ")), File.ReadAllBytes(path)); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void NormalizeFile_Utf16_ConvertsToRequestedUtf8AndReportsChanged() + { + var path = Path.Combine(Path.GetTempPath(), $"powerforge-lineendings-{Guid.NewGuid():N}.ps1"); + try + { + File.WriteAllText(path, "München\r\nΔ", Encoding.Unicode); + + var result = new LineEndingsNormalizer().NormalizeFile(path, new NormalizationOptions(LineEnding.CRLF, true)); + + Assert.True(result.Changed); + var encoding = new UTF8Encoding(true); + Assert.Equal(encoding.GetPreamble().Concat(encoding.GetBytes("München\r\nΔ")), File.ReadAllBytes(path)); + } + finally + { + File.Delete(path); + } + } + [Fact] public void NormalizeFile_ToCrLf_ConvertsBareCrAndLf() { diff --git a/PowerForge.Tests/ModulePipelinePowerShellCompilationTests.cs b/PowerForge.Tests/ModulePipelinePowerShellCompilationTests.cs index 3074bda05e..6e66aa50b1 100644 --- a/PowerForge.Tests/ModulePipelinePowerShellCompilationTests.cs +++ b/PowerForge.Tests/ModulePipelinePowerShellCompilationTests.cs @@ -122,7 +122,7 @@ public void Public_dsl_switches_arbitrary_script_modules_to_hybrid_or_strict_bin var strictRoot = CreateDslModule( testRoot, strictName, - "function Get-StrictValue { param([int] $Number); [int] $Result = $Number; $Result += 1; return $Result }; Export-ModuleMember -Function Get-StrictValue"); + "function Get-StrictValue { [CmdletBinding()] param([int] $Number); [int] $Result = $Number; $Result += 1; return $Result }; Export-ModuleMember -Function Get-StrictValue"); _ = CreateDslModule( testRoot, rejectedName, diff --git a/PowerForge.Tests/PreprocessorTests.cs b/PowerForge.Tests/PreprocessorTests.cs new file mode 100644 index 0000000000..83d1541a19 --- /dev/null +++ b/PowerForge.Tests/PreprocessorTests.cs @@ -0,0 +1,43 @@ +namespace PowerForge.Tests; + +public sealed class PreprocessorTests +{ + [Fact] + public void Process_PreservesCompletedErrorsAndChangesWhenLaterFilesTimeOut() + { + var preprocessor = new Preprocessor(new TimeoutRunner(), new NullLogger()); + + var results = preprocessor.Process( + new[] { "first.ps1", "second.ps1", "third.ps1" }, + new FormatOptions { RemoveEmptyLines = true }); + + Assert.Collection(results, + result => + { + Assert.Equal("first.ps1", result.Path); + Assert.Equal("Error: Access denied", result.Message); + Assert.False(result.Changed); + }, + result => + { + Assert.Equal("second.ps1", result.Path); + Assert.Equal("Preprocessed", result.Message); + Assert.True(result.Changed); + }, + result => + { + Assert.Equal("third.ps1", result.Path); + Assert.Equal("Skipped: Timeout", result.Message); + Assert.False(result.Changed); + }); + var summary = FormattingSummary.FromResults(results); + Assert.Equal(CheckStatus.Fail, summary.Status); + Assert.Equal(1, summary.Errors); + } + + private sealed class TimeoutRunner : IPowerShellRunner + { + public PowerShellRunResult Run(PowerShellRunRequest request) + => new(124, "PRE::ERROR::first.ps1::Access denied\r\nPRE::CHANGED::second.ps1\r\n", string.Empty, "stub"); + } +} diff --git a/PowerForge.Tests/PssaFormatterIsolationTests.cs b/PowerForge.Tests/PssaFormatterIsolationTests.cs index d2644c787a..445e328ea7 100644 --- a/PowerForge.Tests/PssaFormatterIsolationTests.cs +++ b/PowerForge.Tests/PssaFormatterIsolationTests.cs @@ -332,6 +332,106 @@ private static void WriteTestModule(string versionRoot, string version, string m File.WriteAllText(Path.Combine(versionRoot, "PSScriptAnalyzer.psm1"), moduleScript); } + [Fact] + public void FormatBatches_PreservesDifferentSettingsAndPerFileErrorsInOneIsolatedProcess() + => AssertBatchSettingsAndErrors(executableOverride: null); + + [WindowsFact] + public void FormatBatches_PreservesDifferentSettingsAndPerFileErrorsOnWindowsPowerShell() + => AssertBatchSettingsAndErrors(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", "v1.0", "powershell.exe")); + + private static void AssertBatchSettingsAndErrors(string? executableOverride) + { + var root = Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try + { + var moduleRoot = Path.Combine(root, "modules", "PSScriptAnalyzer", "999.0.0"); + Directory.CreateDirectory(moduleRoot); + WriteTestModule(moduleRoot, "999.0.0", """ + function Invoke-Formatter { + [CmdletBinding()] + param([string] $ScriptDefinition, [hashtable] $Settings) + if ($Settings.Reject) { Write-Error 'Rejected first group'; return $ScriptDefinition } + return $ScriptDefinition + "`n# " + $Settings.Marker + } + Export-ModuleMember -Function Invoke-Formatter + """); + var first = Path.Combine(root, "first ü.psd1"); + var second = Path.Combine(root, "second.psm1"); + File.WriteAllText(first, "@{}"); + File.WriteAllText(second, "'module'"); + var formatter = new PssaFormatter( + new EnvironmentPowerShellRunner(new Dictionary + { + ["PSModulePath"] = Path.Combine(root, "modules") + }, executableOverride), new NullLogger()); + + var results = formatter.FormatBatches(new[] + { + new FormattingBatch(new[] { first }, new FormatOptions + { + PssaSettingsJson = "{\"Reject\":true,\"Marker\":\"first\"}" + }), + new FormattingBatch(new[] { second }, new FormatOptions + { + PssaSettingsJson = "{\"Marker\":\"second\"}" + }) + }); + + Assert.Equal(new[] { Path.GetFileName(first), Path.GetFileName(second) }, results.Select(result => Path.GetFileName(result.Path))); + Assert.Equal(first, results[0].Path); + Assert.StartsWith("Error: Rejected first group", results[0].Message); + Assert.Equal("@{}", File.ReadAllText(first)); + Assert.Equal(second, results[1].Path); + Assert.True(results[1].Changed); + Assert.Contains("# second", File.ReadAllText(second)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void FormatBatches_PreservesCompletedErrorsAndChangesWhenLaterFilesTimeOut() + { + var formatter = new PssaFormatter(new StubRunner(new PowerShellRunResult( + 124, + "ERROR::first.psd1::Rejected first group\r\nFORMATTED::second.psm1\r\n", + string.Empty, + "pwsh")), new NullLogger()); + + var results = formatter.FormatBatches(new[] + { + new FormattingBatch(new[] { "first.psd1" }, new FormatOptions()), + new FormattingBatch(new[] { "second.psm1" }, new FormatOptions()), + new FormattingBatch(new[] { "third.ps1" }, new FormatOptions()) + }); + + Assert.Collection(results, + result => + { + Assert.Equal("first.psd1", result.Path); + Assert.Equal("Error: Rejected first group", result.Message); + }, + result => + { + Assert.Equal("second.psm1", result.Path); + Assert.True(result.Changed); + }, + result => + { + Assert.Equal("third.ps1", result.Path); + Assert.Equal("Skipped: Timeout", result.Message); + }); + var summary = FormattingSummary.FromResults(results); + Assert.Equal(CheckStatus.Fail, summary.Status); + Assert.Equal(1, summary.Errors); + } + private sealed class StubRunner : IPowerShellRunner { private readonly PowerShellRunResult _result; @@ -344,10 +444,14 @@ private sealed class StubRunner : IPowerShellRunner private sealed class EnvironmentPowerShellRunner : IPowerShellRunner { private readonly IReadOnlyDictionary _environmentVariables; + private readonly string? _executableOverride; private readonly PowerShellRunner _inner = new(); - public EnvironmentPowerShellRunner(IReadOnlyDictionary environmentVariables) - => _environmentVariables = environmentVariables; + public EnvironmentPowerShellRunner(IReadOnlyDictionary environmentVariables, string? executableOverride = null) + { + _environmentVariables = environmentVariables; + _executableOverride = executableOverride; + } public PowerShellRunResult Run(PowerShellRunRequest request) => _inner.Run(new PowerShellRunRequest( @@ -357,7 +461,7 @@ public PowerShellRunResult Run(PowerShellRunRequest request) request.PreferPwsh, request.WorkingDirectory, _environmentVariables, - request.ExecutableOverride, + _executableOverride ?? request.ExecutableOverride, request.CaptureOutput, request.CaptureError, request.OutputLineReceived, diff --git a/PowerForge.Tests/PssaFormatterSessionTests.cs b/PowerForge.Tests/PssaFormatterSessionTests.cs new file mode 100644 index 0000000000..03a4a3130d --- /dev/null +++ b/PowerForge.Tests/PssaFormatterSessionTests.cs @@ -0,0 +1,169 @@ +using System.Diagnostics; +using System.Text; + +namespace PowerForge.Tests; + +public sealed class PssaFormatterSessionTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Session_ReusesHostWithDistinctPhaseSettingsAndDisposesIt(bool desktop) + { + if (desktop && !OperatingSystem.IsWindows()) return; + using var fixture = new Fixture(desktop); + var formatter = fixture.CreateFormatter(); + int processId; + using (formatter.BeginSession()) + { + var first = fixture.Input("first.ps1", "first"); + var second = fixture.Input("second ü.ps1", "second"); + Assert.True(Assert.Single(formatter.FormatBatches(Batch(first, "one"))).Changed); + Assert.True(Assert.Single(formatter.FormatBatches(Batch(second, "two"))).Changed); + Assert.Equal("first-one", File.ReadAllText(first)); + Assert.Equal("second-two", File.ReadAllText(second)); + var rows = fixture.Rows(); + Assert.Equal(2, rows.Length); + Assert.Equal(rows[0][0], rows[1][0]); + Assert.Equal("one", rows[0][2]); + Assert.Equal("two", rows[1][2]); + Assert.All(rows, row => Assert.Equal(Environment.CurrentDirectory, row[3])); + processId = int.Parse(rows[0][0]); + } + AssertExited(processId); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Session_TimeoutRetainsCompletedErrorsTerminatesHostAndRestartsNextPhase(bool desktop) + { + if (desktop && !OperatingSystem.IsWindows()) return; + using var fixture = new Fixture(desktop); + var formatter = fixture.CreateFormatter(); + using var session = formatter.BeginSession(); + formatter.FormatBatches(Batch(fixture.Input("warm.ps1", "warm"), "warm")); + var processId = int.Parse(fixture.Rows()[0][0]); + var failed = fixture.Input("failed.ps1", "fail"); + var hanging = fixture.Input("hanging.ps1", "hang"); + var unfinished = fixture.Input("unfinished.ps1", "unfinished"); + + var results = formatter.FormatBatches(new[] { new FormattingBatch( + new[] { failed, hanging, unfinished }, new FormatOptions { TimeoutSeconds = 1 }) }); + + Assert.Equal(3, results.Count); + Assert.Equal("Error: phase formatting failed", results.Single(item => item.Path == failed).Message); + Assert.All(results.Where(item => item.Path != failed), item => Assert.Equal("Skipped: Timeout", item.Message)); + Assert.Equal(CheckStatus.Fail, FormattingSummary.FromResults(results).Status); + AssertExited(processId); + var next = fixture.Input("next.ps1", "next"); + Assert.True(Assert.Single(formatter.FormatBatches(Batch(next, "restarted"))).Changed); + Assert.Equal("next-restarted", File.ReadAllText(next)); + Assert.NotEqual(processId, int.Parse(fixture.Rows().Last()[0])); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Session_PhaseFailureUnwindsHostOwnershipAndAllowsAnotherScope(bool desktop) + { + if (desktop && !OperatingSystem.IsWindows()) return; + using var fixture = new Fixture(desktop); + var formatter = fixture.CreateFormatter(); + Assert.Throws((Action)(() => + { + using var session = formatter.BeginSession(); + var results = formatter.FormatBatches(Batch(fixture.Input("failed.ps1", "fail"), "failure")); + Assert.Equal(CheckStatus.Fail, FormattingSummary.FromResults(results).Status); + throw new InvalidOperationException("Stop after staging failure."); + })); + var processId = int.Parse(fixture.Rows()[0][0]); + AssertExited(processId); + using var nextScope = formatter.BeginSession(); + Assert.True(Assert.Single(formatter.FormatBatches(Batch(fixture.Input("next.ps1", "next"), "next"))).Changed); + Assert.NotEqual(processId, int.Parse(fixture.Rows().Last()[0])); + } + + private static FormattingBatch[] Batch(string path, string tag) => new[] + { + new FormattingBatch(new[] { path }, new FormatOptions { PssaSettingsJson = "{\"Tag\":\"" + tag + "\"}" }) + }; + + private static void AssertExited(int processId) + { + try + { + using var process = Process.GetProcessById(processId); + Assert.True(process.HasExited, $"Formatter process {processId} survived its scope."); + } + catch (ArgumentException) { } + } + + private sealed class Fixture : IDisposable + { + private readonly bool _desktop; + private readonly string _root = Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N")); + private string Log => Path.Combine(_root, "requests.log"); + + internal Fixture(bool desktop) + { + _desktop = desktop; + var module = Path.Combine(_root, "modules", "PSScriptAnalyzer", "999.0.0"); + Directory.CreateDirectory(module); + File.WriteAllText(Path.Combine(module, "PSScriptAnalyzer.psd1"), + "@{RootModule='PSScriptAnalyzer.psm1';ModuleVersion='999.0.0';GUID='f34922f7-e271-4c02-abd2-6f987e6411c2';FunctionsToExport=@('Invoke-Formatter')}"); + File.WriteAllText(Path.Combine(module, "PSScriptAnalyzer.psm1"), """ + function Invoke-Formatter { + [CmdletBinding()] + param([string]$ScriptDefinition, [hashtable]$Settings) + $code = $ScriptDefinition.Trim() + $tag = if ($Settings) { $Settings.Tag } else { '' } + [IO.File]::AppendAllText($env:PSSA_SESSION_LOG, "$PID|$code|$tag|$([Environment]::CurrentDirectory)`n") + if ($code -eq 'fail') { Write-Error 'phase formatting failed' } + if ($code -eq 'hang') { Start-Sleep -Seconds 30 } + return "$code-$tag" + } + Export-ModuleMember -Function Invoke-Formatter + """, new UTF8Encoding(true)); + } + + internal string Input(string name, string content) + { + var path = Path.Combine(_root, name); + File.WriteAllText(path, content, new UTF8Encoding(true)); + return path; + } + + internal string[][] Rows() => File.ReadAllLines(Log).Select(line => line.Split('|')).ToArray(); + + internal PssaFormatter CreateFormatter() => new(new EnvironmentRunner(_desktop, new Dictionary + { + ["PSModulePath"] = Path.Combine(_root, "modules"), + ["PSSA_SESSION_LOG"] = Log + }), new NullLogger()); + + public void Dispose() { Directory.Delete(_root, recursive: true); } + } + + private sealed class EnvironmentRunner(bool desktop, IReadOnlyDictionary environment) + : IPowerShellRunner, ICancellablePowerShellRunner + { + private readonly PowerShellRunner _runner = new(); + + public PowerShellRunResult Run(PowerShellRunRequest request) => _runner.Run(Map(request)); + + public Task RunAsync(PowerShellRunRequest request, CancellationToken cancellationToken) + => ((ICancellablePowerShellRunner)_runner).RunAsync(Map(request), cancellationToken); + + private PowerShellRunRequest Map(PowerShellRunRequest request) => new( + request.ScriptPath!, request.Arguments, request.Timeout, + preferPwsh: !desktop, + workingDirectory: request.WorkingDirectory, + environmentVariables: environment, + executableOverride: desktop ? "powershell.exe" : null, + captureOutput: request.CaptureOutput, + captureError: request.CaptureError, + outputLineReceived: request.OutputLineReceived, + errorLineReceived: request.ErrorLineReceived); + } +} diff --git a/PowerForge/Models/FormattingBatch.cs b/PowerForge/Models/FormattingBatch.cs new file mode 100644 index 0000000000..980eae28bb --- /dev/null +++ b/PowerForge/Models/FormattingBatch.cs @@ -0,0 +1,13 @@ +namespace PowerForge; + +internal sealed class FormattingBatch +{ + internal FormattingBatch(IEnumerable files, FormatOptions options) + { + Files = files.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + Options = options; + } + + public string[] Files { get; } + public FormatOptions Options { get; } +} diff --git a/PowerForge/Scripts/Formatting/Invoke-Preprocessor.ps1 b/PowerForge/Scripts/Formatting/Invoke-Preprocessor.ps1 index c277b916da..3ab36cb4a4 100644 --- a/PowerForge/Scripts/Formatting/Invoke-Preprocessor.ps1 +++ b/PowerForge/Scripts/Formatting/Invoke-Preprocessor.ps1 @@ -1,5 +1,6 @@ param([string]$FlagsB64,[Parameter(ValueFromRemainingArguments=$true)][string[]]$Files) -$ErrorActionPreference = 'Stop' +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) try { $flags = $null diff --git a/PowerForge/Scripts/Formatting/Invoke-PssaFormatter.ps1 b/PowerForge/Scripts/Formatting/Invoke-PssaFormatter.ps1 index 33dbac9fa0..cb4fa81311 100644 --- a/PowerForge/Scripts/Formatting/Invoke-PssaFormatter.ps1 +++ b/PowerForge/Scripts/Formatting/Invoke-PssaFormatter.ps1 @@ -1,5 +1,13 @@ -param([string]$SettingsB64,[Parameter(ValueFromRemainingArguments=$true)][string[]]$Files) -$ErrorActionPreference = 'Stop' +[CmdletBinding(PositionalBinding=$false)] +param( + [Parameter(Position=0)][string]$SettingsB64, + [Parameter(Position=1,ValueFromRemainingArguments=$true)][string[]]$Files, + [string]$BatchPath, + [string]$SessionDirectory, + [int]$ParentProcessId +) +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) try { $ModuleRoots = @($env:PSModulePath -split [System.IO.Path]::PathSeparator | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | @@ -71,7 +79,9 @@ try { exit 3 } -$settings = $null +function Invoke-PssaFormattingBatch { +param([string]$BatchPath, [string]$SettingsB64, [string[]]$Files) +$settings = $null if ($SettingsB64) { try { $json = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($SettingsB64)) @@ -99,9 +109,19 @@ function ConvertTo-Hashtable { } return $InputObject } -if ($null -ne $settings) { $settings = ConvertTo-Hashtable $settings } - -foreach ($f in $Files) { +$batches = @([pscustomobject]@{ Files = $Files; Settings = $settings }) +if ($BatchPath) { + $batchEntries = Get-Content -LiteralPath $BatchPath -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json + $batches = @(foreach ($entry in @($batchEntries)) { + $batchSettings = $null + if ($entry.SettingsJson) { $batchSettings = ConvertFrom-Json -InputObject $entry.SettingsJson } + [pscustomobject]@{ Files = $entry.Files; Settings = $batchSettings } + }) +} + +foreach ($batch in $batches) { +$settings = ConvertTo-Hashtable $batch.Settings +foreach ($f in $batch.Files) { try { $text = Get-Content -LiteralPath $f -Raw -ErrorAction Stop $formatterErrors = @() @@ -144,5 +164,30 @@ foreach ($f in $Files) { } catch { Write-Output ("ERROR::" + $f + "::" + $_.Exception.Message) } -} -exit 0 +} +} +} + +if ($SessionDirectory) { + $RequestPath = [System.IO.Path]::Combine($SessionDirectory, 'request.json') + $StopPath = [System.IO.Path]::Combine($SessionDirectory, 'stop') + while (-not [System.IO.File]::Exists($StopPath)) { + if ([System.IO.File]::Exists($RequestPath)) { + $request = [System.IO.File]::ReadAllText($RequestPath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json + [System.IO.File]::Delete($RequestPath) + Set-Location -LiteralPath $request.WorkingDirectory -ErrorAction Stop + [System.Environment]::CurrentDirectory = $request.WorkingDirectory + Invoke-PssaFormattingBatch -BatchPath $request.BatchPath + Write-Output ('PSSA_PHASE_DONE::' + $request.Id) + continue + } + try { + $parentProcess = [System.Diagnostics.Process]::GetProcessById($ParentProcessId) + $parentProcess.Dispose() + } catch { break } + Start-Sleep -Milliseconds 25 + } +} else { + Invoke-PssaFormattingBatch -BatchPath $BatchPath -SettingsB64 $SettingsB64 -Files $Files +} +exit 0 diff --git a/PowerForge/Services/FormattingPipeline.cs b/PowerForge/Services/FormattingPipeline.cs index 66fced6a62..d907868c78 100644 --- a/PowerForge/Services/FormattingPipeline.cs +++ b/PowerForge/Services/FormattingPipeline.cs @@ -15,9 +15,14 @@ public sealed class FormattingPipeline /// Creates a new pipeline using the provided logger and a default runner. /// public FormattingPipeline(ILogger logger) + : this(logger, new PowerShellRunner()) + { + } + + internal FormattingPipeline(ILogger logger, IPowerShellRunner runner) { _logger = logger; - _runner = new PowerShellRunner(); + _runner = runner; _pre = new Preprocessor(_runner, logger); _pssa = new PssaFormatter(_runner, logger); _norm = new LineEndingsNormalizer(); @@ -27,51 +32,65 @@ public FormattingPipeline(ILogger logger) /// Runs the pipeline for the specified files using . /// public IReadOnlyList Run(IEnumerable files, FormatOptions options) - { - var list = files.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); - if (list.Length == 0) return Array.Empty(); + => RunBatches(new[] { new FormattingBatch(files, options) }); - // Step 0: normalize first so PSScriptAnalyzer can parse files that have mixed line endings. - var opts = new NormalizationOptions(options.LineEnding, options.Utf8Bom); - var preNormalize = new List(list.Length); - foreach (var f in list) + /// Owns the isolated PSSA host across sequential formatting phases. + internal IDisposable? BeginSession() => _pssa.BeginSession(); + + internal IReadOnlyList RunBatches(IReadOnlyList batches) + { + var prepared = new List<(FormattingBatch Batch, List Normalized, IReadOnlyList Preprocessed)>(); + foreach (var batch in batches) { - preNormalize.Add(_norm.NormalizeFile(f, opts)); - } + if (batch.Files.Length == 0) continue; + var options = batch.Options; + var opts = new NormalizationOptions(options.LineEnding, options.Utf8Bom); + var preNormalize = new List(batch.Files.Length); + foreach (var f in batch.Files) + preNormalize.Add(_norm.NormalizeFile(f, opts)); - // Step 1: Preprocess - var pre = _pre.Process(list, options); + var pre = options.RemoveCommentsInParamBlock || options.RemoveCommentsBeforeParamBlock || + options.RemoveAllEmptyLines || options.RemoveEmptyLines + ? _pre.Process(batch.Files, options) + : Array.Empty(); + prepared.Add((batch, preNormalize, pre)); + } - // Step 2: PSSA - var pssa = _pssa.FormatFilesWithSettings(list, options.PssaSettingsJson, TimeSpan.FromSeconds(Math.Max(1, options.TimeoutSeconds))); + var pssa = _pssa.FormatBatches(prepared.Select(item => item.Batch).ToArray()); - // Step 3: Normalize - var results = new List(list.Length); - foreach (var f in list) + var results = new List(); + foreach (var item in prepared) { - var n = _norm.NormalizeFile(f, opts); - var preNormalizeResult = preNormalize.FirstOrDefault(x => string.Equals(x.Path, f, StringComparison.OrdinalIgnoreCase)); - var preResult = pre.FirstOrDefault(x => string.Equals(x.Path, f, StringComparison.OrdinalIgnoreCase)); - var pssaResult = pssa.FirstOrDefault(x => string.Equals(x.Path, f, StringComparison.OrdinalIgnoreCase)); + var list = item.Batch.Files; + var opts = new NormalizationOptions(item.Batch.Options.LineEnding, item.Batch.Options.Utf8Bom); + var preNormalize = item.Normalized; + var pre = item.Preprocessed; + foreach (var f in list) + { + var n = _norm.NormalizeFile(f, opts); + var preNormalizeResult = preNormalize.FirstOrDefault(x => string.Equals(x.Path, f, StringComparison.OrdinalIgnoreCase)); + var preResult = pre.FirstOrDefault(x => string.Equals(x.Path, f, StringComparison.OrdinalIgnoreCase)); + var pssaResult = pssa.FirstOrDefault(x => string.Equals(x.Path, f, StringComparison.OrdinalIgnoreCase)); - bool preNormalizeChanged = preNormalizeResult?.Changed ?? false; - bool preChanged = preResult?.Changed ?? false; - bool pssaChanged = pssaResult?.Changed ?? false; - bool changed = preNormalizeChanged || preChanged || pssaChanged || n.Changed; + bool preNormalizeChanged = preNormalizeResult?.Changed ?? false; + bool preChanged = preResult?.Changed ?? false; + bool pssaChanged = pssaResult?.Changed ?? false; + bool changed = preNormalizeChanged || preChanged || pssaChanged || n.Changed; - var details = $"preNorm={(preNormalizeChanged ? '1' : '0')}; pre={(preChanged ? '1' : '0')}; pssa={(pssaChanged ? '1' : '0')}; norm={(n.Changed ? '1' : '0')}"; + var details = $"preNorm={(preNormalizeChanged ? '1' : '0')}; pre={(preChanged ? '1' : '0')}; pssa={(pssaChanged ? '1' : '0')}; norm={(n.Changed ? '1' : '0')}"; - var preMsg = preResult?.Message ?? string.Empty; - var pssaMsg = pssaResult?.Message ?? string.Empty; + var preMsg = preResult?.Message ?? string.Empty; + var pssaMsg = pssaResult?.Message ?? string.Empty; - string? statusMsg = null; - if (FormattingSummary.IsErrorMessage(preMsg)) statusMsg = preMsg; - else if (FormattingSummary.IsErrorMessage(pssaMsg)) statusMsg = pssaMsg; - else if (FormattingSummary.IsSkippedMessage(preMsg)) statusMsg = preMsg; - else if (FormattingSummary.IsSkippedMessage(pssaMsg)) statusMsg = pssaMsg; + string? statusMsg = null; + if (FormattingSummary.IsErrorMessage(preMsg)) statusMsg = preMsg; + else if (FormattingSummary.IsErrorMessage(pssaMsg)) statusMsg = pssaMsg; + else if (FormattingSummary.IsSkippedMessage(preMsg)) statusMsg = preMsg; + else if (FormattingSummary.IsSkippedMessage(pssaMsg)) statusMsg = pssaMsg; - var msg = string.IsNullOrWhiteSpace(statusMsg) ? details : $"{statusMsg}; {details}"; - results.Add(new FormatterResult(f, changed, msg)); + var msg = string.IsNullOrWhiteSpace(statusMsg) ? details : $"{statusMsg}; {details}"; + results.Add(new FormatterResult(f, changed, msg)); + } } return results; } diff --git a/PowerForge/Services/LineEndingsNormalizer.cs b/PowerForge/Services/LineEndingsNormalizer.cs index 9fd8d72fc0..21d7d6074d 100644 --- a/PowerForge/Services/LineEndingsNormalizer.cs +++ b/PowerForge/Services/LineEndingsNormalizer.cs @@ -15,7 +15,11 @@ public sealed class LineEndingsNormalizer : ILineEndingsNormalizer public NormalizationResult NormalizeFile(string path, NormalizationOptions? options = null) { options ??= new NormalizationOptions(); - var text = File.ReadAllText(path); + var originalBytes = File.ReadAllBytes(path); + string text; + using (var stream = new MemoryStream(originalBytes, writable: false)) + using (var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) + text = reader.ReadToEnd(); // Detect dominant line ending if Auto var target = options.LineEnding switch @@ -27,7 +31,6 @@ public NormalizationResult NormalizeFile(string path, NormalizationOptions? opti }; var normalized = NormalizeEndings(text, target); - var changed = !ReferenceEquals(text, normalized) && !text.Equals(normalized, StringComparison.Ordinal); var replacements = CountReplacements(text, normalized); // Save with desired encoding @@ -37,7 +40,13 @@ public NormalizationResult NormalizeFile(string path, NormalizationOptions? opti encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); } - File.WriteAllText(path, normalized, encoding); + var preamble = encoding.GetPreamble(); + var normalizedBytes = new byte[preamble.Length + encoding.GetByteCount(normalized)]; + Buffer.BlockCopy(preamble, 0, normalizedBytes, 0, preamble.Length); + encoding.GetBytes(normalized, 0, normalized.Length, normalizedBytes, preamble.Length); + var changed = !originalBytes.SequenceEqual(normalizedBytes); + if (changed) + File.WriteAllBytes(path, normalizedBytes); return new NormalizationResult(path, changed, replacements, encoding.WebName); } diff --git a/PowerForge/Services/Preprocessor.cs b/PowerForge/Services/Preprocessor.cs index ea94aad7a1..8fd102feff 100644 --- a/PowerForge/Services/Preprocessor.cs +++ b/PowerForge/Services/Preprocessor.cs @@ -64,8 +64,7 @@ public IReadOnlyList Process(IEnumerable files, FormatO } if (result.ExitCode == 124) { - _logger.Warn("Preprocessor: Timeout; skipping."); - return list.Select(p => new FormatterResult(p, false, "Skipped: Timeout")).ToArray(); + _logger.Warn("Preprocessor: Timeout; skipping unfinished files."); } var outputs = new List(list.Length); @@ -96,7 +95,11 @@ public IReadOnlyList Process(IEnumerable files, FormatO { if (!outputs.Any(o => string.Equals(o.Path, p, StringComparison.OrdinalIgnoreCase))) { - if (result.ExitCode != 0) + if (result.ExitCode == 124) + { + outputs.Add(new FormatterResult(p, false, "Skipped: Timeout")); + } + else if (result.ExitCode != 0) { var reason = ExtractFirstLine(result.StdErr) ?? ExtractFirstLine(result.StdOut); var extra = string.IsNullOrWhiteSpace(reason) ? string.Empty : $": {reason}"; diff --git a/PowerForge/Services/PssaFormatter.cs b/PowerForge/Services/PssaFormatter.cs index f7c31b0f6d..78e45e93b2 100644 --- a/PowerForge/Services/PssaFormatter.cs +++ b/PowerForge/Services/PssaFormatter.cs @@ -1,4 +1,5 @@ using System.Text; +using System.Text.Json; namespace PowerForge; @@ -10,6 +11,7 @@ public sealed class PssaFormatter : IFormatter { private readonly IPowerShellRunner _runner; private readonly ILogger _logger; + private PssaFormatterSession? _session; /// /// Creates a new formatter that uses the provided runner and logger. @@ -28,6 +30,29 @@ public IReadOnlyList FormatFiles(IEnumerable files, Tim /// public IReadOnlyList FormatFilesWithSettings(IEnumerable files, string? settingsJson, TimeSpan? timeout = null) + => RunFormatter(files, settingsJson, batches: null, timeout); + + internal IReadOnlyList FormatBatches(IReadOnlyList batches) + => RunFormatter( + batches.SelectMany(batch => batch.Files), + settingsJson: null, + batches, + TimeSpan.FromSeconds(batches.Sum(batch => Math.Max(1, batch.Options.TimeoutSeconds)))); + + /// Reuses a formatter host only within an explicitly owned build-phase scope. + internal IDisposable? BeginSession() + { + if (_runner is not ICancellablePowerShellRunner runner) return null; + if (_session is not null) throw new InvalidOperationException("A formatter session is already active."); + _session = new PssaFormatterSession(runner, () => _session = null); + return _session; + } + + private IReadOnlyList RunFormatter( + IEnumerable files, + string? settingsJson, + IReadOnlyList? batches, + TimeSpan? timeout) { var list = files.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); if (list.Length == 0) return Array.Empty(); @@ -47,9 +72,32 @@ public IReadOnlyList FormatFilesWithSettings(IEnumerable(list.Length + 1) { settingsB64 }; args.AddRange(list); - var result = _runner.Run(new PowerShellRunRequest(scriptPath, args, timeout ?? TimeSpan.FromMinutes(2))); - - try { File.Delete(scriptPath); } catch { /* ignore */ } + string? batchPath = null; + if (batches is not null) + { + batchPath = Path.Combine(tempDir, $"batch_{Guid.NewGuid():N}.json"); + File.WriteAllText(batchPath, JsonSerializer.Serialize(batches.Select(batch => new + { + batch.Files, + SettingsJson = batch.Options.PssaSettingsJson + })), new UTF8Encoding(false)); + args = new List { "-BatchPath", batchPath }; + } + PowerShellRunResult result; + try + { + result = _session is not null && batchPath is not null + ? _session.Run(script, batchPath, timeout ?? TimeSpan.FromMinutes(2)) + : _runner.Run(new PowerShellRunRequest(scriptPath, args, timeout ?? TimeSpan.FromMinutes(2))); + } + finally + { + try { File.Delete(scriptPath); } catch { /* ignore */ } + if (batchPath is not null) + { + try { File.Delete(batchPath); } catch { /* ignore */ } + } + } if (result.ExitCode == 127) { @@ -58,8 +106,7 @@ public IReadOnlyList FormatFilesWithSettings(IEnumerable new FormatterResult(p, false, "Skipped: Timeout")).ToArray(); + _logger.Warn("PSSA: Formatting timed out; skipping unfinished files."); } if (result.ExitCode == 3 || (result.StdOut ?? string.Empty).Contains("PSSA_NOT_FOUND", StringComparison.Ordinal)) { @@ -111,7 +158,9 @@ public IReadOnlyList FormatFilesWithSettings(IEnumerable string.Equals(o.Path, p, StringComparison.OrdinalIgnoreCase))) { - if (result.ExitCode != 0) + if (result.ExitCode == 124) + outputs.Add(new FormatterResult(p, false, "Skipped: Timeout")); + else if (result.ExitCode != 0) outputs.Add(new FormatterResult(p, false, $"Skipped: PSSA failed (exit {result.ExitCode})")); else outputs.Add(new FormatterResult(p, false, "Error: PSSA returned no result")); diff --git a/PowerForge/Services/PssaFormatterSession.cs b/PowerForge/Services/PssaFormatterSession.cs new file mode 100644 index 0000000000..4f952ba4a1 --- /dev/null +++ b/PowerForge/Services/PssaFormatterSession.cs @@ -0,0 +1,178 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; + +namespace PowerForge; + +/// +/// Owns one isolated formatter process for sequential build phases. Each request has +/// its own deadline and captured output; an interrupted host is discarded before reuse. +/// +internal sealed class PssaFormatterSession : IDisposable +{ + private readonly ICancellablePowerShellRunner _runner; + private readonly Action _released; + private readonly object _requests = new(); + private readonly object _output = new(); + private readonly string _directory = Path.Combine(Path.GetTempPath(), "PowerForge", "pssa", $"session_{Guid.NewGuid():N}"); + private CancellationTokenSource? _cancellation; + private Task? _process; + private Phase? _phase; + private bool _disposed; + + internal PssaFormatterSession(ICancellablePowerShellRunner runner, Action released) + { + _runner = runner; + _released = released; + } + + internal PowerShellRunResult Run(string script, string batchPath, TimeSpan timeout) + { + lock (_requests) + { + if (_disposed) throw new ObjectDisposedException(nameof(PssaFormatterSession)); + var phase = new Phase(); + lock (_output) _phase = phase; + using var deadlineCancellation = new CancellationTokenSource(); + var deadline = Task.Delay(timeout > TimeSpan.Zero ? timeout : Timeout.InfiniteTimeSpan, deadlineCancellation.Token); + try + { + Start(script); + var requestPath = Path.Combine(_directory, "request.json"); + File.WriteAllText(requestPath + ".tmp", JsonSerializer.Serialize(new + { + phase.Id, + BatchPath = batchPath, + WorkingDirectory = Environment.CurrentDirectory + }), new UTF8Encoding(false)); + File.Move(requestPath + ".tmp", requestPath); + + var completed = Task.WhenAny(phase.Completion.Task, _process!, deadline).GetAwaiter().GetResult(); + if (completed == phase.Completion.Task) + return Capture(phase, 0, string.Empty); + + if (completed == deadline) + { + // Wait for cancellation/output drainage before taking the snapshot, so + // completed file errors cannot disappear behind unfinished-file timeouts. + Stop(force: true); + return Capture(phase, 124, string.Empty); + } + + var result = _process!.GetAwaiter().GetResult(); + Stop(force: true); + return Capture(phase, result.ExitCode, result.Executable); + } + catch + { + Stop(force: true); + throw; + } + finally + { + deadlineCancellation.Cancel(); + lock (_output) _phase = null; + } + } + } + + private void Start(string script) + { + if (_process?.IsCompleted == true) Stop(force: true); + if (_process is not null) return; + Directory.CreateDirectory(_directory); + var scriptPath = Path.Combine(_directory, "formatter.ps1"); + File.WriteAllText(scriptPath, script, new UTF8Encoding(true)); + _cancellation = new CancellationTokenSource(); + _process = _runner.RunAsync(new PowerShellRunRequest( + scriptPath, + new[] { "-SessionDirectory", _directory, "-ParentProcessId", Process.GetCurrentProcess().Id.ToString() }, + Timeout.InfiniteTimeSpan, + preferPwsh: true, + workingDirectory: Environment.CurrentDirectory, + environmentVariables: null, + executableOverride: null, + captureOutput: true, + captureError: true, + outputLineReceived: ReceiveOutput, + errorLineReceived: ReceiveError), _cancellation.Token); + } + + private void ReceiveOutput(string line) + { + lock (_output) + { + if (_phase is null) return; + if (line == "PSSA_PHASE_DONE::" + _phase.Id) + _phase.Completion.TrySetResult(true); + else + _phase.Output.AppendLine(line); + } + } + + private void ReceiveError(string line) + { + lock (_output) _phase?.Error.AppendLine(line); + } + + private PowerShellRunResult Capture(Phase phase, int exitCode, string executable) + { + lock (_output) + return new PowerShellRunResult(exitCode, phase.Output.ToString(), phase.Error.ToString(), executable); + } + + private void Stop(bool force) + { + if (_process is not null) + { + try + { + if (!_process.IsCompleted) + { + if (!force) + { + File.WriteAllText(Path.Combine(_directory, "stop"), string.Empty); + force = !_process.Wait(TimeSpan.FromSeconds(2)); + } + if (force) _cancellation!.Cancel(); + } + _process.GetAwaiter().GetResult(); + } + catch (OperationCanceledException) { } + catch { /* The active request observes process faults; disposal must not mask its failure. */ } + finally + { + _process = null; + _cancellation?.Dispose(); + _cancellation = null; + } + } + foreach (var name in new[] { "request.json", "request.json.tmp", "stop" }) + { + try { File.Delete(Path.Combine(_directory, name)); } catch { /* best effort */ } + } + } + + public void Dispose() + { + lock (_requests) + { + if (_disposed) return; + _disposed = true; + try { Stop(force: false); } + finally + { + try { Directory.Delete(_directory, recursive: true); } catch { /* best effort */ } + _released(); + } + } + } + + private sealed class Phase + { + internal string Id { get; } = Guid.NewGuid().ToString("N"); + internal StringBuilder Output { get; } = new(); + internal StringBuilder Error { get; } = new(); + internal TaskCompletionSource Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + } +}